443 lines
18 KiB
Python
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"]
|