Files
UmbraTrans/umbratrans_web/service.py
umbra audio fbbe23d7f9 Add output generation, cover image management, and back-translation tool
- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
2026-10-25 15:27:02 -07:00

3580 lines
140 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Account service: enrollment, authentication, sessions, and administration.
This module is the only place that knows how an account is created, how a
password is verified, and what a session is. Both enrollment modes are
implemented here and are separated by a code-level boundary rather than by a
convention:
``temporary_password``
Has two callers, and both keep the plaintext out of the request surface.
The operator CLI reads it from a hidden local prompt and passes it with an
:class:`OperatorContext`. The administrator API never accepts a submitted
password at all: it asks the service to generate a one-time password
in-process, which is hashed immediately and returned to the authenticated
administrator exactly once. Either way the credential is marked
``must_change_password=True`` so the first login can do nothing else.
``oidc``
Requires an authenticated administrator principal. No local password is
created or stored; the identity provider completes enrollment.
Rules enforced here:
* The plaintext password is never persisted, returned, logged, or audited.
* Every login failure returns the same generic error, whatever the real cause;
the cause is recorded in the sanitized audit trail instead.
* Sessions store only a hash of the bearer token and are revoked on
suspension, deactivation, password change, and logout-everywhere.
* Account lookups and mutations are authorized against a server-resolved
principal; no caller-supplied organization or role is trusted.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Callable, Mapping, Sequence
from uuid import UUID, uuid4
from .config import Settings
from .errors import (
ConflictError,
EmailNotVerifiedError,
EnrollmentRefusedError,
ForbiddenError,
InputError,
InvalidCredentialsError,
MailDeliveryUnavailableError,
NotFoundError,
OperatorRefusalError,
PasswordChangeRequiredError,
PasswordPolicyError,
StateTransitionError,
VerificationDeliveryError,
VerificationFailedError,
)
from .mailer import Mailer, MailerError, build_mailer
from .models import (
ALLOWED_ORGANIZATION_TRANSITIONS,
ALLOWED_STATUS_TRANSITIONS,
PASSWORD_RESET_PURPOSE,
PASSWORD_RESET_TOKEN_TTL_SECONDS,
PROJECT_STATUS_TRANSITIONS,
VERIFICATION_PURPOSE_EMAIL,
VERIFICATION_TOKEN_TTL_SECONDS,
AccountStatus,
AccountView,
AuditEvent,
AuthenticationResult,
CredentialRecord,
CredentialSummary,
CredentialType,
EnrollmentMode,
IssuedSession,
Organization,
OrganizationStatus,
OrganizationView,
Principal,
Project,
ProjectStatus,
ProjectView,
RoleName,
SessionRecord,
User,
VerificationToken,
email_key,
normalize_display_name,
normalize_email,
normalize_organization_name,
normalize_project_name,
normalize_project_slug,
normalize_reason,
normalize_username,
require_canonical_organization_slug,
slugify_organization_name,
username_key,
)
from .repository import AccountRepository
from .security import (
PasswordPolicy,
PasswordService,
generate_token,
hash_session_token,
sanitize_audit_detail,
utc_now,
)
from .tenancy import TenantScope, resolve_target, scope_for
from .project_status import ProjectStatusAdapter
from .schemas import (
AudioQueueResponse,
CoverImageResponse,
FrontMatterResponse,
FormatMetadataResponse,
OrganizationCreateResponse,
ProgramCreateResponse,
SpeakerSyncResponse,
TranslationQueueResponse,
UtteranceReviewResponse,
UtteranceTranslationListResponse,
UtteranceTranslationResponse,
)
LOGGER = logging.getLogger("umbratrans_web.service")
#: In-memory job store for language-project workflow jobs (translation,
#: audio queue, etc.). The productions directory is read-only in some
#: deployments, so job state is held here and reported honestly as ephemeral.
_JOB_STORE: dict[str, dict] = {}
#: Sources that may request the operator-only local temporary-password path.
#: Sources that may prove the operator path. An allowlist, not a free string,
#: because holding one of these is what permits creating an account with a local
#: credential without an administrator actor.
#:
#: ``first-run-setup`` is the third, and the only one reachable over HTTP. It is
#: reachable only while no platform administrator exists and only with the
#: journal token; see :mod:`umbratrans_web.setup`. It is listed separately rather
#: than folded into ``operator-service`` so the audit trail distinguishes an
#: administrator creating an account from the deployment creating its own first
#: administrator.
OPERATOR_SOURCES = frozenset({"operator-cli", "operator-service", "first-run-setup"})
#: Audit/log label for administrator-driven temporary-password enrollment.
ADMIN_ENROLLMENT_SOURCE = "admin-api"
#: Audit action names. Stable strings, no free-form data.
ACTION_ACCOUNT_CREATED = "account.created"
ACTION_ACCOUNT_PROVISIONED_LOCAL = "account.provisioned_local"
ACTION_ACCOUNT_STATUS_CHANGED = "account.status_changed"
ACTION_ORGANIZATION_CREATED = "organization.created"
ACTION_ORGANIZATION_UPDATED = "organization.updated"
ACTION_ORGANIZATION_STATUS_CHANGED = "organization.status_changed"
ACTION_AUTH_LOGIN = "auth.login"
ACTION_AUTH_FIRST_PASSWORD_CHANGED = "auth.first_password_changed"
ACTION_AUTH_LOGOUT = "auth.logout"
ACTION_AUTH_LOGOUT_EVERYWHERE = "auth.logout_everywhere"
ACTION_EMAIL_VERIFICATION_ISSUED = "email.verification_issued"
ACTION_EMAIL_VERIFIED = "email.verified"
#: A dashboard shows a tenant's working projects, not a paginated archive. The
#: cap is a bound on one response rather than a page size, so "showing 20 of 47"
#: can never become "showing 20, and no sign there are 27 more".
MAX_WORKSPACE_PROJECTS = 200
@dataclass(frozen=True, slots=True)
class WorkspaceSnapshot:
"""What one caller sees on their dashboard, resolved in one place.
``assigned_to_me`` is derived from ``projects`` rather than fetched
separately, so the two can never disagree about who is working on what.
"""
organization: "OrganizationView | None"
projects: list[ProjectView]
assigned_to_me: list[ProjectView]
total: int
ACTION_PROJECT_CREATED = "project.created"
ACTION_PROJECT_STATUS_CHANGED = "project.status_changed"
ACTION_PROJECT_ASSIGNMENT_ADDED = "project.assignment_added"
ACTION_PROJECT_ASSIGNMENT_REMOVED = "project.assignment_removed"
ACTION_TRANSLATION_QUEUED = "translation.queued"
ACTION_AUDIO_QUEUED = "audio.queued"
ACTION_FORMAT_METADATA_SAVED = "format.metadata_saved"
ACTION_FRONT_MATTER_SAVED = "front_matter.saved"
ACTION_ORGANIZATION_CREATED = "organization.created"
ACTION_PROGRAM_CREATED = "program.created"
ACTION_SPEAKERS_SYNCED = "speakers.synced"
ACTION_TRANSLATIONS_UPSERTED = "translations.upserted"
ACTION_PASSWORD_RESET_REQUESTED = "password.reset_requested"
ACTION_PASSWORD_RESET_COMPLETED = "password.reset_completed"
ACTION_SESSION_REVOKED = "session.revoked"
ACTION_SESSION_EXPIRED = "session.expired"
ACTION_SESSION_INVALIDATED = "session.invalidated"
@dataclass(frozen=True, slots=True)
class OperatorContext:
"""Proves the caller is the operator path, not a public API request.
The HTTP layer never constructs this object, which is what keeps
temporary-password enrollment off the JSON API surface.
"""
source: str
interactive: bool = True
def __post_init__(self) -> None:
if self.source not in OPERATOR_SOURCES:
raise OperatorRefusalError("unknown_operator_source", f"Unknown operator source {self.source!r}.")
@dataclass(frozen=True, slots=True)
class IssuedTemporaryPassword:
"""An account plus the one-time password generated for it.
``temporary_password`` is excluded from ``repr`` so an accidental log line
or traceback cannot carry the value. It exists only in the response that
returns it from account creation, and the caller is expected to display it
once to the account owner.
"""
account: AccountView
temporary_password: str = field(repr=False)
class AccountService:
"""Application service for identity and account management."""
def __init__(
self,
repository: AccountRepository,
settings: Settings,
*,
clock: Callable[[], datetime] = utc_now,
password_service: PasswordService | None = None,
mailer: Mailer | None = None,
) -> None:
self._repository = repository
self._settings = settings
self._clock = clock
self._mailer: Mailer = mailer or build_mailer(settings)
self._passwords = password_service or PasswordService(
memory_cost_kib=settings.argon2_memory_cost_kib,
time_cost=settings.argon2_time_cost,
parallelism=settings.argon2_parallelism,
hash_length=settings.argon2_hash_length,
salt_length=settings.argon2_salt_length,
policy=PasswordPolicy(min_length=settings.password_min_length, max_length=settings.password_max_length),
)
@property
def password_service(self) -> PasswordService:
"""The Argon2id profile in use, so another component shares it.
Exposed rather than reached for: node credentials are hashed with the
*same* profile as user passwords, and a second code path that defaulted
its own would be a second set of parameters nobody reviews. The
alternative - the compute repository constructing its own - is exactly
the thing this prevents.
"""
return self._passwords
# -- introspection -------------------------------------------------------
@property
def settings(self) -> Settings:
return self._settings
@property
def repository(self) -> AccountRepository:
return self._repository
@property
def mailer(self) -> Mailer:
"""The transport in force, so callers probe the one that will be used."""
return self._mailer
def mail_is_deliverable(self) -> bool:
"""Whether mail can actually be sent right now, not merely configured.
Delegates to the transport rather than reading the settings, because a
configured host and a rejected credential look identical in configuration
and are not.
"""
verify = getattr(self._mailer, "verify", None)
if verify is None: # pragma: no cover - every transport implements it
return False
return bool(verify())
def password_policy_description(self) -> dict[str, object]:
return self._passwords.policy.describe()
def _now(self) -> datetime:
return self._clock()
# -- enrollment ----------------------------------------------------------
def create_account(
self,
*,
enrollment_mode: str,
username: str,
display_name: str,
email: str,
role: str = str(RoleName.MEMBER),
organization_id: UUID | None = None,
password: str | None = None,
actor: Principal | None = None,
operator: OperatorContext | None = None,
request_id: str | None = None,
require_email_verification: bool = False,
) -> AccountView:
"""Create an account through one of the two enrollment modes.
``email`` is required. The control plane declares ``users.email`` as
``NOT NULL UNIQUE``, and enforcing it here means a missing address
fails identically against the development repositories and the
authoritative store instead of only at deployment.
``temporary_password`` is the operator path and requires ``operator``
plus ``password``; use :meth:`provision_admin_temporary_password` for
administrator-driven enrollment. ``oidc`` requires an administrator
``actor`` and never stores a password.
"""
mode = _enrollment_mode(enrollment_mode)
if mode is EnrollmentMode.TEMPORARY_PASSWORD:
if require_email_verification:
# Refused rather than ignored. This path is the operator's, and the
# operator attests to the address: the first account on a new
# deployment has to exist before any mail is configured, so this
# path cannot depend on a delivered message. Honouring the flag
# here would deadlock that bootstrap; ignoring it would hand back
# an unverified account to a caller that asked for a verified one.
# Administrator-driven enrollment is the path that sends
# verification mail: see provision_admin_temporary_password.
raise InputError(
"operator_path_skips_verification",
"The operator enrollment path attests to the email address and does not send a "
"verification message. Use the administrator path to require verification.",
)
return self._provision_local_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
password=password,
operator=operator,
request_id=request_id,
)
return self.create_oidc_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
actor=actor,
request_id=request_id,
require_email_verification=require_email_verification,
)
def _provision_local_account(
self,
*,
username: str,
display_name: str,
role: str,
email: str,
organization_id: UUID | None,
password: str | None,
operator: OperatorContext | None,
request_id: str | None,
) -> AccountView:
"""Operator-only enrollment with a one-time local password.
Always creates an active, already-attested account. There is no
verification flag here on purpose: the caller-facing ``create_account``
refuses ``require_email_verification`` on this path rather than quietly
dropping it, so reaching this method with one is not possible.
"""
if operator is None:
raise EnrollmentRefusedError(
"operator_context_required",
"Temporary password enrollment requires the operator provisioning path.",
)
if not isinstance(password, str) or not password:
raise InputError("password_required", "A one-time password is required for this enrollment mode.")
self._validate_organization_for_assignment(organization_id, role=role)
self._passwords.policy.validate(password, username=username)
password_hash = self._passwords.hash_password(password)
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=password_hash,
must_change_password=True,
initial_status=AccountStatus.ACTIVE,
)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"credential_type": credential.credential_type,
"must_change_password": True,
"source": operator.source,
},
)
LOGGER.info(
"account provisioned username=%s user_id=%s mode=temporary_password source=%s",
user.username,
user.id,
operator.source,
)
return self._view(user, now=self._now())
def provision_admin_temporary_password(
self,
*,
username: str,
display_name: str,
role: str = str(RoleName.MEMBER),
email: str,
organization_id: UUID | None = None,
actor: Principal | None = None,
request_id: str | None = None,
) -> IssuedTemporaryPassword:
"""Administrator-driven enrollment with a generated one-time password.
The password is generated inside the service, validated against the
local policy, and hashed before it is returned. No password is accepted
from the caller, so no request body, header, or queue message can carry
one; the plaintext leaves the process only in the single creation
response, is excluded from ``repr``, and is never logged, audited, or
stored. The account is created with ``must_change_password=True``, so it
can do nothing beyond replacing this password on its first login.
"""
self._require_admin(actor)
if actor is None: # pragma: no cover - _require_admin raises first
raise ForbiddenError("administrator_required")
self._authorize_creation(actor, role=role, organization_id=organization_id)
self._validate_organization_for_assignment(organization_id, role=role)
self._require_deliverable_mail()
temporary_password = self._passwords.generate_temporary_password(username=normalize_username(username))
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.LOCAL_PASSWORD,
password_hash=self._passwords.hash_password(temporary_password),
must_change_password=True,
# Administrator-provisioned accounts must prove they can receive
# mail before they can sign in.
initial_status=AccountStatus.INVITED,
)
link = self.issue_email_verification(user.id, actor=actor, request_id=request_id)
self._deliver(user.email, link)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"credential_type": credential.credential_type,
"must_change_password": True,
"source": ADMIN_ENROLLMENT_SOURCE,
},
)
LOGGER.info(
"account provisioned username=%s user_id=%s mode=temporary_password source=%s",
user.username,
user.id,
ADMIN_ENROLLMENT_SOURCE,
)
return IssuedTemporaryPassword(account=self._view(user, now=self._now()), temporary_password=temporary_password)
def create_oidc_account(
self,
*,
username: str,
display_name: str,
email: str,
role: str = str(RoleName.MEMBER),
organization_id: UUID | None = None,
actor: Principal | None = None,
request_id: str | None = None,
require_email_verification: bool = True,
) -> AccountView:
"""Administrator-driven OIDC enrollment. No local password is created."""
self._require_admin(actor)
if actor is None: # pragma: no cover - _require_admin raises first
raise ForbiddenError("administrator_required")
self._authorize_creation(actor, role=role, organization_id=organization_id)
self._validate_organization_for_assignment(organization_id, role=role)
self._require_deliverable_mail()
user, credential = self._insert_account(
username=username,
display_name=display_name,
role=role,
email=email,
organization_id=organization_id,
credential_type=CredentialType.OIDC,
password_hash=None,
must_change_password=False,
initial_status=(AccountStatus.INVITED if require_email_verification else AccountStatus.ACTIVE),
)
if require_email_verification:
link = self.issue_email_verification(user.id, actor=actor, request_id=request_id)
self._deliver(user.email, link)
self._audit(
action=ACTION_ACCOUNT_CREATED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"role": user.role,
"organization_id": user.organization_id,
"enrollment_mode": EnrollmentMode.OIDC,
"credential_type": credential.credential_type,
"must_change_password": False,
},
)
LOGGER.info("account created username=%s user_id=%s mode=oidc", user.username, user.id)
return self._view(user, now=self._now())
def _insert_account(
self,
*,
username: str,
display_name: str,
role: str,
email: str,
organization_id: UUID | None,
credential_type: CredentialType,
password_hash: str | None,
must_change_password: bool,
initial_status: AccountStatus = AccountStatus.ACTIVE,
) -> tuple[User, CredentialRecord]:
now = self._now()
user = User(
id=uuid4(),
username=normalize_username(username),
username_key=username_key(username),
display_name=normalize_display_name(display_name),
role=_role(role),
status=initial_status,
email=normalize_email(email),
organization_id=organization_id,
created_at=now,
updated_at=now,
)
credential = CredentialRecord(
id=uuid4(),
user_id=user.id,
credential_type=credential_type,
password_hash=password_hash,
must_change_password=must_change_password,
created_at=now,
updated_at=now,
)
self._repository.create_user(user)
self._repository.save_credential(credential)
return user, credential
# -- authentication ------------------------------------------------------
def authenticate(self, *, email: str, password: str, request_id: str | None = None) -> AuthenticationResult:
"""Authenticate a local password and issue a server-side session.
The identifier is the email address, because ``users.email`` is
``citext NOT NULL UNIQUE`` in the control plane and is therefore already
a case-insensitively unique login key.
Every rejection path raises the same :class:`InvalidCredentialsError` so
that the response cannot be used to enumerate accounts. The one exception
is an account whose email is not yet verified, which is reported only
*after* the password has been proven correct. That is safe and is the
difference between a user being told to check their inbox and a user
being told their password is wrong when it is not.
"""
now = self._now()
key = _safe_email_key(email)
if key is None or not isinstance(password, str) or not password:
self._passwords.verify_dummy(password if isinstance(password, str) else "")
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
request_id=request_id,
detail={"reason": "malformed_request"},
)
raise InvalidCredentialsError()
user = self._repository.get_user_by_email(key)
if user is None:
self._passwords.verify_dummy(password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
request_id=request_id,
detail={"reason": "unknown_account"},
)
raise InvalidCredentialsError()
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
# OIDC accounts never authenticate with a local password; burn a
# comparable amount of CPU before failing.
self._passwords.verify_dummy(password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "not_local_password_credential"},
)
raise InvalidCredentialsError()
if credential.is_locked(now):
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="blocked",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "credential_locked", "lock_state": "locked"},
)
raise InvalidCredentialsError()
if user.status is not AccountStatus.ACTIVE and user.status is not AccountStatus.INVITED:
# ``invited`` is excluded here and handled after the password has
# been proven, so an unconfirmed address is not distinguishable from
# an unknown one to someone who does not have the password.
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="denied",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": f"account_{user.status.value}", "status": user.status},
)
raise InvalidCredentialsError()
if self._organization_blocks_access(user):
# A suspended or archived tenant must not be able to mint new
# sessions. Revoking the existing ones is not sufficient on its own,
# because each member's own account status is still active.
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="denied",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "organization_not_active", "status": user.status},
)
raise InvalidCredentialsError()
if not self._passwords.verify_password(credential.password_hash, password):
failed_count = credential.failed_login_count + 1
locked_until: datetime | None = None
lock_state = "none"
if failed_count >= self._settings.max_failed_login_attempts:
locked_until = now + timedelta(seconds=self._settings.lockout_seconds)
lock_state = "locked"
self._repository.save_credential(
replace(credential, failed_login_count=failed_count, locked_until=locked_until, updated_at=now)
)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "invalid_password", "lock_state": lock_state, "username": user.username},
)
raise InvalidCredentialsError()
# The password is correct from here on, so it is now safe to say that the
# address is unconfirmed. Doing this check any earlier would turn the
# login form into an oracle for which addresses have accounts.
if user.status is AccountStatus.INVITED:
self._repository.save_credential(replace(credential, last_login_at=now, updated_at=now))
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="denied",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "email_not_verified", "status": user.status},
)
raise EmailNotVerifiedError()
self._repository.save_credential(
replace(credential, failed_login_count=0, locked_until=None, last_login_at=now, updated_at=now)
)
session = self._issue_session(user, now=now, must_change_password=credential.must_change_password)
self._audit(
action=ACTION_AUTH_LOGIN,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"username": user.username,
"must_change_password": credential.must_change_password,
"credential_type": credential.credential_type,
},
)
LOGGER.info("login succeeded user_id=%s must_change_password=%s", user.id, credential.must_change_password)
return AuthenticationResult(user=user, session=session)
def change_first_password(
self,
principal: Principal,
*,
current_password: str,
new_password: str,
request_id: str | None = None,
) -> AuthenticationResult:
"""Complete a mandatory first-login password change.
The temporary password is re-verified so that a stolen session cookie
alone cannot take over the account. On success every session is
revoked and a new one is issued (session rotation).
"""
user = self._require_user(principal.user_id)
credential = self._repository.get_credential(user.id)
if (
credential is None
or credential.credential_type is not CredentialType.LOCAL_PASSWORD
or credential.password_hash is None
):
raise InvalidCredentialsError()
if not isinstance(current_password, str) or not self._passwords.verify_password(
credential.password_hash, current_password
):
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="failure",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "invalid_current_password"},
)
raise InvalidCredentialsError()
try:
self._passwords.policy.validate(new_password, username=user.username)
except PasswordPolicyError as error:
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="failure",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "password_policy"},
)
raise error
if self._passwords.verify_password(credential.password_hash, new_password):
raise PasswordPolicyError(("must_differ_from_current",))
now = self._now()
new_hash = self._passwords.hash_password(new_password)
self._repository.save_credential(
replace(
credential,
password_hash=new_hash,
must_change_password=False,
failed_login_count=0,
locked_until=None,
password_changed_at=now,
updated_at=now,
)
)
self._repository.save_user(replace(user, updated_at=now, version=user.version + 1))
revoked = self._repository.revoke_sessions(user.id, reason="password_rotated", now=now)
session = self._issue_session(user, now=now, must_change_password=False)
self._audit(
action=ACTION_AUTH_FIRST_PASSWORD_CHANGED,
outcome="success",
actor=principal,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"revoked_sessions": revoked, "username": user.username},
)
LOGGER.info("first password changed user_id=%s revoked_sessions=%s", user.id, revoked)
return AuthenticationResult(user=user, session=session)
# -- email verification ---------------------------------------------------
def issue_email_verification(
self,
user_id: UUID,
*,
actor: Principal | None = None,
request_id: str | None = None,
) -> str:
"""Mint a single-use verification link and mail it. Returns the link.
The returned link is the only place the plaintext token exists. It is
returned to the caller so the development mail transport, the audit
record, and a test can all reach it; no endpoint may pass it through.
"""
user = self._require_user(user_id)
if user.status is not AccountStatus.INVITED:
raise StateTransitionError(
"already_verified",
"That account does not need email verification.",
)
now = self._now()
self._repository.delete_expired_verification_tokens(now=now)
token = generate_token(32)
self._repository.create_verification_token(
VerificationToken(
id=uuid4(),
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(seconds=VERIFICATION_TOKEN_TTL_SECONDS),
created_at=now,
purpose=VERIFICATION_PURPOSE_EMAIL,
)
)
self._audit(
action=ACTION_EMAIL_VERIFICATION_ISSUED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle},
)
LOGGER.info("email verification issued user_id=%s", user.id)
return self._verification_link(token)
def verify_email(self, token: str, *, request_id: str | None = None) -> AccountView:
"""Consume a verification link and activate the account.
Every failure - unknown, already used, expired - raises the same error,
so this cannot be used to discover which links exist.
"""
now = self._now()
if not isinstance(token, str) or not token:
raise VerificationFailedError()
# Purpose and clock go to the lookup rather than being checked after it.
# The post-check below is kept as a second opinion, because the two
# repositories compare expiry against different clocks - the database's
# and the caller's - and agreeing is worth the cost of one comparison.
record = self._repository.get_live_verification_token(
hash_session_token(token), purpose=VERIFICATION_PURPOSE_EMAIL, now=now
)
if record is None or not record.is_usable(now) or record.purpose != VERIFICATION_PURPOSE_EMAIL:
raise VerificationFailedError()
# Compare-and-set: two concurrent uses cannot both observe success.
if not self._repository.mark_verification_token_consumed(record.id, now=now):
raise VerificationFailedError()
user = self._require_user(record.user_id)
if AccountStatus.ACTIVE not in ALLOWED_STATUS_TRANSITIONS.get(user.status, frozenset()):
raise VerificationFailedError()
updated = replace(
user,
status=AccountStatus.ACTIVE,
updated_at=now,
version=user.version + 1,
)
self._repository.save_user(updated)
self._audit(
action=ACTION_EMAIL_VERIFIED,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle, "status": updated.status},
)
LOGGER.info("email verified user_id=%s", user.id)
return self._view(updated, now=now)
def resend_email_verification(
self,
email: str,
*,
request_id: str | None = None,
) -> bool:
"""Re-mail a verification link. Returns True when one was sent.
Takes an address rather than a principal, because an unverified account
cannot hold a session and so cannot ask for itself. The response to the
HTTP layer is always the same, so this cannot be used to discover which
addresses have accounts.
"""
key = _safe_email_key(email)
user = self._repository.get_user_by_email(key) if key else None
if user is None or user.status is not AccountStatus.INVITED:
# Nothing is sent and nothing is said. The caller reports the same
# outcome either way.
return False
link = self.issue_email_verification(user.id, request_id=request_id)
self._deliver(user.email, link)
return True
# -- password reset ------------------------------------------------------
def request_password_reset(
self,
actor: Principal,
user_id: UUID,
*,
request_id: str | None = None,
) -> bool:
"""Mail a password-reset link to an account. Returns whether one was sent.
The administrator does not learn the new password and never sets one.
A reset that an administrator chooses is a reset the account owner did
not ask for, and an administrator who can set someone's password can
impersonate them indefinitely. The link goes to the address on the
account, so only the owner can complete it.
The return value distinguishes "mailed" from "not applicable" for the
caller's audit, and deliberately does not distinguish why. See
:meth:`reset_password_with_token` for the enumeration rule that applies
here too.
"""
self._require_admin(actor)
user = self._require_user(user_id)
# Scoped like every other administrative action. Without this an
# organization administrator could start a password reset on an account in
# another tenant, which is a way to cause a targeted denial of service
# even though the link itself stays with the account owner.
self._authorize_target(actor, user)
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
# OIDC accounts have no local password to reset. Refused plainly here
# because the caller is an authenticated administrator who can already
# see the credential type; there is nothing to protect from them.
raise StateTransitionError(
"not_a_local_account",
"That account signs in through an identity provider and has no password to reset.",
)
if user.status not in (AccountStatus.ACTIVE, AccountStatus.INVITED):
# Suspended and deactivated are excluded on purpose. A reset revokes
# sessions and restores sign-in, so allowing one would be a way to
# undo a suspension without going through the status controls, and an
# administrator who wants an account usable again should say so
# explicitly with those.
#
# ``invited`` is allowed, and this was a real deadlock before it was:
# an account that has never confirmed its address cannot sign in, and
# an earlier version of this method refused to reset it, so it could be
# reached by neither route. It is also safe, because the link goes to
# the very address the account has not proved it controls, so only the
# holder can complete it.
raise StateTransitionError(
"account_not_active",
"Only an active or newly-created account can have its password reset.",
)
now = self._now()
self._repository.delete_expired_verification_tokens(now=now)
token = generate_token(32)
self._repository.create_verification_token(
VerificationToken(
id=uuid4(),
user_id=user.id,
token_hash=hash_session_token(token),
expires_at=now + timedelta(seconds=PASSWORD_RESET_TOKEN_TTL_SECONDS),
created_at=now,
purpose=PASSWORD_RESET_PURPOSE,
)
)
# A previously issued reset link is now stale in the only sense that
# matters: consuming this one leaves the earlier one still valid, so the
# newest link is not the only live one. That is deliberate. Revoking the
# old one on issue would mean an administrator repeatedly asking for a
# reset could silently break a link the owner is in the middle of using.
# Both are short-lived, both are single-use, and only the newest is
# described as current in the message.
self._deliver_reset(user.email, self._password_reset_link(token))
self._audit(
action=ACTION_PASSWORD_RESET_REQUESTED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle},
)
LOGGER.info("password reset requested user_id=%s by=%s", user.id, actor.user_id)
return True
def password_reset_is_usable(self, token: str) -> bool:
"""Whether a reset link is still live, without consuming it.
Lets the page avoid presenting a password form for a link that will be
refused on submit. It reads the same record
:meth:`reset_password_with_token` requires, and does not consume it, so
asking twice does not use the link up.
One answer for every reason - unknown, used, expired, wrong purpose - so
it cannot be used to learn whether a link exists.
"""
record = self._repository.get_live_verification_token(
hash_session_token(token or ""), purpose=PASSWORD_RESET_PURPOSE, now=self._now()
)
return record is not None
def reset_password_with_token(
self,
token: str,
new_password: str,
*,
request_id: str | None = None,
) -> None:
"""Consume a reset link and set a new password.
Every failure - unknown, already used, expired, wrong purpose - raises the
same error, so this cannot be used to discover which links exist or what
they are for. In particular a verification link presented here is refused
exactly as an invented link is.
On success every existing session is revoked, not just the one that
completed the reset. Anyone holding a session cookie copied before the
reset keeps access otherwise, and a reset is what a person reaches for
precisely when they believe someone else has their password.
"""
now = self._now()
record = self._repository.get_live_verification_token(
hash_session_token(token), purpose=PASSWORD_RESET_PURPOSE, now=now
)
if record is None:
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="failure",
request_id=request_id,
detail={"reason": "invalid_or_expired_token"},
)
raise VerificationFailedError()
if not self._repository.mark_verification_token_consumed(record.id, now=now):
# Lost the race with a simultaneous request using the same link.
raise VerificationFailedError()
user = self._require_user(record.user_id)
credential = self._repository.get_credential(user.id)
if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD:
raise VerificationFailedError()
try:
self._passwords.policy.validate(new_password, username=user.username)
except PasswordPolicyError as error:
# The token is already spent, so a rejected password costs the user
# another link. That is the safe direction: leaving the token live
# would let a weak first attempt be followed by a stronger one by
# whoever found it.
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "password_policy"},
)
raise error
if self._passwords.verify_password(credential.password_hash, new_password):
raise PasswordPolicyError(("must_differ_from_current",))
self._repository.save_credential(
replace(
credential,
password_hash=self._passwords.hash_password(new_password),
must_change_password=False,
failed_login_count=0,
locked_until=None,
password_changed_at=now,
updated_at=now,
)
)
# Holding the link proves control of the address, which is exactly what
# email verification asserts. For an account that has never been verified
# the two are the same fact, so a completed reset confirms it as well.
#
# Without this the flow does not converge: the person resets the password,
# signs in, and is refused again for an unconfirmed address, using a
# verification link they never received in the first place.
# ``is_email_verified`` is a property derived from status, so confirming
# the address is setting the status. There is no second field to keep in
# step, which is the right way round.
was_invited = user.status is AccountStatus.INVITED
user = replace(
user,
status=AccountStatus.ACTIVE if was_invited else user.status,
updated_at=now,
version=user.version + 1,
)
self._repository.save_user(user)
revoked = self._repository.revoke_sessions(user.id, reason="password_reset", now=now)
self._audit(
action=ACTION_PASSWORD_RESET_COMPLETED,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle, "revoked_sessions": revoked},
)
if was_invited:
# A separate event, because it is a separate fact. An administrator
# reading the trail should be able to see that a reset also confirmed
# an address, not infer it from a password change.
self._audit(
action=ACTION_EMAIL_VERIFIED,
outcome="success",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"username": user.audit_handle, "source": "password_reset"},
)
LOGGER.info("password reset completed user_id=%s sessions_revoked=%d confirmed=%s", user.id, revoked, was_invited)
def _password_reset_link(self, token: str) -> str:
base = self._settings.verification_base_url
return f"{base}/reset-password.html?token={token}" if base else f"/reset-password.html?token={token}"
def _deliver_reset(self, recipient: str, link: str) -> None:
"""Send the reset message, failing loudly without exposing the link."""
subject = "Reset your UmbraTrans password"
body = (
"A password reset was requested for your UmbraTrans account.\n\n"
"If it was you, choose a new password here:\n\n"
f" {link}\n\n"
"The link works once and expires in one hour. If you did not ask for a reset, "
"ignore this message: nothing has changed and no one can use this link without it.\n"
)
try:
self._mailer.send(to=recipient, subject=subject, body=body)
except MailerError:
LOGGER.error(
"password reset mail could not be delivered transport=%s", self._mailer.transport_name
)
raise VerificationDeliveryError() from None
def change_own_password(
self,
principal: Principal,
*,
current_password: str,
new_password: str,
request_id: str | None = None,
) -> AuthenticationResult:
"""Change the signed-in person's own password.
The same rules as a reset, reached with the current password instead of a
mailed link: the password is re-verified, it must satisfy the policy and
differ from the current one, and every session is revoked with a new one
issued. That is why this is not simply the first-login change renamed:
the first-login flow exists for a session that cannot do anything else,
and it does not require the current password, because the holder only has
the one-time one.
"""
return self.change_first_password(
principal,
current_password=current_password,
new_password=new_password,
request_id=request_id,
)
def _verification_link(self, token: str) -> str:
"""Build the link the confirmation message carries.
The path names the file, extension included. The application mounts the
frontend as static files, and that mount resolves ``/verify-email.html``
but not a bare ``/verify-email``, so an extensionless link 404s and the
one step of this flow that cannot be worked around is the one that
breaks.
"""
base = self._settings.verification_base_url
return f"{base}/verify-email.html?token={token}" if base else f"/verify-email.html?token={token}"
def _require_deliverable_mail(self) -> None:
"""Refuse to create an account whose confirmation mail cannot be sent.
Checked in the service, not in the browser. The control panel disables its
own buttons when the health endpoint says mail is undeliverable, but a
client-side check is a courtesy, not a control: any request that reaches
this process would still create the account.
Without it, a deployment with a rejected SMTP credential accepts account
creation and every account created is stranded in ``invited`` with a link
that was never delivered and cannot be re-sent. The administrator sees a
success response and a growing list of accounts that nobody can ever
activate.
Limited to public environments. In development the logging transport is
the point, and refusing there would make the flow untestable; and the
operator enrollment path, which attests the address instead of mailing it,
is deliberately exempt so first-run setup cannot be blocked behind mail
configuration.
"""
if not self._settings.environment.is_public:
return
if self.mail_is_deliverable():
return
raise MailDeliveryUnavailableError(
"mail_not_deliverable",
"Outbound mail is not working, so an account created now could never be "
"activated. Configure and verify the mail transport first.",
)
def _deliver(self, recipient: str, link: str) -> None:
"""Send the message, failing loudly without exposing the link.
A delivery failure must not look like success to the caller, but the
reason must not contain the token either.
"""
subject = "Confirm your UmbraTrans email address"
body = (
"An account was created for this address on UmbraTrans.\n\n"
"Confirm the address to finish setting it up:\n\n"
f" {link}\n\n"
"If you did not expect this, ignore this message. The account cannot "
"be used until the address is confirmed, and nobody can sign in as it "
"before then.\n"
)
try:
self._mailer.send(to=recipient, subject=subject, body=body)
except MailerError:
LOGGER.error("verification mail could not be delivered transport=%s", self._mailer.transport_name)
raise VerificationDeliveryError() from None
# -- sessions ------------------------------------------------------------
def resolve_session(self, token: str, *, request_id: str | None = None) -> Principal:
"""Resolve a session token into a principal, or raise a 401-equivalent."""
now = self._now()
if not isinstance(token, str) or not token:
raise InvalidCredentialsError()
session = self._repository.get_session_by_token_hash(self._token_hash(token))
if session is None or session.is_revoked():
raise InvalidCredentialsError()
user = self._repository.get_user(session.user_id)
if user is None:
raise InvalidCredentialsError()
if session.is_expired(now):
self._repository.save_session(replace(session, revoked_at=now, revoked_reason="expired"))
self._audit(
action=ACTION_SESSION_EXPIRED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "deadline_exceeded"},
)
raise InvalidCredentialsError()
if user.status is not AccountStatus.ACTIVE:
revoked = self._repository.revoke_sessions(user.id, reason=f"account_{user.status.value}", now=now)
self._audit(
action=ACTION_SESSION_INVALIDATED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"status": user.status, "revoked_sessions": revoked},
)
raise InvalidCredentialsError()
if self._organization_blocks_access(user):
# Defense in depth. set_organization_status already revokes member
# sessions, so reaching this means the organization was suspended
# without going through that path; refuse and clean up anyway.
revoked = self._repository.revoke_sessions(user.id, reason="organization_not_active", now=now)
self._audit(
action=ACTION_SESSION_INVALIDATED,
outcome="failure",
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"reason": "organization_not_active", "revoked_sessions": revoked},
)
raise InvalidCredentialsError()
credential = self._repository.get_credential(user.id)
must_change = bool(credential and credential.must_change_password)
self._touch_session(session, now=now)
return Principal(
user_id=user.id,
username=user.username,
role=user.role,
session_id=session.id,
organization_id=user.organization_id,
must_change_password=must_change,
)
def logout(self, principal: Principal, *, request_id: str | None = None) -> int:
"""Revoke only the caller's current session.
Other sessions of the same account survive; ``logout_everywhere`` is
the explicit way to end all of them.
"""
now = self._now()
revoked = 0
session = self._repository.get_session(principal.session_id)
if session is not None and not session.is_revoked():
self._repository.save_session(replace(session, revoked_at=now, revoked_reason="logout"))
revoked = 1
self._audit(
action=ACTION_AUTH_LOGOUT,
outcome="success",
actor=principal,
target_user_id=principal.user_id,
request_id=request_id,
detail={"revoked_sessions": revoked},
)
return revoked
def logout_everywhere(self, principal: Principal, *, request_id: str | None = None) -> int:
"""Revoke every session belonging to the caller, including the current one."""
now = self._now()
revoked = self._repository.revoke_sessions(principal.user_id, reason="logout_everywhere", now=now)
self._audit(
action=ACTION_AUTH_LOGOUT_EVERYWHERE,
outcome="success",
actor=principal,
target_user_id=principal.user_id,
request_id=request_id,
detail={"revoked_sessions": revoked},
)
LOGGER.info("logout everywhere user_id=%s revoked_sessions=%s", principal.user_id, revoked)
return revoked
# -- administration ------------------------------------------------------
def self_view(self, user_id: UUID) -> AccountView:
"""Return the sanitized view of one account for its own profile.
The caller must already be authenticated as that account; no
organization scoping is needed because the identifier comes from the
resolved principal, never from the browser.
"""
return self._view(self._require_user(user_id), now=self._now())
def list_accounts(
self,
actor: Principal,
*,
organization_id: UUID | None = None,
status: AccountStatus | None = None,
role: RoleName | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[AccountView], int]:
"""List accounts. An organization administrator never sees other organizations."""
self._require_admin(actor)
scope = self._admin_scope(actor, organization_id)
users, total = self._repository.find_users(
organization_id=scope, status=status, role=str(role) if role else None, limit=limit, offset=offset
)
now = self._now()
return [self._view(user, now=now, visible_to=actor) for user in users], total
def get_account(self, actor: Principal, user_id: UUID) -> AccountView:
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
return self._view(user, now=self._now(), visible_to=actor)
def set_account_status(
self,
actor: Principal,
user_id: UUID,
status: str,
*,
reason: str | None = None,
request_id: str | None = None,
) -> tuple[AccountView, int]:
"""Suspend, reactivate, or deactivate an account and revoke its sessions."""
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
new_status = _account_status(status)
if new_status is user.status:
raise StateTransitionError("status_unchanged", "The account already has that status.")
if new_status not in ALLOWED_STATUS_TRANSITIONS[user.status]:
raise StateTransitionError()
if new_status is not AccountStatus.ACTIVE and user.role is RoleName.PLATFORM_ADMIN:
if self._count_active_platform_admins() <= 1:
raise ConflictError(
"last_platform_admin",
"The last active platform administrator cannot be suspended or deactivated.",
)
if (
new_status is AccountStatus.ACTIVE
and user.status is AccountStatus.DEACTIVATED
and actor.role is not RoleName.PLATFORM_ADMIN
):
raise ForbiddenError(
"reactivation_requires_platform_admin",
"Only a platform administrator may reactivate a deactivated account.",
)
now = self._now()
updated = replace(user, status=new_status, updated_at=now, version=user.version + 1)
self._repository.save_user(updated)
revoked = 0
if new_status is not AccountStatus.ACTIVE:
revoked = self._repository.revoke_sessions(user.id, reason=f"account_{new_status.value}", now=now)
self._audit(
action=ACTION_ACCOUNT_STATUS_CHANGED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={
"status": new_status,
"previous_status": user.status,
"reason": normalize_reason(reason),
"role": user.role,
"revoked_sessions": revoked,
},
)
LOGGER.info("account status changed user_id=%s status=%s revoked_sessions=%s", user.id, new_status, revoked)
return self._view(updated, now=now, visible_to=actor), revoked
def revoke_sessions(self, actor: Principal, user_id: UUID, *, request_id: str | None = None) -> int:
"""Administrative session revocation for one account."""
self._require_admin(actor)
user = self._require_user(user_id)
self._authorize_target(actor, user)
now = self._now()
revoked = self._repository.revoke_sessions(user.id, reason="administrative_revocation", now=now)
self._audit(
action=ACTION_SESSION_REVOKED,
outcome="success",
actor=actor,
target_user_id=user.id,
organization_id=user.organization_id,
request_id=request_id,
detail={"revoked_sessions": revoked, "reason": "administrative_revocation"},
)
return revoked
# -- organizations -------------------------------------------------------
def list_organizations(
self,
actor: Principal,
*,
status: OrganizationStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[OrganizationView], int]:
"""List organizations visible to the caller.
A platform administrator sees every organization. An organization
administrator is pinned to the single organization on their own
principal, so a browser-supplied filter cannot widen the result.
"""
self._require_admin(actor)
scope: tuple[UUID, ...] | None = None
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
scope = (actor.organization_id,)
organizations, total = self._repository.find_organizations(
status=status, organization_ids=scope, limit=limit, offset=offset
)
return [self._organization_view(organization) for organization in organizations], total
def get_organization(self, actor: Principal, organization_id: UUID) -> OrganizationView:
self._require_admin(actor)
organization = self._require_organization(organization_id)
self._authorize_organization_target(actor, organization)
return self._organization_view(organization)
def create_organization(
self,
actor: Principal,
*,
name: str,
slug: str | None = None,
request_id: str | None = None,
) -> OrganizationView:
"""Create a tenant. Platform administrators only.
An organization administrator cannot create one, which keeps the
tenant boundary under a single authority.
"""
self._require_platform_admin(actor)
now = self._now()
canonical_name = normalize_organization_name(name)
# A missing slug is derived from the name. A supplied slug is validated
# as written so a rejected value reports the slug rule, not the name.
canonical_slug = (
require_canonical_organization_slug(slug) if slug is not None else slugify_organization_name(name)
)
organization = Organization(
id=uuid4(),
name=canonical_name,
slug=canonical_slug,
status=OrganizationStatus.ACTIVE,
created_at=now,
updated_at=now,
)
self._repository.create_organization(organization)
self._audit(
action=ACTION_ORGANIZATION_CREATED,
outcome="success",
actor=actor,
target_user_id=None,
organization_id=organization.id,
request_id=request_id,
detail={
"organization_name": organization.name,
"organization_slug": organization.slug,
"status": organization.status,
},
)
LOGGER.info("organization created organization_id=%s slug=%s", organization.id, organization.slug)
return self._organization_view(organization)
def update_organization(
self,
actor: Principal,
organization_id: UUID,
*,
name: str | None = None,
slug: str | None = None,
request_id: str | None = None,
) -> OrganizationView:
"""Rename an organization or change its slug. Platform administrators only.
The slug is a historical handle, so changing it is audited as its own
event rather than folded into a rename.
"""
self._require_platform_admin(actor)
organization = self._require_organization(organization_id)
if name is None and slug is None:
raise InputError("no_changes", "Supply a name, a slug, or both.")
updated = organization
if name is not None:
updated = replace(updated, name=normalize_organization_name(name), updated_at=self._now())
if slug is not None:
updated = replace(updated, slug=require_canonical_organization_slug(slug), updated_at=self._now())
updated = replace(updated, version=organization.version + 1)
self._repository.save_organization(updated)
self._audit(
action=ACTION_ORGANIZATION_UPDATED,
outcome="success",
actor=actor,
organization_id=organization.id,
request_id=request_id,
detail={
"organization_name": updated.name,
"organization_slug": updated.slug,
"previous_status": organization.status,
},
)
LOGGER.info("organization updated organization_id=%s slug=%s", organization.id, updated.slug)
return self._organization_view(updated)
def set_organization_status(
self,
actor: Principal,
organization_id: UUID,
status: str,
*,
reason: str | None = None,
request_id: str | None = None,
) -> tuple[OrganizationView, int]:
"""Suspend, reactivate, or archive an organization.
Leaving ``active`` revokes every live session belonging to a member of
that organization, because a suspended tenant must not keep serving
already-authenticated browsers.
"""
self._require_platform_admin(actor)
organization = self._require_organization(organization_id)
new_status = _organization_status(status)
if new_status is organization.status:
raise StateTransitionError("status_unchanged", "The organization already has that status.")
if new_status not in ALLOWED_ORGANIZATION_TRANSITIONS[organization.status]:
raise StateTransitionError()
now = self._now()
updated = replace(organization, status=new_status, updated_at=now, version=organization.version + 1)
self._repository.save_organization(updated)
revoked = 0
if new_status is not OrganizationStatus.ACTIVE:
members, _ = self._repository.find_users(organization_id=organization.id, limit=10_000)
for member in members:
if member.status is AccountStatus.ACTIVE:
revoked += self._repository.revoke_sessions(
member.id, reason=f"organization_{new_status.value}", now=now
)
self._audit(
action=ACTION_ORGANIZATION_STATUS_CHANGED,
outcome="success",
actor=actor,
organization_id=organization.id,
request_id=request_id,
detail={
"status": new_status,
"previous_status": organization.status,
"organization_name": organization.name,
"organization_slug": organization.slug,
"reason": normalize_reason(reason),
"revoked_sessions": revoked,
},
)
LOGGER.info(
"organization status changed organization_id=%s status=%s revoked_sessions=%s",
organization.id,
new_status,
revoked,
)
return self._organization_view(updated), revoked
def organization_summary(self, actor: Principal) -> dict[str, int]:
"""Counts for the administrator dashboard, within the caller's scope.
Recomputed on every call from the same scoped queries the list
endpoints use, so a summary can never disclose a number the caller
could not reach by listing.
"""
self._require_admin(actor)
scope: UUID | None = None
organization_scope: tuple[UUID, ...] | None = None
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
scope = actor.organization_id
organization_scope = (actor.organization_id,)
organizations, _ = self._repository.find_organizations(organization_ids=organization_scope, limit=10_000)
accounts, _ = self._repository.find_users(organization_id=scope, limit=10_000)
pending_first_login = 0
for account in accounts:
credential = self._repository.get_credential(account.id)
if credential is not None and credential.must_change_password:
pending_first_login += 1
return {
"total_organizations": len(organizations),
"active_organizations": sum(1 for item in organizations if item.status is OrganizationStatus.ACTIVE),
"total_accounts": len(accounts),
"active_accounts": sum(1 for item in accounts if item.status is AccountStatus.ACTIVE),
"suspended_accounts": sum(1 for item in accounts if item.status is AccountStatus.SUSPENDED),
"pending_first_login": pending_first_login,
"organization_admin_count": sum(1 for item in accounts if item.role is RoleName.ORGANIZATION_ADMIN),
}
def list_audit_events(
self,
actor: Principal,
*,
target_user_id: UUID | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[AuditEvent], int]:
"""List sanitized audit events within the caller's administrative scope."""
self._require_admin(actor)
scope = self._admin_scope(actor, None)
if target_user_id is not None:
self._authorize_target(actor, self._require_user(target_user_id))
events, total = self._repository.list_audit_events(
organization_id=scope,
target_user_id=target_user_id,
limit=limit,
offset=offset,
)
return list(events), total
# -- internals -----------------------------------------------------------
def _token_hash(self, token: str) -> str:
return hash_session_token(token, pepper=self._settings.token_pepper)
def _issue_session(self, user: User, *, now: datetime, must_change_password: bool) -> IssuedSession:
token = generate_token()
absolute_deadline = now + timedelta(seconds=self._settings.session_absolute_seconds)
idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), absolute_deadline)
session = SessionRecord(
id=uuid4(),
user_id=user.id,
token_hash=self._token_hash(token),
created_at=now,
last_seen_at=now,
idle_deadline_at=idle_deadline,
absolute_deadline_at=absolute_deadline,
mfa_verified_at=None,
)
self._repository.create_session(session)
return IssuedSession(
session_id=session.id,
token=token,
idle_expires_at=idle_deadline,
absolute_expires_at=absolute_deadline,
must_change_password=must_change_password,
)
def _touch_session(self, session: SessionRecord, *, now: datetime) -> None:
"""Refresh ``last_seen_at`` at most once per configured interval."""
interval = self._settings.last_seen_write_interval_seconds
if interval <= 0 or (now - session.last_seen_at).total_seconds() < interval:
return
idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), session.absolute_deadline_at)
self._repository.save_session(
replace(session, last_seen_at=now, idle_deadline_at=max(idle_deadline, session.idle_deadline_at))
)
def _require_user(self, user_id: UUID) -> User:
user = self._repository.get_user(user_id)
if user is None:
raise NotFoundError("account_not_found")
return user
def _require_organization(self, organization_id: UUID) -> Organization:
organization = self._repository.get_organization(organization_id)
if organization is None:
raise NotFoundError("organization_not_found")
return organization
def _organization_blocks_access(self, user: User) -> bool:
"""Report whether the user's organization forbids access.
An account with no organization is platform-scoped and is never
blocked. A missing organization record counts as blocking: an
unresolvable tenant reference must fail closed rather than grant
access by accident.
"""
if user.organization_id is None:
return False
organization = self._repository.get_organization(user.organization_id)
if organization is None:
return True
return organization.status is not OrganizationStatus.ACTIVE
def _validate_organization_for_assignment(
self,
organization_id: UUID | None,
*,
role: str | None = None,
) -> None:
"""Reject an account assignment that the tenancy model cannot hold.
Without this, any caller-supplied UUID becomes a silent tenant
reference, because nothing else in phase 1 backs
``user.organization_id``.
``role`` is checked here rather than left to the store because the two
repositories must agree. ``user_roles`` requires a non-platform role to
carry an organization, so an organization-less member is storable in the
development JSON store and rejected by PostgreSQL. The same call would
then succeed in development and fail in production, which is the failure
mode this layer exists to prevent, so the rule is enforced here where it
behaves identically against every repository.
"""
if role is not None and role != str(RoleName.PLATFORM_ADMIN) and organization_id is None:
raise InputError(
"organization_required",
"Only a platform administrator can be created without an organization.",
)
if organization_id is None:
return
organization = self._require_organization(organization_id)
if organization.status is not OrganizationStatus.ACTIVE:
raise InputError(
"organization_not_active",
"Accounts can only be assigned to an active organization.",
)
def _organization_view(self, organization: Organization) -> OrganizationView:
return OrganizationView(
id=organization.id,
name=organization.name,
slug=organization.slug,
status=organization.status,
member_count=self._repository.count_users(organization.id),
created_at=organization.created_at,
updated_at=organization.updated_at,
)
def _require_admin(self, actor: Principal | None) -> None:
if actor is None:
raise ForbiddenError("administrator_required")
if actor.must_change_password:
raise PasswordChangeRequiredError()
if not actor.is_admin:
raise ForbiddenError()
def _require_platform_admin(self, actor: Principal | None) -> None:
self._require_admin(actor)
if actor is None or actor.role is not RoleName.PLATFORM_ADMIN: # pragma: no cover - _require_admin raises first
raise ForbiddenError("platform_admin_only")
def _authorize_organization_target(self, actor: Principal, organization: Organization) -> None:
if actor.role is RoleName.PLATFORM_ADMIN:
return
if actor.organization_id != organization.id:
raise ForbiddenError("cross_organization_forbidden")
def _admin_scope(self, actor: Principal, requested_organization: UUID | None) -> UUID | None:
"""Return the organization filter an administrator may use.
An organization administrator is always pinned to their own
organization; a browser-supplied organization identifier is ignored.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return requested_organization
if actor.organization_id is None:
return None
return actor.organization_id
def _authorize_target(self, actor: Principal, target: User) -> None:
if actor.role is RoleName.PLATFORM_ADMIN:
return
if target.role is RoleName.PLATFORM_ADMIN:
raise ForbiddenError("platform_admin_only")
if actor.organization_id is None or target.organization_id != actor.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def _authorize_creation(self, actor: Principal, *, role: str, organization_id: UUID | None) -> None:
"""Stop an organization administrator escalating its own scope.
An organization administrator may only create members inside its own
organization. A browser-supplied organization or role is never honoured
outside that scope.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return
if _role(role) is RoleName.PLATFORM_ADMIN:
raise ForbiddenError("platform_admin_only")
if organization_id != actor.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def _count_active_platform_admins(self) -> int:
users, _ = self._repository.find_users(role=str(RoleName.PLATFORM_ADMIN), limit=10_000)
return sum(1 for user in users if user.status is AccountStatus.ACTIVE)
def _view(self, user: User, *, now: datetime, visible_to: Principal | None = None) -> AccountView:
credential = self._repository.get_credential(user.id)
summary = (
credential.public_summary(now=now)
if credential is not None
else CredentialSummary(
credential_type=CredentialType.OIDC,
must_change_password=False,
failed_login_count=0,
locked=False,
)
)
return AccountView(
id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
status=user.status,
credential=summary,
username=user.username,
organization_id=user.organization_id,
created_at=user.created_at,
updated_at=user.updated_at,
active_session_count=self._repository.count_sessions(user.id),
)
def setup_is_required(self) -> bool:
"""Whether the deployment still has no usable platform administrator.
Asked of the store on every call rather than cached, so the answer cannot
go stale: the window closes the instant the first administrator exists,
even with several processes running against one database.
"""
return self._repository.count_users_with_role(RoleName.PLATFORM_ADMIN) == 0
def create_initial_administrator(
self,
*,
email: str,
display_name: str,
password: str,
username: str | None = None,
request_id: str | None = None,
) -> IssuedTemporaryPassword:
"""Create the first platform administrator during first-run setup.
Platform scope, so no organization is needed and none is invented. The
address is attested rather than verified, which is what makes first-run
setup possible at all: a fresh deployment has no mail configured, and
demanding a delivered message before the first account exists would
deadlock. Every subsequent account goes through the administrator path,
which does send verification.
"""
if not self.setup_is_required():
raise ConflictError(
"setup_not_required",
"This deployment already has a platform administrator.",
)
account = self._provision_local_account(
username=username,
display_name=display_name,
role=str(RoleName.PLATFORM_ADMIN),
email=email,
organization_id=None,
password=password,
operator=OperatorContext(source="first-run-setup"),
request_id=request_id,
)
self._audit(
action=ACTION_ACCOUNT_PROVISIONED_LOCAL,
outcome="success",
target_user_id=account.id,
organization_id=None,
request_id=request_id,
detail={
"username": account.username,
"role": str(RoleName.PLATFORM_ADMIN),
"enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD,
"source": "first-run-setup",
},
)
LOGGER.warning("first-run setup completed: platform administrator created user_id=%s", account.id)
return IssuedTemporaryPassword(
account=self._view(account, now=self._now()),
temporary_password=password,
)
# -- projects and tenant visibility ---------------------------------------
def visible_scope(self, actor: Principal, requested: UUID | None = None) -> TenantScope:
"""The caller's tenant scope. Every project read and write goes through this.
Exposed on the service so the routes have one call to make rather than
each choosing its own idea of what a caller may see. The rules live in
:mod:`umbratrans_web.tenancy`.
"""
return scope_for(actor, requested)
def list_projects(
self,
actor: Principal,
*,
organization_id: UUID | None = None,
statuses: Sequence[ProjectStatus] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[ProjectView], int]:
"""Projects the caller may see.
Everyone in an organization sees every project in it. Being assigned to a
project says who is working on it, not who may know it exists, so the
caller's own assignments are not required and are not used as a filter.
A platform administrator sees every organization's projects.
This is a deliberate divergence from the database's own
``app.can_access_project``, which restricts to assigned members. See
:mod:`umbratrans_web.tenancy` for why, and for what it would mean if
row-level security were ever switched on.
"""
scope = scope_for(actor, organization_id)
rows, total = self._repository.find_projects(
organization_id=scope.organization_id, statuses=statuses, limit=limit, offset=offset
)
return [self._project_view(row) for row in rows], total
def get_project(self, actor: Principal, project_id: UUID) -> ProjectView:
"""One project, or a refusal that does not say whether it exists elsewhere.
A caller outside the owning organization gets ``cross_organization_forbidden``
whether the project is missing, belongs to another tenant, or is theirs.
The store is asked with the organization in the predicate, so a foreign row
is never fetched and then judged.
"""
scope = scope_for(actor)
row = self._repository.get_project(project_id, organization_id=scope.organization_id)
if row is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
return self._project_view(row)
def create_project(
self,
actor: Principal,
*,
name: str,
slug: str | None = None,
organization_id: UUID | None = None,
request_id: str | None = None,
) -> ProjectView:
"""Create a project inside the caller's organization.
A project cannot be created without an organization, unlike a platform
administrator. A project belonging to no tenant would be visible to nobody
and editable by whoever found it, which is not a state worth having.
A *read* naming another organization is quietly ignored, because refusing
would confirm the identifier exists. A *write* is not: creating in
organization A while naming organization B would put the project somewhere
the caller did not ask for and does not expect to find it, so it is
refused. Silently retargeting a write is how a tenant ends up holding rows
it never agreed to.
"""
self._require_admin(actor)
scope = scope_for(actor, organization_id)
target = resolve_target(scope, organization_id)
self._validate_organization_for_assignment(target, role=str(RoleName.MEMBER))
now = self._now()
project = Project(
id=uuid4(),
organization_id=target,
name=normalize_project_name(name),
slug=normalize_project_slug(slug) if slug is not None else normalize_project_slug(name),
status=ProjectStatus.ACTIVE,
created_by=actor.user_id,
created_at=now,
updated_at=now,
)
self._repository.create_project(project)
self._audit(
action=ACTION_PROJECT_CREATED,
outcome="success",
actor=actor,
organization_id=target,
request_id=request_id,
# Keyed as a project, not an organization. An audit record whose fields
# name the wrong entity is worse than one that omits them, because it
# reads as though an organization had been created.
detail={"project_name": project.name, "project_slug": project.slug},
)
LOGGER.info("project created project_id=%s organization_id=%s", project.id, target)
return self._project_view(project)
def assign_account_to_project(
self,
actor: Principal,
project_id: UUID,
user_id: UUID,
*,
request_id: str | None = None,
) -> ProjectView:
"""Say an account is working on a project.
Scoped twice: the project must be in the caller's organization and the
account must be in it too. Assigning someone from another tenant to your
project would be a way to expose an account across the boundary, and the
row that records it would not show the crossing.
"""
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
account = self._require_user(user_id)
if account.organization_id != project.organization_id:
raise ForbiddenError("cross_organization_forbidden")
self._repository.assign_user_to_project(project.id, account.id, organization_id=project.organization_id)
self._audit(
action=ACTION_PROJECT_ASSIGNMENT_ADDED,
outcome="success",
actor=actor,
target_user_id=account.id,
organization_id=project.organization_id,
request_id=request_id,
detail={"username": account.audit_handle, "organization_slug": project.slug},
)
return self._project_view(self._repository.get_project(project.id) or project)
def unassign_account_from_project(
self,
actor: Principal,
project_id: UUID,
user_id: UUID,
*,
request_id: str | None = None,
) -> ProjectView:
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
account = self._require_user(user_id)
if account.organization_id != project.organization_id:
raise ForbiddenError("cross_organization_forbidden")
removed = self._repository.unassign_user_from_project(project.id, account.id)
self._audit(
action=ACTION_PROJECT_ASSIGNMENT_REMOVED,
outcome="success" if removed else "no_change",
actor=actor,
target_user_id=account.id,
organization_id=project.organization_id,
request_id=request_id,
detail={"username": account.audit_handle, "organization_slug": project.slug},
)
return self._project_view(self._repository.get_project(project.id) or project)
def set_project_status(
self,
actor: Principal,
project_id: UUID,
status: ProjectStatus,
*,
reason: str | None = None,
request_id: str | None = None,
) -> ProjectView:
"""Move a project through its lifecycle.
Transitions are constrained rather than accepted, matching the
organization status controls: archived is terminal, so a project that has
been archived cannot be reopened by anyone, including a platform
administrator.
"""
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
allowed = PROJECT_STATUS_TRANSITIONS[project.status]
if status not in allowed:
raise StateTransitionError(
"invalid_project_transition",
f"A project that is {project.status.value} cannot become {status.value}.",
)
updated = replace(project, status=status, updated_at=self._now())
self._repository.save_project(updated)
self._audit(
action=ACTION_PROJECT_STATUS_CHANGED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"previous_status": project.status.value,
"status": status.value,
"organization_slug": project.slug,
"reason": reason or "",
},
)
return self._project_view(updated)
# -- language-project workflow ------------------------------------------
def _productions_dir_for(self, project: Project) -> Path | None:
"""Return the productions directory for a project, or None if unmapped."""
from .project_status import _SLUG_TO_PROD
prod_name = _SLUG_TO_PROD.get(getattr(project, "slug", ""))
if not prod_name:
return None
root = self._settings.productions_root
if root is None:
return None
return Path(root) / prod_name
def _job_key(self, project_id: UUID, language_tag: str, job_type: str) -> str:
return f"{project_id}:{language_tag}:{job_type}"
def _read_json(self, path: Path) -> dict | None:
if not path.is_file():
return None
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
return data if isinstance(data, dict) else None
except Exception:
return None
def _parse_utc(self, value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def queue_translation(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
*,
request_id: str | None = None,
) -> TranslationQueueResponse:
"""Queue a translation job for one language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
adapter = ProjectStatusAdapter(self._settings.productions_root)
status = adapter.status_for(project)
lang = next(
(l for l in status.get("languages", []) if l.get("language_tag") == language_tag),
None,
)
if lang is None:
raise NotFoundError("language_not_found")
tr_state = (lang.get("translation") or {}).get("state")
if tr_state in ("in_progress", "complete"):
raise ConflictError(
"translation_already_active",
f"Translation for {language_tag} is already {tr_state}.",
)
job_id = str(uuid4())
job_record = {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"type": "translation",
"state": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
}
_JOB_STORE[self._job_key(project_id, language_tag, "translation")] = job_record
self._audit(
action=ACTION_TRANSLATION_QUEUED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"job_id": job_id,
},
)
LOGGER.info(
"translation queued project_id=%s language_tag=%s job_id=%s",
project_id,
language_tag,
job_id,
)
return TranslationQueueResponse(
project_id=project_id,
language_tag=language_tag,
state="queued",
job_id=job_id,
queued_at=datetime.now(timezone.utc),
)
def queue_audio(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
voice_config: dict | None = None,
*,
request_id: str | None = None,
) -> AudioQueueResponse:
"""Queue an audio generation job for one language project.
Refuses unless the language's translation is already complete, matching
the workflow: translate first, then audio.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
adapter = ProjectStatusAdapter(self._settings.productions_root)
status = adapter.status_for(project)
lang = next(
(l for l in status.get("languages", []) if l.get("language_tag") == language_tag),
None,
)
if lang is None:
raise NotFoundError("language_not_found")
tr_state = (lang.get("translation") or {}).get("state")
if tr_state != "complete":
raise ConflictError(
"translation_not_complete",
f"Translation for {language_tag} must be complete before audio can be queued (current: {tr_state}).",
)
job_id = str(uuid4())
job_record = {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"type": "audio",
"state": "queued",
"voice_config": voice_config or {},
"created_at": datetime.now(timezone.utc).isoformat(),
}
_JOB_STORE[self._job_key(project_id, language_tag, "audio")] = job_record
self._audit(
action=ACTION_AUDIO_QUEUED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"job_id": job_id,
},
)
LOGGER.info(
"audio queued project_id=%s language_tag=%s job_id=%s",
project_id,
language_tag,
job_id,
)
return AudioQueueResponse(
project_id=project_id,
language_tag=language_tag,
state="queued",
job_id=job_id,
queued_at=datetime.now(timezone.utc),
)
def set_format_metadata(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
metadata: dict,
*,
request_id: str | None = None,
) -> FormatMetadataResponse:
"""Save format metadata (EPUB/PDF) for one language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
# No productions directory: hold metadata in the in-memory job store.
key = self._job_key(project_id, language_tag, "format")
_JOB_STORE[key] = {
"language_tag": language_tag,
"updated_at": datetime.now(timezone.utc).isoformat(),
**{k: v for k, v in metadata.items() if v is not None},
}
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
},
)
return FormatMetadataResponse(
project_id=project_id,
language_tag=language_tag,
saved=True,
updated_at=datetime.now(timezone.utc),
)
meta_dir = prod_dir / "metadata" / language_tag
meta_dir.mkdir(parents=True, exist_ok=True)
existing = self._read_json(meta_dir / "format.json") or {}
existing.update(
{key: value for key, value in metadata.items() if value is not None}
)
existing["language_tag"] = language_tag
existing["updated_at"] = datetime.now(timezone.utc).isoformat()
self._write_json(meta_dir / "format.json", existing)
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
},
)
return FormatMetadataResponse(
project_id=project_id,
language_tag=language_tag,
saved=True,
updated_at=datetime.now(timezone.utc),
)
def get_format_metadata(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
) -> FormatMetadataResponse:
"""Read format metadata for one language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
stored = _JOB_STORE.get(self._job_key(project_id, language_tag, "format"))
return FormatMetadataResponse(
project_id=project_id,
language_tag=language_tag,
saved=stored is not None,
updated_at=self._parse_utc(stored.get("updated_at") if stored else None),
)
existing = self._read_json(prod_dir / "metadata" / language_tag / "format.json")
# Also check in-memory store (for deployments with read-only productions).
stored = _JOB_STORE.get(self._job_key(project_id, language_tag, "format"))
if stored and (not existing or stored.get("updated_at", "") >= existing.get("updated_at", "")):
existing = stored
return FormatMetadataResponse(
project_id=project_id,
language_tag=language_tag,
saved=existing is not None,
updated_at=self._parse_utc(existing.get("updated_at") if existing else None),
)
def get_chapters(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
) -> list[dict]:
"""Return chapter metadata (id, title, matter, paragraph count) for one language project.
English chapters are read from source/book.json. Translated chapters
are read from runs/<chapter>/translation.json.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return []
is_source = language_tag == "en"
if is_source:
book_path = prod_dir / "source" / "book.json"
book = self._read_json(book_path)
if book is None:
return []
chapters = []
for ch in book.get("chapters", []):
chapters.append({
"id": ch.get("id", ""),
"title": ch.get("title", ""),
"matter": ch.get("matter", "body"),
"index": ch.get("index", 0),
"paragraph_count": len(ch.get("paragraphs", []) or []),
})
chapters.sort(key=lambda c: c["index"])
return chapters
# Translated language: scan runs directory for chapter folders.
runs_dir = prod_dir / "runs"
if not runs_dir.is_dir():
return []
chapters = []
for ch_dir in sorted(runs_dir.iterdir()):
if ch_dir.is_dir() and ch_dir.name.startswith("ch-"):
translation_path = ch_dir / "translation.json"
data = self._read_json(translation_path)
if data is None:
continue
chapters.append({
"id": data.get("chapter_id", ch_dir.name),
"title": data.get("title", ch_dir.name),
"matter": "body",
"index": data.get("chapter_index", 0),
"paragraph_count": len(data.get("paragraphs", {}) or {}),
})
chapters.sort(key=lambda c: c["index"])
return chapters
def get_chapter_content(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
chapter_id: str,
) -> dict:
"""Return full paragraph content for one chapter (front matter editing).
English chapters are read from source/book.json. Translated chapters
read from runs/<chapter>/translation.json.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return {"chapter_id": chapter_id, "matter": "body", "paragraphs": []}
is_source = language_tag == "en"
if is_source:
book_path = prod_dir / "source" / "book.json"
book = self._read_json(book_path)
if book is None:
return {"chapter_id": chapter_id, "matter": "body", "paragraphs": []}
for ch in book.get("chapters", []):
if ch.get("id") == chapter_id:
return {
"chapter_id": ch.get("id", chapter_id),
"title": ch.get("title", ""),
"matter": ch.get("matter", "body"),
"paragraphs": ch.get("paragraphs", []) or [],
}
return {"chapter_id": chapter_id, "matter": "body", "paragraphs": []}
# Translated language: read from runs/<chapter>/translation.json
translation_path = prod_dir / "runs" / chapter_id / "translation.json"
data = self._read_json(translation_path)
if data is None:
return {"chapter_id": chapter_id, "matter": "body", "paragraphs": []}
paragraphs = data.get("paragraphs", {}) or {}
return {
"chapter_id": data.get("chapter_id", chapter_id),
"title": data.get("title", chapter_id),
"matter": "body",
"paragraphs": [
{"id": key, "speaker": None, "type": "dialogue", "text": value}
for key, value in sorted(paragraphs.items())
],
}
def get_utterance_review(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
chapter_id: str,
) -> UtteranceReviewResponse:
"""Return utterances for one chapter of one language, for speaker-alignment review.
English source text is read from ``source/book.json``; translated text is
read from ``runs/<chapter>/translation.json``.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return UtteranceReviewResponse(
project_id=project_id,
language_tag=language_tag,
chapter_id=chapter_id,
utterances=[],
speakers=[],
)
is_source = language_tag == "en"
if is_source:
# English source: read from source/book.json
book_path = prod_dir / "source" / "book.json"
book = self._read_json(book_path)
if book is None:
return UtteranceReviewResponse(
project_id=project_id,
language_tag=language_tag,
chapter_id=chapter_id,
utterances=[],
speakers=[],
)
paragraphs = []
for ch in book.get("chapters", []):
if ch.get("id") == chapter_id:
paragraphs = ch.get("paragraphs", []) or []
break
utterances = [
{
"id": p.get("id", ""),
"text": p.get("text", ""),
"speaker": p.get("speaker"),
"speaker_confidence": None,
"needs_review": True,
}
for p in paragraphs
]
# Collect all unique speakers from this chapter for the dropdown.
speaker_ids = set()
for p in paragraphs:
sp = p.get("speaker")
if sp:
speaker_ids.add(sp)
speakers = [
{"id": sp, "name": sp, "role": "speaker"}
for sp in sorted(speaker_ids)
]
else:
# Translated language: read from runs/<chapter>/translation.json.
# Speaker info comes from the English source chapter, matched by
# paragraph id, since translated text doesn't carry speaker data.
translation_path = prod_dir / "runs" / chapter_id / "translation.json"
data = self._read_json(translation_path)
if data is None:
return UtteranceReviewResponse(
project_id=project_id,
language_tag=language_tag,
chapter_id=chapter_id,
utterances=[],
speakers=[],
)
trans_paragraphs = data.get("paragraphs", {}) or {}
# Load English source for speaker data, matched by paragraph id.
speaker_by_id = {}
book_path = prod_dir / "source" / "book.json"
book = self._read_json(book_path)
if book is not None:
for ch in book.get("chapters", []):
if ch.get("id") == chapter_id:
for p in ch.get("paragraphs", []) or []:
speaker_by_id[p.get("id", "")] = p.get("speaker")
break
utterances = [
{
"id": key,
"text": value,
"speaker": speaker_by_id.get(key),
"speaker_confidence": None,
"needs_review": True,
}
for key, value in sorted(trans_paragraphs.items())
]
# Collect all unique speakers from the English source for the dropdown.
speaker_ids = set(speaker_by_id.values())
speaker_ids.discard(None)
# Build speaker dropdown options.
if speaker_ids:
speakers = [
{"id": sp, "name": sp, "role": "speaker"}
for sp in sorted(speaker_ids)
]
else:
speakers = [
{"id": "narrator", "name": "Narrator", "role": "narrator"}
]
return UtteranceReviewResponse(
project_id=project_id,
language_tag=language_tag,
chapter_id=chapter_id,
utterances=utterances,
speakers=speakers,
)
def get_front_matter(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
) -> FrontMatterResponse:
"""Read front-matter items for one language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
stored = _JOB_STORE.get(self._job_key(project_id, language_tag, "front_matter"))
if stored:
items = [
FrontMatterItemResponse(
key=item.get("key", ""),
label=item.get("label", ""),
value=item.get("value"),
output_page=item.get("output_page"),
)
for item in stored.get("items", [])
]
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=items,
version=stored.get("version", 1),
)
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=[],
version=1,
)
data = self._read_json(prod_dir / "front-matter" / language_tag / "items.json")
# Also check in-memory store (for deployments with read-only productions).
stored = _JOB_STORE.get(self._job_key(project_id, language_tag, "front_matter"))
if stored and (not data or stored.get("version", 0) >= data.get("version", 0)):
data = stored
if data is None:
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=[],
version=1,
)
items = [
FrontMatterItemResponse(
key=item.get("key", ""),
label=item.get("label", ""),
value=item.get("value"),
output_page=item.get("output_page"),
)
for item in data.get("items", [])
]
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=items,
version=data.get("version", 1),
)
def save_front_matter(
self,
actor: Principal,
project_id: UUID,
language_tag: str,
items: list[dict],
*,
request_id: str | None = None,
) -> FrontMatterResponse:
"""Save front-matter items for one language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
# No productions directory: hold front matter in the in-memory job store.
key = self._job_key(project_id, language_tag, "front_matter")
version = int((_JOB_STORE.get(key) or {}).get("version", 1)) + 1
item_records = [
{
"key": item.get("key", ""),
"label": item.get("label", ""),
"value": item.get("value"),
"output_page": item.get("output_page"),
}
for item in items
]
_JOB_STORE[key] = {
"language_tag": language_tag,
"items": item_records,
"version": version,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_FRONT_MATTER_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"version": version,
},
)
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=[
FrontMatterItemResponse(
key=r["key"],
label=r["label"],
value=r["value"],
output_page=r["output_page"],
)
for r in item_records
],
version=version,
)
fm_dir = prod_dir / "front-matter" / language_tag
fm_dir.mkdir(parents=True, exist_ok=True)
existing = self._read_json(fm_dir / "items.json") or {}
version = int(existing.get("version", 1)) + 1
item_records = [
{
"key": item.get("key", ""),
"label": item.get("label", ""),
"value": item.get("value"),
"output_page": item.get("output_page"),
}
for item in items
]
self._write_json(
fm_dir / "items.json",
{
"language_tag": language_tag,
"items": item_records,
"version": version,
"updated_at": datetime.now(timezone.utc).isoformat(),
},
)
self._audit(
action=ACTION_FRONT_MATTER_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"version": version,
},
)
return FrontMatterResponse(
project_id=project_id,
language_tag=language_tag,
items=[
FrontMatterItemResponse(
key=r["key"],
label=r["label"],
value=r["value"],
output_page=r["output_page"],
)
for r in item_records
],
version=version,
)
def workspace(self, actor: Principal) -> "WorkspaceSnapshot":
"""The signed-in caller's organization and the work inside it.
One call, scoped server-side, because a dashboard assembled in the browser
from several endpoints is a dashboard that can show two organizations at
once - if a filter is mistimed, or a response is cached from a previous
sign-in, the pieces come from different tenants and nothing notices.
The organization is ``None`` for a platform administrator, who belongs to
no tenant. That is not an error: they see the platform, and choosing which
organization's work to look at is an explicit act, not a default.
"""
scope = scope_for(actor)
organization: OrganizationView | None = None
if scope.organization_id is not None:
organization = self._organization_view(
self._require_organization(scope.organization_id)
)
rows, total = self._repository.find_projects(organization_id=scope.organization_id, limit=MAX_WORKSPACE_PROJECTS)
projects = [self._project_view(row) for row in rows]
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
# ------------------------------------------------------------------
# Cover image (controller integration endpoint)
# ------------------------------------------------------------------
def get_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None = None,
) -> CoverImageResponse:
"""Get cover image info for a project (optionally per-language)."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
cover_dir = prod_dir / "source" / "covers"
cover_path = cover_dir / "cover.png"
if cover_path.is_file():
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=True,
cover_path=str(cover_path),
content_type="image/png",
size_bytes=cover_path.stat().st_size,
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
def set_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None,
image_data: bytes,
content_type: str = "image/png",
) -> CoverImageResponse:
"""Save cover image for a project (optionally per-language)."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
raise NotFoundError("productions_not_configured")
cover_dir = prod_dir / "source" / "covers"
cover_dir.mkdir(parents=True, exist_ok=True)
cover_path = cover_dir / "cover.png"
cover_path.write_bytes(image_data)
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"action": "cover_image_saved",
},
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
saved=True,
cover_path=str(cover_path),
content_type=content_type,
size_bytes=cover_path.stat().st_size,
)
def delete_cover_image(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None = None,
) -> CoverImageResponse:
"""Delete cover image for a project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
cover_path = prod_dir / "source" / "covers" / "cover.png"
if cover_path.is_file():
cover_path.unlink()
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"action": "cover_image_deleted",
},
)
return CoverImageResponse(
project_id=project_id,
language_tag=language_tag,
has_cover=False,
)
# ------------------------------------------------------------------
# Output generation (controller integration endpoint)
# ------------------------------------------------------------------
def queue_output(
self,
actor: Principal,
project_id: UUID,
language_tag: str | None,
output_format: str,
) -> dict:
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
prod_dir = self._productions_dir_for(project)
if prod_dir is None:
raise NotFoundError("productions_not_configured")
# Queue the output generation job
key = self._job_key(project_id, language_tag or "en", f"output_{output_format}")
job_id = str(uuid4())
_JOB_STORE[key] = {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"type": f"output_{output_format}",
"state": "queued",
"created_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_FORMAT_METADATA_SAVED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
detail={
"project_slug": project.slug,
"language_tag": language_tag,
"output_format": output_format,
"job_id": job_id,
},
)
LOGGER.info(
"output queued project_id=%s language_tag=%s format=%s job_id=%s",
project_id, language_tag, output_format, job_id,
)
return {
"job_id": job_id,
"project_id": str(project_id),
"language_tag": language_tag,
"format": output_format,
"state": "queued",
}
# ------------------------------------------------------------------
# Controller integration: organizations and programs
# ------------------------------------------------------------------
def create_organization(
self,
actor: Principal,
*,
name: str,
slug: str | None = None,
request_id: str | None = None,
) -> OrganizationView:
"""Create an organization for the caller.
Only a platform administrator may create an organization.
"""
self._require_admin(actor)
from .models import normalize_organization_name, normalize_organization_slug
now = self._now()
organization = Organization(
id=uuid4(),
name=normalize_organization_name(name),
slug=normalize_organization_slug(slug) if slug is not None else normalize_organization_slug(name),
status=OrganizationStatus.ACTIVE,
created_at=now,
updated_at=now,
)
self._repository.create_organization(organization)
self._audit(
action=ACTION_ORGANIZATION_CREATED,
outcome="success",
actor=actor,
organization_id=organization.id,
request_id=request_id,
detail={"organization_name": organization.name, "organization_slug": organization.slug},
)
LOGGER.info("organization created organization_id=%s name=%s", organization.id, organization.name)
return OrganizationView(
id=organization.id,
name=organization.name,
slug=organization.slug,
status=organization.status,
member_count=organization.member_count,
created_at=organization.created_at,
updated_at=organization.updated_at,
)
def create_program(
self,
actor: Principal,
*,
organization_id: UUID,
name: str,
slug: str | None = None,
source_language_tag: str = "en",
request_id: str | None = None,
) -> ProjectView:
"""Create a program (project) inside an organization.
Only a platform administrator or organization administrator may create
a program.
"""
self._require_admin(actor)
scope = scope_for(actor, organization_id)
target = resolve_target(scope, organization_id)
from .models import normalize_project_name, normalize_project_slug
now = self._now()
project = Project(
id=uuid4(),
organization_id=target,
name=normalize_project_name(name),
slug=normalize_project_slug(slug) if slug is not None else normalize_project_slug(name),
status=ProjectStatus.ACTIVE,
created_by=actor.user_id,
created_at=now,
updated_at=now,
)
self._repository.create_project(project)
self._audit(
action=ACTION_PROGRAM_CREATED,
outcome="success",
actor=actor,
organization_id=target,
request_id=request_id,
detail={"project_name": project.name, "project_slug": project.slug},
)
LOGGER.info("program created project_id=%s organization_id=%s", project.id, target)
return self._project_view(project)
# ------------------------------------------------------------------
# Controller integration: speaker sync
# ------------------------------------------------------------------
def sync_speakers(
self,
actor: Principal,
project_id: UUID,
speakers: list[dict],
*,
request_id: str | None = None,
) -> dict[str, int]:
"""Sync speaker data from the audio node's speakers.json.
Creates or updates character records in the translation_speakers table
(via the productions directory's speakers.json) and records the sync
in the in-memory job store for cross-system consistency.
Returns counts: {created, updated, aliases_added}.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
created = 0
updated = 0
aliases_added = 0
prod_dir = self._productions_dir_for(project)
speakers_path = prod_dir / "speakers.json" if prod_dir else None
if speakers_path and speakers_path.exists():
import json
with speakers_path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
existing_speakers = data.get("speakers", []) if isinstance(data, dict) else data
else:
existing_speakers = []
# Merge incoming speakers with existing
speaker_map = {s.get("external_id", s.get("display_name", "")): s for s in existing_speakers}
for spk in speakers:
external_id = spk.get("external_id", "")
display_name = spk.get("display_name", "")
if not display_name:
continue
if external_id in speaker_map:
# Update existing
existing = speaker_map[external_id]
existing_aliases = set(existing.get("aliases", []))
new_aliases = set(spk.get("aliases", []))
added = new_aliases - existing_aliases
aliases_added += len(added)
existing["aliases"] = list(existing_aliases | new_aliases)
existing["voice_ref_path"] = spk.get("voice_ref_path", existing.get("voice_ref_path"))
updated += 1
else:
# Create new
speaker_map[external_id] = {
"external_id": external_id,
"display_name": display_name,
"role": spk.get("role", "character"),
"aliases": spk.get("aliases", []),
"voice_ref_path": spk.get("voice_ref_path"),
}
created += 1
# Write back
if prod_dir:
speakers_path.parent.mkdir(parents=True, exist_ok=True)
with speakers_path.open("w", encoding="utf-8") as fh:
json.dump({"speakers": list(speaker_map.values())}, fh, indent=2)
# Also store in job store for cross-system consistency
key = self._job_key(project_id, "speakers", "sync")
_JOB_STORE[key] = {
"project_id": str(project_id),
"speakers": list(speaker_map.values()),
"created": created,
"updated": updated,
"aliases_added": aliases_added,
"synced_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_SPEAKERS_SYNCED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"created": created,
"updated": updated,
"aliases_added": aliases_added,
},
)
return {"created": created, "updated": updated, "aliases_added": aliases_added}
# ------------------------------------------------------------------
# Controller integration: utterance translations
# ------------------------------------------------------------------
def upsert_utterance_translations(
self,
actor: Principal,
project_id: UUID,
language_code: str,
language_name: str,
translations: list[dict],
*,
request_id: str | None = None,
) -> int:
"""Upsert utterance translations for one language.
Each translation dict should carry: utterance_id, translated_text,
and optionally speaker_name, status, source_unit_key.
"""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
key = self._job_key(project_id, language_code, "translations")
existing = _JOB_STORE.get(key, {"translations": []})
existing_translations = {t.get("utterance_id"): t for t in existing.get("translations", [])}
upserted = 0
for t in translations:
utt_id = str(t.get("utterance_id", ""))
if not utt_id:
continue
existing_translations[utt_id] = {
"id": str(uuid4()),
"utterance_id": utt_id,
"language_code": language_code,
"language_name": language_name,
"text": t.get("translated_text", ""),
"speaker_name": t.get("speaker_name"),
"status": t.get("status", "draft"),
"origin": t.get("origin", "umbratrans"),
"source_ref": t.get("source_ref"),
"source_unit_key": t.get("source_unit_key"),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
upserted += 1
_JOB_STORE[key] = {
"project_id": str(project_id),
"language_code": language_code,
"language_name": language_name,
"translations": list(existing_translations.values()),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
self._audit(
action=ACTION_TRANSLATIONS_UPSERTED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"project_slug": project.slug,
"language_code": language_code,
"upserted": upserted,
},
)
return upserted
def get_utterance_translations(
self,
actor: Principal,
project_id: UUID,
language_code: str,
) -> dict[str, Any]:
"""Get all utterance translations for one project and language."""
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
key = self._job_key(project_id, language_code, "translations")
stored = _JOB_STORE.get(key, {"translations": [], "language_name": language_code})
return {
"project_id": str(project_id),
"language_code": language_code,
"language_name": stored.get("language_name", language_code),
"translations": stored.get("translations", []),
}
# ------------------------------------------------------------------
# End of controller integration methods
# ------------------------------------------------------------------
def _project_view(self, project: Project) -> ProjectView:
assigned = tuple(self._repository.list_project_member_ids(project.id))
return ProjectView(
id=project.id,
organization_id=project.organization_id,
name=project.name,
slug=project.slug,
status=project.status,
created_at=project.created_at,
created_by=project.created_by,
updated_at=project.updated_at,
assigned_account_ids=assigned,
assigned_count=len(assigned),
)
def _audit( self,
*,
action: str,
outcome: str,
actor: Principal | None = None,
target_user_id: UUID | None = None,
organization_id: UUID | None = None,
request_id: str | None = None,
detail: Mapping[str, object] | None = None,
) -> AuditEvent:
event = AuditEvent(
id=str(uuid4()),
occurred_at=self._now(),
action=action,
outcome=outcome,
actor_user_id=actor.user_id if actor else None,
target_user_id=target_user_id,
organization_id=organization_id,
request_id=request_id,
detail=MappingProxyType(sanitize_audit_detail(detail)),
)
self._repository.append_audit_event(event)
LOGGER.info(
"audit action=%s outcome=%s actor=%s target=%s request_id=%s",
action,
outcome,
event.actor_user_id,
event.target_user_id,
event.request_id,
)
return event
def _enrollment_mode(value: str) -> EnrollmentMode:
try:
return EnrollmentMode(value)
except ValueError as error:
raise InputError("invalid_enrollment_mode", "Unknown enrollment mode.") from error
def _account_status(value: str) -> AccountStatus:
try:
return AccountStatus(value)
except ValueError as error:
raise InputError("invalid_status", "Unknown account status.") from error
def _organization_status(value: str) -> OrganizationStatus:
try:
return OrganizationStatus(value)
except ValueError as error:
raise InputError("invalid_organization_status", "Unknown organization status.") from error
def _role(value: str) -> RoleName:
try:
return RoleName(value)
except ValueError as error:
raise InputError("invalid_role", "Unknown role.") from error
def _safe_email_key(email: str) -> str | None:
"""Return a lookup key for a submitted address, or None if it is malformed.
A malformed identifier is treated as "no such account" rather than raising,
so a probe cannot distinguish a bad address from an absent one.
"""
if not isinstance(email, str):
return None
try:
return email_key(email)
except InputError:
return None
def _safe_username_key(username: str) -> str | None:
if not isinstance(username, str):
return None
try:
return username_key(username)
except InputError:
return None
__all__ = [
"ACTION_ACCOUNT_CREATED",
"ACTION_ACCOUNT_PROVISIONED_LOCAL",
"ACTION_ACCOUNT_STATUS_CHANGED",
"ACTION_AUTH_FIRST_PASSWORD_CHANGED",
"ACTION_AUTH_LOGIN",
"ACTION_AUTH_LOGOUT",
"ACTION_AUTH_LOGOUT_EVERYWHERE",
"ACTION_ORGANIZATION_CREATED",
"ACTION_ORGANIZATION_STATUS_CHANGED",
"ACTION_ORGANIZATION_UPDATED",
"ACTION_SESSION_EXPIRED",
"ACTION_SESSION_INVALIDATED",
"ACTION_SESSION_REVOKED",
"ADMIN_ENROLLMENT_SOURCE",
"OPERATOR_SOURCES",
"AccountService",
"IssuedTemporaryPassword",
"OperatorContext",
]