feat(auth): add velodrome create-admin to bootstrap the first user

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
This commit is contained in:
2026-09-21 22:30:42 -04:00
co-authored by Claude Opus 5
parent 3b80034f0e
commit b7b4c31296
7 changed files with 588 additions and 10 deletions
+69 -4
View File
@@ -23,7 +23,7 @@ from velodrome.auth.security import (
)
from velodrome.config import get_settings
from velodrome.db import auth_session
from velodrome.models import Invite, Session, User
from velodrome.models import ROLE_ADMIN, Invite, Session, User
class AuthError(Exception):
@@ -51,6 +51,14 @@ class AuthenticatedSession:
user_id: UUID
email: str
display_name: str
# Carried here so the role a user actually has is available wherever identity is — an
# authorization check on the first admin-only endpoint (realistically invite management) is
# then a comparison against a value already in hand, not another query bolted on later. This
# is data plumbing, not an authorization mechanism: nothing reads it yet, deliberately, since
# there is no admin-only endpoint to protect (docs/DECISIONS.md D18). It is not a secret and
# is not in any response model — `schemas.auth.UserOut` deliberately doesn't declare it, so
# adding it here changes no HTTP response and no OpenAPI contract.
role: str
async def register(
@@ -103,7 +111,58 @@ async def register(
invite.used_count += 1
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
async def create_admin(*, email: str, password: str, display_name: str) -> AuthenticatedSession:
"""Create a user with the admin role, with no invite. Operator path only — see velodrome.cli.
This is the one deliberate hole in "open signup does not exist": a fresh deployment has no
users and therefore nobody who can issue the first invite, so the first account has to come
from outside the HTTP API. It lives here rather than in cli.py because this is where
`auth_session` belongs (see db.py's docstring — nothing outside this module imports it), and
because the lookup below is exactly the pattern that module documents as safe: an exact match
on a unique key, never a scan.
It is not reachable over HTTP and never will be — nothing in `api/` calls it. Reaching it
requires the ability to run a process inside the container, which is already the ability to
read and rewrite the SQLite file directly, so it grants an operator-turned-attacker nothing
they did not already have.
Refuses outright if the email is taken, rather than updating the row — see docs/DECISIONS.md
D18. Creating an account and resetting an existing account's password are different operations
with different blast radii, and an operator re-running a bootstrap command they last ran
months ago means the first, never the second. The check-then-insert is safe against a
concurrent `register()` for the same email the same way invite redemption is: db.py issues
`BEGIN IMMEDIATE`, so the two transactions serialize instead of interleaving, with the unique
index on `users.email` as the backstop underneath that.
"""
async with auth_session() as db:
async with db.begin():
existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise EmailAlreadyRegistered("an account with this email already exists")
user = User(
email=email,
display_name=display_name,
password_hash=hash_password(password),
role=ROLE_ADMIN,
)
db.add(user)
await db.flush() # populate user.id before we reference it below
return AuthenticatedSession(
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
@@ -140,7 +199,10 @@ async def login(
db.add(session_row)
return raw_token, AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
@@ -184,7 +246,10 @@ async def validate_session(raw_token: str) -> AuthenticatedSession:
)
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
user_id=user.id,
email=user.email,
display_name=user.display_name,
role=user.role,
)
+181
View File
@@ -0,0 +1,181 @@
"""Operator CLI — `velodrome <command>`.
Installed as a console script (`[project.scripts]` in pyproject.toml) into the same venv as
`alembic` and `uvicorn`, which `deploy/entrypoint.sh` already invokes by their installed names, so
the deployed container has this on PATH with no extra wiring:
docker exec -it velodrome velodrome create-admin --email you@example.com
It exists because there is otherwise **no way to create the first user**. Registration requires a
valid invite (docs/PLAN.md "Auth": open signup does not exist as a setting), invites are created by
an existing admin, and a fresh database has neither — so a new deployment is unusable without a
path in from outside the HTTP API. docs/PLAN.md always called for this command; it was simply
never built during Phase 0. See docs/DECISIONS.md D18 for the three decisions recorded here: why
it refuses to touch an existing account, why it is *not* restricted to the very first user, and
why `role="admin"` is recorded but not yet enforced anywhere.
argparse rather than Typer/Click: one command with three options does not justify a runtime
dependency the deployed image would have to carry, and the stdlib covers this case completely.
There is deliberately **no `--password` flag** — see `_read_password`.
"""
import argparse
import asyncio
import getpass
import sys
from collections.abc import Sequence
from pydantic import BaseModel, EmailStr, ValidationError
from velodrome.auth import service
from velodrome.config import get_settings
from velodrome.schemas.auth import DisplayName, Password
class CliError(Exception):
"""An operator-facing failure: printed as `error: <message>`, exit code 1.
Distinct from argparse's own usage errors, which exit 2 — so a script driving this can tell
"you called it wrong" apart from "it ran and refused".
"""
class _CreateAdminInput(BaseModel):
"""The same constraints the HTTP register endpoint applies, reused rather than restated — a
CLI-created account must not be able to hold a password the API would have rejected.
"""
email: EmailStr
password: Password
display_name: DisplayName
def _validate(*, email: str, password: str, display_name: str) -> _CreateAdminInput:
try:
return _CreateAdminInput(email=email, password=password, display_name=display_name)
except ValidationError as exc:
# Deliberately not `str(exc)`: pydantic's rendered message embeds the offending value
# ("... [type=string_too_short, input_value='hunter2', input_type=str]"), which for the
# password field prints the password to the operator's terminal and into whatever
# captures that output. CLAUDE.md invariant #5 — only `loc` and `msg` are safe to show.
details = "; ".join(
f"{'.'.join(str(part) for part in err['loc'])}: {err['msg']}"
for err in exc.errors(include_url=False, include_input=False)
)
raise CliError(f"invalid input — {details}") from exc
def _read_password(*, from_stdin: bool) -> str:
"""Prompt for a password, or read one line from stdin.
No `--password` flag exists on purpose: an argument lands in the operator's shell history, in
`ps` output for as long as the process runs, and — because the realistic invocation here is
`docker exec` — in the Docker daemon's own record of the exec'd command. A TTY prompt and a
pipe are the two forms that avoid all three, and they're the same two forms `docker login`
offers for exactly this reason.
"""
if from_stdin:
line = sys.stdin.readline()
# Strip only the line ending, not surrounding whitespace — a trailing space in a password
# is legitimate, and silently trimming it would create a password that can never be typed
# back in correctly.
password = line.rstrip("\r\n")
if not password:
raise CliError("--password-stdin was given but the first line of stdin was empty")
return password
if not sys.stdin.isatty():
raise CliError(
"no terminal available to prompt on. Either allocate one (note the -t):\n"
" docker exec -it velodrome velodrome create-admin --email you@example.com\n"
"or pipe the password in:\n"
" printf '%s' \"$PASSWORD\" | docker exec -i velodrome \\\n"
" velodrome create-admin --email you@example.com --password-stdin"
)
password = getpass.getpass("Password: ")
if password != getpass.getpass("Confirm password: "):
raise CliError("passwords did not match")
return password
async def _create_admin(args: argparse.Namespace) -> int:
email: str = args.email
# The local part is a reasonable default for a name nobody but the operator will see until
# they change it in the UI; it keeps the common invocation to a single flag.
display_name: str = args.name if args.name is not None else email.partition("@")[0]
password = _read_password(from_stdin=args.password_stdin)
validated = _validate(email=email, password=password, display_name=display_name)
try:
created = await service.create_admin(
email=str(validated.email),
password=validated.password,
display_name=validated.display_name,
)
except service.EmailAlreadyRegistered as exc:
raise CliError(
f"an account already exists for {email} — refusing to modify it. This command only "
"ever creates a new account; it will not reset an existing one's password (see "
"docs/DECISIONS.md D18). To add a different admin, re-run with another --email."
) from exc
print("Created admin user:")
print(f" id {created.user_id}")
print(f" email {created.email}")
print(f" display name {created.display_name}")
print(f" role {created.role}")
print()
print(f"Log in at {get_settings().public_url}")
print(
"Note: the admin role is recorded on the account but nothing enforces it yet — no "
"admin-only endpoint exists (docs/DECISIONS.md D18)."
)
return 0
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="velodrome",
description="Velodrome operator commands. Run inside the container, e.g. "
"`docker exec -it velodrome velodrome create-admin --email you@example.com`.",
)
subcommands = parser.add_subparsers(dest="command", required=True)
create_admin = subcommands.add_parser(
"create-admin",
help="create a user with the admin role, bypassing the invite requirement",
description="Create a user with the admin role, bypassing the invite requirement. This is "
"how the first account on a fresh deployment is made — registration needs an invite, and "
"a fresh database has none. Refuses to modify an account that already exists.",
)
create_admin.add_argument("--email", required=True, help="the account's email address")
create_admin.add_argument(
"--name",
default=None,
help="display name (default: the part of the email address before the @)",
)
create_admin.add_argument(
"--password-stdin",
action="store_true",
help="read the password from the first line of stdin instead of prompting for it",
)
return parser
async def run(argv: Sequence[str] | None = None) -> int:
"""The async entrypoint. `main` wraps this in `asyncio.run`; tests call it directly."""
args = _build_parser().parse_args(argv)
try:
if args.command == "create-admin":
return await _create_admin(args)
except CliError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
raise AssertionError(f"unhandled command {args.command!r}") # pragma: no cover
def main(argv: Sequence[str] | None = None) -> int:
return asyncio.run(run(argv))
+2 -2
View File
@@ -1,4 +1,4 @@
from velodrome.models.base import Base
from velodrome.models.identity import ApiToken, Invite, Session, User
from velodrome.models.identity import ROLE_ADMIN, ROLE_MEMBER, ApiToken, Invite, Session, User
__all__ = ["ApiToken", "Base", "Invite", "Session", "User"]
__all__ = ["ROLE_ADMIN", "ROLE_MEMBER", "ApiToken", "Base", "Invite", "Session", "User"]
+12 -2
View File
@@ -24,6 +24,16 @@ def _now_utc() -> datetime:
return datetime.now(UTC)
# The only two values `users.role` and `invites.role` are ever set to. Named constants so the set
# is discoverable from one place: `velodrome.cli`'s create-admin writes ROLE_ADMIN, and
# registration copies whatever role the redeemed invite carries. Nothing *enforces* a role yet —
# no admin-only endpoint exists (docs/DECISIONS.md D18) — and the column stays a plain string
# rather than a DB-level enum or CHECK constraint so adding a third role later is an application
# change, not a migration.
ROLE_MEMBER = "member"
ROLE_ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -31,7 +41,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query.
unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
@@ -53,7 +63,7 @@ class Invite(Base):
Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False
)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
role: Mapped[str] = mapped_column(String(20), nullable=False, default=ROLE_MEMBER)
expires_at: Mapped[datetime] = mapped_column(nullable=False)
max_uses: Mapped[int] = mapped_column(nullable=False, default=1)
used_count: Mapped[int] = mapped_column(nullable=False, default=0)
+10 -2
View File
@@ -6,15 +6,23 @@ simply not being listed here is what keeps password_hash/token_hash out of every
adding a new field, ask whether it belongs in a response before adding it, not after.
"""
from typing import Annotated
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# Named aliases rather than inline constraints, because these two rules are also applied outside
# the HTTP layer: `velodrome.cli` validates `create-admin`'s input against exactly the same ones,
# so an account created from the CLI can't hold a password the register endpoint would have
# rejected. Defined once here so the two can't drift apart.
Password = Annotated[str, Field(min_length=8, max_length=200)]
DisplayName = Annotated[str, Field(min_length=1, max_length=200)]
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=200)
display_name: str = Field(min_length=1, max_length=200)
password: Password
display_name: DisplayName
invite_code: str = Field(min_length=1, max_length=200)