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

271 lines
15 KiB
Markdown

# 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:
```text
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:
```text
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:
```text
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.
```text
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
```text
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
```text
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
```text
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
```text
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.*