Files
UmbraTrans/umbratrans_web/README.md
umbra audio 31fd8720ec Add the PostgreSQL adapter for the authoritative store
Implements AccountRepository against the control-plane schema plus the
phase-1 identity migration, and wires it in so production can select it.

The adapter is not a thin SQL wrapper, because the control plane does not
store the two things the application needs on the users row.

Role and organization come from user_roles. There is no role column and no
organization column on users; a role is assigned 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 therefore a join, and
a user holding more than one active assignment is refused rather than resolved
to an arbitrary row. The organization on that assignment becomes the scope of
every administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership stays an open
review question, recorded as such.

Email became a required field on account creation. users.email is NOT NULL
UNIQUE, so accepting a missing address would let account creation succeed
against the development repositories and then fail on the authoritative store.
It is now required in the service, the API schema, and the bootstrap command,
so it fails identically everywhere.

Audit identifiers now belong to the store. audit_events.id is a bigint
identity, so the adapter reports that value; the application correlation UUID
is preserved in the detail and never displaces the row key. AuditEvent.id is a
string for that reason, which removes a type the two stores disagreed about.

Three further fail-closed behaviours: a users.status of invited or locked is
refused rather than coerced onto active; a row with mfa_required is refused,
because this phase cannot satisfy a second factor and authenticating it anyway
would grant access to someone who was supposed to present one; and a missing
organization blocks access rather than granting it. save_user deliberately does
not write username or email, since both are identity keys with no reviewed
change path and no audit event behind them.

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 is inert until the reviewed RLS
policies exist; the explicit WHERE clauses enforce scope today. A statement
timeout bounds how long a pooled connection can be held.

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. Verified by running both, including that an unreachable host
refuses to start rather than degrading.

The migration gains an index on audit_events (target_type, target_id), which
the administrator audit filter needs and nothing covered, and the review
questions now include the three things that must be checked before
provisioning: the roles rows and their scopes, any row with mfa_required set,
and any user holding more than one active assignment.

Verification, and its limit. 236 tests pass with 3 skipped. The adapter's row
mapping, role resolution, conflict mapping, and configuration are unit tested
and run everywhere. Its SQL, transactions, and pooled reuse are NOT executed:
this environment has no database credentials, because sudo to the postgres user
needs a password, there is no umbra database role, and there is no .pgpass. The
integration suite is written and selected by UMBRATRANS_TEST_DSN, and its
fixture refuses any DSN whose database name contains umbracontrol. A green run
here says nothing about whether the SQL is correct.
2026-09-26 20:28:07 -07:00

12 KiB

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 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:

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.

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

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

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.