ADR 0011: First-run setup — the empty installation creates its administrator once, under a lock¶
- Status: Accepted
- Date: 2026-10-09
- Amends: ADR 0009 §4, §6 and the operational consequence on how the first administrator is created.
- Applies to: the
feat(identity)andfeat(curator)commits of cycle 1.1 —POST /api/v1/setup/admin,GET /api/v1/setup/status, the first-run screen and the two lines that move in the open surface.
Context¶
ADR 0009 §6 put the first administrator in the CLI and refused a route that creates one:
a route that creates an administrator when no account exists is an open door whose only defence is a race on the first deploy.
That objection is precise, and it is about the race and the window — not about the convenience.
A guarded design answers it, and the cost of not having one is paid by the installer. Today the first
account comes from python -m scrinalia.domains.identity.cli create, which means opening a shell on
the host or docker compose exec into the container before the UI can be used at all; and because the
CLI generates the password, it is printed to the terminal and the account carries
must_change_password=True so that the password sitting in the scrollback stops being a credential.
The screen is one page of the SPA, and the person installing the system is usually the person who will
sign in.
The guard proposed for the route does not hold. The straightforward form — one statement,
INSERT … SELECT … WHERE NOT EXISTS (SELECT 1 FROM auth_users) — is not atomic. Measured against
PostgreSQL 15 with two concurrent transactions, the first left uncommitted while the second ran:
-- both sessions, READ COMMITTED
INSERT INTO auth_users (email, …) SELECT 'a@x', … WHERE NOT EXISTS (SELECT 1 FROM auth_users);
INSERT 0 1 -- session A (holding, uncommitted)
INSERT 0 1 -- session B, inside the window
SELECT count(*) FROM auth_users; -> 2
The second transaction's statement takes its snapshot when the statement starts, and the first
transaction's row is not in it. NOT EXISTS is a predicate over a snapshot, not a lock on the table.
Two administrators exist, and the deployment's only defence was that nobody happened to click twice.
Two repairs were measured before choosing, with the same two-session harness:
| repair | outcome |
|---|---|
LOCK TABLE auth_users IN EXCLUSIVE MODE; then the same INSERT … WHERE NOT EXISTS |
session B waits for A to commit, then inserts 0 rows — one administrator, and B is told why |
BEGIN ISOLATION LEVEL SERIALIZABLE; + the single statement |
one row, but the first transaction is the one aborted (40001 could not serialize access due to read/write dependencies), at commit, after the hash was paid for |
The second repair makes the loser the request that arrived first, and it turns a business answer (409) into a database error that has to be retried to become one. The first makes the loser the request that arrived second, which is what "somebody already did this" means.
Decision¶
1. The route exists, and the lock is what guards it¶
POST /api/v1/setup/admin creates the first account only when auth_users is empty, in one
transaction that takes the lock before it evaluates the predicate:
LOCK TABLE auth_users IN EXCLUSIVE MODE—EXCLUSIVEconflicts with itself, so two setup calls cannot interleave, and it conflicts with theROW EXCLUSIVEof an ordinary write, so the CLI cannot create an account behind the route's back either;INSERT INTO auth_users (…) SELECT … WHERE NOT EXISTS (SELECT 1 FROM auth_users).
A concurrent second call waits on the lock, then evaluates the predicate against a snapshot taken after the first transaction committed, inserts nothing, and answers 409.
The repository owns this and not the service: it is the one invariant only SQL can hold, exactly like the unique address it already owns. The service above it decides the policy — the password, the role, the session — and never sees the lock.
2. Once any account exists, the route answers 409 forever¶
The predicate is auth_users is empty. Not "has an active administrator", not "has an account that
can sign in": a predicate that a deactivation could reopen would be a way back to the open door, and
"this installation has been configured" is the fact that must never become false again.
The answer is SetupAlreadyCompleteError, a DomainException mapped to 409 — a business refusal the
API owes a client, not a defect, so it is not written to the unhandled-failure ledger.
3. The empty installation is not a secret; the steady state must not cost a hash¶
GET /api/v1/setup/status (PUBLIC) answers {needs_setup: bool} and nothing else. The screen has to
decide before it has a session, and an installation with no accounts has no secret to protect.
Because that fact is public, the POST's steady state is reachable by anyone: after setup, every call
would otherwise run the password policy and argon2id before discovering the table is not empty.
Measured at the shipped defaults (AUTH_PASSWORD_TIME_COST=3, AUTH_PASSWORD_MEMORY_KIB=65536,
AUTH_PASSWORD_PARALLELISM=4): ~42 ms per hash on the reference machine. So the service reads
is_empty() before it hashes, answers 409 on the fast path, and leaves the lock and the predicate
as the guarantee for the one window where a race is possible. The lock is therefore held only while
the installation is genuinely unconfigured — that is, at a moment when there is nobody signed in
whose sign-in it could stall.
4. The account is a real account, not a bootstrap¶
- It is an
ADMIN: the first account administers the installation, and a weaker first account would make the next screen (/configuracoes/usuarios) unreachable. - It does not carry
must_change_password. The person chose that password a second earlier; the CLI's flag exists because the CLI generated the one it printed, and there is nothing to replace here. - It opens a session, and the route answers the account with the cookie. The person already typed the password, and it is the same session code the login uses — not a second path into the session table. The route stops answering 201 after its one successful call, so this is the only cookie it ever hands out.
The CLI stays, for a headless install and as the way back in, and the install guide documents both paths. Recovery remains administrator-guided: no e-mail and no SMTP. The e-mail address remains the account identifier for now.
5. The open surface grows by two operations, deliberately¶
ADR 0009 §4 pinned it to "the login and the two diffusion routes".
testing/unit/api/test_route_access.py pins it still — the set now holds five operations, and the
update is part of this diff, in the open, so a sixth can only appear the same way.
Rationale¶
Why not keep the CLI as the only path. The refusal was never about the convenience; it was about the race. Once the race is closed with a lock, the remaining exposure is the window itself, and the window is identical either way: an installation that is reachable and unconfigured can be claimed by whoever reaches it first, whether the claiming requires a shell or a form. What changes is who can claim it legitimately — and "the installer, from the UI they were told to open" is a smaller ask than "the installer, with a shell on the host, copying a temporary password out of a terminal".
Why not SERIALIZABLE. Measured above: it protects the invariant and aborts the wrong request,
and the answer it produces is a database error that only a retry turns into the 409 the client
understands. A retry loop for a once-in-an-installation path is machinery bought to avoid one
LOCK TABLE.
Why a table lock and not pg_advisory_xact_lock. The advisory lock is lighter — it would not
block a sign-in's last_login_at write — and it only serializes writers that agree to take it. The
CLI does not, and would not: it is the other path that creates accounts. Locking the table whose
emptiness is the predicate needs no cooperation from anyone.
Why not a bootstrap token the CLI prints. A one-shot secret in the URL or a header is the CLI with an extra step, and it puts a credential in the address bar, in the browser history and in the access log — the three places a one-time secret should never be.
Why not "declare POST /api/v1/users public while the table is empty". The same guard on the
normal account-creation route makes a route's permission depend on the data, which is precisely
what the one-declaration-per-operation rule exists to prevent: the classification would have to be
read twice, and a reader of the diff could no longer tell what a route requires.
Consequences¶
What changes. The contract grows two operations and two schemas. The install guide's step 6 becomes
two paths instead of one. AUTH_* does not change, no table changes, and no migration lands: the
predicate reads a table that already exists.
What breaks, on purpose. Nothing that exists — the CLI keeps every command it had. The open surface test is edited, which is the visible act of opening the surface.
The accepted cost. An instance that is exposed to a network before it is configured can be claimed by whoever reaches it first. That was already true of an unconfigured instance reachable by SSH, and the mitigation is the same one: configure it before exposing it — now one screen instead of a shell. The lock closed the race; it cannot close the window, because the window is what "first" means.
Operational. The lock is held for the duration of one request, and only while the installation is
unconfigured, so no sign-in can be waiting on it. After the first account exists the route is a single
indexed SELECT and a 409.
Alternatives considered¶
- One statement with
WHERE NOT EXISTS, as proposed. Measured; two rows. Rejected: it is the open door it was meant to close. SERIALIZABLEand a retry. Measured; rejected for aborting the first request and for the retry machinery (see above).- A unique partial index that admits one row. PostgreSQL cannot express "at most one row" as a
constraint — every candidate index has to name a value, and a
(true) WHERE …index would make the second account illegal forever, which is the opposite of what the installation needs. - The CLI generating the password and the screen only setting it. Two half-flows, and the installer still needs the shell before the screen will render.
Revisit trigger¶
- A hosted deployment that is reachable before it is configured. The window is the exposure; an installation that cannot be reached until the operator says so needs no change, and one that can should be created behind the operator's network.
- A second account provider (OIDC, LDAP) lands. The first account stops being the bootstrap and
becomes one more account; this route's
needs_setuppredicate is then about the directory, not the table, and the ADR has to be rewritten rather than amended.