Files
UmbraTrans/umbratrans_web/repository.py
2026-10-18 19:01:05 -07:00

750 lines
32 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""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"]