750 lines
32 KiB
Python
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"]
|