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.
271 lines
7.5 KiB
Python
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",
|
|
]
|