feat(auth): add velodrome create-admin to bootstrap the first user #10

Merged
BBergle merged 2 commits from feat/auth-create-admin-cli into main 2026-09-21 22:34:58 -04:00
2 changed files with 102 additions and 0 deletions
Showing only changes of commit d0c0d98307 - Show all commits
+35
View File
@@ -43,6 +43,41 @@ front of port 8080 — this container only ever serves plain HTTP itself.
`GET http://<host>:8080/api/v1/healthz` should return `{"status": "ok"}` once it's up.
## Create the first admin user
**A fresh deployment has no users and you cannot sign up for one.** Registration requires an invite
code, invites are issued by an existing admin, and a new database has neither — so the first account
is created from inside the container (`docs/DECISIONS.md` D18 for why it's a CLI and not a
first-run web page):
```sh
docker exec -it velodrome velodrome create-admin --email you@example.com
```
That prompts for the password twice and prints the new account's id, email and role. Then log in at
`VELODROME_PUBLIC_URL`. Note the **`-t`** — without a TTY there's nothing to prompt on; the command
says so rather than hanging. Add `--name "Your Name"` to set a display name (it defaults to the part
of the email before the `@`); it's editable in the UI later either way.
For a non-interactive run (a provisioning script), pipe the password in instead — note `-i` rather
than `-it`:
```sh
printf '%s' "$ADMIN_PASSWORD" | docker exec -i velodrome \
velodrome create-admin --email you@example.com --password-stdin
```
There is deliberately no `--password` flag: an argument would land in your shell history, in `ps`
output, and in the Docker daemon's record of the exec'd command.
Re-run it with a different `--email` to add another admin. Re-running it with an email that already
exists **refuses and changes nothing** — it is not a password-reset tool, and there isn't one yet
(D18). Minimum password length is 8 characters, the same rule the register endpoint applies.
Nothing enforces the admin role yet — no admin-only endpoint exists — so today this differs from an
invited account only in the role recorded on it. Invite management in a later phase is what starts
reading it.
## Environment variables
All read by `apps/api/velodrome/config.py` (prefix `VELODROME_`) — the app and Alembic both read
+67
View File
@@ -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