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.
6.2 KiB
Phase-1 identity migrations
Status: proposed, never applied, awaiting review.
The SQL in this directory has not been executed against any database, including any development database. It exists so a reviewer can assess the phase-1 identity change before an operator runs it under a change ticket.
| File | Purpose |
|---|---|
0001_phase1_identity.sql |
Forward migration: users.username, user_credentials, organizations, additive sessions columns |
0001_phase1_identity_rollback.sql |
Destructive rollback with prerequisites and verification queries |
What the forward migration changes
Exactly four things, matching docs/umbratrans-backend-design.md:
users.username— nullable, unique,citext(case-insensitive). Existing rows stayNULLuntil an operator supplies an explicit mapping.user_credentials— one credential row per user, withcredential_typein (local_password,oidc), an Argon2idpassword_hashthat is required forlocal_passwordand forbidden foroidc,must_change_password,failed_login_count,locked_until,last_login_at,password_changed_at, and timestamps.organizations— created withCREATE TABLE IF NOT EXISTSand the slug constraint attached, so it is a no-op when the control plane already owns the table. The application's phase-1 service validatesusers.organization_idagainst it, so without it that column referenced nothing.sessions— additive columnsmfa_verified_at,idle_deadline_at,last_seen_at. Nothing is dropped, renamed, or retyped.
It creates no credential rows, backfills no username, inserts no organization, and changes no project, role, permission, invitation, or audit table.
Review questions the file cannot answer itself
These are BLOCKER-level unknowns. This workspace has no access to the production schema, so the file states its assumptions instead of guessing.
-
Membership model divergence (the important one). The control plane derives organization membership from
user_roleswithscope = 'organization', and supports a project-scoped membership the application slice does not model. The application currently stores membership as a column on the user record. One of these has to change before the PostgreSQL adapter is written:- keep the column and accept that a user has at most one organization and no project membership, or
- move the service onto
user_roles, which means reading and writing role assignments and reconcilingRoleNamewith therolestable.
This migration deliberately does not decide it. It creates the
organizationstable and adds no membership column, so the choice stays open and reviewable. -
Does
usershave auuidprimary key namedid, and no existing column namedusername? -
Does
organizationsalready exist in the control plane, and if so does itsstatuscheck constraint already allowarchived? The rollback keys on that constraint, and a mismatch would make the guarded drop fall through to theRAISE NOTICEbranch and leave the table in place. -
What is the name of the "current user" RLS helper (
app.current_user_id(), a request-scoped GUC, or something else), and can a client influence it? -
Are
usersandsessionsalready under RLS, and withFORCE? -
What are the migration role and application role names?
-
Is
citextavailable to the migration role? If not, use alower(...)unique index instead of a case-sensitive text column. -
Are the
rolesrows the application needs present, with the right scope? The adapter resolves a role by name frompublic.rolesand requiresplatform_adminat scopeplatform, andorganization_adminandmemberat scopeorganization.app.validate_user_role_scope()enforces the pairing, so a missing or wrongly scoped role makes account creation fail. Check before provisioning, not during:SELECT name, scope FROM public.roles WHERE name IN ('platform_admin', 'organization_admin', 'member') ORDER BY name; -
Does any row carry
users.mfa_required = true? The adapter refuses to resolve an account that requires a second factor, because this phase cannot satisfy one. Until MFA is implemented, every row must be false or those accounts are unusable:SELECT count(*) FROM public.users WHERE mfa_required; -
Does every user hold at most one active
user_rolesrow? The adapter refuses to resolve a user with more than one active assignment rather than pick one, because the organization on the assignment becomes the scope of every administrative decision:SELECT user_id, count(*) FROM public.user_roles WHERE status = 'active' GROUP BY 1 HAVING count(*) > 1; -
Do all existing users have an
email? It isNOT NULL, so any row added by another system already has one; the check confirms the constraint the application depends on is the live one. -
Does a generic
set_updated_at()trigger helper exist, or does the application maintainupdated_at? -
Is
sessionslarge enough thatCREATE INDEXmust be replaced withCREATE INDEX CONCURRENTLYoutside the transaction? -
Which existing
sessionscolumn should backfilllast_seen_at, if any?
Gates that must pass before approval
- Logical backup taken, checksummed, and restore-tested on a scratch database.
- Migration role and application role privileges verified.
- RLS tests covering two organizations and two projects, including
FORCE ROW LEVEL SECURITYand a client-controlled-GUC escalation attempt. - Account-management tests passing against an isolated PostgreSQL database with the PostgreSQL adapter implemented.
- Rollback rehearsed on the scratch database.
Until then, production PostgreSQL stays untouched and the application runs against a development repository.
Data classification
user_credentials.password_hash— authentication secret (hash only). Never selected into a report, an endpoint response, or a log.sessions.token_hash— authentication secret (hash only), unchanged by this migration.users.usernameandusers.email— personal data. Included in data-export and retention reviews for the control plane.