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.
537 lines
18 KiB
Python
537 lines
18 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
|
|
MAX_ORGANIZATION_NAME_LENGTH = 120
|
|
MIN_ORGANIZATION_NAME_LENGTH = 2
|
|
MAX_ORGANIZATION_SLUG_LENGTH = 63
|
|
|
|
_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]")
|
|
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
_SLUG_SEPARATOR_RE = re.compile(r"[^a-z0-9]+")
|
|
|
|
#: 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",
|
|
"member_count",
|
|
"must_change_password",
|
|
"organization_id",
|
|
"organization_name",
|
|
"organization_slug",
|
|
"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"
|
|
|
|
|
|
class OrganizationStatus(StrEnum):
|
|
"""Lifecycle of a tenant.
|
|
|
|
Mirrors the control-plane ``organizations.status`` check constraint
|
|
(``active``, ``suspended``, ``archived``) rather than the account status
|
|
set, so an organization and its members never share a vocabulary.
|
|
"""
|
|
|
|
ACTIVE = "active"
|
|
SUSPENDED = "suspended"
|
|
ARCHIVED = "archived"
|
|
|
|
|
|
#: Allowed organization status transitions. ``archived`` is terminal: a
|
|
#: deliberate, separately reviewed operation restores an archived organization,
|
|
#: because its name and slug are historically significant.
|
|
ALLOWED_ORGANIZATION_TRANSITIONS: Mapping[OrganizationStatus, frozenset[OrganizationStatus]] = MappingProxyType(
|
|
{
|
|
OrganizationStatus.ACTIVE: frozenset({OrganizationStatus.SUSPENDED, OrganizationStatus.ARCHIVED}),
|
|
OrganizationStatus.SUSPENDED: frozenset({OrganizationStatus.ACTIVE, OrganizationStatus.ARCHIVED}),
|
|
OrganizationStatus.ARCHIVED: frozenset(),
|
|
}
|
|
)
|
|
|
|
|
|
#: 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:
|
|
"""Return the canonical stored form of an email address.
|
|
|
|
Required, not optional. The control plane declares ``users.email`` as
|
|
``NOT NULL UNIQUE`` because it is the account's contact and recovery
|
|
address. Accepting a missing email here would let account creation succeed
|
|
against the development repositories and then fail on the authoritative
|
|
store, so the rule is enforced in the domain where it behaves the same
|
|
everywhere.
|
|
"""
|
|
|
|
if value is None:
|
|
raise InputError("email_required", "An email address is required.")
|
|
text = value.strip()
|
|
if not text:
|
|
raise InputError("email_required", "An email address is required.")
|
|
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 normalize_organization_name(value: str) -> str:
|
|
text = " ".join(value.split())
|
|
if not MIN_ORGANIZATION_NAME_LENGTH <= len(text) <= MAX_ORGANIZATION_NAME_LENGTH or _CONTROL_CHARS_RE.search(text):
|
|
raise InputError(
|
|
"invalid_organization_name",
|
|
f"Organization name must be {MIN_ORGANIZATION_NAME_LENGTH}-{MAX_ORGANIZATION_NAME_LENGTH} characters.",
|
|
)
|
|
return text
|
|
|
|
|
|
def normalize_organization_slug(value: str) -> str:
|
|
"""Return the canonical stored form of an organization slug.
|
|
|
|
The slug is the operator-facing handle used in URLs and is stored
|
|
case-insensitively as ``citext``, so the canonical form is lower case with
|
|
single hyphens between alphanumeric groups.
|
|
"""
|
|
|
|
text = _SLUG_SEPARATOR_RE.sub("-", value.strip().lower()).strip("-")
|
|
if not text or len(text) > MAX_ORGANIZATION_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
|
|
raise InputError(
|
|
"invalid_organization_slug",
|
|
"Organization slug must be lowercase letters, digits, and single hyphens.",
|
|
)
|
|
return text
|
|
|
|
|
|
def require_canonical_organization_slug(value: str) -> str:
|
|
"""Validate an operator-supplied slug without rewriting it.
|
|
|
|
A derived slug may be normalized out of any display name, but a slug the
|
|
operator typed is a handle they will paste into URLs and scripts. Silently
|
|
turning ``Not A Slug`` into ``not-a-slug`` hands back something they did
|
|
not write, so a supplied slug must already be canonical.
|
|
"""
|
|
|
|
text = value.strip()
|
|
if not text or len(text) > MAX_ORGANIZATION_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
|
|
raise InputError(
|
|
"invalid_organization_slug",
|
|
"Organization slug must be lowercase letters, digits, and single hyphens.",
|
|
)
|
|
return text
|
|
|
|
|
|
def slugify_organization_name(value: str) -> str:
|
|
"""Derive a candidate slug from a display name.
|
|
|
|
Used when an administrator does not supply one. Raises the same
|
|
:class:`InputError` as :func:`normalize_organization_slug` when the name
|
|
contains nothing slug-safe, so the caller reports one consistent message.
|
|
"""
|
|
|
|
return normalize_organization_slug(value)
|
|
|
|
|
|
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`` is a store-owned identifier rendered as a string. The control plane
|
|
uses a ``bigint`` identity and the in-memory repository uses a UUID, so the
|
|
domain does not claim a type the stores disagree about. The application
|
|
correlation UUID is kept in ``detail`` under ``application_event_id`` when
|
|
the store allows it, and never displaces the store's own key.
|
|
"""
|
|
|
|
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: 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 Organization:
|
|
"""A tenant that owns projects and, through it, accounts.
|
|
|
|
Phase 1 keeps organization membership as ``User.organization_id`` rather
|
|
than a ``user_roles`` join. That is a deliberate simplification recorded in
|
|
``migrations/README.md`` as an open review question, because the control
|
|
plane derives membership from ``user_roles`` with a project scope this
|
|
slice does not model.
|
|
"""
|
|
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
status: OrganizationStatus = OrganizationStatus.ACTIVE
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
version: int = 1
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "status", OrganizationStatus(self.status))
|
|
object.__setattr__(self, "name", normalize_organization_name(self.name))
|
|
object.__setattr__(self, "slug", normalize_organization_slug(self.slug))
|
|
|
|
@property
|
|
def slug_key(self) -> str:
|
|
return self.slug.casefold()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OrganizationView:
|
|
"""The only organization representation that may cross the API boundary."""
|
|
|
|
id: UUID
|
|
name: str
|
|
slug: str
|
|
status: OrganizationStatus
|
|
member_count: int = 0
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
@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_ORGANIZATION_TRANSITIONS",
|
|
"ALLOWED_STATUS_TRANSITIONS",
|
|
"MAX_DISPLAY_NAME_LENGTH",
|
|
"MAX_EMAIL_LENGTH",
|
|
"MAX_ORGANIZATION_NAME_LENGTH",
|
|
"MAX_ORGANIZATION_SLUG_LENGTH",
|
|
"MAX_REASON_LENGTH",
|
|
"MAX_USERNAME_LENGTH",
|
|
"MIN_ORGANIZATION_NAME_LENGTH",
|
|
"MIN_USERNAME_LENGTH",
|
|
"AccountStatus",
|
|
"AccountView",
|
|
"AuditEvent",
|
|
"AuthenticationResult",
|
|
"CredentialRecord",
|
|
"CredentialSummary",
|
|
"CredentialType",
|
|
"EnrollmentMode",
|
|
"IssuedSession",
|
|
"Organization",
|
|
"OrganizationStatus",
|
|
"OrganizationView",
|
|
"Principal",
|
|
"RoleName",
|
|
"SessionRecord",
|
|
"User",
|
|
"normalize_display_name",
|
|
"normalize_email",
|
|
"normalize_organization_name",
|
|
"normalize_organization_slug",
|
|
"normalize_reason",
|
|
"normalize_username",
|
|
"require_canonical_organization_slug",
|
|
"slugify_organization_name",
|
|
"username_key",
|
|
"utc_isoformat",
|
|
]
|