Files
FabledSteward/steward/core/api_tokens.py
T
bvandeusenandClaude Opus 5.5 1c60fb6b4d
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 37s
CI / unit (push) Successful in 58s
CI / publish (push) Skipped
feat(api): personal API tokens and the AI access page
First step of milestone 461 (MCP server). A token is the credential an
MCP client presents as `Authorization: Bearer stw_…` to act as its user.

- api_tokens table (0026): sha256 hash only, display prefix, role
  ceiling, expiry, revocation, throttled last_used_at.
- steward/core/api_tokens.py: mint, list, revoke, resolve. Resolution
  re-reads the user's current role and acts with the lower of that and
  the token's ceiling, so demoting or disabling a user weakens or kills
  their tokens with nothing to revoke by hand.
- /ai-access/ (sidebar → Automation → AI access): every user manages
  their own tokens, can't grant above their own role, sees the token
  once. Session-only: a share-link viewer is refused. Create and revoke
  are audited.
- Integration tests drive the routes against real Postgres so their
  transactions run for real (#884 escaped CI behind a mocked session).

Task #4965 "MCP-1 — Personal API tokens".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:15:23 -04:00

161 lines
5.6 KiB
Python

"""steward/core/api_tokens.py
Personal API tokens: the credential an AI client (MCP) presents as
`Authorization: Bearer stw_…` to act as its user.
Tokens are high-entropy (`secrets.token_urlsafe(32)`), so they are stored as a
plain sha256 — bcrypt is for low-entropy passwords — the same scheme as
dashboard share tokens and the host-agent ingest token. The plaintext exists
only in the create response.
A token's `role` is a CEILING. Every resolution re-reads the user's current
role and acts with the lower of the two, so demoting or disabling a user takes
effect on their tokens at the next request with nothing to revoke by hand.
"""
from __future__ import annotations
import hashlib
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from steward.auth.middleware import role_meets
from steward.models.api_tokens import ApiToken
from steward.models.users import User, UserRole
TOKEN_PREFIX = "stw_"
# How many plaintext characters the UI keeps to tell tokens apart.
_DISPLAY_CHARS = 12
# last_used_at is a "roughly when" signal; don't write a row per MCP call.
_LAST_USED_RESOLUTION = timedelta(seconds=60)
@dataclass(frozen=True)
class Caller:
"""Who an authenticated token request acts as, with its effective role."""
user_id: str
username: str
role: UserRole
token_id: int
def generate_token() -> str:
return TOKEN_PREFIX + secrets.token_urlsafe(32)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def display_prefix(raw: str) -> str:
return raw[:_DISPLAY_CHARS]
def effective_role(ceiling: UserRole, user_role: UserRole) -> UserRole:
"""The lower of a token's ceiling and its user's current role."""
return ceiling if role_meets(user_role, ceiling) else user_role
def grantable_roles(user_role: UserRole) -> list[UserRole]:
"""Roles a user may give a new token: their own and anything below it."""
return [r for r in (UserRole.viewer, UserRole.operator, UserRole.admin)
if role_meets(user_role, r)]
def token_state(token: ApiToken, now: datetime | None = None) -> str:
"""'active', 'expired' or 'revoked' — for display and resolution alike."""
now = now or datetime.now(timezone.utc)
if token.revoked_at is not None:
return "revoked"
if token.expires_at is not None and token.expires_at <= now:
return "expired"
return "active"
async def create_token(
db, *, user_id: str, user_role: UserRole, name: str, role: UserRole,
expires_in_days: int | None = None,
) -> tuple[str, ApiToken]:
"""Mint a token. Returns (plaintext, row); the plaintext is never stored.
Raises ValueError when the requested ceiling is above the user's own role
or the name is empty. The caller owns the transaction and commits it.
"""
name = (name or "").strip()[:128]
if not name:
raise ValueError("Give the token a name, so you can tell it apart later.")
if not role_meets(user_role, role):
raise ValueError(
f"A token can't carry a higher role than yours ({user_role.value})."
)
now = datetime.now(timezone.utc)
raw = generate_token()
token = ApiToken(
user_id=user_id,
name=name,
token_hash=hash_token(raw),
prefix=display_prefix(raw),
role=role,
created_at=now,
expires_at=now + timedelta(days=expires_in_days) if expires_in_days else None,
)
db.add(token)
await db.flush()
return raw, token
async def list_tokens(db, user_id: str) -> list[ApiToken]:
result = await db.execute(
select(ApiToken).where(ApiToken.user_id == user_id)
.order_by(ApiToken.created_at.desc())
)
return list(result.scalars())
async def revoke_token(db, *, user_id: str, token_id: int) -> ApiToken | None:
"""Revoke one of the user's own tokens. Returns it, or None if not theirs."""
result = await db.execute(
select(ApiToken).where(ApiToken.id == token_id, ApiToken.user_id == user_id)
)
token = result.scalar_one_or_none()
if token is not None and token.revoked_at is None:
token.revoked_at = datetime.now(timezone.utc)
return token
async def resolve_token(app, raw: str | None) -> Caller | None:
"""Resolve a bearer token's plaintext to the Caller it acts as.
None for a missing, unknown, revoked or expired token, or one whose user is
disabled. Never raises on a bad token; a database failure propagates so
the transport can report the outage rather than a misleading 401.
"""
if not raw or not raw.startswith(TOKEN_PREFIX):
return None
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as db:
async with db.begin():
result = await db.execute(
select(ApiToken, User)
.join(User, User.id == ApiToken.user_id)
.where(ApiToken.token_hash == hash_token(raw))
)
row = result.first()
if row is None:
return None
token, user = row
if token_state(token, now) != "active" or not user.is_active:
return None
if token.last_used_at is None or now - token.last_used_at >= _LAST_USED_RESOLUTION:
await db.execute(
update(ApiToken).where(ApiToken.id == token.id)
.values(last_used_at=now)
)
return Caller(
user_id=user.id,
username=user.username,
role=effective_role(token.role, user.role),
token_id=token.id,
)