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,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/<int:token_id>/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/")
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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 %}
|
||||
<div style="max-width:640px;">
|
||||
<h1 class="page-title">Token created</h1>
|
||||
|
||||
<div class="card" style="border-color:var(--gold);margin-bottom:1rem;">
|
||||
<div style="font-size:0.8rem;font-weight:600;color:var(--gold);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.75rem;">
|
||||
Copy this token now. It can't be shown again.
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||
<input type="text" id="api-token" value="{{ raw_token }}" readonly
|
||||
aria-label="API token"
|
||||
style="flex:1;font-family:ui-monospace,monospace;font-size:0.8rem;background:var(--bg);color:var(--text);">
|
||||
<button type="button"
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('api-token').value).then(()=>this.textContent='Copied')"
|
||||
class="btn btn-ghost btn-sm" style="white-space:nowrap;">Copy</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted);">
|
||||
<strong>Name:</strong> {{ token.name }}
|
||||
· <strong>Role:</strong> {{ token.role.value }}
|
||||
· <strong>Expires:</strong>
|
||||
{% if token.expires_at %}{{ token.expires_at.strftime("%Y-%m-%d") }}{% else %}Never{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);line-height:1.55;">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<a href="/ai-access/" class="btn btn-ghost">Back to AI access</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}AI access — Steward{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
.ai-access-grid { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:1.5rem; align-items:start; }
|
||||
.ai-access-grid .card-flush { overflow-x:auto; }
|
||||
@media (max-width: 900px) { .ai-access-grid { grid-template-columns:minmax(0,1fr); } }
|
||||
</style>
|
||||
{% 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.",
|
||||
} %}
|
||||
<h1 class="page-title">AI access</h1>
|
||||
<p style="color:var(--text-muted);font-size:0.9rem;max-width:720px;margin:-0.5rem 0 1.5rem;line-height:1.55;">
|
||||
A token lets an AI client such as Claude Code connect to Steward over MCP and act as
|
||||
<strong style="color:var(--text);">you</strong>, limited to the role you give the token.
|
||||
Everything a token changes is written to the audit log under your name.
|
||||
</p>
|
||||
|
||||
<div class="ai-access-grid">
|
||||
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Your tokens</div>
|
||||
{% if rows %}
|
||||
<div class="card-flush">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Last used</th>
|
||||
<th>Expires</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
{% set t = row.token %}
|
||||
<tr {% if row.state != 'active' %}style="opacity:0.55;"{% endif %}>
|
||||
<td>
|
||||
<div style="font-weight:600;">{{ t.name }}</div>
|
||||
<div style="font-family:ui-monospace,monospace;font-size:0.75rem;color:var(--text-muted);">{{ t.prefix }}…</div>
|
||||
</td>
|
||||
<td><span class="badge badge-dim">{{ t.role.value }}</span></td>
|
||||
<td style="white-space:nowrap;">{{ t.created_at.strftime("%Y-%m-%d") }}</td>
|
||||
<td style="white-space:nowrap;">
|
||||
{% if t.last_used_at %}{{ t.last_used_at.strftime("%Y-%m-%d %H:%M") }} UTC{% else %}<span style="color:var(--text-muted);">Never</span>{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap;">
|
||||
{% if row.state == 'revoked' %}<span class="badge badge-red">Revoked</span>
|
||||
{% elif row.state == 'expired' %}<span class="badge badge-red">Expired</span>
|
||||
{% elif t.expires_at %}{{ t.expires_at.strftime("%Y-%m-%d") }}
|
||||
{% else %}<span style="color:var(--text-muted);">Never</span>{% endif %}
|
||||
</td>
|
||||
<td class="td-actions">
|
||||
{% if row.state == 'active' %}
|
||||
<form method="post" action="/ai-access/tokens/{{ t.id }}/revoke" style="margin:0;"
|
||||
onsubmit='return confirm({{ ("Revoke “" ~ t.name ~ "”? Any client using it stops working immediately.")|tojson }})'>
|
||||
<button type="submit" class="btn btn-danger btn-sm">Revoke</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card empty">
|
||||
You have no tokens yet. Create one on the right, then paste it into your AI client.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Create a token</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<form method="post" action="/ai-access/tokens">
|
||||
<div class="form-group">
|
||||
<label for="tok-name">Name</label>
|
||||
<input type="text" id="tok-name" name="name" required maxlength="128"
|
||||
placeholder="e.g. Claude Code on my laptop">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tok-role">Role</label>
|
||||
<select id="tok-role" name="role">
|
||||
{% for r in grantable %}
|
||||
<option value="{{ r.value }}" {% if loop.first %}selected{% endif %}>{{ r.value|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.4rem;line-height:1.5;">
|
||||
{% for r in grantable %}
|
||||
<div><strong style="color:var(--text);">{{ r.value|capitalize }}:</strong> {{ role_help[r.value] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tok-exp">Expires after</label>
|
||||
<select id="tok-exp" name="expires_in_days">
|
||||
{% for value, label in expiry_choices %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create token</button>
|
||||
</form>
|
||||
</div>
|
||||
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.75rem;line-height:1.5;">
|
||||
The token is shown <strong>once</strong>. A token never acts above your own role
|
||||
({{ user_role.value }}), even if your role is lowered later.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -313,6 +313,7 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">Automation</div>
|
||||
<a href="/ansible/" {% if _p.startswith('/ansible') %}class="active" aria-current="page"{% endif %}>Ansible</a>
|
||||
<a href="/ai-access/" {% if _p.startswith('/ai-access') %}class="active" aria-current="page"{% endif %}>AI access</a>
|
||||
</div>
|
||||
{% if session.user_role == 'admin' %}
|
||||
<div class="nav-group">
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Integration: personal API tokens against real Postgres.
|
||||
|
||||
Resolution reads the token and its user's CURRENT role in one query, so a
|
||||
demoted or disabled user's tokens weaken or die with nothing revoked by hand.
|
||||
The routes are driven through the test client so their transactions run for
|
||||
real: a mocked session cannot catch a double-begin (#884).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_NEEDS_DB = pytest.mark.skipif(
|
||||
not os.environ.get("STEWARD_DATABASE_URL"),
|
||||
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
async def _make_user(app, role):
|
||||
from steward.models.users import User
|
||||
uid = str(uuid.uuid4())
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=f"tok-{uid[:8]}", email=f"{uid}@test",
|
||||
password_hash="x", role=role, is_active=True))
|
||||
return uid
|
||||
|
||||
|
||||
async def _mint(app, uid, user_role, role, **kw):
|
||||
from steward.core import api_tokens
|
||||
async with app.db_sessionmaker() as s:
|
||||
raw, token = await api_tokens.create_token(
|
||||
s, user_id=uid, user_role=user_role, name="it", role=role, **kw)
|
||||
await s.commit()
|
||||
return raw, token.id
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_resolve_lifecycle(app):
|
||||
from sqlalchemy import select, update
|
||||
from steward.core import api_tokens
|
||||
from steward.models.api_tokens import ApiToken
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
async def _go():
|
||||
uid = await _make_user(app, UserRole.admin)
|
||||
raw, tid = await _mint(app, uid, UserRole.admin, UserRole.operator)
|
||||
|
||||
caller = await api_tokens.resolve_token(app, raw)
|
||||
assert caller is not None
|
||||
assert (caller.user_id, caller.role, caller.token_id) == (uid, UserRole.operator, tid)
|
||||
async with app.db_sessionmaker() as s:
|
||||
used = (await s.execute(select(ApiToken.last_used_at).where(ApiToken.id == tid))).scalar_one()
|
||||
assert used is not None
|
||||
|
||||
# A wrong token, and the right one with a character changed, resolve to nobody.
|
||||
assert await api_tokens.resolve_token(app, api_tokens.generate_token()) is None
|
||||
assert await api_tokens.resolve_token(app, raw[:-1] + ("A" if raw[-1] != "A" else "B")) is None
|
||||
|
||||
# Demoting the user caps the token at the user's new role.
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(update(User).where(User.id == uid).values(role=UserRole.viewer))
|
||||
assert (await api_tokens.resolve_token(app, raw)).role == UserRole.viewer
|
||||
|
||||
# Disabling the user kills the token.
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(update(User).where(User.id == uid).values(is_active=False))
|
||||
assert await api_tokens.resolve_token(app, raw) is None
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(update(User).where(User.id == uid).values(is_active=True))
|
||||
assert await api_tokens.resolve_token(app, raw) is not None
|
||||
|
||||
# Revoking kills it, and only the owner can revoke.
|
||||
other = await _make_user(app, UserRole.admin)
|
||||
async with app.db_sessionmaker() as s:
|
||||
assert await api_tokens.revoke_token(s, user_id=other, token_id=tid) is None
|
||||
await s.commit()
|
||||
assert await api_tokens.resolve_token(app, raw) is not None
|
||||
async with app.db_sessionmaker() as s:
|
||||
assert await api_tokens.revoke_token(s, user_id=uid, token_id=tid) is not None
|
||||
await s.commit()
|
||||
assert await api_tokens.resolve_token(app, raw) is None
|
||||
|
||||
# An expired token resolves to nobody.
|
||||
raw2, tid2 = await _mint(app, uid, UserRole.viewer, UserRole.viewer)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(update(ApiToken).where(ApiToken.id == tid2).values(
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
|
||||
assert await api_tokens.resolve_token(app, raw2) is None
|
||||
|
||||
# Deleting the user takes their tokens with them.
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(User.__table__.delete().where(User.id == uid))
|
||||
left = (await s.execute(select(ApiToken).where(ApiToken.user_id == uid))).all()
|
||||
assert left == []
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_routes_create_list_and_revoke(app):
|
||||
from sqlalchemy import select
|
||||
from steward.core import api_tokens
|
||||
from steward.models.api_tokens import ApiToken
|
||||
from steward.models.audit import AuditEvent
|
||||
from steward.models.users import UserRole
|
||||
|
||||
async def _go():
|
||||
uid = await _make_user(app, UserRole.operator)
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess["user_id"] = uid
|
||||
sess["user_role"] = "operator"
|
||||
sess["username"] = "route-test"
|
||||
|
||||
resp = await client.get("/ai-access/")
|
||||
assert resp.status_code == 200
|
||||
page = await resp.get_data(as_text=True)
|
||||
assert "You have no tokens yet" in page
|
||||
# An operator can't offer admin.
|
||||
assert 'value="admin"' not in page
|
||||
|
||||
resp = await client.post("/ai-access/tokens", form={
|
||||
"name": "laptop", "role": "admin", "expires_in_days": ""})
|
||||
assert resp.status_code == 400
|
||||
assert "higher role" in await resp.get_data(as_text=True)
|
||||
|
||||
resp = await client.post("/ai-access/tokens", form={
|
||||
"name": "laptop", "role": "operator", "expires_in_days": "30"})
|
||||
assert resp.status_code == 200
|
||||
raw = re.search(r'value="(stw_[^"]+)"', await resp.get_data(as_text=True)).group(1)
|
||||
caller = await api_tokens.resolve_token(app, raw)
|
||||
assert caller is not None and caller.role == UserRole.operator
|
||||
|
||||
async with app.db_sessionmaker() as s:
|
||||
token = (await s.execute(select(ApiToken).where(ApiToken.user_id == uid))).scalar_one()
|
||||
page = await (await client.get("/ai-access/")).get_data(as_text=True)
|
||||
assert "laptop" in page and token.prefix in page
|
||||
assert raw not in page
|
||||
|
||||
resp = await client.post(f"/ai-access/tokens/{token.id}/revoke")
|
||||
assert resp.status_code == 302
|
||||
assert await api_tokens.resolve_token(app, raw) is None
|
||||
|
||||
async with app.db_sessionmaker() as s:
|
||||
actions = (await s.execute(
|
||||
select(AuditEvent.action).where(AuditEvent.user_id == uid))).scalars().all()
|
||||
assert "api_token.create" in actions and "api_token.revoke" in actions
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Personal API tokens: the pure rules (hashing, role ceiling, state) and the
|
||||
route gate. Resolution and the routes' transactions run against real Postgres
|
||||
in tests/integration/test_api_tokens.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.core import api_tokens
|
||||
from steward.models.api_tokens import ApiToken
|
||||
from steward.models.users import UserRole
|
||||
|
||||
V, O, A = UserRole.viewer, UserRole.operator, UserRole.admin
|
||||
|
||||
|
||||
def test_generated_tokens_are_prefixed_and_unique():
|
||||
a, b = api_tokens.generate_token(), api_tokens.generate_token()
|
||||
assert a.startswith("stw_") and b.startswith("stw_")
|
||||
assert a != b
|
||||
assert len(a) > 40
|
||||
|
||||
|
||||
def test_hash_is_sha256_of_the_plaintext():
|
||||
raw = "stw_example"
|
||||
assert api_tokens.hash_token(raw) == hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
def test_display_prefix_never_reveals_the_whole_token():
|
||||
raw = api_tokens.generate_token()
|
||||
shown = api_tokens.display_prefix(raw)
|
||||
assert raw.startswith(shown)
|
||||
assert len(shown) < len(raw) // 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ceiling,user_role,expected", [
|
||||
(A, A, A), (O, A, O), (V, A, V),
|
||||
(A, O, O), (O, O, O), (V, O, V),
|
||||
(A, V, V), (O, V, V), (V, V, V),
|
||||
])
|
||||
def test_effective_role_is_the_lower_of_ceiling_and_user(ceiling, user_role, expected):
|
||||
assert api_tokens.effective_role(ceiling, user_role) == expected
|
||||
|
||||
|
||||
def test_grantable_roles_stop_at_the_users_own():
|
||||
assert api_tokens.grantable_roles(V) == [V]
|
||||
assert api_tokens.grantable_roles(O) == [V, O]
|
||||
assert api_tokens.grantable_roles(A) == [V, O, A]
|
||||
|
||||
|
||||
def _token(**kw) -> ApiToken:
|
||||
return ApiToken(name="t", token_hash="x", prefix="stw_x", role=V, **kw)
|
||||
|
||||
|
||||
def test_token_state():
|
||||
now = datetime.now(timezone.utc)
|
||||
assert api_tokens.token_state(_token(), now) == "active"
|
||||
assert api_tokens.token_state(_token(expires_at=now + timedelta(days=1)), now) == "active"
|
||||
assert api_tokens.token_state(_token(expires_at=now), now) == "expired"
|
||||
assert api_tokens.token_state(_token(revoked_at=now), now) == "revoked"
|
||||
# Revoked wins over expired: it is the more deliberate fact.
|
||||
assert api_tokens.token_state(
|
||||
_token(revoked_at=now, expires_at=now - timedelta(days=1)), now) == "revoked"
|
||||
|
||||
|
||||
def _fake_db():
|
||||
db = MagicMock()
|
||||
db.flush = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
async def test_create_token_stores_only_the_hash():
|
||||
db = _fake_db()
|
||||
raw, token = await api_tokens.create_token(
|
||||
db, user_id="u1", user_role=O, name=" laptop ", role=O, expires_in_days=30)
|
||||
db.add.assert_called_once_with(token)
|
||||
assert token.token_hash == api_tokens.hash_token(raw)
|
||||
assert raw not in (token.token_hash, token.prefix)
|
||||
assert token.name == "laptop"
|
||||
assert token.role == O
|
||||
assert token.expires_at is not None
|
||||
|
||||
|
||||
async def test_create_token_refuses_a_ceiling_above_the_users_role():
|
||||
db = _fake_db()
|
||||
with pytest.raises(ValueError, match="higher role"):
|
||||
await api_tokens.create_token(db, user_id="u1", user_role=O, name="x", role=A)
|
||||
db.add.assert_not_called()
|
||||
|
||||
|
||||
async def test_create_token_requires_a_name():
|
||||
with pytest.raises(ValueError, match="name"):
|
||||
await api_tokens.create_token(_fake_db(), user_id="u1", user_role=A, name=" ", role=V)
|
||||
|
||||
|
||||
async def test_resolve_rejects_non_steward_tokens_without_touching_the_db():
|
||||
app = MagicMock()
|
||||
for raw in (None, "", "Bearer x", "abc123"):
|
||||
assert await api_tokens.resolve_token(app, raw) is None
|
||||
app.db_sessionmaker.assert_not_called()
|
||||
|
||||
|
||||
async def test_ai_access_requires_login(client):
|
||||
resp = await client.get("/ai-access/")
|
||||
assert resp.status_code == 302
|
||||
assert "/login" in resp.headers["Location"]
|
||||
|
||||
|
||||
async def test_ai_access_refuses_a_request_with_no_session_user():
|
||||
# A share-link viewer passes require_role(viewer) but has no session user to
|
||||
# own tokens; the guard must refuse rather than act for nobody.
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from steward.ai_access import routes
|
||||
|
||||
db = MagicMock()
|
||||
db.execute = AsyncMock()
|
||||
with patch.object(routes, "current_user_id", return_value=None):
|
||||
with pytest.raises(Forbidden):
|
||||
await routes._session_user(db)
|
||||
db.execute.assert_not_called()
|
||||
|
||||
|
||||
async def test_ai_access_refuses_a_disabled_user():
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from steward.ai_access import routes
|
||||
from steward.models.users import User
|
||||
|
||||
user = User(id="u1", username="x", email="x@e", password_hash="h", role=A, is_active=False)
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = user
|
||||
db = MagicMock()
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
with patch.object(routes, "current_user_id", return_value="u1"):
|
||||
with pytest.raises(Forbidden):
|
||||
await routes._session_user(db)
|
||||
Reference in New Issue
Block a user