Files
UmbraTrans/umbratrans_web/models.py
umbra audio b474995414 Add phase-1 account management backend slice
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.
2026-09-26 08:29:23 -07:00

378 lines
12 KiB
Python

"""Domain models for the UmbraTrans phase-1 identity slice.
The records here mirror the reviewed but deliberately **unapplied** migration in
``umbratrans_web/migrations/0001_phase1_identity.sql``. Nothing in this module
talks to PostgreSQL; the in-memory and local JSON development repositories
implement the same shapes.
Secret handling rules enforced by this module:
* A credential stores an Argon2id hash or nothing at all. A ``local_password``
credential must carry a hash; an ``oidc`` credential must not.
* ``password_hash`` and ``token_hash`` are ``repr=False`` so that logging an
object, an exception payload, or a dataclass dump cannot disclose them.
* :class:`AccountView` is the only account representation that may reach the
API. It carries a :class:`CredentialSummary` and never a hash.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import StrEnum
from types import MappingProxyType
from typing import Any, Mapping
from uuid import UUID
from .errors import InputError
MAX_USERNAME_LENGTH = 64
MIN_USERNAME_LENGTH = 3
MAX_DISPLAY_NAME_LENGTH = 120
MAX_EMAIL_LENGTH = 254
MAX_REASON_LENGTH = 200
_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s.]+(?:\.[^@\s.]+)+$")
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
#: Detail keys an audit event may carry. Anything else is dropped, so a future
#: caller cannot accidentally record a password or a token by adding a key.
ALLOWED_AUDIT_DETAIL_KEYS = frozenset(
{
"credential_type",
"enrollment_mode",
"lock_state",
"must_change_password",
"organization_id",
"previous_status",
"reason",
"revoked_sessions",
"role",
"source",
"status",
"username",
}
)
#: Roles that may call the ``/api/uadmin`` surface.
ADMIN_ROLES = frozenset({"platform_admin", "organization_admin"})
class CredentialType(StrEnum):
"""Authentication mechanism bound to a canonical user record."""
LOCAL_PASSWORD = "local_password"
OIDC = "oidc"
class EnrollmentMode(StrEnum):
"""How an account obtains its first credential.
``TEMPORARY_PASSWORD`` yields a credential that must be replaced on first
login. It is reached either through the operator CLI, which supplies the
password from a hidden prompt, or through
:func:`umbratrans_web.service.AccountService.provision_admin_temporary_password`,
which generates it in process. ``OIDC`` stores no local password. See
:func:`umbratrans_web.service.AccountService.create_account`.
"""
TEMPORARY_PASSWORD = "temporary_password"
OIDC = "oidc"
class AccountStatus(StrEnum):
ACTIVE = "active"
SUSPENDED = "suspended"
DEACTIVATED = "deactivated"
class RoleName(StrEnum):
PLATFORM_ADMIN = "platform_admin"
ORGANIZATION_ADMIN = "organization_admin"
MEMBER = "member"
#: Allowed account status transitions. ``deactivated`` may only be reversed by
#: a platform administrator, which the service checks separately.
ALLOWED_STATUS_TRANSITIONS: Mapping[AccountStatus, frozenset[AccountStatus]] = MappingProxyType(
{
AccountStatus.ACTIVE: frozenset({AccountStatus.SUSPENDED, AccountStatus.DEACTIVATED}),
AccountStatus.SUSPENDED: frozenset({AccountStatus.ACTIVE, AccountStatus.DEACTIVATED}),
AccountStatus.DEACTIVATED: frozenset({AccountStatus.ACTIVE}),
}
)
def normalize_username(value: str) -> str:
"""Return the canonical stored form of a username.
The value is trimmed but otherwise preserved; case-insensitive comparison
is handled separately by :func:`username_key` so that stored identifiers
keep the operator's spelling (matching the ``citext`` behaviour of the
reviewed migration).
"""
text = value.strip()
if not _USERNAME_RE.fullmatch(text):
raise InputError(
"invalid_username",
"Username must be 3-64 characters using letters, digits, dot, underscore or hyphen.",
)
return text
def username_key(value: str) -> str:
"""Return the case-insensitive lookup key for a username."""
return normalize_username(value).casefold()
def normalize_display_name(value: str) -> str:
text = " ".join(value.split())
if not text or len(text) > MAX_DISPLAY_NAME_LENGTH or _CONTROL_CHARS_RE.search(text):
raise InputError("invalid_display_name", "Display name is missing or too long.")
return text
def normalize_email(value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
if not text:
return None
if len(text) > MAX_EMAIL_LENGTH or not _EMAIL_RE.fullmatch(text):
raise InputError("invalid_email", "Email address is not valid.")
return text.lower()
def normalize_reason(value: str | None) -> str | None:
if value is None:
return None
text = " ".join(value.split())
if not text:
return None
if len(text) > MAX_REASON_LENGTH or _CONTROL_CHARS_RE.search(text):
raise InputError("invalid_reason", "Reason is missing or too long.")
return text
def utc_isoformat(value: datetime) -> str:
"""Serialize an aware datetime as UTC with a ``Z`` suffix."""
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
@dataclass(frozen=True, slots=True)
class CredentialRecord:
"""A single authentication credential for one canonical user.
Exactly one credential exists per user in phase 1 (the migration enforces
``user_id`` uniqueness). Keeping it separate from the profile is what
allows OIDC and local passwords to coexist in one ``users`` table.
"""
id: UUID
user_id: UUID
credential_type: CredentialType
password_hash: str | None = field(default=None, repr=False)
must_change_password: bool = False
failed_login_count: int = 0
locked_until: datetime | None = None
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "credential_type", CredentialType(self.credential_type))
if self.credential_type is CredentialType.LOCAL_PASSWORD and not self.password_hash:
raise InputError("credential_hash_required", "A local password credential requires a hash.")
if self.credential_type is CredentialType.OIDC and self.password_hash is not None:
raise InputError("credential_hash_forbidden", "An OIDC credential must not store a password hash.")
if self.failed_login_count < 0:
raise InputError("invalid_failed_login_count", "Failed login count cannot be negative.")
def is_locked(self, now: datetime) -> bool:
return self.locked_until is not None and self.locked_until > now
def public_summary(self, *, now: datetime) -> CredentialSummary:
return CredentialSummary(
credential_type=self.credential_type,
must_change_password=self.must_change_password,
failed_login_count=self.failed_login_count,
locked=self.is_locked(now),
last_login_at=self.last_login_at,
password_changed_at=self.password_changed_at,
)
@dataclass(frozen=True, slots=True)
class User:
"""A canonical account. Contains no password material of any kind."""
id: UUID
username: str
username_key: str
display_name: str
role: RoleName
status: AccountStatus
organization_id: UUID | None = None
email: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
version: int = 1
def __post_init__(self) -> None:
object.__setattr__(self, "role", RoleName(self.role))
object.__setattr__(self, "status", AccountStatus(self.status))
object.__setattr__(self, "username_key", username_key(self.username))
@property
def is_admin(self) -> bool:
return self.role.value in ADMIN_ROLES
@dataclass(frozen=True, slots=True)
class SessionRecord:
"""A server-side session. Only the hash of the bearer token is stored."""
id: UUID
user_id: UUID
token_hash: str = field(repr=False)
created_at: datetime
last_seen_at: datetime
idle_deadline_at: datetime
absolute_deadline_at: datetime
revoked_at: datetime | None = None
revoked_reason: str | None = None
mfa_verified_at: datetime | None = None
def is_revoked(self) -> bool:
return self.revoked_at is not None
def is_expired(self, now: datetime) -> bool:
return now >= self.idle_deadline_at or now >= self.absolute_deadline_at
@dataclass(frozen=True, slots=True)
class AuditEvent:
"""A sanitized record of a material account or session change."""
id: UUID
occurred_at: datetime
action: str
outcome: str
actor_user_id: UUID | None = None
target_user_id: UUID | None = None
organization_id: UUID | None = None
request_id: str | None = None
detail: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
def to_dict(self) -> dict[str, Any]:
return {
"id": str(self.id),
"occurred_at": self.occurred_at.isoformat(),
"action": self.action,
"outcome": self.outcome,
"actor_user_id": str(self.actor_user_id) if self.actor_user_id else None,
"target_user_id": str(self.target_user_id) if self.target_user_id else None,
"organization_id": str(self.organization_id) if self.organization_id else None,
"request_id": self.request_id,
"detail": dict(self.detail),
}
@dataclass(frozen=True, slots=True)
class CredentialSummary:
"""Sanitized credential attributes that are safe to return over the API."""
credential_type: CredentialType
must_change_password: bool
failed_login_count: int
locked: bool
last_login_at: datetime | None = None
password_changed_at: datetime | None = None
@dataclass(frozen=True, slots=True)
class AccountView:
"""The only account representation that may cross the API boundary."""
id: UUID
username: str
display_name: str
role: RoleName
status: AccountStatus
credential: CredentialSummary
organization_id: UUID | None = None
email: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
active_session_count: int = 0
@dataclass(frozen=True, slots=True)
class Principal:
"""A resolved caller identity. Never built from browser-supplied values."""
user_id: UUID
username: str
role: RoleName
session_id: UUID
organization_id: UUID | None = None
must_change_password: bool = False
@property
def is_admin(self) -> bool:
return self.role.value in ADMIN_ROLES
@dataclass(frozen=True, slots=True)
class IssuedSession:
"""A freshly created session. The raw token exists only in this object."""
session_id: UUID
token: str = field(repr=False)
idle_expires_at: datetime
absolute_expires_at: datetime
must_change_password: bool = False
@dataclass(frozen=True, slots=True)
class AuthenticationResult:
user: User
session: IssuedSession
__all__ = [
"ADMIN_ROLES",
"ALLOWED_AUDIT_DETAIL_KEYS",
"ALLOWED_STATUS_TRANSITIONS",
"MAX_DISPLAY_NAME_LENGTH",
"MAX_EMAIL_LENGTH",
"MAX_REASON_LENGTH",
"MAX_USERNAME_LENGTH",
"MIN_USERNAME_LENGTH",
"AccountStatus",
"AccountView",
"AuditEvent",
"AuthenticationResult",
"CredentialRecord",
"CredentialSummary",
"CredentialType",
"EnrollmentMode",
"IssuedSession",
"Principal",
"RoleName",
"SessionRecord",
"User",
"normalize_display_name",
"normalize_email",
"normalize_reason",
"normalize_username",
"username_key",
"utc_isoformat",
]