Files
UmbraTrans/docs/umbratrans-backend-design.md
umbra audio 0461dff9d2 Use the email address as the login identifier and require confirmation
Sign-in now takes the email address rather than a username. This needed no
migration: users.email is already citext NOT NULL UNIQUE in the control plane,
so it is a case-insensitively unique login key on its own. username survives as
an optional display handle for audit lines and is never a login path, which
removes the redundancy of asking an administrator for two identifiers.

An account created by an administrator now starts in the control plane's
invited state and cannot sign in until a single-use link is consumed. The token
maps onto bootstrap_tokens, which was already shaped for exactly this and had
been unused, so that needed no migration either. Modelling invited as a real
state also retires the adapter's earlier fail-closed refusal of that value,
which existed because invited was previously unrepresentable.

Three decisions worth stating plainly.

The "email not verified" message is sent only after the password has been
verified. Reporting it earlier would turn the login form into an oracle for
which addresses have accounts, which is the one thing the uniform
authentication error exists to prevent. A test asserts that a wrong password and
an unknown account are byte-identical, and it caught a real bug: the check was
first written before the password comparison, and the generic "not active"
branch swallowed invited accounts. Both are now ordered deliberately.

The operator bootstrap path does not require verification. The operator is
attesting to the address themselves, which is the standing an administrator
already has, and requiring a delivered email there would deadlock first-time
setup on SMTP configuration that does not exist yet.

The logging mail transport is refused in production. A deployment that logged
links instead of sending them would appear to work while every new account sat
permanently unverified.

The development transport writes the link with the token redacted, because a log
is where live credentials end up. That means a human cannot complete the flow by
reading the log; the test harness captures the message in memory instead. This
is a deliberate limitation rather than an oversight, and it is documented.

Also: production now requires a mail transport and an absolute verification base
URL alongside the existing DSN gate, because an account that cannot be verified
cannot be used at all.

269 tests pass, 3 skipped. Verified end to end against a real uvicorn server:
bootstrap login, organization creation, an invited account with its one-time
password, and a handle rejected as a login path.
2026-09-26 21:21:01 -07:00

15 KiB

UmbraTrans backend design — phase 1 identity and account management

Status: implementation-ready design; production migration not applied Transition state: paused for the audio-system handoff Primary application: umbratrans.bdmurphy.me Administrative surface: /uadmin/

Objective

Begin the UmbraTrans backend with the account and user-management system required by the new web UI. The audio system remains an independent producer and is not blocked by this phase.

The first backend slice must provide:

  • enterprise user accounts;
  • an administrator able to provision, suspend, reactivate, and list accounts;
  • temporary local-password enrollment;
  • OIDC enrollment as a supported future/parallel path;
  • mandatory password change for temporary-password accounts;
  • secure server-side sessions;
  • organization and project membership boundaries;
  • audit events for account and membership changes.

No audiobook, translation, voice, or object-storage workflow is implemented in this first slice.

Authentication decision

The UI supports both account-enrollment paths, but the first implementation will use an adapter boundary.

Local temporary password

  • An administrator provisions a user with a one-time temporary password.
  • The password is never submitted by a client. The service generates it in process, hashes it immediately, and returns it exactly once in the creation response, so no request body, header, URL, or queue message can carry a secret.
  • The first administrator is instead provisioned by an operator command that reads the password from a hidden local prompt, for the case where the operator must choose the value.
  • The password is never logged, printed to a log, emailed, placed in a URL, or stored in plaintext. The one-time display to the provisioning administrator is the only time it leaves the process, and it is not retrievable again through any endpoint.
  • Ambiguous glyphs are excluded from generated values so a single transcription does not fail; the local policy is length-first and is asserted on every generated value.
  • Only an Argon2id hash is persisted.
  • The credential record is marked must_change_password = true.
  • The first login may authenticate only far enough to change the password; every other route, including the administrator surface, refuses the session until the change succeeds.
  • The change rotates the session and revokes every other session for the account, so the one-time password is spent.
  • Failed attempts are rate-limited and audited.

OIDC enrollment

  • An administrator provisions a user or invitation but does not create a local password.
  • The identity provider completes enrollment.
  • The callback maps the provider subject to the canonical user record.
  • The same session, organization, project, and authorization model is used as for local credentials.

The first implementation may ship local-password development flows while keeping the OIDC interface explicit and unconnected. No OIDC client secret belongs in the repository.

Data model for phase 1

The existing control-plane tables remain authoritative. Additive changes must be reviewed and migrated separately.

users

Add a nullable, unique canonical login identifier during migration:

username citext UNIQUE

Existing accounts are backfilled only through an explicit mapping. New accounts require a username.

user_credentials

A separate table avoids mixing authentication mechanisms with the user profile:

id uuid primary key
user_id uuid not null unique references users(id) on delete cascade
credential_type text not null check (credential_type in ('local_password', 'oidc'))
password_hash text null
must_change_password boolean not null default false
failed_login_count integer not null default 0
locked_until timestamptz null
last_login_at timestamptz null
password_changed_at timestamptz null
created_at timestamptz not null default now()
updated_at timestamptz not null default now()

Rules:

  • local_password requires a password hash.
  • oidc does not store a password hash.
  • Password rotation replaces the hash and clears must_change_password only after a successful first-login change.
  • Credential rows are never returned by the API.

sessions

Add only the fields required for the first identity slice:

mfa_verified_at timestamptz null
idle_deadline_at timestamptz null
last_seen_at timestamptz not null default now()

The session token is random and only its hash is stored. The browser receives an HttpOnly, Secure in production, SameSite=Lax cookie.

organizations

Tenants that own accounts and projects. Until this was added, users.organization_id was a bare UUID pointing at nothing, so the administrator interface had no tenant to manage and account creation could not validate its own assignment.

id uuid primary key
name text not null check (char_length(name) between 2 and 120)
slug citext not null unique
status text not null default 'active' check (status in ('active', 'suspended', 'archived'))
created_at timestamptz not null default now()
updated_at timestamptz not null default now()

Rules:

  • A slug derived from the display name is normalized; a slug the operator typed must already be canonical, so nothing is silently rewritten behind their back.
  • archived is terminal. Restoring an archived tenant is a separate, reviewed operation, because its name and slug are historically significant.
  • Leaving active revokes the live sessions of every member, and both authenticate and resolve_session refuse a member of a non-active organization. Revoking sessions alone was not enough: each member's own account status stays active, so without those checks a suspended tenant could still mint new sessions.
  • A missing organization record blocks access rather than granting it.

Open review question, not a settled decision. The control plane derives organization membership from user_roles with scope = 'organization', and also supports project-scoped membership. This phase models membership as a single column on the user record, which means one organization per account and no project membership. The migration creates the organizations table but adds no membership column, so the choice stays open until the PostgreSQL adapter is written. It is recorded in umbratrans_web/migrations/README.md.

Existing tables used unchanged

  • projects
  • roles
  • permissions
  • user_roles
  • invitations
  • audit_events

user_roles is untouched by this phase. Do not add a second project_members table.

Phase 1 API

All routes require a server-side session and a resolved principal. The API never trusts browser-supplied user, organization, project, or role values.

Authentication

POST /api/auth/login
POST /api/auth/change-first-password
POST /api/auth/logout
POST /api/auth/logout-everywhere
GET  /api/auth/me

Administrator account management

GET    /api/uadmin/summary
GET    /api/uadmin/accounts
POST   /api/uadmin/accounts
GET    /api/uadmin/accounts/{user_id}
PATCH  /api/uadmin/accounts/{user_id}/status
POST   /api/uadmin/accounts/{user_id}/revoke-sessions

Creating an account accepts:

  • username;
  • display name;
  • email;
  • organization;
  • initial role;
  • enrollment mode (temporary_password or oidc).

Neither path accepts a password from a client. POST /api/uadmin/accounts with enrollment_mode: "temporary_password" asks the service to generate the value; the request schema has no password field, so a submitted one is rejected before it reaches a handler. The generated value is returned once in that response for the administrator to hand to the account owner, and the first administrator instead uses the operator command's hidden local prompt.

GET /api/uadmin/summary returns the dashboard counts. They are recomputed on every request from the same scoped queries the list endpoints use, so a summary can never disclose a number the caller could not reach by listing.

Administrator organization management

GET    /api/uadmin/organizations
POST   /api/uadmin/organizations
GET    /api/uadmin/organizations/{organization_id}
PATCH  /api/uadmin/organizations/{organization_id}
PATCH  /api/uadmin/organizations/{organization_id}/status

GET is available to any administrator. Every write is platform-administrator-only, which keeps the tenant boundary under a single authority. An organization administrator is pinned to the organization on their own principal: a browser-supplied organization filter cannot widen a list, and a foreign organization identifier is refused with the same generic error as any other forbidden target.

Administrator audit

GET    /api/uadmin/audit

Scoped to the caller's organization, with an allow-listed, redacted detail object. No endpoint can return a password, a hash, or a session token.

First administrator bootstrap

The first administrator is provisioned through a separate operator-only command, not the public admin API.

Requirements:

  • username: Umbraadmin (configurable as a non-secret bootstrap identifier);
  • email: supplied by the operator at provisioning time; it is required by the existing users.email constraint and is never guessed;
  • organization: selected or created by the operator at provisioning time; it is never guessed;
  • password: entered through a hidden local prompt;
  • Argon2id hash only;
  • must_change_password = true on first credential;
  • account status is explicitly audited;
  • no password appears in shell history, environment variables, logs, reports, or the UI;
  • the command refuses to run against production until the migration and backup gates pass.

The supplied one-time value is not written to any file by the implementation and is not included in this document.

Security requirements

  • Argon2id password hashing with reviewed parameters.
  • Constant-time verification through the password library.
  • Rate-limited login and password-change endpoints.
  • CSRF protection for cookie-authenticated state changes.
  • Session rotation after login and first-password change.
  • Session revocation on suspension, deactivation, and administrative removal.
  • No local password in the users table.
  • No service or administrator action accepts a trusted identity from the browser.
  • Audit every account creation, suspension, reactivation, role change, enrollment, login failure, password change, and session revocation.
  • Error responses do not disclose whether an account exists.
  • Logs contain request IDs and sanitized account/project IDs, never credentials or session tokens.

Development and production separation

The first implementation slice uses a development repository and tests. It must not connect to the production umbracontrol database until:

  1. the additive identity migration is reviewed;
  2. a logical backup and restore test pass;
  3. the migration role and application roles are verified;
  4. RLS tests cover two organizations and two projects;
  5. the account-management tests pass against PostgreSQL.

Production PostgreSQL remains the authoritative store. Local development data stays below the workspace development data root and is never a substitute for production.

Implementation order

  1. Add the identity/account-management domain types and repository interface. Done.
  2. Implement an in-memory development repository with deterministic tests. Done.
  3. Implement Argon2id credential service, temporary-password enrollment, and first-login password change. Done.
  4. Add FastAPI routes and secure cookie/session middleware. Done.
  5. Add administrator account-management routes and audit events. Done.
  6. Add the reviewed PostgreSQL migration and adapter. Migration drafted and unapplied; adapter written and unit tested, but never executed against a database.
  7. Add PostgreSQL integration tests in an isolated database. Written and selected by UMBRATRANS_TEST_DSN; not yet run.
  8. Connect the static prototype to the API.
  9. Only then provision the first administrator in the approved environment.

Steps 1 through 5 are implemented in umbratrans_web/ against development repositories only. The application fails closed rather than starting against an unreviewed store, so nothing in this slice can touch umbracontrol by accident.

Acceptance criteria for phase 1

  • An administrator can create a local-password account and an OIDC-enrollment account. Done.
  • A generated one-time password is returned only by the creation response and is unrecoverable afterwards. Done.
  • A temporary-password account cannot reach any route, including /api/auth/me and the administrator surface, before changing its password. Done.
  • After the change, the one-time password no longer authenticates. Done.
  • Passwords are never persisted or logged in plaintext, and a submitted password field is rejected by the schema. Done.
  • A suspended user cannot create a session or use an existing session. Done.
  • An administrator cannot access another organization through a forged ID, and cannot escalate to platform administrator. Done.
  • Account-management actions create sanitized audit events with the requesting administrator as the actor. Done.
  • A user can access only projects granted through active membership. Pending: project membership is not in this slice.
  • The PostgreSQL adapter refuses rather than guesses: an unmapped account status, a row requiring MFA, an account with more than one active role assignment, and a missing organization all fail closed. Unit tested; the SQL is not yet executed.
  • The static UI can be connected to the API without changing the audio worker.
  • Production migration and bootstrap remain blocked until explicitly approved.
  • Email is a required field on account creation, because users.email is NOT NULL UNIQUE. Enforced in the domain so it fails the same way in development and production. Done.
  • The email address is the login identifier, and username is an optional display handle that is never a login path. users.email is already citext NOT NULL UNIQUE, so this needed no migration. Done.
  • An administrator-provisioned account starts invited and cannot sign in until a single-use verification link is consumed, using the previously unused bootstrap_tokens table. The unverified message is only sent after the password is verified, so the login form cannot enumerate accounts. The operator bootstrap path skips verification, because the operator attests to the address themselves. Done.
  • A second factor is deliberately not the email address. mfa_methods constrains the method to totp, webauthn, or recovery_code, and email is excluded; an emailed code is not a second factor because email is not secret. TOTP remains the next step and can use the existing table. Pending.