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

443 lines
18 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Local JSON development repository.
This store exists so the first slice can be exercised end to end without
PostgreSQL. It is deliberately unsuitable for anything else:
* it refuses to run in production mode;
* the file is created with mode ``0600`` inside a ``0700`` directory, and both
are verified after every write;
* writes are atomic (temporary file in the same directory, ``fsync``,
``os.replace``, directory ``fsync``) and a symlinked target is refused;
* it persists Argon2id hashes and session-token hashes only, exactly like the
PostgreSQL adapter would. No plaintext password is ever passed to it.
The file is development data, ignored by Git, and is not a backup of anything.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
import threading
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import UUID
from .config import Settings
from .errors import ConfigurationError, RepositoryError
from .models import (
AccountStatus,
AuditEvent,
CredentialRecord,
CredentialType,
Organization,
OrganizationStatus,
RoleName,
SessionRecord,
User,
VerificationToken,
)
from .repository import InMemoryAccountRepository
#: Payload version. Organizations were added after the first files were
#: written, and a missing ``organizations`` key loads as an empty list, so an
#: older development file still opens. The version is bumped only when an
#: existing file would load into a wrong state.
SCHEMA_VERSION = 1
DIRECTORY_MODE = 0o700
FILE_MODE = 0o600
MAX_FILE_BYTES = 8 * 1024 * 1024
def _utc(value: datetime) -> str:
return value.isoformat()
def _parse_datetime(value: str) -> datetime:
return datetime.fromisoformat(value)
def _uuid(value: str | None) -> UUID | None:
return UUID(value) if value else None
class JsonDevelopmentRepository(InMemoryAccountRepository):
"""In-memory repository with a restrictive, atomic JSON file on disk."""
def __init__(self, settings: Settings) -> None:
if settings.is_production:
raise ConfigurationError(
"development_repository_in_production",
"The JSON development repository must never be used in production.",
)
if settings.repository_path is None:
raise ConfigurationError("repository_path_required", "A development repository path is required.")
super().__init__()
self._settings = settings
self._path = settings.repository_path
self._write_lock = threading.RLock()
self._load()
# -- properties ----------------------------------------------------------
@property
def path(self) -> Path:
return self._path
@property
def settings(self) -> Settings:
return self._settings
# -- persistence ---------------------------------------------------------
def _ensure_directory(self) -> None:
parent = self._path.parent
if parent.is_symlink():
raise RepositoryError("symlink_refused", "The repository directory must not be a symlink.")
parent.mkdir(mode=DIRECTORY_MODE, parents=True, exist_ok=True)
current = stat.S_IMODE(parent.stat().st_mode)
if current & 0o077:
parent.chmod(DIRECTORY_MODE)
def _load(self) -> None:
if not self._path.exists():
return
if self._path.is_symlink():
raise RepositoryError("symlink_refused", "The repository file must not be a symlink.")
size = self._path.stat().st_size
if size > MAX_FILE_BYTES:
raise RepositoryError("repository_too_large", "The development repository file is unexpectedly large.")
raw = self._path.read_text(encoding="utf-8")
if not raw.strip():
return
try:
payload = json.loads(raw)
except json.JSONDecodeError as error:
raise RepositoryError("repository_corrupt", "The development repository file is not valid JSON.") from error
version = payload.get("schema_version")
if version != SCHEMA_VERSION:
raise RepositoryError(
"repository_schema_mismatch",
f"Unsupported development repository schema version: {version!r}.",
)
with self._lock:
for item in payload.get("organizations", []):
organization = _organization_from_payload(item)
self._organizations[organization.id] = organization
self._organizations_by_slug[organization.slug_key] = organization.id
for item in payload.get("users", []):
self._users[UUID(item["id"])] = _user_from_payload(item)
self._users_by_key[item["username_key"]] = UUID(item["id"])
for item in payload.get("credentials", []):
credential = _credential_from_payload(item)
self._credentials[credential.id] = credential
for item in payload.get("sessions", []):
session = _session_from_payload(item)
self._sessions[session.id] = session
self._sessions_by_token[session.token_hash] = session.id
for item in payload.get("verification_tokens", []):
token = _verification_from_payload(item)
self._verification_tokens[token.id] = token
for item in payload.get("audit_events", []):
self._audit.append(_audit_from_payload(item))
def _payload(self) -> dict[str, Any]:
with self._lock:
return {
"schema_version": SCHEMA_VERSION,
"environment": str(self._settings.environment),
"organizations": [
_organization_to_payload(organization) for organization in self._organizations.values()
],
"users": [_user_to_payload(user) for user in self._users.values()],
"credentials": [_credential_to_payload(item) for item in self._credentials.values()],
"sessions": [_session_to_payload(item) for item in self._sessions.values()],
"verification_tokens": [
_verification_to_payload(token) for token in self._verification_tokens.values()
],
"audit_events": [_audit_to_payload(item) for item in self._audit],
}
def flush(self) -> None:
"""Write the current state atomically with restrictive permissions."""
with self._write_lock:
self._ensure_directory()
payload = json.dumps(self._payload(), indent=2, sort_keys=True)
handle, temporary_name = tempfile.mkstemp(dir=str(self._path.parent), prefix=".dev-repo-", suffix=".tmp")
temporary = Path(temporary_name)
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(payload)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, FILE_MODE)
os.replace(temporary, self._path)
except OSError as error:
temporary.unlink(missing_ok=True)
raise RepositoryError(
"repository_write_failed", "The development repository could not be written."
) from error
directory_fd = os.open(str(self._path.parent), os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
self._verify_permissions()
def _verify_permissions(self) -> None:
file_mode = stat.S_IMODE(self._path.stat().st_mode)
if file_mode & 0o077:
self._path.chmod(FILE_MODE)
file_mode = stat.S_IMODE(self._path.stat().st_mode)
if file_mode & 0o077:
raise RepositoryError("insecure_permissions", "The development repository file is not private.")
# -- write-through hooks -------------------------------------------------
def create_user(self, user: User) -> User:
result = super().create_user(user)
self.flush()
return result
def create_organization(self, organization: Organization) -> Organization:
result = super().create_organization(organization)
self.flush()
return result
def save_organization(self, organization: Organization) -> Organization:
result = super().save_organization(organization)
self.flush()
return result
def save_user(self, user: User) -> User:
result = super().save_user(user)
self.flush()
return result
def save_credential(self, credential: CredentialRecord) -> CredentialRecord:
result = super().save_credential(credential)
self.flush()
return result
def create_session(self, session: SessionRecord) -> SessionRecord:
result = super().create_session(session)
self.flush()
return result
def save_session(self, session: SessionRecord) -> SessionRecord:
result = super().save_session(session)
self.flush()
return result
def create_verification_token(self, token: VerificationToken) -> VerificationToken:
result = super().create_verification_token(token)
self.flush()
return result
def mark_verification_token_consumed(self, token_id: UUID, *, now: datetime) -> bool:
result = super().mark_verification_token_consumed(token_id, now=now)
if result:
self.flush()
return result
def delete_expired_verification_tokens(self, *, now: datetime) -> int:
result = super().delete_expired_verification_tokens(now=now)
if result:
self.flush()
return result
def append_audit_event(self, event: AuditEvent) -> AuditEvent:
result = super().append_audit_event(event)
self.flush()
return result
def _organization_to_payload(organization: Organization) -> dict[str, Any]:
return {
"id": str(organization.id),
"name": organization.name,
"slug": organization.slug,
"status": str(organization.status),
"created_at": _utc(organization.created_at) if organization.created_at else None,
"updated_at": _utc(organization.updated_at) if organization.updated_at else None,
"version": organization.version,
}
def _organization_from_payload(payload: Mapping[str, Any]) -> Organization:
return Organization(
id=UUID(payload["id"]),
name=payload["name"],
slug=payload["slug"],
status=OrganizationStatus(payload["status"]),
created_at=_parse_datetime(payload["created_at"]) if payload.get("created_at") else None,
updated_at=_parse_datetime(payload["updated_at"]) if payload.get("updated_at") else None,
version=int(payload.get("version", 1)),
)
def _user_to_payload(user: User) -> dict[str, Any]:
return {
"id": str(user.id),
"username": user.username,
"username_key": user.username_key,
"display_name": user.display_name,
"role": str(user.role),
"status": str(user.status),
"organization_id": str(user.organization_id) if user.organization_id else None,
"email": user.email,
"created_at": _utc(user.created_at) if user.created_at else None,
"updated_at": _utc(user.updated_at) if user.updated_at else None,
"version": user.version,
}
def _user_from_payload(payload: Mapping[str, Any]) -> User:
return User(
id=UUID(payload["id"]),
username=payload["username"],
username_key=payload["username_key"],
display_name=payload["display_name"],
role=RoleName(payload["role"]),
status=AccountStatus(payload["status"]),
organization_id=_uuid(payload.get("organization_id")),
email=payload.get("email"),
created_at=_parse_datetime(payload["created_at"]) if payload.get("created_at") else None,
updated_at=_parse_datetime(payload["updated_at"]) if payload.get("updated_at") else None,
version=int(payload.get("version", 1)),
)
def _credential_to_payload(credential: CredentialRecord) -> dict[str, Any]:
"""Serialize a credential. Only the Argon2id hash is written, never plaintext."""
return {
"id": str(credential.id),
"user_id": str(credential.user_id),
"credential_type": str(credential.credential_type),
"password_hash": credential.password_hash,
"must_change_password": credential.must_change_password,
"failed_login_count": credential.failed_login_count,
"locked_until": _utc(credential.locked_until) if credential.locked_until else None,
"last_login_at": _utc(credential.last_login_at) if credential.last_login_at else None,
"password_changed_at": _utc(credential.password_changed_at) if credential.password_changed_at else None,
"created_at": _utc(credential.created_at) if credential.created_at else None,
"updated_at": _utc(credential.updated_at) if credential.updated_at else None,
}
def _credential_from_payload(payload: Mapping[str, Any]) -> CredentialRecord:
return CredentialRecord(
id=UUID(payload["id"]),
user_id=UUID(payload["user_id"]),
credential_type=CredentialType(payload["credential_type"]),
password_hash=payload.get("password_hash"),
must_change_password=bool(payload.get("must_change_password", False)),
failed_login_count=int(payload.get("failed_login_count", 0)),
locked_until=_parse_datetime(payload["locked_until"]) if payload.get("locked_until") else None,
last_login_at=_parse_datetime(payload["last_login_at"]) if payload.get("last_login_at") else None,
password_changed_at=(
_parse_datetime(payload["password_changed_at"]) if payload.get("password_changed_at") else None
),
created_at=_parse_datetime(payload["created_at"]) if payload.get("created_at") else None,
updated_at=_parse_datetime(payload["updated_at"]) if payload.get("updated_at") else None,
)
def _session_to_payload(session: SessionRecord) -> dict[str, Any]:
"""Serialize a session. Only the token hash is written, never the token."""
return {
"id": str(session.id),
"user_id": str(session.user_id),
"token_hash": session.token_hash,
"created_at": _utc(session.created_at),
"last_seen_at": _utc(session.last_seen_at),
"idle_deadline_at": _utc(session.idle_deadline_at),
"absolute_deadline_at": _utc(session.absolute_deadline_at),
"revoked_at": _utc(session.revoked_at) if session.revoked_at else None,
"revoked_reason": session.revoked_reason,
"mfa_verified_at": _utc(session.mfa_verified_at) if session.mfa_verified_at else None,
}
def _session_from_payload(payload: Mapping[str, Any]) -> SessionRecord:
return SessionRecord(
id=UUID(payload["id"]),
user_id=UUID(payload["user_id"]),
token_hash=payload["token_hash"],
created_at=_parse_datetime(payload["created_at"]),
last_seen_at=_parse_datetime(payload["last_seen_at"]),
idle_deadline_at=_parse_datetime(payload["idle_deadline_at"]),
absolute_deadline_at=_parse_datetime(payload["absolute_deadline_at"]),
revoked_at=_parse_datetime(payload["revoked_at"]) if payload.get("revoked_at") else None,
revoked_reason=payload.get("revoked_reason"),
mfa_verified_at=_parse_datetime(payload["mfa_verified_at"]) if payload.get("mfa_verified_at") else None,
)
def _verification_to_payload(token: VerificationToken) -> dict[str, Any]:
"""Serialize a token. Only the hash is written, never the plaintext."""
return {
"id": str(token.id),
"user_id": str(token.user_id),
"token_hash": token.token_hash,
"expires_at": _utc(token.expires_at),
"created_at": _utc(token.created_at),
"consumed_at": _utc(token.consumed_at) if token.consumed_at else None,
"purpose": token.purpose,
}
def _verification_from_payload(payload: Mapping[str, Any]) -> VerificationToken:
return VerificationToken(
id=UUID(payload["id"]),
user_id=UUID(payload["user_id"]),
token_hash=payload["token_hash"],
expires_at=_parse_datetime(payload["expires_at"]),
created_at=_parse_datetime(payload["created_at"]),
consumed_at=_parse_datetime(payload["consumed_at"]) if payload.get("consumed_at") else None,
purpose=payload.get("purpose", "email_verification"),
)
def _audit_to_payload(event: AuditEvent) -> dict[str, Any]:
return {
"id": str(event.id),
"occurred_at": _utc(event.occurred_at),
"action": event.action,
"outcome": event.outcome,
"actor_user_id": str(event.actor_user_id) if event.actor_user_id else None,
"target_user_id": str(event.target_user_id) if event.target_user_id else None,
"organization_id": str(event.organization_id) if event.organization_id else None,
"request_id": event.request_id,
"detail": dict(event.detail),
}
def _audit_from_payload(payload: Mapping[str, Any]) -> AuditEvent:
return AuditEvent(
id=UUID(payload["id"]),
occurred_at=_parse_datetime(payload["occurred_at"]),
action=payload["action"],
outcome=payload["outcome"],
actor_user_id=_uuid(payload.get("actor_user_id")),
target_user_id=_uuid(payload.get("target_user_id")),
organization_id=_uuid(payload.get("organization_id")),
request_id=payload.get("request_id"),
detail=dict(payload.get("detail", {})),
)
__all__ = ["FILE_MODE", "SCHEMA_VERSION", "JsonDevelopmentRepository"]