Files
UmbraTrans/umbratrans_web/README.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

270 lines
13 KiB
Markdown

# 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.