Implements AccountRepository against the control-plane schema plus the phase-1 identity migration, and wires it in so production can select it. The adapter is not a thin SQL wrapper, because the control plane does not store the two things the application needs on the users row. Role and organization come from user_roles. There is no role column and no organization column on users; a role is assigned through user_roles with a scope, and app.validate_user_role_scope() enforces that the role's scope matches the assignment. Resolving one role per user is therefore a join, and a user holding more than one active assignment is refused rather than resolved to an arbitrary row. The organization on that assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority. Reconciling this with project-scoped membership stays an open review question, recorded as such. Email became a required field on account creation. users.email is NOT NULL UNIQUE, so accepting a missing address would let account creation succeed against the development repositories and then fail on the authoritative store. It is now required in the service, the API schema, and the bootstrap command, so it fails identically everywhere. Audit identifiers now belong to the store. audit_events.id is a bigint identity, so the adapter reports that value; the application correlation UUID is preserved in the detail and never displaces the row key. AuditEvent.id is a string for that reason, which removes a type the two stores disagreed about. Three further fail-closed behaviours: a users.status of invited or locked is refused rather than coerced onto active; a row with mfa_required is refused, because this phase cannot satisfy a second factor and authenticating it anyway would grant access to someone who was supposed to present one; and a missing organization blocks access rather than granting it. save_user deliberately does not write username or email, since both are identity keys with no reviewed change path and no audit event behind them. Every operation runs in one transaction on one pooled connection and publishes the caller with SET LOCAL app.current_user_id, so a returned connection cannot carry one request's identity into the next. That is inert until the reviewed RLS policies exist; the explicit WHERE clauses enforce scope today. A statement timeout bounds how long a pooled connection can be held. Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. Verified by running both, including that an unreachable host refuses to start rather than degrading. The migration gains an index on audit_events (target_type, target_id), which the administrator audit filter needs and nothing covered, and the review questions now include the three things that must be checked before provisioning: the roles rows and their scopes, any row with mfa_required set, and any user holding more than one active assignment. Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row mapping, role resolution, conflict mapping, and configuration are unit tested and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed: this environment has no database credentials, because sudo to the postgres user needs a password, there is no umbra database role, and there is no .pgpass. The integration suite is written and selected by UMBRATRANS_TEST_DSN, and its fixture refuses any DSN whose database name contains umbracontrol. A green run here says nothing about whether the SQL is correct.
362 lines
10 KiB
Python
362 lines
10 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,
|
|
OrganizationStatus,
|
|
OrganizationView,
|
|
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):
|
|
# A store-owned identifier, so a string rather than the UUID the
|
|
# development repository happens to use.
|
|
id: str
|
|
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`.
|
|
|
|
``email`` is required because the control plane declares ``users.email`` as
|
|
``NOT NULL UNIQUE``. Making it mandatory here means a missing address is a
|
|
clear field-level rejection rather than a failure that only appears once
|
|
the authoritative store is in use.
|
|
"""
|
|
|
|
username: str = Field(min_length=1, max_length=64)
|
|
display_name: str = Field(min_length=1, max_length=120)
|
|
email: str = Field(min_length=3, max_length=254)
|
|
enrollment_mode: EnrollmentMode = EnrollmentMode.OIDC
|
|
role: RoleName = RoleName.MEMBER
|
|
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
|
|
|
|
|
|
class OrganizationResponse(BaseModel):
|
|
"""An organization as returned by the administrator API."""
|
|
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
status: OrganizationStatus
|
|
member_count: int
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
@classmethod
|
|
def from_view(cls, view: OrganizationView) -> OrganizationResponse:
|
|
return cls(
|
|
id=view.id,
|
|
name=view.name,
|
|
slug=view.slug,
|
|
status=view.status,
|
|
member_count=view.member_count,
|
|
created_at=view.created_at,
|
|
updated_at=view.updated_at,
|
|
)
|
|
|
|
|
|
class OrganizationListResponse(BaseModel):
|
|
items: list[OrganizationResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class CreateOrganizationRequest(RequestModel):
|
|
"""Create a tenant. Platform administrators only.
|
|
|
|
``slug`` is optional and derived from ``name`` when omitted. A supplied
|
|
slug is validated as written, so a rejected value reports the slug rule.
|
|
"""
|
|
|
|
name: str = Field(min_length=1, max_length=120)
|
|
slug: str | None = Field(default=None, max_length=63)
|
|
|
|
|
|
class UpdateOrganizationRequest(RequestModel):
|
|
"""Rename or re-slug an organization. At least one field is required."""
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
slug: str | None = Field(default=None, max_length=63)
|
|
|
|
|
|
class SetOrganizationStatusRequest(RequestModel):
|
|
status: OrganizationStatus
|
|
reason: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
class OrganizationStatusChangeResponse(BaseModel):
|
|
organization: OrganizationResponse
|
|
revoked_sessions: int
|
|
|
|
|
|
class OrganizationSummaryResponse(BaseModel):
|
|
"""Counts for the administrator dashboard.
|
|
|
|
Derived from the caller's visible scope on every request, so it can never
|
|
report a number the caller could not obtain from the list endpoints.
|
|
"""
|
|
|
|
total_organizations: int
|
|
active_organizations: int
|
|
total_accounts: int
|
|
active_accounts: int
|
|
suspended_accounts: int
|
|
pending_first_login: int
|
|
organization_admin_count: int
|
|
|
|
|
|
__all__ = [
|
|
"AccountListResponse",
|
|
"AccountResponse",
|
|
"AuditEventResponse",
|
|
"AuditListResponse",
|
|
"ChangeFirstPasswordRequest",
|
|
"CreateAccountRequest",
|
|
"CreateAccountResponse",
|
|
"CredentialSummaryResponse",
|
|
"ErrorResponse",
|
|
"HealthResponse",
|
|
"LoginRequest",
|
|
"LoginResponse",
|
|
"LogoutResponse",
|
|
"CreateOrganizationRequest",
|
|
"OrganizationListResponse",
|
|
"OrganizationResponse",
|
|
"OrganizationStatusChangeResponse",
|
|
"OrganizationSummaryResponse",
|
|
"RevokeSessionsResponse",
|
|
"SessionUserResponse",
|
|
"SetAccountStatusRequest",
|
|
"SetOrganizationStatusRequest",
|
|
"StatusChangeResponse",
|
|
"UpdateOrganizationRequest",
|
|
"UserResponse",
|
|
]
|