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
Owner

What and why

A fresh deployment could not be used at all. Registration requires a valid invite code, invites can only be issued by an existing admin, and a newly migrated database has neither — so there was no way to create the first account. Now that D16/D17 made a real deployment possible, the first thing a working container gives you is a login page nobody can get past.

This adds velodrome create-admin, a console script ([project.scripts]velodrome.cli:main) that installs into the same venv as alembic and uvicorn, so the deployed image already has it on PATH:

docker exec -it velodrome velodrome create-admin --email you@example.com

docs/PLAN.md always called for this command; it was simply never built during Phase 0.

The account-creating logic lives in auth/service.py as create_admin(), not in cli.py, so db.auth_session stays confined to that module as its docstring requires. Its lookup is an exact match on a unique key — the pattern db.py documents as safe on that session.

Deliberate constraints, each with a test (reasoning in D18):

  • Refuses an email that already exists rather than updating the row. An operator re-running a months-old command from shell history means "create", never "reset the password". Accepting it would make this an undocumented password-reset tool that any container-exec grants.
  • Not restricted to "only when there are zero users." That restriction buys nothing — reaching the command already requires running a process inside the container, which already permits rewriting the SQLite file directly — while removing the two cases that do happen: adding a second admin, and recovering an instance whose only admin was lost.
  • No --password flag. An argument lands in shell history, in ps output, and 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 — the same pair docker login offers for the same reason.
  • argparse, not Typer/Click — one command with three options doesn't justify a runtime dependency the image has to carry.

role="admin" is recorded but nothing enforces it yet; there is no admin-only endpoint until invite management in Phase 1. ROLE_ADMIN/ROLE_MEMBER become named constants and AuthenticatedSession carries the role so that future check has it in hand. It is deliberately absent from UserOut, so no HTTP response and no OpenAPI contract changes.

RegisterRequest's password and display-name constraints move to named aliases (Password, DisplayName) in schemas/auth.py, so the CLI applies exactly the same rules rather than a copy that drifts.

How this was verified

  • CI is green
  • Tests added or updated for the behaviour that changed
  • Verified manually (describe how):

Testsapps/api/tests/test_cli.py, 15 new tests, real SQLite via the existing conftest (real Alembic migrations, real Argon2id, no mocks). Full suite: 28 passed. The ones that carry weight:

  • test_create_admin_creates_an_account_that_can_actually_log_in — deliberately not "a row appeared with a hash in it": it bootstraps an admin through the CLI and then logs in as them over HTTP. This command runs once, by a human with no way to debug it, and "printed success but the password doesn't work" is indistinguishable from a broken deployment.
  • test_create_admin_refuses_an_existing_email_without_touching_the_account — asserts refusal three ways (original password still works, new one rejected, display name unchanged), because exit code 1 alone would also be satisfied by a command that failed after corrupting the row.
  • test_create_admin_error_message_never_echoes_the_password — pydantic's default ValidationError rendering embeds the offending value, i.e. it prints the password. _validate shows only loc/msg; this pins that. test_success_output_never_contains_the_password covers the other path.
  • test_create_admin_records_the_admin_role — asserts against the stored column and against ROLE_MEMBER, so it can't pass vacuously if the default changes. Nothing else in the suite would notice a silent member.
  • test_password_stdin_preserves_a_trailing_space — proven through a real login, not by inspecting a hash.
  • test_the_console_script_is_registered_under_the_name_the_docs_use — an editable install imports velodrome.cli fine whether or not the entry point exists, so nothing else would catch the README's command being wrong.

Gates, all run from apps/api/ after the rebase: ruff check . clean · ruff format --check . clean (29 files) · mypy --strict velodrome clean (20 files) · pytest -q 28 passed · alembic check reports no model drift.

Manual, against a scratch SQLite database (migrated with alembic upgrade head, then driving the real entry point):

  • Creating an admin printed the id/email/display name/role and the login URL — and not the password.
  • Re-running with the same email: error: an account already exists for you@example.com — refusing to modify it…, exit 1.
  • Running without a TTY and without --password-stdin: the message naming both working forms (docker exec -it … and the piped form), exit 1 — rather than a bare OSError from getpass.

Not verified: this has not been run inside the actual built image on the Unraid host — only against a local venv and scratch database. The entry point's presence is asserted from pyproject.toml, and PATH already covers alembic/uvicorn by the same mechanism (deploy/entrypoint.sh invokes both by installed script name), but the first real docker exec is unproven. Worth running once after the next image build.

Invariants

  • No secret can reach a response model, log line, or error message

#5 is the one this change is most exposed to, in two places, both tested: the pydantic error rendering above, and the absence of a --password flag. The new role field on AuthenticatedSession is deliberately not added to UserOut.

Not applicable: no ingestion, no wear/odometer, no physical quantities, no new tables, no migration (alembic check confirms no model drift — ROLE_MEMBER is the same "member" string the column already defaulted to, now named).

Risks and follow-ups

  • create_admin() bypasses the invite requirement by design. It is not reachable over HTTP and nothing in api/ calls it; reaching it requires process execution inside the container, which already permits rewriting the SQLite file directly. Worth a reviewer's eye on that reasoning specifically — it's the load-bearing security argument, and it's the reason the "only when zero users" guard was rejected rather than forgotten.
  • The admin role is inert. Enforcement arrives with the first admin-only endpoint (invite management, Phase 1). Until then a CLI-created account differs from an invited one only in a string column.
  • There is no password reset. The refusal path tells the operator so. A reset should be a separate command that says what it does in its name.
  • Deliberately left out: listing/promoting/demoting users, and any --force overwrite.
  • Note for the reviewer: this branch was rebased onto current main (through #9). docs/DECISIONS.md conflicted — D17 was taken by the registry-TLS decision that merged while this was in flight, so this entry is D18, and all code comments reference D18.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG

## What and why **A fresh deployment could not be used at all.** Registration requires a valid invite code, invites can only be issued by an existing admin, and a newly migrated database has neither — so there was no way to create the first account. Now that D16/D17 made a real deployment possible, the first thing a working container gives you is a login page nobody can get past. This adds `velodrome create-admin`, a console script (`[project.scripts]` → `velodrome.cli:main`) that installs 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 ``` `docs/PLAN.md` always called for this command; it was simply never built during Phase 0. The account-creating logic lives in `auth/service.py` as `create_admin()`, not in `cli.py`, so `db.auth_session` stays confined to that module as its docstring requires. Its lookup is an exact match on a unique key — the pattern `db.py` documents as safe on that session. Deliberate constraints, each with a test (reasoning in **D18**): - **Refuses an email that already exists** rather than updating the row. An operator re-running a months-old command from shell history means "create", never "reset the password". Accepting it would make this an undocumented password-reset tool that any container-exec grants. - **Not restricted to "only when there are zero users."** That restriction buys nothing — reaching the command already requires running a process inside the container, which already permits rewriting the SQLite file directly — while removing the two cases that do happen: adding a second admin, and recovering an instance whose only admin was lost. - **No `--password` flag.** An argument lands in shell history, in `ps` output, and 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 — the same pair `docker login` offers for the same reason. - **argparse, not Typer/Click** — one command with three options doesn't justify a runtime dependency the image has to carry. `role="admin"` is recorded but **nothing enforces it yet**; there is no admin-only endpoint until invite management in Phase 1. `ROLE_ADMIN`/`ROLE_MEMBER` become named constants and `AuthenticatedSession` carries the role so that future check has it in hand. It is deliberately **absent from `UserOut`**, so no HTTP response and no OpenAPI contract changes. `RegisterRequest`'s password and display-name constraints move to named aliases (`Password`, `DisplayName`) in `schemas/auth.py`, so the CLI applies exactly the same rules rather than a copy that drifts. ## How this was verified - [x] CI is green - [x] Tests added or updated for the behaviour that changed - [x] Verified manually (describe how): **Tests** — `apps/api/tests/test_cli.py`, 15 new tests, real SQLite via the existing conftest (real Alembic migrations, real Argon2id, no mocks). Full suite: **28 passed**. The ones that carry weight: - `test_create_admin_creates_an_account_that_can_actually_log_in` — deliberately **not** "a row appeared with a hash in it": it bootstraps an admin through the CLI and then logs in as them over HTTP. This command runs once, by a human with no way to debug it, and "printed success but the password doesn't work" is indistinguishable from a broken deployment. - `test_create_admin_refuses_an_existing_email_without_touching_the_account` — asserts refusal three ways (original password still works, new one rejected, display name unchanged), because exit code 1 alone would also be satisfied by a command that failed *after* corrupting the row. - `test_create_admin_error_message_never_echoes_the_password` — pydantic's default `ValidationError` rendering embeds the offending value, i.e. it prints the password. `_validate` shows only `loc`/`msg`; this pins that. `test_success_output_never_contains_the_password` covers the other path. - `test_create_admin_records_the_admin_role` — asserts against the stored column *and* against `ROLE_MEMBER`, so it can't pass vacuously if the default changes. Nothing else in the suite would notice a silent `member`. - `test_password_stdin_preserves_a_trailing_space` — proven through a real login, not by inspecting a hash. - `test_the_console_script_is_registered_under_the_name_the_docs_use` — an editable install imports `velodrome.cli` fine whether or not the entry point exists, so nothing else would catch the README's command being wrong. **Gates, all run from `apps/api/` after the rebase:** `ruff check .` clean · `ruff format --check .` clean (29 files) · `mypy --strict velodrome` clean (20 files) · `pytest -q` 28 passed · `alembic check` reports no model drift. **Manual, against a scratch SQLite database** (migrated with `alembic upgrade head`, then driving the real entry point): - Creating an admin printed the id/email/display name/role and the login URL — and **not** the password. - Re-running with the same email: `error: an account already exists for you@example.com — refusing to modify it…`, exit **1**. - Running without a TTY and without `--password-stdin`: the message naming both working forms (`docker exec -it …` and the piped form), exit **1** — rather than a bare `OSError` from getpass. **Not verified:** this has not been run inside the actual built image on the Unraid host — only against a local venv and scratch database. The entry point's presence is asserted from `pyproject.toml`, and `PATH` already covers `alembic`/`uvicorn` by the same mechanism (`deploy/entrypoint.sh` invokes both by installed script name), but the first real `docker exec` is unproven. Worth running once after the next image build. ## Invariants - [x] No secret can reach a response model, log line, or error message **#5 is the one this change is most exposed to**, in two places, both tested: the pydantic error rendering above, and the absence of a `--password` flag. The new `role` field on `AuthenticatedSession` is deliberately not added to `UserOut`. Not applicable: no ingestion, no wear/odometer, no physical quantities, no new tables, **no migration** (`alembic check` confirms no model drift — `ROLE_MEMBER` is the same `"member"` string the column already defaulted to, now named). ## Risks and follow-ups - **`create_admin()` bypasses the invite requirement by design.** It is not reachable over HTTP and nothing in `api/` calls it; reaching it requires process execution inside the container, which already permits rewriting the SQLite file directly. Worth a reviewer's eye on that reasoning specifically — it's the load-bearing security argument, and it's the reason the "only when zero users" guard was rejected rather than forgotten. - **The admin role is inert.** Enforcement arrives with the first admin-only endpoint (invite management, Phase 1). Until then a CLI-created account differs from an invited one only in a string column. - **There is no password reset.** The refusal path tells the operator so. A reset should be a separate command that says what it does in its name. - Deliberately left out: listing/promoting/demoting users, and any `--force` overwrite. - **Note for the reviewer:** this branch was rebased onto current `main` (through #9). `docs/DECISIONS.md` conflicted — D17 was taken by the registry-TLS decision that merged while this was in flight, so this entry is **D18**, and all code comments reference D18. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
BBergle added 2 commits 2026-09-21 22:31:46 -04:00
A fresh deployment could not be used. Registration requires a valid invite
code, invites can only be issued by an existing admin, and a newly migrated
database has neither -- so there was no way to create the first account.
docs/PLAN.md always called for this command; it was never built during
Phase 0, and D16 (single-container deploy) made the gap reachable.

Adds a console script -- `[project.scripts]` -> velodrome.cli:main -- which
installs into the same venv as alembic and uvicorn, so the deployed image
already has it on PATH:

    docker exec -it velodrome velodrome create-admin --email you@example.com

The account-creating logic is `auth.service.create_admin`, not something in
cli.py, so that `db.auth_session` stays confined to auth/service.py as its
docstring requires. Its lookup is an exact match on a unique key, which is
the pattern db.py documents as safe on that session.

Deliberate constraints, all covered by tests (see docs/DECISIONS.md D18):

- Refuses an email that already exists rather than updating the row. An
  operator re-running a months-old command from shell history means "create",
  never "reset the password"; silently accepting would make this an
  undocumented password-reset tool that any container-exec grants.
- Not restricted to "only when there are zero users". The restriction buys
  nothing -- reaching the command already requires process execution inside
  the container, which already permits rewriting the SQLite file directly --
  while removing the cases that do happen: a second admin, and recovering an
  instance whose only admin was lost.
- No --password flag. An argument lands in shell history, in ps output, and
  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.
- Pydantic's ValidationError is never printed verbatim: its rendering embeds
  the offending value, which for a short password prints the password itself.
  Only loc and msg are shown (CLAUDE.md invariant #5).

role="admin" is recorded but nothing enforces it yet -- there is no
admin-only endpoint until invite management in Phase 1. ROLE_ADMIN/
ROLE_MEMBER become named constants, and AuthenticatedSession carries the
role for that future check. It is deliberately absent from UserOut, so no
HTTP response and no OpenAPI contract changes. The register endpoint's
password and display-name constraints move to named aliases in schemas/auth
so the CLI applies exactly the same rules rather than a drifting copy.

Verified: ruff check, ruff format --check, mypy --strict, and the full
pytest suite (28 passed) from apps/api/; `alembic check` reports no model
drift. Also smoke-tested end to end against a scratch database -- creation,
the duplicate-email refusal, and the no-TTY message all behave as described.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
docs: record D18 (admin bootstrap) and the deploy bootstrap step
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 19s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
d0c0d98307
The deploy README described how to start the container but not how to get
into it, which left the first-run experience at a login page nobody can get
past. Adds the actual command, both the interactive and the piped form, and
says why there is no --password flag.

D18 records the three decisions worth arguing with later rather than
rediscovering: why this is a CLI instead of a bootstrap HTTP endpoint or an
env var (both rejected, with reasons), why it refuses an existing email, why
it is not restricted to the first user, and why the admin role is recorded
but not yet enforced.

Numbered D18 because D17 was taken by the registry-TLS decision that merged
while this branch was in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
BBergle merged commit 65879e8659 into main 2026-09-21 22:34:58 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: BBergle/bike-app#10