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.
378 lines
12 KiB
Python
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",
|
|
]
|