The old file proved a policy refusing a row. With no shared table there is no policy and nothing to refuse, so the tested property is gone rather than rewritten - and Umbratools_2.md 5.0 says outright that this trade should be noticed rather than discovered later. This commit is the noticing. What is proved instead is stronger, not differently shaped. A statement with no organization in hand asks for `projects` unqualified and is told the relation does not exist: not zero rows, an error. That is a different claim from "the policy filtered it", because a policy that was silently not enforced returned zero rows and looked fine - which is the failure G2 was, and the reason the old test had to run as a non-owner role to mean anything. The new tests keep the non-owner discipline for the part that needs it. Naming another tenant's schema is refused outright; repointing the search path at it and using an ordinary name finds no table at all, because a role without USAGE on a schema does not see its contents when names are resolved. That second one is the outcome an application actually experiences, so both are asserted rather than the convenient one. Positive cases are here because a suite that only proves refusal passes just as happily against a database where every write is refused: a write that must be allowed is performed and then counted in both tenants. What the file cannot prove is written down in its docstring rather than left to be assumed. The application owns every tenant schema, so it can reach another tenant's tables by naming that schema, and schema-per-tenant cannot change that with one role serving every tenant. What it changes is that there is no shared relation to reach through, and reaching another tenant means writing down another organization's name - one call site in tenancy.py rather than a predicate in every query.
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.statusisinvitedorlockedis refused, not coerced ontoactive. - A row with
users.mfa_required = trueis 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.
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_accountrequires anOperatorContext, which only the operator CLI builds, plus amust_change_password = TrueArgon2id 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_passwordgenerates the one-time password in process, validates it against the local policy, hashes it, and returns it once in the creation response asIssuedTemporaryPassword. The value is excluded from that object'sreprand is not retrievable again by any endpoint.oidcrequires an authenticated administrator principal and stores no local password. Anoidccredential 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, andSecurewhenever production settings are used. - CSRF uses the double-submit pattern: the readable
umbratrans_csrfcookie plus anX-CSRF-Tokenheader compared in constant time.POST /api/auth/loginis exempt because it carries credentials an attacker cannot know. A safe request (for exampleGET /api/healthorGET /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/loginreturns the CSRF token;GET /api/auth/mereturns 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_rolesmembership, invitations, and audit export. - Connection to
umbracontrol, application ofmigrations/0001_*, and the first administrator in any approved environment. - Serving the static prototype:
web/is unchanged and is not mounted.