Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
10 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_passwordrequires a password hash.oidcdoes not store a password hash.- Password rotation replaces the hash and clears
must_change_passwordonly 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.
Existing tables used unchanged
organizationsprojectsrolespermissionsuser_rolesinvitationsaudit_events
user_roles remains the membership source of truth. 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/accounts
POST /api/uadmin/accounts
GET /api/uadmin/accounts/{user_id}
PATCH /api/uadmin/accounts/{user_id}/status
POST /api/uadmin/accounts/{user_id}/resend-enrollment
POST /api/uadmin/accounts/{user_id}/revoke-sessions
Creating an account accepts:
- username;
- display name;
- email;
- organization;
- initial role;
- enrollment mode (
temporary_passwordoroidc).
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.
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.emailconstraint 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 = trueon 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
userstable. - 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:
- the additive identity migration is reviewed;
- a logical backup and restore test pass;
- the migration role and application roles are verified;
- RLS tests cover two organizations and two projects;
- 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
- Add the identity/account-management domain types and repository interface. Done.
- Implement an in-memory development repository with deterministic tests. Done.
- Implement Argon2id credential service, temporary-password enrollment, and first-login password change. Done.
- Add FastAPI routes and secure cookie/session middleware. Done.
- Add administrator account-management routes and audit events. Done.
- Add the reviewed PostgreSQL migration and adapter. Migration drafted and unapplied; adapter not started.
- Add PostgreSQL integration tests in an isolated database.
- Connect the static prototype to the API.
- 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/meand 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 static UI can be connected to the API without changing the audio worker.
- Production migration and bootstrap remain blocked until explicitly approved.