"""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 why two DB roles exist. RLS policies for these tables are created in the baseline Alembic migration (alembic/versions/0001_baseline.py), not here — SQLAlchemy models describe columns, not database-level security policy, and keeping the policy SQL visible and reviewable in the migration is deliberate. """ from datetime import datetime from uuid import UUID from sqlalchemy import ARRAY, ForeignKey, LargeBinary, String, Text from sqlalchemy.dialects.postgresql import INET from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from velodrome.ids import new_id from velodrome.models.base import Base class User(Base): __tablename__ = "users" id: Mapped[UUID] = mapped_column(PGUUID(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="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(server_default=func.now(), nullable=False) 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(PGUUID(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( PGUUID(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") 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(PGUUID(as_uuid=True), primary_key=True, default=new_id) user_id: Mapped[UUID] = mapped_column( PGUUID(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) ip: Mapped[str | None] = mapped_column(INET, nullable=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) last_seen_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) 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(PGUUID(as_uuid=True), primary_key=True, default=new_id) user_id: Mapped[UUID] = mapped_column( PGUUID(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) scopes: Mapped[list[str]] = mapped_column(ARRAY(String), 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")