Files
UmbraTrans/umbratrans_web/models.py
2026-10-18 19:01:05 -07:00

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",
]