Every project statement now says `projects` unqualified and takes its
organization through search_path. A shared table filtered by
`organization_id = %s` is a filter somebody has to remember, and there is always
a write path that would not have it; an unqualified name resolves into the
caller's own schema or it does not resolve at all. The strongest possible
mistake - forgetting to scope a query - now raises instead of returning a wider
result set.
Project.organization_id survives in the model and is filled in from the schema
the answer came from. Callers still see which tenant a row belongs to; it is
derived from where the row is rather than from what the row says about itself.
Two consequences that are worth more than the mechanical change:
- assign_user_to_project now takes the organization as an argument. It used to
read it off the project row and copy it into user_roles, and there is no
column to read now. user_roles is platform-level, so it cannot be written to
by looking inside a tenant schema - so the caller, which has already resolved
the project, has to say it. The protocol declares it, and the in-memory store
accepts and ignores it rather than being the one implementation with a
different signature.
- A platform administrator listing projects without naming an organization now
gets the answer assembled across tenant schemas, because there is no shared
table to ask. That is one round trip per organization and it is paid only by
that one caller; every per-tenant path, which is what an ordinary request
does, is still a single statement. The merge reproduces the ordering and the
total a shared table would have given, so paging across the boundary is the
same as paging inside one.
747 lines
32 KiB
Python
747 lines
32 KiB
Python
"""Repository boundary for account, credential, session, and audit records.
|
|
|
|
:class:`AccountRepository` is a :class:`typing.Protocol` so the PostgreSQL
|
|
adapter can replace :class:`InMemoryAccountRepository` without touching the
|
|
service. The protocol is synchronous; the HTTP handlers are ``def`` (not
|
|
``async def``) so FastAPI runs the Argon2 work in its worker threadpool.
|
|
|
|
Only the in-memory development repository is implemented in this slice. No
|
|
module here connects to PostgreSQL.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import replace
|
|
from datetime import datetime
|
|
from typing import Protocol, Sequence, runtime_checkable
|
|
from uuid import UUID
|
|
|
|
from .errors import ConflictError, NotFoundError, RepositoryError
|
|
from .models import (
|
|
VERIFICATION_PURPOSE_EMAIL,
|
|
AccountStatus,
|
|
AuditEvent,
|
|
CredentialRecord,
|
|
Organization,
|
|
OrganizationStatus,
|
|
Project,
|
|
ProjectStatus,
|
|
RoleName,
|
|
SessionRecord,
|
|
User,
|
|
VerificationToken,
|
|
email_key,
|
|
username_key,
|
|
)
|
|
from .security import utc_now
|
|
|
|
|
|
@runtime_checkable
|
|
class AccountRepository(Protocol):
|
|
"""Storage contract required by :class:`umbratrans_web.service.AccountService`."""
|
|
|
|
def create_user(self, user: User) -> User:
|
|
"""Insert a new account, or raise :class:`ConflictError` on duplicates."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement create_user().")
|
|
|
|
def save_user(self, user: User) -> User:
|
|
"""Persist a modified account, or raise :class:`NotFoundError`."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement save_user().")
|
|
|
|
def get_user(self, user_id: UUID) -> User | None:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_user().")
|
|
|
|
def get_user_by_email(self, key: str) -> User | None:
|
|
"""Look up an account by its case-insensitive email key.
|
|
|
|
This is the login path. ``users.email`` is ``citext NOT NULL UNIQUE`` in
|
|
the control plane, so the address is already a case-insensitively
|
|
unique identifier and no separate username lookup is used to sign in.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_user_by_email().")
|
|
|
|
def get_user_by_username(self, key: str) -> User | None:
|
|
"""Look up an account by its optional display handle. Not a login path."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_user_by_username().")
|
|
|
|
def count_users(self, organization_id: UUID | None = None) -> int:
|
|
"""Count accounts, optionally within one organization."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement count_users().")
|
|
|
|
# -- projects ------------------------------------------------------------
|
|
|
|
def create_project(self, project: Project) -> Project:
|
|
"""Insert a project. The organization is part of the row, not a filter."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement create_project().")
|
|
|
|
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
|
|
"""Fetch one project.
|
|
|
|
``organization_id`` is part of the lookup, not a check afterwards. A
|
|
project identifier is unique on its own, so a lookup that ignored the
|
|
organization would hand back a project belonging to another tenant, which
|
|
is the one mistake a tenant boundary must not make.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_project().")
|
|
|
|
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
|
|
"""Fetch a project by slug, within one organization.
|
|
|
|
The organization is required rather than optional because a slug is only
|
|
unique within an organization, so an unscoped lookup would be ambiguous by
|
|
design.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_project_by_slug().")
|
|
|
|
def save_project(self, project: Project) -> Project:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement save_project().")
|
|
|
|
def find_projects(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
statuses: Sequence[ProjectStatus] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Project], int]:
|
|
"""List projects.
|
|
|
|
``organization_id`` of ``None`` means every organization, and is only
|
|
reachable from a platform administrator: everyone else is pinned to a
|
|
concrete identifier by :func:`umbratrans_web.tenancy.scope_for`.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement find_projects().")
|
|
|
|
def count_projects(self, *, organization_id: UUID | None = None) -> int:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement count_projects().")
|
|
|
|
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
|
|
"""Accounts assigned to a project, from active project-scoped assignments.
|
|
|
|
This is who is *working* on a project, not who may see it. Within an
|
|
organization every project is visible to every account; see
|
|
:mod:`umbratrans_web.tenancy`.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement list_project_member_ids().")
|
|
|
|
def assign_user_to_project(self, project_id: UUID, user_id: UUID, *, organization_id: UUID) -> bool:
|
|
"""Assign an account to a project. Returns False if already assigned.
|
|
|
|
``organization_id`` is required, and it is required because the assignment
|
|
table is platform-level while the project table is not. Before the
|
|
schema-per-tenant migration this statement read the organization off the
|
|
project row; there is no such column now, and the tenant schema the project
|
|
was found in is where the organization came from. Every implementation has
|
|
to accept it, including the ones that keep the organization on the row and
|
|
have no use for it, because a protocol parameter is what makes the call
|
|
sites agree.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement assign_user_to_project().")
|
|
|
|
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
|
|
"""Remove an assignment. Returns False if there was not one."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement unassign_user_from_project().")
|
|
|
|
def count_users_with_role(self, role: RoleName) -> int:
|
|
"""Count accounts holding a role, with an active assignment.
|
|
|
|
Used to decide whether any platform administrator exists, which is what
|
|
gates first-run setup. Counting accounts is not enough: the check has to
|
|
be about the *assignment*, because a suspended or revoked platform
|
|
administrator cannot administer anything and a deployment holding only
|
|
those is still unconfigured.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement count_users_with_role().")
|
|
|
|
def find_organizations(
|
|
self,
|
|
*,
|
|
status: OrganizationStatus | None = None,
|
|
organization_ids: tuple[UUID, ...] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Organization], int]:
|
|
"""Return a page of organizations and the total match count."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement find_organizations().")
|
|
|
|
def get_organization(self, organization_id: UUID) -> Organization | None:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_organization().")
|
|
|
|
def create_organization(self, organization: Organization) -> Organization:
|
|
"""Insert a new organization, or raise :class:`ConflictError` on a duplicate slug."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement create_organization().")
|
|
|
|
def save_organization(self, organization: Organization) -> Organization:
|
|
"""Persist a modified organization, or raise :class:`NotFoundError`."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement save_organization().")
|
|
|
|
def find_users(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
status: AccountStatus | None = None,
|
|
role: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[User], int]:
|
|
"""Return a page of accounts and the total match count."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement find_users().")
|
|
|
|
def get_credential(self, user_id: UUID) -> CredentialRecord | None:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_credential().")
|
|
|
|
def save_credential(self, credential: CredentialRecord) -> CredentialRecord:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement save_credential().")
|
|
|
|
def create_session(self, session: SessionRecord) -> SessionRecord:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement create_session().")
|
|
|
|
def get_session(self, session_id: UUID) -> SessionRecord | None:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_session().")
|
|
|
|
def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_session_by_token_hash().")
|
|
|
|
def save_session(self, session: SessionRecord) -> SessionRecord:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement save_session().")
|
|
|
|
def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement list_sessions().")
|
|
|
|
def count_sessions(self, user_id: UUID) -> int:
|
|
"""Count currently valid (not revoked, not expired) sessions."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement count_sessions().")
|
|
|
|
def revoke_sessions(
|
|
self,
|
|
user_id: UUID,
|
|
*,
|
|
reason: str,
|
|
now: datetime,
|
|
exclude_session_id: UUID | None = None,
|
|
) -> int:
|
|
"""Revoke live sessions for one account and return how many were revoked."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement revoke_sessions().")
|
|
|
|
def create_verification_token(self, token: VerificationToken) -> VerificationToken:
|
|
"""Store a single-use token. Only the hash is written."""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement create_verification_token().")
|
|
|
|
def get_live_verification_token(
|
|
self,
|
|
token_hash: str,
|
|
*,
|
|
purpose: str = VERIFICATION_PURPOSE_EMAIL,
|
|
now: datetime | None = None,
|
|
) -> VerificationToken | None:
|
|
"""Return an unconsumed, unexpired token for this purpose, if any.
|
|
|
|
Used to reject a second use of the same link without revealing whether
|
|
the first use succeeded.
|
|
|
|
``purpose`` and ``now`` are part of the lookup rather than checks applied
|
|
to its result. A password-reset token and an email-verification token are
|
|
both a bare hash against a user, so a lookup that ignored the purpose
|
|
would let one stand in for the other. And the in-memory implementation has
|
|
no database clock to consult, so it is told what time it is; the SQL
|
|
adapter takes the same argument and ignores it, because there the
|
|
comparison happens inside the query.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement get_live_verification_token().")
|
|
|
|
def mark_verification_token_consumed(self, token_id: UUID, *, now: datetime) -> bool:
|
|
"""Consume a token. Returns False if it was already consumed.
|
|
|
|
The compare-and-set is what makes the token single-use under
|
|
concurrency: two simultaneous requests cannot both observe success.
|
|
"""
|
|
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement mark_verification_token_consumed().")
|
|
|
|
def delete_expired_verification_tokens(self, *, now: datetime) -> int:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement delete_expired_verification_tokens().")
|
|
|
|
def append_audit_event(self, event: AuditEvent) -> AuditEvent:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement append_audit_event().")
|
|
|
|
def list_audit_events(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
target_user_id: UUID | None = None,
|
|
actor_user_id: UUID | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[AuditEvent], int]:
|
|
raise NotImplementedError(f"{type(self).__name__} does not implement list_audit_events().")
|
|
|
|
|
|
class InMemoryAccountRepository:
|
|
"""Thread-safe in-memory repository for development and tests.
|
|
|
|
Records are frozen dataclasses, so handing a stored instance to a caller
|
|
cannot mutate the store. Only hashes (never plaintext) are held here,
|
|
which the test suite asserts directly.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
self._users: dict[UUID, User] = {}
|
|
self._users_by_key: dict[str, UUID] = {}
|
|
self._organizations: dict[UUID, Organization] = {}
|
|
self._organizations_by_slug: dict[str, UUID] = {}
|
|
self._credentials: dict[UUID, CredentialRecord] = {}
|
|
self._sessions: dict[UUID, SessionRecord] = {}
|
|
self._sessions_by_token: dict[str, UUID] = {}
|
|
self._verification_tokens: dict[UUID, VerificationToken] = {}
|
|
self._audit: list[AuditEvent] = []
|
|
self._projects: dict[UUID, Project] = {}
|
|
# Project membership is a second assignment for an account, alongside the
|
|
# one the domain User carries. The control plane holds both as rows in
|
|
# user_roles; here it is a plain set of identifiers, which is the same fact
|
|
# with less ceremony.
|
|
self._project_members: dict[UUID, set[UUID]] = {}
|
|
|
|
# -- users ---------------------------------------------------------------
|
|
|
|
def create_user(self, user: User) -> User:
|
|
with self._lock:
|
|
if user.id in self._users:
|
|
raise ConflictError("account_exists", "An account with this identifier already exists.")
|
|
if user.username_key in self._users_by_key:
|
|
raise ConflictError("username_taken", "That username is already in use.")
|
|
self._users[user.id] = user
|
|
self._users_by_key[user.username_key] = user.id
|
|
return user
|
|
|
|
def save_user(self, user: User) -> User:
|
|
with self._lock:
|
|
existing = self._users.get(user.id)
|
|
if existing is None:
|
|
raise NotFoundError("account_not_found")
|
|
if existing.username_key != user.username_key:
|
|
if user.username_key in self._users_by_key:
|
|
raise ConflictError("username_taken", "That username is already in use.")
|
|
del self._users_by_key[existing.username_key]
|
|
self._users_by_key[user.username_key] = user.id
|
|
self._users[user.id] = user
|
|
return user
|
|
|
|
def get_user(self, user_id: UUID) -> User | None:
|
|
with self._lock:
|
|
return self._users.get(user_id)
|
|
|
|
def get_user_by_email(self, key: str) -> User | None:
|
|
with self._lock:
|
|
for user in self._users.values():
|
|
if email_key(user.email) == key.casefold():
|
|
return user
|
|
return None
|
|
|
|
def get_user_by_username(self, key: str) -> User | None:
|
|
with self._lock:
|
|
user_id = self._users_by_key.get(username_key(key)) if username_key(key) else None
|
|
return self._users.get(user_id) if user_id else None
|
|
|
|
def count_users(self, organization_id: UUID | None = None) -> int:
|
|
with self._lock:
|
|
if organization_id is None:
|
|
return len(self._users)
|
|
return sum(1 for user in self._users.values() if user.organization_id == organization_id)
|
|
|
|
def count_users_with_role(self, role: RoleName) -> int:
|
|
with self._lock:
|
|
return sum(1 for user in self._users.values() if user.role is role and user.status is AccountStatus.ACTIVE)
|
|
|
|
# -- projects ------------------------------------------------------------
|
|
|
|
def create_project(self, project: Project) -> Project:
|
|
with self._lock:
|
|
if project.id in self._projects:
|
|
raise ConflictError("project_exists", "A project with that identifier already exists.")
|
|
if any(
|
|
item.organization_id == project.organization_id and item.slug_key == project.slug_key
|
|
for item in self._projects.values()
|
|
):
|
|
raise ConflictError("project_slug_taken", "That project slug is already in use in this organization.")
|
|
self._projects[project.id] = project
|
|
return project
|
|
|
|
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
|
|
with self._lock:
|
|
project = self._projects.get(project_id)
|
|
if project is None:
|
|
return None
|
|
if organization_id is not None and project.organization_id != organization_id:
|
|
return None
|
|
return project
|
|
|
|
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
|
|
key = str(slug).casefold()
|
|
with self._lock:
|
|
for project in self._projects.values():
|
|
if project.organization_id == organization_id and project.slug_key == key:
|
|
return project
|
|
return None
|
|
|
|
def save_project(self, project: Project) -> Project:
|
|
with self._lock:
|
|
if project.id not in self._projects:
|
|
raise NotFoundError("project_not_found")
|
|
self._projects[project.id] = project
|
|
return project
|
|
|
|
def find_projects(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
statuses: Sequence[ProjectStatus] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Project], int]:
|
|
with self._lock:
|
|
selected = [
|
|
project
|
|
for project in self._projects.values()
|
|
if (organization_id is None or project.organization_id == organization_id)
|
|
and (statuses is None or project.status in statuses)
|
|
]
|
|
selected.sort(key=lambda project: (project.created_at, str(project.id)), reverse=True)
|
|
return selected[max(offset, 0) : max(offset, 0) + max(limit, 0)], len(selected)
|
|
|
|
def count_projects(self, *, organization_id: UUID | None = None) -> int:
|
|
with self._lock:
|
|
if organization_id is None:
|
|
return len(self._projects)
|
|
return sum(1 for project in self._projects.values() if project.organization_id == organization_id)
|
|
|
|
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
|
|
with self._lock:
|
|
return sorted(self._project_members.get(project_id, set()))
|
|
|
|
def assign_user_to_project(self, project_id: UUID, user_id: UUID, *, organization_id: UUID) -> bool:
|
|
# `organization_id` is accepted and not used: this store keeps the
|
|
# organization on the project row itself, so it has no schema to resolve
|
|
# and nothing to look up. It is here because the protocol declares it, and
|
|
# because an argument one implementation needs and another ignores is
|
|
# exactly the signature drift the protocol check exists to catch.
|
|
with self._lock:
|
|
members = self._project_members.setdefault(project_id, set())
|
|
if user_id in members:
|
|
return False
|
|
members.add(user_id)
|
|
return True
|
|
|
|
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
|
|
with self._lock:
|
|
members = self._project_members.get(project_id)
|
|
if not members or user_id not in members:
|
|
return False
|
|
members.discard(user_id)
|
|
return True
|
|
|
|
# -- organizations -------------------------------------------------------
|
|
|
|
def create_organization(self, organization: Organization) -> Organization:
|
|
with self._lock:
|
|
if organization.id in self._organizations:
|
|
raise ConflictError("organization_exists", "An organization with this identifier already exists.")
|
|
if organization.slug_key in self._organizations_by_slug:
|
|
raise ConflictError("organization_slug_taken", "That organization slug is already in use.")
|
|
self._organizations[organization.id] = organization
|
|
self._organizations_by_slug[organization.slug_key] = organization.id
|
|
return organization
|
|
|
|
def save_organization(self, organization: Organization) -> Organization:
|
|
with self._lock:
|
|
existing = self._organizations.get(organization.id)
|
|
if existing is None:
|
|
raise NotFoundError("organization_not_found")
|
|
if existing.slug_key != organization.slug_key:
|
|
if organization.slug_key in self._organizations_by_slug:
|
|
raise ConflictError("organization_slug_taken", "That organization slug is already in use.")
|
|
del self._organizations_by_slug[existing.slug_key]
|
|
self._organizations_by_slug[organization.slug_key] = organization.id
|
|
self._organizations[organization.id] = organization
|
|
return organization
|
|
|
|
def get_organization(self, organization_id: UUID) -> Organization | None:
|
|
with self._lock:
|
|
return self._organizations.get(organization_id)
|
|
|
|
def get_organization_by_slug(self, slug: str) -> Organization | None:
|
|
with self._lock:
|
|
organization_id = self._organizations_by_slug.get(slug.casefold())
|
|
return self._organizations.get(organization_id) if organization_id else None
|
|
|
|
def find_organizations(
|
|
self,
|
|
*,
|
|
status: OrganizationStatus | None = None,
|
|
organization_ids: tuple[UUID, ...] | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[Organization], int]:
|
|
with self._lock:
|
|
matches = list(self._organizations.values())
|
|
if organization_ids is not None:
|
|
allowed = set(organization_ids)
|
|
matches = [organization for organization in matches if organization.id in allowed]
|
|
if status is not None:
|
|
matches = [organization for organization in matches if organization.status is status]
|
|
matches.sort(
|
|
key=lambda organization: (organization.created_at or organization.updated_at, str(organization.id)),
|
|
reverse=True,
|
|
)
|
|
total = len(matches)
|
|
return matches[offset : offset + max(limit, 0)], total
|
|
|
|
def find_users(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
status: AccountStatus | None = None,
|
|
role: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[User], int]:
|
|
with self._lock:
|
|
matches = list(self._users.values())
|
|
if organization_id is not None:
|
|
matches = [user for user in matches if user.organization_id == organization_id]
|
|
if status is not None:
|
|
matches = [user for user in matches if user.status == status]
|
|
if role is not None:
|
|
matches = [user for user in matches if str(user.role) == role]
|
|
matches.sort(key=lambda user: (user.created_at or user.updated_at, str(user.id)), reverse=True)
|
|
total = len(matches)
|
|
return matches[offset : offset + max(limit, 0)], total
|
|
|
|
# -- credentials ---------------------------------------------------------
|
|
|
|
def get_credential(self, user_id: UUID) -> CredentialRecord | None:
|
|
with self._lock:
|
|
for credential in self._credentials.values():
|
|
if credential.user_id == user_id:
|
|
return credential
|
|
return None
|
|
|
|
def save_credential(self, credential: CredentialRecord) -> CredentialRecord:
|
|
with self._lock:
|
|
self._credentials[credential.id] = credential
|
|
return credential
|
|
|
|
# -- sessions ------------------------------------------------------------
|
|
|
|
def create_session(self, session: SessionRecord) -> SessionRecord:
|
|
with self._lock:
|
|
self._sessions[session.id] = session
|
|
self._sessions_by_token[session.token_hash] = session.id
|
|
return session
|
|
|
|
def get_session(self, session_id: UUID) -> SessionRecord | None:
|
|
with self._lock:
|
|
return self._sessions.get(session_id)
|
|
|
|
def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None:
|
|
with self._lock:
|
|
session_id = self._sessions_by_token.get(token_hash)
|
|
return self._sessions.get(session_id) if session_id else None
|
|
|
|
def save_session(self, session: SessionRecord) -> SessionRecord:
|
|
with self._lock:
|
|
if session.id not in self._sessions:
|
|
raise NotFoundError("session_not_found")
|
|
self._sessions[session.id] = session
|
|
return session
|
|
|
|
def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]:
|
|
with self._lock:
|
|
sessions = [session for session in self._sessions.values() if session.user_id == user_id]
|
|
if include_revoked:
|
|
return sessions
|
|
return [session for session in sessions if not session.is_revoked()]
|
|
|
|
def count_sessions(self, user_id: UUID) -> int:
|
|
return len(self.list_sessions(user_id))
|
|
|
|
def revoke_sessions(
|
|
self,
|
|
user_id: UUID,
|
|
*,
|
|
reason: str,
|
|
now: datetime,
|
|
exclude_session_id: UUID | None = None,
|
|
) -> int:
|
|
with self._lock:
|
|
revoked = 0
|
|
for session in self._sessions.values():
|
|
if session.user_id != user_id or session.is_revoked():
|
|
continue
|
|
if exclude_session_id is not None and session.id == exclude_session_id:
|
|
continue
|
|
self._sessions[session.id] = replace(session, revoked_at=now, revoked_reason=reason)
|
|
revoked += 1
|
|
return revoked
|
|
|
|
# -- audit ---------------------------------------------------------------
|
|
|
|
# -- email verification --------------------------------------------------
|
|
|
|
def create_verification_token(self, token: VerificationToken) -> VerificationToken:
|
|
with self._lock:
|
|
self._verification_tokens[token.id] = token
|
|
return token
|
|
|
|
def get_live_verification_token(
|
|
self,
|
|
token_hash: str,
|
|
*,
|
|
purpose: str = VERIFICATION_PURPOSE_EMAIL,
|
|
now: datetime | None = None,
|
|
) -> VerificationToken | None:
|
|
"""A live, unexpired, unconsumed token for this hash *and purpose*.
|
|
|
|
The purpose is part of the lookup, not a filter applied afterwards. A
|
|
reset token and a verification token are both a bare hash against the
|
|
same user, so a lookup that ignored purpose would let one stand in for the
|
|
other: clicking a verification link would set a password, and clicking a
|
|
reset link would confirm an address.
|
|
|
|
Expiry is part of the lookup too, and takes a clock so the caller can
|
|
supply the one it is already using. This was missing, and the two
|
|
repositories disagreed about it: the SQL adapter filters
|
|
``expires_at > now()`` in the query, so PostgreSQL refused an expired
|
|
link while this one honoured it. Every test that runs against the
|
|
in-memory store was therefore asserting a rule the authoritative store
|
|
does not follow, which is the worst direction for that kind of
|
|
divergence to go in.
|
|
"""
|
|
|
|
moment = now or utc_now()
|
|
with self._lock:
|
|
for token in self._verification_tokens.values():
|
|
if (
|
|
token.token_hash == token_hash
|
|
and token.purpose == purpose
|
|
and not token.is_consumed()
|
|
and token.expires_at > moment
|
|
):
|
|
return token
|
|
return None
|
|
|
|
def mark_verification_token_consumed(self, token_id: UUID, *, now: datetime) -> bool:
|
|
with self._lock:
|
|
token = self._verification_tokens.get(token_id)
|
|
if token is None or token.is_consumed():
|
|
return False
|
|
self._verification_tokens[token_id] = replace(token, consumed_at=now)
|
|
return True
|
|
|
|
def delete_expired_verification_tokens(self, *, now: datetime) -> int:
|
|
with self._lock:
|
|
stale = [token_id for token_id, token in self._verification_tokens.items() if token.is_expired(now)]
|
|
for token_id in stale:
|
|
del self._verification_tokens[token_id]
|
|
return len(stale)
|
|
|
|
def append_audit_event(self, event: AuditEvent) -> AuditEvent:
|
|
with self._lock:
|
|
self._audit.append(event)
|
|
return event
|
|
|
|
def list_audit_events(
|
|
self,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
target_user_id: UUID | None = None,
|
|
actor_user_id: UUID | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[AuditEvent], int]:
|
|
with self._lock:
|
|
events = list(self._audit)
|
|
if organization_id is not None:
|
|
events = [event for event in events if event.organization_id == organization_id]
|
|
if target_user_id is not None:
|
|
events = [event for event in events if event.target_user_id == target_user_id]
|
|
if actor_user_id is not None:
|
|
events = [event for event in events if event.actor_user_id == actor_user_id]
|
|
events.sort(key=lambda event: (event.occurred_at, str(event.id)), reverse=True)
|
|
total = len(events)
|
|
return events[offset : offset + max(limit, 0)], total
|
|
|
|
|
|
def build_repository(settings: object, *, repository: AccountRepository | None = None) -> AccountRepository:
|
|
"""Return the requested repository implementation.
|
|
|
|
Kept in one place so the app factory never guesses a store. The PostgreSQL
|
|
adapter is imported lazily so a deployment that never selects it does not
|
|
pay for ``psycopg_pool``.
|
|
"""
|
|
|
|
if repository is not None:
|
|
return repository
|
|
kind = getattr(settings, "repository_kind", "memory")
|
|
if kind == "memory":
|
|
return InMemoryAccountRepository()
|
|
if kind == "json":
|
|
from .dev_repository import JsonDevelopmentRepository
|
|
|
|
return JsonDevelopmentRepository(settings) # type: ignore[arg-type]
|
|
if kind == "postgres":
|
|
from .postgres_repository import PostgresAccountRepository
|
|
|
|
dsn = getattr(settings, "database_dsn", None)
|
|
if not dsn:
|
|
# Refuse to start rather than fall back to a development store, which
|
|
# would let a production deployment appear to work while holding no
|
|
# real data.
|
|
raise RepositoryError(
|
|
"database_dsn_missing",
|
|
"The PostgreSQL repository requires a connection string.",
|
|
)
|
|
repository = PostgresAccountRepository.from_dsn(
|
|
dsn,
|
|
min_size=int(getattr(settings, "database_pool_min", 1)),
|
|
max_size=int(getattr(settings, "database_pool_max", 10)),
|
|
)
|
|
# Checked here, at startup, so a mismatch between the setting this adapter
|
|
# publishes and the one the policy helpers read is a refusal to start
|
|
# rather than a running service in which every query quietly returns zero
|
|
# rows. The failure is silent by nature: policies evaluate false, the
|
|
# database looks empty, and nothing raises.
|
|
repository.verify_rls_contract()
|
|
# The same argument for the tenant schema name, which exists in two
|
|
# languages and has the same silent-and-total failure if they disagree:
|
|
# every tenant query resolves in a schema that does not exist, and the
|
|
# error names the statement rather than the cause.
|
|
repository.verify_tenant_schema_contract()
|
|
return repository
|
|
raise RepositoryError("unknown_repository", "Unknown repository kind.")
|
|
|
|
|
|
__all__ = ["AccountRepository", "InMemoryAccountRepository", "build_repository"]
|