feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c131543abf
commit
c8fa13dd6a
@@ -294,12 +294,29 @@ before the id is even parsed as a valid `EkklesiaUserId` — this command never
|
||||
signup order or "the only user in the database"; the operator must name the exact, opaque id
|
||||
twice.
|
||||
|
||||
**The command grants authority; it does not create credentials.** Production public signup is
|
||||
prohibited, and the repository currently has no invitation flow or operator-owned user-creation
|
||||
command. A fresh production installation therefore has no supported path to create the initial
|
||||
identity this command requires. Development integration tests may enable signup, create a user,
|
||||
bootstrap that existing id, and prove its Dashboard session, but that is test composition — not a
|
||||
production first-user provisioning workflow. This gap must be closed before launch.
|
||||
**The command grants authority; it does not create credentials.** It requires a user id that
|
||||
already exists, which is the right shape when an identity is already there and only ownership is
|
||||
missing.
|
||||
|
||||
For a *fresh* installation, where no identity exists at all, use
|
||||
`pnpm identity:create-initial-owner` instead
|
||||
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||||
|
||||
```bash
|
||||
pnpm migrations:apply # the database must already be migrated
|
||||
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
It creates the identity through the identity system's own sign-up path and then performs this exact
|
||||
single-use grant, so both commands consume the same sentinel and neither weakens it. Whichever runs
|
||||
first is the one bootstrap this installation gets. The password is prompted for invisibly, or read
|
||||
from standard input for automation; it is never a command-line argument and never an environment
|
||||
variable. See [Identity](identity.md#creating-the-first-platform-owner) for the full operator
|
||||
workflow, including its rerun and recovery behaviour.
|
||||
|
||||
Development integration tests may still enable signup, create a user, bootstrap that existing id,
|
||||
and prove its Dashboard session; that remains test composition exercising the public signup
|
||||
transport, which is a different thing from the production bootstrap path.
|
||||
|
||||
### Bootstrap is permanently single-use
|
||||
|
||||
|
||||
@@ -262,6 +262,87 @@ roles/groups/teams/tenants model exists.
|
||||
|
||||
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
|
||||
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
|
||||
an existing identity; it does not create one. Because production signup is prohibited and no
|
||||
operator enrollment command exists, creating the initial production identity needed for owner
|
||||
bootstrap remains an explicit operational gap.
|
||||
an existing identity; it does not create one. Creating the *first* identity on a fresh installation
|
||||
is what `pnpm identity:create-initial-owner` is for — see below.
|
||||
|
||||
## Creating the first platform owner
|
||||
|
||||
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
|
||||
default and production cannot enable it at all, so one explicit, operator-invoked command creates
|
||||
the initial identity and grants it platform ownership
|
||||
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
|
||||
|
||||
```bash
|
||||
pnpm migrations:apply # the schema must already exist; this command never migrates
|
||||
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
|
||||
# Password: (typed invisibly)
|
||||
# Confirm password:
|
||||
# Initial platform owner created successfully.
|
||||
# Email: [email protected]
|
||||
# You can now sign in to the Ekklesia dashboard.
|
||||
```
|
||||
|
||||
`--name` is optional and defaults to the email address; the identity model requires a display name,
|
||||
and asking for one that nobody can edit yet would be a question without a purpose.
|
||||
|
||||
### The password never becomes an argument or a variable
|
||||
|
||||
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
|
||||
explanation**, because a command-line argument is visible in shell history and to every process on
|
||||
the machine through the process list. For automated deployment the password is read from standard
|
||||
input:
|
||||
|
||||
```bash
|
||||
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
|
||||
```
|
||||
|
||||
**No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used
|
||||
once to create an account; it is not deployment configuration, and an environment variable is
|
||||
precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash,
|
||||
a session token, or the database URL.
|
||||
|
||||
### Required environment
|
||||
|
||||
`DATABASE_URL`, and the same optional transport variables every other Core command honours
|
||||
(`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity
|
||||
secret, trusted origins, or a public base URL: none is needed to create an account, so the first
|
||||
owner can be created on a freshly provisioned server before TLS or DNS exist.
|
||||
|
||||
### Password and email rules are the identity system's own
|
||||
|
||||
The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up
|
||||
path a browser would use, not by a separate CLI policy that could drift from it. The address is
|
||||
normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports
|
||||
the stored address, so the one you are told to sign in with is the one that exists.
|
||||
|
||||
### It runs once
|
||||
|
||||
Ownership bootstrap is permanently single-use (see
|
||||
[Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and:
|
||||
|
||||
- creates no second owner,
|
||||
- changes no existing password,
|
||||
- creates no stray identity, even when a different address is supplied.
|
||||
|
||||
### Rerun and recovery
|
||||
|
||||
| situation | behaviour |
|
||||
| --- | --- |
|
||||
| Bootstrap already completed | Refused. Nothing changes. |
|
||||
| Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. |
|
||||
| A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. |
|
||||
| Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. |
|
||||
| Database unmigrated or unreachable | Refused with a clear message. The command never migrates. |
|
||||
|
||||
Nothing is deleted automatically on failure: removing an account that was created successfully, on
|
||||
the strength of a later error, would destroy something an operator may need.
|
||||
|
||||
### This is not signup
|
||||
|
||||
There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no
|
||||
temporary setup route, and no setup token. The login page stays login-only, and public registration
|
||||
stays disabled in every environment — an integration test asserts that a freshly bootstrapped
|
||||
deployment still refuses `POST /api/auth/sign-up/email`.
|
||||
|
||||
Creating additional users or owners is not this command's job. That belongs to user administration,
|
||||
which does not exist yet.
|
||||
|
||||
@@ -506,6 +506,44 @@ outstanding.
|
||||
consuming the single-use platform-owner bootstrap. This repository does not provide that way
|
||||
yet.
|
||||
|
||||
### The initial-owner command is trusted local tooling
|
||||
|
||||
A fresh installation needs a first administrator, and public registration is disabled everywhere and
|
||||
prohibited in production. `pnpm identity:create-initial-owner` fills that gap
|
||||
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)). What makes it safe
|
||||
is **where it can be invoked**, not a weaker policy:
|
||||
|
||||
- **It is not reachable from a browser.** No `/signup`, `/register`, or `/setup` route, no
|
||||
unauthenticated bootstrap endpoint, no temporary setup route, and no setup token. It serves no HTTP
|
||||
route and nothing in `apps/api` imports it. Its authority is already having the database
|
||||
credentials.
|
||||
- **Public signup stays disabled.** The command constructs its own in-process identity instance
|
||||
rather than relaxing the server's posture. A test asserts that a freshly bootstrapped deployment
|
||||
still refuses `POST /api/auth/sign-up/email`.
|
||||
- **The password never lands anywhere durable.** Prompted invisibly or read from standard input;
|
||||
`--password` is refused rather than ignored, because an argument is visible in shell history and in
|
||||
the process list. No environment variable is read for it and none is in `.env.example`, so it
|
||||
cannot become permanent deployment configuration. It is never printed, logged, or placed in an
|
||||
error.
|
||||
- **It writes no password hash of its own.** Hashing, salting, normalization, and the 8–128 bound are
|
||||
the identity system's, applied through the same sign-up path a browser uses — so a CLI-created
|
||||
account can never diverge from a browser-created one.
|
||||
- **It cannot become a standing superuser factory.** Ownership bootstrap is permanently single-use
|
||||
and this command consumes the same sentinel; there is no `--force`, reset, or override. A second
|
||||
run creates no owner, changes no password, and creates no stray identity.
|
||||
- **It never takes over an account.** An existing usable identity is adopted — granted ownership with
|
||||
its password untouched — and reported as an adoption. A password reset is never performed. An
|
||||
identity that has no password at all is refused rather than made an owner nobody can sign in as,
|
||||
which would consume the single use and leave the installation unusable.
|
||||
- **Races cannot mint two owners.** Whole invocations are serialized by an advisory lock, on top of
|
||||
the grant's own locked, single-use transaction.
|
||||
- **Errors stay sanitized.** No SQL, connection string, driver detail, Better Auth response body, or
|
||||
cause reaches the operator's terminal — only a stable code and a message describing what is still
|
||||
true of the installation.
|
||||
|
||||
It deliberately does not solve creating *additional* users or owners. That is user administration,
|
||||
which does not exist yet.
|
||||
|
||||
### Rate limiting is per-process today
|
||||
|
||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||
|
||||
@@ -328,6 +328,29 @@ The canonical template builds and mounts both artifacts through `parseModuleUiEx
|
||||
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
|
||||
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
|
||||
|
||||
A further suite (`apps/api/tests/initial-owner.integration.ts`, same harness) covers the initial
|
||||
platform-owner bootstrap ([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)).
|
||||
It deliberately does not stop at asserting rows: a fresh installation is bootstrapped and the
|
||||
resulting account is then signed in through the exact browser-facing login transport, its principal
|
||||
read from `GET /api/v1/principal`, and used to open a platform-owner-protected resource that stays
|
||||
closed to an anonymous caller. Anything less would prove a user was written, not that an operator can
|
||||
reach their dashboard.
|
||||
|
||||
Every scenario runs with `publicSignupEnabled: false` — the production posture — so nothing can
|
||||
accidentally depend on the signup route a real deployment does not have, and one scenario asserts
|
||||
that a freshly bootstrapped deployment still refuses `POST /api/auth/sign-up/email`. The suite also
|
||||
pins the guard (a second run creates no owner, changes no password, and leaves no stray identity),
|
||||
the 8–128 password bound and email validation coming from the identity system rather than a
|
||||
CLI-specific policy, adoption of an existing identity without touching its password, refusal of a
|
||||
credential-less row *without* consuming the single-use sentinel, a partial failure that stays
|
||||
recoverable by rerunning, and five racing invocations yielding exactly one owner, one sentinel, and
|
||||
one user row.
|
||||
|
||||
The command itself is exercised as a real child process — success and failure exit codes, the
|
||||
password arriving on standard input and never in `argv`, `--password` refused, a missing
|
||||
`DATABASE_URL`, and an unmigrated database refused rather than silently migrated — with assertions
|
||||
that neither stdout nor stderr contains the password or the connection string.
|
||||
|
||||
### Which kind of timing proof lives where
|
||||
|
||||
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
|
||||
|
||||
Reference in New Issue
Block a user