Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
338 lines
11 KiB
Python
338 lines
11 KiB
Python
"""MCP tokens: issue, list, revoke, verify.
|
|
|
|
Owned by Agent 1. One table, one service, three rules that matter:
|
|
|
|
1. **A token is shown once.** Only ``sha256(token)`` and a six-character prefix
|
|
are stored, so a database leak cannot authenticate anyone; the prefix is for
|
|
the audit trail and the settings screen, and is never enough to log in.
|
|
2. **Verification is constant-time and total.** ``hmac.compare_digest`` on the
|
|
hash, and every failure mode — unknown, revoked, expired — returns the same
|
|
``None``. A caller cannot tell "wrong token" from "revoked token", because a
|
|
revoked token is not a fact about the token, it is a decision.
|
|
3. **Scopes are a floor, not a ceiling.** A token carries the scopes it was
|
|
minted with and nothing else; an empty scope list is "no scopes", never "all".
|
|
The per-organization switch lives in ``org_policies.mcp_enabled`` (Phase 3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from datetime import timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.errors import NotFoundError, ValidationError
|
|
from app.domain.base import new_id, utcnow
|
|
from app.infrastructure.db.models_org import McpTokenRow
|
|
|
|
#: Every scope the gateway understands. A scope nobody granted a token is a
|
|
#: scope the gateway refuses, whatever the asks say.
|
|
MCP_SCOPES: tuple[str, ...] = (
|
|
"app:read",
|
|
"tasks:read",
|
|
"tasks:write",
|
|
"memory:read",
|
|
"memory:write",
|
|
"delivery:read",
|
|
)
|
|
|
|
#: Enough entropy that a token is unguessable, short enough to paste into a
|
|
#: client config file.
|
|
TOKEN_ENTROPY_BYTES = 24
|
|
|
|
#: How the raw token looks to a human: the prefix is what the UI shows back.
|
|
TOKEN_PREFIX = "smo_"
|
|
|
|
#: Displayed characters; ``token_hash`` is what authenticates.
|
|
PREFIX_DISPLAY_CHARS = 6
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class McpToken:
|
|
"""A token as the service hands it back — never the raw secret."""
|
|
|
|
id: str
|
|
organization_id: str
|
|
name: str
|
|
prefix: str
|
|
scopes: list[str]
|
|
created_by_user_id: str | None = None
|
|
expires_at: object | None = None
|
|
last_used_at: object | None = None
|
|
revoked_at: object | None = None
|
|
created_at: object | None = None
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class IssuedToken:
|
|
"""The one and only response that carries the raw token."""
|
|
|
|
token: str
|
|
record: McpToken
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class TokenIdentity:
|
|
"""What a verified token proves: which organization, and what it may do."""
|
|
|
|
organization_id: str
|
|
token_id: str
|
|
prefix: str
|
|
scopes: frozenset[str] = field(default_factory=frozenset)
|
|
|
|
def allows(self, scope: str) -> bool:
|
|
return scope in self.scopes
|
|
|
|
|
|
def as_utc(value):
|
|
"""Normalise a timestamp read from the database for comparison.
|
|
|
|
SQLite (the development and test store) hands back naive datetimes; every
|
|
expiry and revocation check compares against ``utcnow()``, so a naive value
|
|
is assumed to be UTC rather than raising in the middle of a refusal.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
from datetime import UTC
|
|
|
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
|
|
|
|
|
def hash_token(raw: str) -> str:
|
|
"""The only form of a token that is ever stored."""
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _mint() -> str:
|
|
return f"{TOKEN_PREFIX}{secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)}"
|
|
|
|
|
|
def _scopes_of(raw: str) -> list[str]:
|
|
"""Parse a CSV scope field, keeping only known scopes and their order."""
|
|
seen: list[str] = []
|
|
for piece in (raw or "").split(","):
|
|
scope = piece.strip()
|
|
if scope and scope in MCP_SCOPES and scope not in seen:
|
|
seen.append(scope)
|
|
return seen
|
|
|
|
|
|
def validate_scopes(scopes: list[str] | None) -> list[str]:
|
|
"""Refuse an unknown scope rather than silently dropping it."""
|
|
if not scopes:
|
|
return []
|
|
unknown = [scope for scope in scopes if scope not in MCP_SCOPES]
|
|
if unknown:
|
|
raise ValidationError(f"محدودهٔ ناشناخته: {', '.join(sorted(unknown))}")
|
|
# De-duplicated, order preserved, so the UI's checkbox set round-trips.
|
|
return list(dict.fromkeys(scopes))
|
|
|
|
|
|
class TokenRateLimiter:
|
|
"""In-memory, per-token call budget for the current minute.
|
|
|
|
Deliberately process-local: the gateway is one process, and a distributed
|
|
limiter would need the Redis the deployment does not have. A restart forgives
|
|
a burst — which is the right trade for a spare process only the owner's own
|
|
agents reach.
|
|
"""
|
|
|
|
def __init__(self, *, limit_per_minute: int | None = None) -> None:
|
|
self.limit = int(limit_per_minute or settings.mcp_rate_limit_per_minute)
|
|
self._calls: dict[str, deque[float]] = {}
|
|
|
|
def allow(self, key: str) -> bool:
|
|
import time
|
|
|
|
now = time.monotonic()
|
|
window_start = now - 60.0
|
|
bucket = self._calls.setdefault(key, deque())
|
|
while bucket and bucket[0] < window_start:
|
|
bucket.popleft()
|
|
if len(bucket) >= self.limit:
|
|
return False
|
|
bucket.append(now)
|
|
return True
|
|
|
|
def reset(self) -> None:
|
|
self._calls.clear()
|
|
|
|
|
|
class McpService:
|
|
"""Token lifecycle and verification for the MCP gateway."""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
|
|
# -- lifecycle -----------------------------------------------------------
|
|
|
|
async def issue(
|
|
self,
|
|
*,
|
|
organization_id: str,
|
|
name: str,
|
|
scopes: list[str],
|
|
created_by_user_id: str | None = None,
|
|
ttl_days: int | None = None,
|
|
) -> IssuedToken:
|
|
clean_name = name.strip()
|
|
if not clean_name:
|
|
raise ValidationError("نام توکن نمیتواند خالی باشد.")
|
|
wanted = validate_scopes(scopes)
|
|
if not wanted:
|
|
raise ValidationError("حداقل یک محدودهٔ دسترسی انتخاب کنید.")
|
|
days = int(ttl_days or settings.mcp_token_ttl_days)
|
|
if days < 1:
|
|
raise ValidationError("مدت اعتبار توکن نامعتبر است.")
|
|
|
|
raw = _mint()
|
|
row = McpTokenRow(
|
|
id=new_id(),
|
|
organization_id=organization_id,
|
|
name=clean_name[:120],
|
|
token_hash=hash_token(raw),
|
|
prefix=raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS],
|
|
scopes=",".join(wanted),
|
|
created_by_user_id=created_by_user_id,
|
|
expires_at=utcnow() + timedelta(days=days),
|
|
)
|
|
self.session.add(row)
|
|
await self.session.flush()
|
|
return IssuedToken(token=raw, record=_to_domain(row))
|
|
|
|
async def list(self, organization_id: str) -> list[McpToken]:
|
|
rows = (
|
|
(
|
|
await self.session.execute(
|
|
select(McpTokenRow)
|
|
.where(McpTokenRow.organization_id == organization_id)
|
|
.order_by(McpTokenRow.created_at.desc())
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [_to_domain(row) for row in rows]
|
|
|
|
async def revoke(self, *, organization_id: str, token_id: str) -> None:
|
|
"""Revoke a token. Revoking twice is a 404, not a silent success — the
|
|
same idempotence rule the grant endpoints follow, so a caller can tell
|
|
"already gone" from "never existed" only by looking, not by guessing."""
|
|
row = await self._row(organization_id, token_id)
|
|
if row is None or row.revoked_at is not None:
|
|
raise NotFoundError("توکن یافت نشد.")
|
|
row.revoked_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
# -- verification --------------------------------------------------------
|
|
|
|
async def verify(self, raw_token: str) -> TokenIdentity | None:
|
|
"""Resolve a raw token, or ``None`` for every kind of refusal.
|
|
|
|
The lookup is by hash (the token is never stored), and the comparison is
|
|
constant-time so a token that shares a prefix with a real one cannot be
|
|
discovered by timing. An expired or revoked token is simply not an
|
|
identity any more.
|
|
"""
|
|
if not raw_token or not raw_token.startswith(TOKEN_PREFIX):
|
|
return None
|
|
digest = hash_token(raw_token)
|
|
rows = (
|
|
(
|
|
await self.session.execute(
|
|
select(McpTokenRow).where(
|
|
McpTokenRow.token_hash == digest,
|
|
McpTokenRow.revoked_at.is_(None),
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
if not rows:
|
|
return None
|
|
row = rows[0]
|
|
if not hmac.compare_digest(row.token_hash, digest):
|
|
return None
|
|
if as_utc(row.expires_at) is not None and as_utc(row.expires_at) <= utcnow():
|
|
return None
|
|
return TokenIdentity(
|
|
organization_id=row.organization_id,
|
|
token_id=row.id,
|
|
prefix=row.prefix,
|
|
scopes=frozenset(_scopes_of(row.scopes)),
|
|
)
|
|
|
|
async def creator_of(self, token_id: str) -> str | None:
|
|
"""The user who minted this token — the accountable actor for its writes.
|
|
|
|
A task created through the gateway is attributed to the administrator who
|
|
created the token, not to a ghost user: the token is a delegated key, and
|
|
delegation never erases who holds it.
|
|
"""
|
|
row = await self.session.get(McpTokenRow, token_id)
|
|
return row.created_by_user_id if row else None
|
|
|
|
async def touch(self, token_id: str) -> None:
|
|
"""Record last use. Never allowed to fail a call."""
|
|
row = await self.session.get(McpTokenRow, token_id)
|
|
if row is not None:
|
|
row.last_used_at = utcnow()
|
|
await self.session.flush()
|
|
|
|
async def count_per_org(self) -> dict[str, int]:
|
|
"""Active tokens per organization, for the owner's /admin/mcp screen."""
|
|
rows = (
|
|
await self.session.execute(
|
|
select(McpTokenRow.organization_id).where(
|
|
McpTokenRow.revoked_at.is_(None)
|
|
)
|
|
)
|
|
).scalars()
|
|
counts: dict[str, int] = {}
|
|
for org_id in rows:
|
|
counts[org_id] = counts.get(org_id, 0) + 1
|
|
return counts
|
|
|
|
async def _row(self, organization_id: str, token_id: str) -> McpTokenRow | None:
|
|
return await self.session.scalar(
|
|
select(McpTokenRow).where(
|
|
McpTokenRow.organization_id == organization_id,
|
|
McpTokenRow.id == token_id,
|
|
)
|
|
)
|
|
|
|
|
|
def _to_domain(row: McpTokenRow) -> McpToken:
|
|
return McpToken(
|
|
id=row.id,
|
|
organization_id=row.organization_id,
|
|
name=row.name,
|
|
prefix=row.prefix,
|
|
scopes=_scopes_of(row.scopes),
|
|
created_by_user_id=row.created_by_user_id,
|
|
expires_at=row.expires_at,
|
|
last_used_at=row.last_used_at,
|
|
revoked_at=row.revoked_at,
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"MCP_SCOPES",
|
|
"TOKEN_PREFIX",
|
|
"IssuedToken",
|
|
"McpService",
|
|
"McpToken",
|
|
"TokenIdentity",
|
|
"TokenRateLimiter",
|
|
"hash_token",
|
|
"validate_scopes",
|
|
]
|