"""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, )