Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
290 lines
11 KiB
Python
290 lines
11 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,
|
|
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 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._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 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 intentionally absent from this slice.
|
|
"""
|
|
|
|
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":
|
|
raise RepositoryError(
|
|
"postgres_adapter_unavailable",
|
|
"The PostgreSQL adapter is not part of this development slice.",
|
|
)
|
|
raise RepositoryError("unknown_repository", "Unknown repository kind.")
|
|
|
|
|
|
__all__ = ["AccountRepository", "InMemoryAccountRepository", "build_repository"]
|