feat(api): personal API tokens and the AI access page
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 37s
CI / unit (push) Successful in 58s
CI / publish (push) Skipped

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:
2026-10-06 07:15:23 -04:00
co-authored by Claude Opus 5.5
parent 0da88c7076
commit 1c60fb6b4d
12 changed files with 804 additions and 1 deletions
+2 -1
View File
@@ -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",
+35
View File
@@ -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)