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

+41
View File
@@ -1079,3 +1079,44 @@ device_auto_approve_patterns_table = Table(
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
UniqueConstraint("device_id", "user_id", "pattern", name="device_auto_approve_uidx"),
)
# --- Personal access tokens (migration 0032) --------------------------------
# Scoped user-level API credentials. Only the SHA-256 of the secret is stored.
personal_access_tokens_table = Table(
"personal_access_tokens",
metadata,
Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()),
Column("user_id", Text, nullable=False),
Column("name", Text, nullable=False),
Column("token_hash", Text, nullable=False),
Column("token_prefix", Text, nullable=False),
Column("scopes", ARRAY(Text), nullable=False, server_default="{}"),
Column("resource_filter", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("status", Text, nullable=False, server_default="active"),
Column("expires_at", DateTime(timezone=True)),
Column("last_used_at", DateTime(timezone=True)),
Column("last_used_ip", Text),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("revoked_at", DateTime(timezone=True)),
Column("revoke_reason", Text),
CheckConstraint("status IN ('active', 'revoked')", name="personal_access_tokens_status_check"),
)
Index(
"personal_access_tokens_hash_uidx",
personal_access_tokens_table.c.token_hash,
unique=True,
)
Index(
"personal_access_tokens_user_name_uidx",
personal_access_tokens_table.c.user_id,
personal_access_tokens_table.c.name,
unique=True,
postgresql_where=personal_access_tokens_table.c.status == "active",
)
Index(
"personal_access_tokens_user_idx",
personal_access_tokens_table.c.user_id,
personal_access_tokens_table.c.created_at.desc(),
)
@@ -0,0 +1,156 @@
"""Repository for the ``personal_access_tokens`` table."""
from __future__ import annotations
import json
from datetime import datetime
from typing import Optional
from sqlalchemy import Connection, text
from docsgpt.storage.db.base_repository import row_to_dict
# token_hash never leaves the repository except through find_active_by_hash.
_PUBLIC_COLUMNS = (
"id, user_id, name, token_prefix, scopes, resource_filter, status, "
"expires_at, last_used_at, last_used_ip, created_at, revoked_at, revoke_reason"
)
class PersonalAccessTokensRepository:
"""CRUD for personal access tokens. Callers hash the secret; only the hash is stored."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def create(
self,
user_id: str,
name: str,
*,
token_hash: str,
token_prefix: str,
scopes: list[str],
resource_filter: Optional[dict] = None,
expires_at: Optional[datetime] = None,
) -> dict:
row = self._conn.execute(
text(
f"""
INSERT INTO personal_access_tokens (
user_id, name, token_hash, token_prefix, scopes,
resource_filter, expires_at
) VALUES (
:user_id, :name, :token_hash, :token_prefix, :scopes,
CAST(:resource_filter AS jsonb), :expires_at
) RETURNING {_PUBLIC_COLUMNS}
"""
),
{
"user_id": user_id,
"name": name,
"token_hash": token_hash,
"token_prefix": token_prefix,
"scopes": list(scopes),
"resource_filter": json.dumps(resource_filter or {}),
"expires_at": expires_at,
},
).fetchone()
return row_to_dict(row)
def get(self, token_id: str, user_id: Optional[str] = None) -> Optional[dict]:
sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE id = CAST(:id AS uuid)"
params: dict = {"id": token_id}
if user_id is not None:
sql += " AND user_id = :user_id"
params["user_id"] = user_id
row = self._conn.execute(text(sql), params).fetchone()
return row_to_dict(row) if row is not None else None
def list_for_user(self, user_id: str, *, include_revoked: bool = False) -> list[dict]:
sql = f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens WHERE user_id = :user_id"
if not include_revoked:
sql += " AND status = 'active'"
sql += " ORDER BY created_at DESC"
result = self._conn.execute(text(sql), {"user_id": user_id})
return [row_to_dict(r) for r in result.fetchall()]
def count_active(self, user_id: str) -> int:
"""Live tokens only: revoked and expired rows don't count against the per-user cap."""
return self._conn.execute(
text(
"SELECT count(*) FROM personal_access_tokens "
"WHERE user_id = :user_id AND status = 'active' "
"AND (expires_at IS NULL OR expires_at > now())"
),
{"user_id": user_id},
).scalar_one()
def name_in_use(self, user_id: str, name: str) -> bool:
return (
self._conn.execute(
text(
"SELECT 1 FROM personal_access_tokens "
"WHERE user_id = :user_id AND name = :name AND status = 'active' LIMIT 1"
),
{"user_id": user_id, "name": name},
).fetchone()
is not None
)
def find_active_by_hash(self, token_hash: str) -> Optional[dict]:
"""Resolve the credential on each request.
Revoked and expired tokens never match, and neither do the tokens of a
deactivated user (admin or SCIM), so deactivation needs no token sweep
and reactivation restores them.
"""
row = self._conn.execute(
text(
f"SELECT {_PUBLIC_COLUMNS} FROM personal_access_tokens pat "
"WHERE token_hash = :token_hash AND status = 'active' "
"AND (expires_at IS NULL OR expires_at > now()) "
"AND NOT EXISTS (SELECT 1 FROM users u "
"WHERE u.user_id = pat.user_id AND u.active = false) "
"LIMIT 1"
),
{"token_hash": token_hash},
).fetchone()
return row_to_dict(row) if row is not None else None
def touch_last_used(self, token_id: str, ip: Optional[str], *, min_interval_seconds: int = 60) -> None:
"""Record use, at most once per ``min_interval_seconds`` so hot tokens don't write per request."""
self._conn.execute(
text(
"UPDATE personal_access_tokens "
"SET last_used_at = now(), last_used_ip = :ip "
"WHERE id = CAST(:id AS uuid) AND (last_used_at IS NULL "
"OR last_used_at <= now() - make_interval(secs => :min_interval))"
),
{"id": token_id, "ip": ip, "min_interval": min_interval_seconds},
)
def revoke(self, token_id: str, user_id: Optional[str] = None, *, reason: str = "user_revoked") -> bool:
"""Revoke one token. ``user_id=None`` is the admin path (any owner)."""
sql = (
"UPDATE personal_access_tokens "
"SET status = 'revoked', revoked_at = now(), revoke_reason = :reason "
"WHERE id = CAST(:id AS uuid) AND status = 'active'"
)
params: dict = {"id": token_id, "reason": reason}
if user_id is not None:
sql += " AND user_id = :user_id"
params["user_id"] = user_id
return self._conn.execute(text(sql), params).rowcount > 0
def revoke_all_for_user(self, user_id: str, *, reason: str = "admin_revoked") -> int:
result = self._conn.execute(
text(
"UPDATE personal_access_tokens "
"SET status = 'revoked', revoked_at = now(), revoke_reason = :reason "
"WHERE user_id = :user_id AND status = 'active'"
),
{"user_id": user_id, "reason": reason},
)
return result.rowcount