# UmbraTrans web account and organization management — phase-1 slice Backend for `docs/umbratrans-backend-design.md`: accounts and organizations, both enrollment modes, Argon2id credentials, server-side sessions, administrator lifecycle actions, sanitized audit events, and a FastAPI application factory that can also serve the browser application from the same origin. **The PostgreSQL adapter is written but has never been run.** The reviewed migration in `migrations/` has not been applied, and the integration suite has not been executed, because this workspace has no database credentials. The adapter's pure logic is unit tested; its SQL is not. Read [Verification status](#verification-status) before trusting it with real data. ## The PostgreSQL adapter Three mappings in `postgres_repository.py` are load-bearing and are the reason it is not a thin SQL wrapper. **Role and organization come from `user_roles`.** The control plane stores no role column and no organization column on `users`; it assigns a role through `user_roles` with a scope, and `app.validate_user_role_scope()` enforces that the role's scope matches the assignment. Resolving one role per user is a join. When a user holds more than one active assignment the adapter **refuses** rather than picking a row, because the organization on the assignment becomes the scope of every administrative decision, so guessing would hand out the wrong tenant's authority. **A missing email is impossible.** `users.email` is `NOT NULL UNIQUE`, so email is a required field in the service and in the API schema. Enforcing it in the domain means a missing address fails identically against the development repositories and the authoritative store. **Audit identifiers belong to the store.** `audit_events.id` is a `bigint` identity, so the adapter reports that value. The application's correlation UUID is preserved in the detail under `application_event_id` and never displaces the row key. Three further fail-closed behaviours are worth knowing: - A row whose `users.status` is `invited` or `locked` is refused, not coerced onto `active`. - A row with `users.mfa_required = true` is refused, because this phase cannot satisfy a second factor. Until MFA exists, every row must be false. - A missing organization record blocks access rather than granting it. Every operation runs in one transaction on one pooled connection and publishes the caller with `SET LOCAL app.current_user_id`, so a returned connection cannot carry one request's identity into the next. That setting is inert until the reviewed RLS policies are applied; today the explicit `WHERE` clauses are what enforce scope. ## Configuration | Variable | Meaning | | --- | --- | | `UMBRATRANS_REPOSITORY` | `memory`, `json`, or `postgres` | | `UMBRATRANS_DATABASE_DSN` | Connection string. Required for `postgres`, and excluded from `repr` so it cannot leak into a settings dump. | | `UMBRATRANS_DATABASE_POOL_MIN` / `_MAX` | Pool bounds, default 1 and 10 | | `UMBRATRANS_REPOSITORY_PATH` | Local JSON store path | | `UMBRATRANS_STATIC_ROOT` | Serve the browser client from this directory | | `UMBRATRANS_ALLOWED_HOSTS` | Comma-separated host allow-list | | `UMBRATRANS_COOKIE_SECURE` | Required `true` in production | | `UMBRATRANS_REQUIRE_CSRF` | Required `true` in production | Production fails closed at two independent gates: the settings layer refuses without a DSN, and the repository boundary refuses to build a PostgreSQL store without one. A production process cannot start against a development store while appearing to hold real data. ## Verification status | Area | Status | | --- | --- | | Row mapping, role resolution, conflict mapping, configuration | Unit tested, runs everywhere | | SQL statements, transactions, RLS setting, pooled reuse | **Not executed.** No database credentials in the authoring environment. | | `tests/test_postgres_repository.py` integration cases | **Skipped.** Selected by `UMBRATRANS_TEST_DSN`. | Run the integration layer against a scratch database, never `umbracontrol`: ```bash UMBRATRANS_TEST_DSN='postgresql://app@localhost/umbratrans_scratch' \ /home/umbra/.local/bin/uv run pytest umbratrans_web/tests/test_postgres_repository.py ``` The fixture refuses a DSN whose database name contains `umbracontrol`. ## Email is the login identifier, and it must be confirmed Sign-in uses the email address. `users.email` is already `citext NOT NULL UNIQUE` in the control plane, so it is a case-insensitively unique login key and **no migration was needed**. `username` is now an optional display handle for audit lines and is never a login path. An account created by an administrator starts in the control plane's `invited` state and cannot sign in until a single-use link is consumed. The token maps onto the existing `bootstrap_tokens` table, which was already shaped for this and was unused, so that needed no migration either. Three details that matter: - **The "email not verified" message is only sent after the password is proven correct.** Reporting it earlier would turn the login form into an oracle for which addresses have accounts. A test asserts that a wrong password and an unknown account are indistinguishable. - **The operator bootstrap path needs no verification.** The operator is attesting to the address themselves, which is the same standing an administrator has. Requiring a delivered email there would deadlock first-time setup on the SMTP configuration. - **The logging mail transport is refused in production.** Otherwise a deployment would appear to send verification mail that never left the machine, and accounts would sit unverified with nobody noticing. The development transport writes the link to the log **with the token redacted**, because a log is where live credentials end up. So a human cannot complete the flow by reading the log: use SMTP, or exercise it through the test harness, which captures the message in memory. ## Tenancy `organizations` is a first-class record with `name`, `slug`, and a status of `active`, `suspended`, or `archived`. Account creation validates the target organization and refuses a non-active one. Phase 1 keeps membership as a single organization per user rather than the `user_roles` join the control plane uses. That is a deliberate simplification, recorded as an open review question in `migrations/README.md`; the migration creates the table and adds no membership column, so the decision is not silently settled. Suspending or archiving an organization revokes its members' live sessions, and both `authenticate` and `resolve_session` refuse a member of a non-active organization. Revoking sessions alone is not sufficient: each member's own account status stays `active` and could otherwise mint a new session. ## Same-origin frontend Set `UMBRATRANS_STATIC_ROOT` and the application serves the browser client from `/` after the API routes are registered, so the session cookie and the CSRF header work without CORS. There is deliberately no CORS middleware. A missing or non-directory root is refused rather than silently serving an API-only application. **This slice does not connect to PostgreSQL.** The reviewed migration in `migrations/` has not been applied, and the production repository adapter is deliberately absent, so the application fails closed instead of starting against an unreviewed store. ## Layout | Module | Responsibility | | --- | --- | | `models.py` | Frozen dataclasses and enums. Hash fields are `repr=False`; `AccountView` and `OrganizationView` are the only shapes that may cross the API. | | `security.py` | Argon2id hashing, password policy, one-time password generation, session/CSRF tokens, log and audit redaction. | | `config.py` | Fail-closed settings. Production requires secure cookies, CSRF, and a non-development repository. | | `errors.py` | Domain errors with stable codes. Messages are generic so responses cannot enumerate accounts. | | `repository.py` | `AccountRepository` protocol plus the thread-safe in-memory development repository. | | `postgres_repository.py` | The authoritative PostgreSQL adapter: pooled connections, one transaction per operation, `SET LOCAL` identity, and the control-plane role mapping. | | `dev_repository.py` | Optional local JSON store: `0600` file, `0700` directory, atomic writes, refuses production. | | `service.py` | `AccountService`: enrollment, authentication, first-password change, sessions, organizations, administration, audit. | | `schemas.py` | Pydantic request/response models. Request models forbid unknown fields. | | `app.py` | Application factory, security middleware, CSRF, error mapping, static mounting, dependencies. | | `routes_auth.py`, `routes_uadmin.py` | `/api/auth/*` and `/api/uadmin/*`. | | `cli.py` | Hidden-prompt operator command and a loopback development server. | | `migrations/` | Proposed, unapplied phase-1 identity and tenancy SQL and its rollback. | ## Enrollment boundary Both modes are separated by type rather than by convention, and neither accepts a password from a client: - `temporary_password`, operator path: `AccountService.create_account` requires an `OperatorContext`, which only the operator CLI builds, plus a `must_change_password = True` Argon2id credential. The plaintext exists only inside the hidden prompt call and is never persisted, logged, or audited. - `temporary_password`, administrator path: `AccountService.provision_admin_temporary_password` generates the one-time password in process, validates it against the local policy, hashes it, and returns it once in the creation response as `IssuedTemporaryPassword`. The value is excluded from that object's `repr` and is not retrievable again by any endpoint. - `oidc` requires an authenticated administrator principal and stores no local password. An `oidc` credential with a hash is rejected by the model. `CreateAccountRequest` has no `password` field and refuses extra fields, so a submitted password is rejected by the schema before a handler runs. Account creation returns `CreateAccountResponse`, whose `temporary_password` field is populated only for the generated-password mode. ## API ``` GET /api/health POST /api/auth/login POST /api/auth/change-first-password POST /api/auth/logout POST /api/auth/logout-everywhere GET /api/auth/me GET /api/uadmin/summary GET /api/uadmin/accounts POST /api/uadmin/accounts (either enrollment mode) GET /api/uadmin/accounts/{id} PATCH /api/uadmin/accounts/{id}/status POST /api/uadmin/accounts/{id}/revoke-sessions GET /api/uadmin/organizations POST /api/uadmin/organizations (platform admin only) GET /api/uadmin/organizations/{id} PATCH /api/uadmin/organizations/{id} (platform admin only) PATCH /api/uadmin/organizations/{id}/status (platform admin only) GET /api/uadmin/audit ``` - Sessions are server-side. The cookie holds a random token; only its hash is stored. The cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever production settings are used. - CSRF uses the double-submit pattern: the readable `umbratrans_csrf` cookie plus an `X-CSRF-Token` header compared in constant time. `POST /api/auth/login` is exempt because it carries credentials an attacker cannot know. A safe request (for example `GET /api/health` or `GET /api/auth/me`) issues the cookie and returns the token. - No CORS middleware is installed; the API is same-origin behind the approved reverse proxy. - Error bodies are `{"error": {"code", "message"}}`. Validation failures report field names only, because FastAPI's default payload would echo a submitted password. - `POST /api/auth/login` returns the CSRF token; `GET /api/auth/me` returns it again, so a client can bootstrap without a separate endpoint. ## Operator bootstrap ```bash cd /home/umbra/src/UmbraTrans UMBRA_ENV=development \ /home/umbra/.local/bin/uv run umbratrans-bootstrap-admin \ --username Umbraadmin --repository-path .local/data/development/umbratrans-web/dev-accounts.json ``` The command reads the one-time password twice from a hidden TTY prompt, refuses to run without a terminal, refuses production mode, and writes only to the local development store. Nothing it prints contains the password. It has not been run in this workspace. `python -m umbratrans_web bootstrap-admin` is equivalent. `python -m umbratrans_web serve` starts the development API on `127.0.0.1` and refuses a non-loopback bind or a production environment. ## Development ```bash cd /home/umbra/src/UmbraTrans /home/umbra/.local/bin/uv sync --python /usr/bin/python3 /home/umbra/.local/bin/uv run pytest -q /home/umbra/.local/bin/uv run ruff check . /home/umbra/.local/bin/uv run ruff format --check . /home/umbra/.local/bin/uv run python -m compileall -q umbratrans_web ``` The suite uses the in-memory repository and a reduced Argon2 cost for speed; `tests/test_security.py` asserts the production parameter profile separately. Test passwords are generated per run, so no fixture file carries credential material. ## Not in this slice - The PostgreSQL adapter, the OIDC callback and subject mapping, MFA enforcement, `user_roles` membership, invitations, and audit export. - Connection to `umbracontrol`, application of `migrations/0001_*`, and the first administrator in any approved environment. - Serving the static prototype: `web/` is unchanged and is not mounted.