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,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">
|
||||
|
||||
Reference in New Issue
Block a user