797 lines
27 KiB
Python
797 lines
27 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""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
|
|
|
|
#: Lifetime of an email-verification link. Long enough to survive a weekend, not
|
|
#: long enough that a link found in a mailbox months later still works.
|
|
VERIFICATION_TOKEN_TTL_SECONDS = 48 * 60 * 60
|
|
|
|
#: What a single-use token authorises. These are deliberately distinct values and
|
|
#: the store filters on them: a password-reset token and an email-verification
|
|
#: token are not interchangeable, because each would otherwise grant the other's
|
|
#: capability. ``bootstrap_tokens`` carries the purpose for exactly this reason.
|
|
VERIFICATION_PURPOSE_EMAIL = "email_verification"
|
|
PASSWORD_RESET_PURPOSE = "password_reset"
|
|
|
|
#: Every purpose a token may be minted for, in the order the migration declares
|
|
#: them. A purpose outside this set is refused rather than stored.
|
|
TOKEN_PURPOSES = (VERIFICATION_PURPOSE_EMAIL, PASSWORD_RESET_PURPOSE)
|
|
|
|
#: Lifetime of a password-reset link.
|
|
#:
|
|
#: Much shorter than a verification link, and deliberately so. A verification link
|
|
#: only confirms an address the person already controls. A reset link grants a new
|
|
#: password, so one left in a mailbox, in a browser's history, or in a forwarded
|
|
#: thread is a live credential for as long as it is valid. One hour is long enough
|
|
#: to read a message and click a link, and short enough that a link found later is
|
|
#: almost certainly stale.
|
|
PASSWORD_RESET_TOKEN_TTL_SECONDS = 60 * 60
|
|
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):
|
|
"""Lifecycle of one account.
|
|
|
|
``INVITED`` is the control plane's own state for an account that exists but
|
|
has not proved it can receive mail at its address. It is modelled here
|
|
rather than treated as an unmapped value, because the authoritative schema
|
|
already uses it: an unvalidated account that could sign in would defeat the
|
|
point of validating the address, and an account stuck in ``invited`` forever
|
|
would be indistinguishable from a bug.
|
|
|
|
``LOCKED`` exists in the control-plane check constraint and is deliberately
|
|
absent here. Credential lockout is tracked per credential by
|
|
``failed_login_count`` and ``locked_until``, so a second account-level status
|
|
for the same condition would be two sources of truth.
|
|
"""
|
|
|
|
INVITED = "invited"
|
|
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.
|
|
#: ``invited -> active`` is email validation and is the only way out of it.
|
|
ALLOWED_STATUS_TRANSITIONS: Mapping[AccountStatus, frozenset[AccountStatus]] = MappingProxyType(
|
|
{
|
|
AccountStatus.INVITED: frozenset({AccountStatus.ACTIVE, AccountStatus.DEACTIVATED}),
|
|
AccountStatus.ACTIVE: frozenset({AccountStatus.SUSPENDED, AccountStatus.DEACTIVATED}),
|
|
AccountStatus.SUSPENDED: frozenset({AccountStatus.ACTIVE, AccountStatus.DEACTIVATED}),
|
|
AccountStatus.DEACTIVATED: frozenset({AccountStatus.ACTIVE}),
|
|
}
|
|
)
|
|
|
|
|
|
def normalize_username(value: str | None) -> str | None:
|
|
"""Return the canonical stored form of an optional display handle.
|
|
|
|
The login identifier is the email address, not this. A username is kept only
|
|
as a short handle for audit lines and operator-facing lists, so it is
|
|
optional: the reviewed migration declares ``users.username`` nullable
|
|
precisely so an account is not forced to carry two identifiers. Case
|
|
insensitivity is handled by :func:`username_key`, matching the ``citext``
|
|
behaviour of that migration.
|
|
"""
|
|
|
|
if value is None:
|
|
return None
|
|
text = value.strip()
|
|
if not text:
|
|
return None
|
|
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 | None) -> str | None:
|
|
"""Return the case-insensitive lookup key for an optional username."""
|
|
|
|
normalized = normalize_username(value)
|
|
return normalized.casefold() if normalized is not None else None
|
|
|
|
|
|
def email_key(value: str) -> str:
|
|
"""Return the case-insensitive lookup key for the login identifier.
|
|
|
|
The control plane declares ``users.email`` as ``citext NOT NULL UNIQUE``, so
|
|
the address is already a case-insensitively unique login key and needs no
|
|
migration. Lowercasing here matches that behaviour exactly; note it is a
|
|
*key* computation only, and the stored address keeps the case the operator
|
|
typed so it is not silently rewritten in the database.
|
|
"""
|
|
|
|
return normalize_email(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
|
|
|
|
|
|
MIN_PROJECT_NAME_LENGTH = 2
|
|
MAX_PROJECT_NAME_LENGTH = 120
|
|
|
|
#: A project slug is a URL handle, so it carries the same limits as an
|
|
#: organization's. It is unique within an organization, not globally, so two
|
|
#: tenants may both have a project called "book-one".
|
|
MAX_PROJECT_SLUG_LENGTH = 63
|
|
|
|
|
|
def normalize_project_name(value: str) -> str:
|
|
"""Return the canonical stored form of a project name."""
|
|
|
|
text = " ".join(value.split())
|
|
if not MIN_PROJECT_NAME_LENGTH <= len(text) <= MAX_PROJECT_NAME_LENGTH or _CONTROL_CHARS_RE.search(text):
|
|
raise InputError(
|
|
"invalid_project_name",
|
|
f"Project name must be {MIN_PROJECT_NAME_LENGTH}-{MAX_PROJECT_NAME_LENGTH} characters.",
|
|
)
|
|
return text
|
|
|
|
|
|
def normalize_project_slug(value: str) -> str:
|
|
"""Return the canonical stored form of a project slug."""
|
|
|
|
text = _SLUG_SEPARATOR_RE.sub("-", value.strip().lower()).strip("-")
|
|
if not text or len(text) > MAX_PROJECT_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
|
|
raise InputError(
|
|
"invalid_project_slug",
|
|
"Project slug must be lowercase letters, digits, and single hyphens.",
|
|
)
|
|
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.
|
|
|
|
``email`` is the login identifier and is required. ``username`` is an
|
|
optional short handle for audit lines; it is not a login path, and the
|
|
service never authenticates against it.
|
|
"""
|
|
|
|
id: UUID
|
|
username: str | None
|
|
username_key: str | None
|
|
display_name: str
|
|
role: RoleName
|
|
status: AccountStatus
|
|
email: str
|
|
organization_id: UUID | 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))
|
|
object.__setattr__(self, "username", normalize_username(self.username))
|
|
# The address is required and normalised on every construction, so an
|
|
# account can never be built with a missing or malformed login key.
|
|
object.__setattr__(self, "email", normalize_email(self.email))
|
|
|
|
@property
|
|
def is_admin(self) -> bool:
|
|
return self.role.value in ADMIN_ROLES
|
|
|
|
@property
|
|
def is_email_verified(self) -> bool:
|
|
"""Whether the account has proved it can receive mail at its address.
|
|
|
|
Derived from status rather than stored twice: the control plane has one
|
|
status column and duplicating the fact would let the two disagree.
|
|
"""
|
|
|
|
return self.status is not AccountStatus.INVITED
|
|
|
|
@property
|
|
def audit_handle(self) -> str:
|
|
"""A stable, human-readable identifier for audit records."""
|
|
|
|
return self.username or self.email
|
|
|
|
|
|
@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
|
|
email: str
|
|
display_name: str
|
|
role: RoleName
|
|
status: AccountStatus
|
|
credential: CredentialSummary
|
|
username: str | None = None
|
|
organization_id: UUID | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
active_session_count: int = 0
|
|
|
|
@property
|
|
def is_email_verified(self) -> bool:
|
|
"""Whether the address has been confirmed. Derived, never stored twice."""
|
|
|
|
return self.status is not AccountStatus.INVITED
|
|
|
|
|
|
class ProjectStatus(StrEnum):
|
|
"""Lifecycle of a project, matching the control plane's own check constraint.
|
|
|
|
Read from the schema rather than invented, so a status the database would
|
|
refuse is never offered:
|
|
|
|
CHECK (status = ANY (ARRAY['active', 'on_hold', 'released', 'archived']))
|
|
|
|
``archived`` is terminal. ``released`` means the work has shipped; it is not
|
|
the same as archived, and it is not terminal either - a released project can
|
|
still be read.
|
|
"""
|
|
|
|
ACTIVE = "active"
|
|
ON_HOLD = "on_hold"
|
|
RELEASED = "released"
|
|
ARCHIVED = "archived"
|
|
|
|
@property
|
|
def is_terminal(self) -> bool:
|
|
return self is ProjectStatus.ARCHIVED
|
|
|
|
|
|
#: The transitions an administrator may make. Archived is terminal, and a
|
|
#: released project may be archived or returned to active; an archived one may do
|
|
#: nothing at all.
|
|
PROJECT_STATUS_TRANSITIONS: Mapping[ProjectStatus, tuple[ProjectStatus, ...]] = {
|
|
ProjectStatus.ACTIVE: (ProjectStatus.ON_HOLD, ProjectStatus.RELEASED, ProjectStatus.ARCHIVED),
|
|
ProjectStatus.ON_HOLD: (ProjectStatus.ACTIVE, ProjectStatus.RELEASED, ProjectStatus.ARCHIVED),
|
|
ProjectStatus.RELEASED: (ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED),
|
|
ProjectStatus.ARCHIVED: (),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Project:
|
|
"""One unit of work, belonging to exactly one organization.
|
|
|
|
The table is ``projects`` in the control plane and was already there, unused
|
|
by this application, with ``(id, organization_id)`` unique so a project
|
|
identifier cannot be ambiguous across tenants.
|
|
"""
|
|
|
|
id: UUID
|
|
organization_id: UUID
|
|
name: str
|
|
slug: str
|
|
status: ProjectStatus
|
|
created_at: datetime
|
|
created_by: UUID | None = None
|
|
updated_at: datetime | None = None
|
|
version: int = 1
|
|
|
|
@property
|
|
def slug_key(self) -> str:
|
|
return str(self.slug).casefold()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ProjectView:
|
|
"""The only project representation that may cross the API boundary.
|
|
|
|
``assigned_account_ids`` is who is *working* on the project, which is not the
|
|
same as who can see it. Within an organization every project is visible to
|
|
every account; see :mod:`umbratrans_web.tenancy` for why those are separate
|
|
questions.
|
|
"""
|
|
|
|
id: UUID
|
|
organization_id: UUID
|
|
name: str
|
|
slug: str
|
|
status: ProjectStatus
|
|
created_at: datetime
|
|
created_by: UUID | None = None
|
|
updated_at: datetime | None = None
|
|
assigned_account_ids: tuple[UUID, ...] = ()
|
|
assigned_count: int = 0
|
|
|
|
@property
|
|
def is_visible_to_all_in_organization(self) -> bool:
|
|
"""Always true, and stated so the rule is visible where projects are used."""
|
|
|
|
return True
|
|
|
|
|
|
@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 VerificationToken:
|
|
"""A single-use token proving control of an email address.
|
|
|
|
Maps onto the control plane's ``bootstrap_tokens`` table, which is already
|
|
shaped for this: ``(id, user_id, token_hash, expires_at, consumed_at)``.
|
|
That table existed and was unused, so email validation needs no migration.
|
|
|
|
``token_hash`` is ``repr=False`` and the plaintext is never stored, never
|
|
returned by an endpoint, and never logged. The link that carries the
|
|
plaintext is emailed and exists only in the operator's inbox and in the
|
|
transport's own buffer.
|
|
"""
|
|
|
|
id: UUID
|
|
user_id: UUID
|
|
token_hash: str = field(repr=False)
|
|
expires_at: datetime
|
|
created_at: datetime
|
|
consumed_at: datetime | None = None
|
|
purpose: str = "email_verification"
|
|
|
|
def is_consumed(self) -> bool:
|
|
return self.consumed_at is not None
|
|
|
|
def is_expired(self, now: datetime) -> bool:
|
|
return now >= self.expires_at
|
|
|
|
def is_usable(self, now: datetime) -> bool:
|
|
return not self.is_consumed() and not self.is_expired(now)
|
|
|
|
|
|
@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",
|
|
"PASSWORD_RESET_PURPOSE",
|
|
"PASSWORD_RESET_TOKEN_TTL_SECONDS",
|
|
"TOKEN_PURPOSES",
|
|
"VERIFICATION_PURPOSE_EMAIL",
|
|
"VERIFICATION_TOKEN_TTL_SECONDS",
|
|
"AccountStatus",
|
|
"AccountView",
|
|
"AuditEvent",
|
|
"AuthenticationResult",
|
|
"CredentialRecord",
|
|
"CredentialSummary",
|
|
"CredentialType",
|
|
"EnrollmentMode",
|
|
"IssuedSession",
|
|
"Organization",
|
|
"Project",
|
|
"normalize_project_name",
|
|
"normalize_project_slug",
|
|
"ProjectStatus",
|
|
"ProjectView",
|
|
"OrganizationStatus",
|
|
"OrganizationView",
|
|
"Principal",
|
|
"RoleName",
|
|
"SessionRecord",
|
|
"User",
|
|
"VerificationToken",
|
|
"email_key",
|
|
"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",
|
|
]
|