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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user