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. `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 ## Environment variables
All read by `apps/api/velodrome/config.py` (prefix `VELODROME_`) — the app and Alembic both read 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 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. 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 ## Deliberately deferred