Files
UmbraTrans/umbratrans_web/schemas.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

271 lines
7.5 KiB
Python

"""Pydantic request and response models for the phase-1 API.
Two rules shape every model here:
* Request models forbid unknown fields. A ``password`` field sent to
``POST /api/uadmin/accounts`` is therefore rejected by the schema itself, so
a one-time password can never arrive through a normal JSON body.
* Response models contain no credential material. Accounts are serialized from
:class:`umbratrans_web.models.AccountView`, which exposes a
:class:`~umbratrans_web.models.CredentialSummary` and never a hash.
"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from .models import (
AccountStatus,
AccountView,
AuditEvent,
CredentialSummary,
CredentialType,
EnrollmentMode,
RoleName,
)
class RequestModel(BaseModel):
"""Base for request bodies: strict, no silent field coercion."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=False)
class ErrorBody(BaseModel):
code: str
message: str
class ErrorResponse(BaseModel):
error: ErrorBody
class HealthResponse(BaseModel):
status: str
service: str
version: str
environment: str
repository: str
cookie_secure: bool
csrf_required: bool
time: datetime
class CredentialSummaryResponse(BaseModel):
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
@classmethod
def from_summary(cls, summary: CredentialSummary) -> CredentialSummaryResponse:
return cls(
credential_type=summary.credential_type,
must_change_password=summary.must_change_password,
failed_login_count=summary.failed_login_count,
locked=summary.locked,
last_login_at=summary.last_login_at,
password_changed_at=summary.password_changed_at,
)
class UserResponse(BaseModel):
id: UUID
username: 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
@classmethod
def from_view(cls, view: AccountView) -> UserResponse:
return cls(
id=view.id,
username=view.username,
display_name=view.display_name,
role=view.role,
status=view.status,
organization_id=view.organization_id,
email=view.email,
created_at=view.created_at,
updated_at=view.updated_at,
)
class AccountResponse(UserResponse):
credential: CredentialSummaryResponse
active_session_count: int
@classmethod
def from_view(cls, view: AccountView) -> AccountResponse:
return cls(
id=view.id,
username=view.username,
display_name=view.display_name,
role=view.role,
status=view.status,
organization_id=view.organization_id,
email=view.email,
created_at=view.created_at,
updated_at=view.updated_at,
credential=CredentialSummaryResponse.from_summary(view.credential),
active_session_count=view.active_session_count,
)
class AccountListResponse(BaseModel):
items: list[AccountResponse]
total: int
limit: int
offset: int
class SessionUserResponse(BaseModel):
"""``GET /api/auth/me``. Includes the CSRF token the browser must echo."""
user: UserResponse
role: RoleName
organization_id: UUID | None = None
must_change_password: bool
csrf_token: str
idle_expires_at: datetime | None = None
absolute_expires_at: datetime | None = None
class LoginResponse(BaseModel):
user: UserResponse
must_change_password: bool
csrf_token: str
idle_expires_at: datetime
absolute_expires_at: datetime
class LogoutResponse(BaseModel):
revoked_sessions: int
class RevokeSessionsResponse(BaseModel):
revoked_sessions: int
class AuditEventResponse(BaseModel):
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: dict[str, str]
@classmethod
def from_event(cls, event: AuditEvent) -> AuditEventResponse:
return cls(
id=event.id,
occurred_at=event.occurred_at,
action=event.action,
outcome=event.outcome,
actor_user_id=event.actor_user_id,
target_user_id=event.target_user_id,
organization_id=event.organization_id,
request_id=event.request_id,
detail=dict(event.detail),
)
class AuditListResponse(BaseModel):
items: list[AuditEventResponse]
total: int
limit: int
offset: int
class LoginRequest(RequestModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=1024)
class ChangeFirstPasswordRequest(RequestModel):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
class CreateAccountRequest(RequestModel):
"""Administrator account creation for either enrollment mode.
The request never carries a password: the schema forbids unknown fields, so
a submitted ``password`` is rejected outright. For
``enrollment_mode="temporary_password"`` the service generates the one-time
password itself and returns it once in :class:`CreateAccountResponse`.
"""
username: str = Field(min_length=1, max_length=64)
display_name: str = Field(min_length=1, max_length=120)
enrollment_mode: EnrollmentMode = EnrollmentMode.OIDC
role: RoleName = RoleName.MEMBER
email: str | None = Field(default=None, max_length=254)
organization_id: UUID | None = None
class CreateAccountResponse(BaseModel):
"""Result of ``POST /api/uadmin/accounts``.
``temporary_password`` is populated only when the account was enrolled with
a one-time local password. It is the single response that ever contains
that value, it is served with ``Cache-Control: no-store``, and it is never
persisted, logged, or retrievable again: the account must replace it on its
first login. It is ``None`` for OIDC enrollment.
"""
account: AccountResponse
temporary_password: str | None = None
@classmethod
def from_oidc(cls, view: AccountView) -> CreateAccountResponse:
return cls(account=AccountResponse.from_view(view))
@classmethod
def from_temporary_password(cls, view: AccountView, temporary_password: str) -> CreateAccountResponse:
return cls(account=AccountResponse.from_view(view), temporary_password=temporary_password)
class SetAccountStatusRequest(RequestModel):
status: AccountStatus
reason: str | None = Field(default=None, max_length=200)
class StatusChangeResponse(BaseModel):
account: AccountResponse
revoked_sessions: int
__all__ = [
"AccountListResponse",
"AccountResponse",
"AuditEventResponse",
"AuditListResponse",
"ChangeFirstPasswordRequest",
"CreateAccountRequest",
"CreateAccountResponse",
"CredentialSummaryResponse",
"ErrorResponse",
"HealthResponse",
"LoginRequest",
"LoginResponse",
"LogoutResponse",
"RevokeSessionsResponse",
"SessionUserResponse",
"SetAccountStatusRequest",
"StatusChangeResponse",
"UserResponse",
]