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.
343 lines
14 KiB
Python
343 lines
14 KiB
Python
"""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,
|
|
RoleName,
|
|
SessionRecord,
|
|
User,
|
|
)
|
|
from .repository import InMemoryAccountRepository
|
|
|
|
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("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("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),
|
|
"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()],
|
|
"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 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 append_audit_event(self, event: AuditEvent) -> AuditEvent:
|
|
result = super().append_audit_event(event)
|
|
self.flush()
|
|
return result
|
|
|
|
|
|
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 _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"]
|