"""api_tokens: admin-issued access tokens (phase 79) Revision ID: 0012 Revises: 0011 Create Date: 2026-09-07 Phase 79 (API tokens: the admin generates named tokens and hands them out so people can sign in to the app — the ONLY anonymous content left is the shared chats; extends the phase-16 single-admin auth, one additive, reversible table, no other schema change, A13): * ``api_tokens`` — one row per admin-generated token (``bor_`` + 32 hex chars — ``secrets.token_hex(16)``, service in task 02). The **plaintext exists only in the 201 create response, returned exactly once**; the row never carries it. * ``token_hash`` — the stored credential: the SHA-256 hex digest of the **full** token string (String(64) — the ``documents.content_hash`` precedent). Hashing the full string, not the suffix, so a stripped prefix can never collide. Lineage of ``saved_chats.share_token`` / ``doc_drafts.token`` — but HASHED: unlike those unguessable ``uuid4`` link tokens these are long-lived hand-out credentials, and a leaked database must not hand anyone working tokens. * ``ix_api_tokens_token_hash`` — UNIQUE index on ``token_hash``: the lookup key (a unique-index hit — sha256's pre-image resistance means there is no enumeration surface beyond the lookup). The explicit ``op.create_index`` shape of ``ix_saved_chats_share_token`` (0009) and ``ix_doc_drafts_token`` (0011). * ``label`` — String(120) NOT NULL: the hand-out name (e.g. "alice") — display-only: no index, not unique (two tokens may share a label). * ``created_at`` — TIMESTAMPTZ NOT NULL, server default now(). * ``last_used_at`` / ``revoked_at`` — TIMESTAMPTZ NULL: bumped/set by the service (tasks 02/03); NULL until the token is first used / revoked. """ from __future__ import annotations import sqlalchemy as sa from sqlalchemy.dialects import postgresql from alembic import op revision = "0012" down_revision = "0011" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "api_tokens", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), sa.Column("label", sa.String(length=120), nullable=False), sa.Column("token_hash", sa.String(length=64), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, ), sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), ) # The hash is the stored credential — the unique lookup handle (the # saved_chats.share_token / doc_drafts.token unique-index precedent, # phases 51/59). op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True) def downgrade() -> None: # Safe order: drop the token-hash index first, then the table (A13 — # fully reversible, no other schema change). op.drop_index("ix_api_tokens_token_hash", table_name="api_tokens") op.drop_table("api_tokens")