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.
|
`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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user