docs: record D18 (admin bootstrap) and the deploy bootstrap step
The deploy README described how to start the container but not how to get into it, which left the first-run experience at a login page nobody can get past. Adds the actual command, both the interactive and the piped form, and says why there is no --password flag. D18 records the three decisions worth arguing with later rather than rediscovering: why this is a CLI instead of a bootstrap HTTP endpoint or an env var (both rejected, with reasons), why it refuses an existing email, why it is not restricted to the first user, and why the admin role is recorded but not yet enforced. Numbered D18 because D17 was taken by the registry-TLS decision that merged while this branch was in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
This commit is contained in:
@@ -289,6 +289,73 @@ they're ready to make that call deliberately, not bundled into this fix.
|
||||
and certs (`vaultwarden.bbergle.com` etc.) — untouched, new proxy host only. No other container on
|
||||
the Unraid host was restarted, reconfigured, or otherwise touched to make this work.
|
||||
|
||||
### D18 — Admin bootstrap is a CLI command, not an HTTP endpoint or a first-run mode
|
||||
|
||||
**Chosen:** `velodrome create-admin`, a console script (`[project.scripts]` in
|
||||
`apps/api/pyproject.toml` → `velodrome.cli:main`) installed into the same venv as `alembic` and
|
||||
`uvicorn`, so the deployed image already has it on PATH:
|
||||
|
||||
```sh
|
||||
docker exec -it velodrome velodrome create-admin --email you@example.com
|
||||
```
|
||||
|
||||
**Why this exists at all:** registration requires a valid invite code (`docs/PLAN.md` "Auth" — open
|
||||
signup does not exist, not even as a setting), invites can only be created by an existing admin,
|
||||
and a freshly migrated database has neither. A new deployment was therefore unusable: there was no
|
||||
way to create the first account. `docs/PLAN.md` always called for this command; it was simply never
|
||||
built during Phase 0, and the gap only became visible once D16 made a real deployment possible.
|
||||
|
||||
**Why a CLI rather than the alternatives:**
|
||||
- *A bootstrap HTTP endpoint that works only while the users table is empty* — rejected. It puts an
|
||||
unauthenticated account-creating route on the public internet permanently, whose safety depends
|
||||
entirely on a row count staying zero. The window is real (between first start and first login),
|
||||
it's the exact window where the deployment is least watched, and the failure is silent: whoever
|
||||
wins the race owns the instance.
|
||||
- *An env var like `VELODROME_INITIAL_ADMIN_PASSWORD`* — rejected. A password in an env var is
|
||||
visible in `docker inspect`, in the Unraid template's saved config on disk, and in the container's
|
||||
own `/proc/1/environ` for the process's whole life. D16 deliberately moved configuration into
|
||||
Unraid's UI, which would mean the bootstrap password sitting in that UI indefinitely.
|
||||
- *Seeding a default account in a migration* — rejected outright. It would mean a known-credential
|
||||
account existing on every deployment, and it contradicts the reason migrations are schema-only.
|
||||
|
||||
**Why it refuses an email that already exists, rather than updating it:** creating an account and
|
||||
resetting an existing account's password are different operations with different blast radii, and
|
||||
the realistic scenario — an operator re-running a command they last ran months ago, from shell
|
||||
history — means the first, never the second. Silently accepting it would make this an undocumented
|
||||
password-reset tool that any container-exec grants, and would make the command's behaviour depend
|
||||
on state the operator can't see. It exits 1 and says what it refused. A genuine password reset is a
|
||||
separate future command that should have to say so in its name.
|
||||
|
||||
**Why it is *not* restricted to "only when there are zero users":** that restriction sounds safer
|
||||
and isn't. It buys nothing — the command already requires the ability to run a process inside the
|
||||
container, which is already the ability to read and rewrite the SQLite file directly, so a
|
||||
restriction only constrains the legitimate operator, never an attacker who is by definition already
|
||||
past it. Meanwhile it removes the two cases that actually happen: a second admin for a family
|
||||
member, and recovering an instance whose only admin account was lost. The invite system remains the
|
||||
normal path for adding users; this stays the operator's escape hatch.
|
||||
|
||||
**Why `role="admin"` is recorded but nothing enforces it yet:** there is no admin-only endpoint to
|
||||
protect. Invite management — the first thing that genuinely needs the distinction — is Phase 1.
|
||||
Writing the column now means the first account is correctly marked when that check does arrive,
|
||||
rather than needing a data fix-up later; writing an *enforcement* mechanism now would be guessing at
|
||||
the shape of a check with no caller. `ROLE_ADMIN`/`ROLE_MEMBER` are named constants in
|
||||
`models/identity.py`, and the column stays a plain string rather than a DB enum or CHECK constraint
|
||||
so a third role later is an application change, not a migration. `AuthenticatedSession.role` carries
|
||||
the value for that future check; it is deliberately absent from `schemas.auth.UserOut`, so this
|
||||
changes no HTTP response and no OpenAPI contract.
|
||||
|
||||
**Why there is no `--password` flag:** an argument lands in shell history, in `ps` output for the
|
||||
process's lifetime, and — because the realistic invocation is `docker exec` — in the Docker daemon's
|
||||
record of the exec'd command. A TTY prompt (with confirmation) and `--password-stdin` are the two
|
||||
forms that avoid all three, which is the same pair `docker login` offers for the same reason.
|
||||
Pydantic's `ValidationError` rendering is also deliberately not printed verbatim: it embeds the
|
||||
offending value, which for a too-short password prints the password to the terminal. Only `loc` and
|
||||
`msg` are shown (CLAUDE.md invariant #5); `tests/test_cli.py` asserts this on both the failure and
|
||||
success paths.
|
||||
|
||||
**argparse, not Typer/Click:** one command with three options doesn't justify a runtime dependency
|
||||
the deployed image has to carry.
|
||||
|
||||
---
|
||||
|
||||
## Deliberately deferred
|
||||
|
||||
Reference in New Issue
Block a user