- 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)
3580 lines
140 KiB
Python
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",
|
|
]
|