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
119 lines
5.7 KiB
Python
119 lines
5.7 KiB
Python
"""Identity tables: users, invites, sessions, api_tokens.
|
|
|
|
See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and db.py's
|
|
module docstring for how isolation between users is enforced now that there's no RLS to do it at
|
|
the database level (docs/DECISIONS.md D15).
|
|
|
|
Types are deliberately dialect-generic (`sqlalchemy.Uuid`, `JSON`, plain `String` for the IP
|
|
column) rather than the `postgresql.*` variants Phase 0 originally used — this schema now targets
|
|
SQLite only, but there's no reason to hand-tie it to a Postgres-only type where a portable one
|
|
works identically. See models/base.py for why timestamps need a custom type at all.
|
|
"""
|
|
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import JSON, ForeignKey, LargeBinary, String, Text, Uuid
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from velodrome.ids import new_id
|
|
from velodrome.models.base import Base
|
|
|
|
|
|
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"
|
|
|
|
id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
|
|
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=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")
|
|
is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
|
|
created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
|
|
|
|
sessions: Mapped[list["Session"]] = relationship(back_populates="user")
|
|
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user")
|
|
|
|
|
|
class Invite(Base):
|
|
__tablename__ = "invites"
|
|
|
|
id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
|
|
# sha256 digest of the invite code. The code itself is never stored anywhere — see
|
|
# auth/service.py. 32 bytes for sha256.
|
|
code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
|
created_by: Mapped[UUID] = mapped_column(
|
|
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=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)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
|
|
|
|
class Session(Base):
|
|
__tablename__ = "sessions"
|
|
|
|
id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
|
|
user_id: Mapped[UUID] = mapped_column(
|
|
Uuid(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
# sha256 of the opaque bearer token. The token itself is returned to the client exactly once,
|
|
# at login, and never stored — see auth/security.py.
|
|
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
|
client: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
|
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# Plain string, not a native INET type — SQLite has no such type, and app code never queries
|
|
# or indexes on structure within the address, only stores/displays it.
|
|
ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
|
|
last_seen_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
|
|
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
|
|
user: Mapped["User"] = relationship(back_populates="sessions")
|
|
|
|
|
|
class ApiToken(Base):
|
|
__tablename__ = "api_tokens"
|
|
|
|
id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
|
|
user_id: Mapped[UUID] = mapped_column(
|
|
Uuid(as_uuid=True),
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
|
# JSON, not ARRAY(String) — SQLite has no array type. Stored as a JSON-encoded TEXT column;
|
|
# SQLAlchemy handles the (de)serialization transparently.
|
|
scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
|
|
user: Mapped["User"] = relationship(back_populates="api_tokens")
|