From 1c60fb6b4d45bc2c4141f2e2526edd6de421e7f5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 07:15:23 -0400 Subject: [PATCH] feat(api): personal API tokens and the AI access page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- steward/ai_access/__init__.py | 0 steward/ai_access/routes.py | 99 ++++++++++ steward/app.py | 2 + steward/core/api_tokens.py | 160 +++++++++++++++++ .../migrations/versions/0026_api_tokens.py | 41 +++++ steward/models/__init__.py | 3 +- steward/models/api_tokens.py | 35 ++++ steward/templates/ai_access/created.html | 38 ++++ steward/templates/ai_access/index.html | 119 ++++++++++++ steward/templates/base.html | 1 + tests/integration/test_api_tokens.py | 170 ++++++++++++++++++ tests/test_api_tokens.py | 137 ++++++++++++++ 12 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 steward/ai_access/__init__.py create mode 100644 steward/ai_access/routes.py create mode 100644 steward/core/api_tokens.py create mode 100644 steward/migrations/versions/0026_api_tokens.py create mode 100644 steward/models/api_tokens.py create mode 100644 steward/templates/ai_access/created.html create mode 100644 steward/templates/ai_access/index.html create mode 100644 tests/integration/test_api_tokens.py create mode 100644 tests/test_api_tokens.py diff --git a/steward/ai_access/__init__.py b/steward/ai_access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/steward/ai_access/routes.py b/steward/ai_access/routes.py new file mode 100644 index 0000000..9f3ef04 --- /dev/null +++ b/steward/ai_access/routes.py @@ -0,0 +1,99 @@ +"""AI access: personal API tokens for MCP clients. + +Every logged-in user manages their own tokens here, whatever their role — a +token acts as its user, capped at the role it was given. These routes are +session-only: a share-link viewer has no user to mint tokens for, and no token +can reach them (MCP tools never mint or revoke tokens). +""" +from __future__ import annotations +from datetime import datetime, timezone + +from quart import Blueprint, abort, current_app, redirect, render_template, request +from sqlalchemy import select + +from steward.auth.middleware import current_user_id, require_role +from steward.core import api_tokens +from steward.core.audit import log_audit +from steward.models.users import User, UserRole + +ai_access_bp = Blueprint("ai_access", __name__, url_prefix="/ai-access") + +_EXPIRY_CHOICES = [("", "Never"), ("30", "30 days"), ("90", "90 days"), ("365", "1 year")] + + +async def _session_user(db) -> User: + user_id = current_user_id() + if not user_id: + abort(403) + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user is None or not user.is_active: + abort(403) + return user + + +async def _render_index(db, user: User, *, error: str | None = None, status: int = 200): + tokens = await api_tokens.list_tokens(db, user.id) + now = datetime.now(timezone.utc) + rows = [{"token": t, "state": api_tokens.token_state(t, now)} for t in tokens] + body = await render_template( + "ai_access/index.html", + rows=rows, + user_role=user.role, + grantable=api_tokens.grantable_roles(user.role), + expiry_choices=_EXPIRY_CHOICES, + error=error, + ) + return body, status + + +@ai_access_bp.get("/") +@require_role(UserRole.viewer) +async def index(): + async with current_app.db_sessionmaker() as db: + user = await _session_user(db) + return await _render_index(db, user) + + +@ai_access_bp.post("/tokens") +@require_role(UserRole.viewer) +async def create(): + form = await request.form + name = form.get("name", "") + expires = form.get("expires_in_days", "") + try: + role = UserRole(form.get("role", UserRole.viewer.value)) + except ValueError: + role = None + + async with current_app.db_sessionmaker() as db: + user = await _session_user(db) + if role is None: + return await _render_index(db, user, error="Pick a role for the token.", status=400) + try: + raw, token = await api_tokens.create_token( + db, user_id=user.id, user_role=user.role, name=name, role=role, + expires_in_days=int(expires) if expires.isdigit() and int(expires) > 0 else None, + ) + except ValueError as exc: + return await _render_index(db, user, error=str(exc), status=400) + await db.commit() + + await log_audit(current_app, user.id, user.username, "api_token.create", + entity_type="api_token", entity_id=str(token.id), + detail={"name": token.name, "role": token.role.value}) + return await render_template("ai_access/created.html", raw_token=raw, token=token) + + +@ai_access_bp.post("/tokens//revoke") +@require_role(UserRole.viewer) +async def revoke(token_id: int): + async with current_app.db_sessionmaker() as db: + user = await _session_user(db) + token = await api_tokens.revoke_token(db, user_id=user.id, token_id=token_id) + await db.commit() + if token is None: + abort(404) + await log_audit(current_app, user.id, user.username, "api_token.revoke", + entity_type="api_token", entity_id=str(token_id), + detail={"name": token.name}) + return redirect("/ai-access/") diff --git a/steward/app.py b/steward/app.py index 1c241a1..e66e696 100644 --- a/steward/app.py +++ b/steward/app.py @@ -150,6 +150,7 @@ def create_app( from .ansible.inventory_routes import inventory_bp from .settings.routes import settings_bp from .audit.routes import audit_bp + from .ai_access.routes import ai_access_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -161,6 +162,7 @@ def create_app( app.register_blueprint(inventory_bp) app.register_blueprint(settings_bp) app.register_blueprint(audit_bp) + app.register_blueprint(ai_access_bp) # Register the unified Monitor status source for the Status page. Plugins # may register additional sources from setup(). Idempotent. diff --git a/steward/core/api_tokens.py b/steward/core/api_tokens.py new file mode 100644 index 0000000..e61c4cb --- /dev/null +++ b/steward/core/api_tokens.py @@ -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, + ) diff --git a/steward/migrations/versions/0026_api_tokens.py b/steward/migrations/versions/0026_api_tokens.py new file mode 100644 index 0000000..2c68082 --- /dev/null +++ b/steward/migrations/versions/0026_api_tokens.py @@ -0,0 +1,41 @@ +"""Personal API tokens for MCP clients + +Revision ID: 0026_api_tokens +Revises: 0025_drop_http_plugin_setting +Create Date: 2026-10-06 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "0026_api_tokens" +down_revision: Union[str, None] = "0025_drop_http_plugin_setting" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "api_tokens", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.String(36), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True), + sa.Column("prefix", sa.String(16), nullable=False), + # Reuses the users.role enum type created in 0001. + sa.Column("role", postgresql.ENUM("admin", "operator", "viewer", + name="userrole", create_type=False), + nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_api_tokens_user_id", "api_tokens") + op.drop_table("api_tokens") diff --git a/steward/models/__init__.py b/steward/models/__init__.py index 189720f..8d390fd 100644 --- a/steward/models/__init__.py +++ b/steward/models/__init__.py @@ -1,5 +1,6 @@ from .base import Base from .users import User +from .api_tokens import ApiToken from .hosts import Host from .monitors import Monitor, MonitorResult, MonitorType from .metrics import PluginMetric @@ -11,7 +12,7 @@ from .settings import AppSetting from .dashboard import Dashboard, DashboardWidget, DashboardShareToken __all__ = [ - "Base", "User", + "Base", "User", "ApiToken", "Host", "Monitor", "MonitorResult", "MonitorType", "PluginMetric", diff --git a/steward/models/api_tokens.py b/steward/models/api_tokens.py new file mode 100644 index 0000000..f12f657 --- /dev/null +++ b/steward/models/api_tokens.py @@ -0,0 +1,35 @@ +from __future__ import annotations +from datetime import datetime, timezone +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column +from .base import Base +from .users import UserRole + + +class ApiToken(Base): + """A personal bearer token an AI client (MCP) uses to act as its user. + + Only the sha256 of the token is stored; the plaintext is shown once at + creation. `role` is a ceiling: the token acts with the lower of this and + its user's current role, so demoting a user demotes their tokens too. + """ + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + name: Mapped[str] = mapped_column(String(128), nullable=False) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + # Leading characters of the plaintext, for telling tokens apart in the UI. + prefix: Mapped[str] = mapped_column(String(16), nullable=False) + role: Mapped[UserRole] = mapped_column( + Enum(UserRole, name="userrole", create_type=False), nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/steward/templates/ai_access/created.html b/steward/templates/ai_access/created.html new file mode 100644 index 0000000..f097b45 --- /dev/null +++ b/steward/templates/ai_access/created.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% from "_macros.html" import crumbs %} +{% block title %}Token created — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("AI access", "/ai-access/"), ("Token created", "")]) }}{% endblock %} +{% block content %} +
+

Token created

+ +
+
+ Copy this token now. It can't be shown again. +
+ +
+ + +
+ +
+ Name: {{ token.name }} +  · Role: {{ token.role.value }} +  · Expires: + {% if token.expires_at %}{{ token.expires_at.strftime("%Y-%m-%d") }}{% else %}Never{% endif %} +
+
+ +

+ Treat it like a password: anyone holding it can act as you, up to this token's role. + If it leaks, revoke it from the AI access page. +

+ + Back to AI access +
+{% endblock %} diff --git a/steward/templates/ai_access/index.html b/steward/templates/ai_access/index.html new file mode 100644 index 0000000..eda6d1f --- /dev/null +++ b/steward/templates/ai_access/index.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}AI access — Steward{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +{% set role_help = { + "viewer": "Reads everything you can see. Changes nothing.", + "operator": "Also runs playbooks and changes monitors and alerts.", + "admin": "Full access, the same as an admin in the UI.", +} %} +

AI access

+

+ A token lets an AI client such as Claude Code connect to Steward over MCP and act as + you, limited to the role you give the token. + Everything a token changes is written to the audit log under your name. +

+ +
+ +
+
Your tokens
+ {% if rows %} +
+ + + + + + + + + + + + + {% for row in rows %} + {% set t = row.token %} + + + + + + + + + {% endfor %} + +
NameRoleCreatedLast usedExpires
+
{{ t.name }}
+
{{ t.prefix }}…
+
{{ t.role.value }}{{ t.created_at.strftime("%Y-%m-%d") }} + {% if t.last_used_at %}{{ t.last_used_at.strftime("%Y-%m-%d %H:%M") }} UTC{% else %}Never{% endif %} + + {% if row.state == 'revoked' %}Revoked + {% elif row.state == 'expired' %}Expired + {% elif t.expires_at %}{{ t.expires_at.strftime("%Y-%m-%d") }} + {% else %}Never{% endif %} + + {% if row.state == 'active' %} +
+ +
+ {% endif %} +
+
+ {% else %} +
+ You have no tokens yet. Create one on the right, then paste it into your AI client. +
+ {% endif %} +
+ +
+
Create a token
+
+
+
+ + +
+
+ + +
+ {% for r in grantable %} +
{{ r.value|capitalize }}: {{ role_help[r.value] }}
+ {% endfor %} +
+
+
+ + +
+ +
+
+
+ The token is shown once. A token never acts above your own role + ({{ user_role.value }}), even if your role is lowered later. +
+
+ +
+{% endblock %} diff --git a/steward/templates/base.html b/steward/templates/base.html index 4bd46e5..caa3df5 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -313,6 +313,7 @@ body.dash-editing .widget-drawer { transform:translateY(0); } {% if session.user_role == 'admin' %}