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>
36 lines
1.7 KiB
Python
36 lines
1.7 KiB
Python
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)
|