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.
270 lines
13 KiB
Markdown
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.
|