feat(auth): add velodrome create-admin to bootstrap the first user
#10
@@ -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
|
||||
|
||||
@@ -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