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>
161 lines
5.6 KiB
Python
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,
|
|
)
|