diff --git a/deploy/README.md b/deploy/README.md index fa00fd2..41fb2fe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -43,6 +43,41 @@ front of port 8080 — this container only ever serves plain HTTP itself. `GET http://: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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index dca3727..fdac4a5 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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