feat(pat): add personal access token storage and settings

A personal_access_tokens table (migration 0032) holds scoped user-level API
credentials. Only the SHA-256 of the secret is stored, like device session
tokens. Lookups exclude revoked and expired tokens and the tokens of
deactivated users. PAT_* settings cover the feature switch, default and
maximum lifetime, the operator opt-in for non-expiring tokens and the
per-user cap.
This commit is contained in:
arc53-machine committed 2026-09-19 23:39:58 +01:00
1 parent 0952a05aab
commit 22ecc0aee3
7 files changed
+494

No files matched your search

@@ -0,0 +1,72 @@
"""0032 personal access tokens — scoped, user-level API credentials.
A personal access token (PAT) authenticates its owner against the management
API for CLI and CI/CD use. Only the SHA-256 of the secret is stored, mirroring
``devices.token_hash``: the plaintext is shown once at creation and a database
leak cannot reconstruct it. ``token_prefix`` keeps the first characters so a
user can tell their tokens apart in the UI.
``scopes`` is the server-side grant list (never read from the credential
itself). ``resource_filter`` optionally narrows a resource family to specific
ids, e.g. ``{"agents": ["<uuid>"]}``; an absent family is unrestricted within
the token's scopes. ``expires_at`` is NULL only when the operator allows
non-expiring tokens.
``user_id`` is the auth ``sub``; no FK or trigger, mirroring ``devices`` and
``user_roles`` so a token row never blocks user deletion.
Revision ID: 0032_personal_access_tokens
Revises: 0031_token_usage_cache_tokens
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0032_personal_access_tokens"
down_revision: Union[str, None] = "0031_token_usage_cache_tokens"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS personal_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
scopes TEXT[] NOT NULL DEFAULT '{}',
resource_filter JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'revoked')),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
last_used_ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
revoke_reason TEXT
);
"""
)
# Looked up on every PAT-authenticated request.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_hash_uidx "
"ON personal_access_tokens(token_hash);"
)
# Names are unique among a user's live tokens; a revoked name can be reused.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS personal_access_tokens_user_name_uidx "
"ON personal_access_tokens(user_id, name) WHERE status = 'active';"
)
op.execute(
"CREATE INDEX IF NOT EXISTS personal_access_tokens_user_idx "
"ON personal_access_tokens(user_id, created_at DESC);"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS personal_access_tokens;")