Files
UmbraTrans/umbratrans_web/repository.py
umbra audio 31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned through user_roles with a
scope, and app.validate_user_role_scope() enforces that the role's scope
matches the assignment. Resolving one role per user is therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

Every operation runs in one transaction on one pooled connection and publishes
the caller with SET LOCAL app.current_user_id, so a returned connection cannot
carry one request's identity into the next. That is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

Production fails closed at two independent gates: the settings layer refuses
without a DSN, and the repository boundary refuses to build a PostgreSQL store
without one. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00

390 lines
15 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, runtime_checkable
from uuid import UUID
from .errors import ConflictError, NotFoundError, RepositoryError
from .models import (
AccountStatus,
AuditEvent,
CredentialRecord,
Organization,
OrganizationStatus,
SessionRecord,
User,
username_key,
)
@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."""
def save_user(self, user: User) -> User:
"""Persist a modified account, or raise :class:`NotFoundError`."""
def get_user(self, user_id: UUID) -> User | None: ...
def get_user_by_username(self, key: str) -> User | None:
"""Look up an account by its case-insensitive username key."""
def count_users(self, organization_id: UUID | None = None) -> int:
"""Count accounts, optionally within one organization."""
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."""
def get_organization(self, organization_id: UUID) -> Organization | None: ...
def create_organization(self, organization: Organization) -> Organization:
"""Insert a new organization, or raise :class:`ConflictError` on a duplicate slug."""
def save_organization(self, organization: Organization) -> Organization:
"""Persist a modified organization, or raise :class:`NotFoundError`."""
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."""
def get_credential(self, user_id: UUID) -> CredentialRecord | None: ...
def save_credential(self, credential: CredentialRecord) -> CredentialRecord: ...
def create_session(self, session: SessionRecord) -> SessionRecord: ...
def get_session(self, session_id: UUID) -> SessionRecord | None: ...
def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None: ...
def save_session(self, session: SessionRecord) -> SessionRecord: ...
def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]: ...
def count_sessions(self, user_id: UUID) -> int:
"""Count currently valid (not revoked, not expired) 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."""
def append_audit_event(self, event: AuditEvent) -> AuditEvent: ...
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]: ...
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._audit: list[AuditEvent] = []
# -- 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_username(self, key: str) -> User | None:
with self._lock:
user_id = self._users_by_key.get(username_key(key))
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)
# -- 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 ---------------------------------------------------------------
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.",
)
return PostgresAccountRepository.from_dsn(
dsn,
min_size=int(getattr(settings, "database_pool_min", 1)),
max_size=int(getattr(settings, "database_pool_max", 10)),
)
raise RepositoryError("unknown_repository", "Unknown repository kind.")
__all__ = ["AccountRepository", "InMemoryAccountRepository", "build_repository"]