Drift-audit Group 5 (high-severity contract drift): - MCP read-only keys could call every write tool: the Bearer resolver discarded api_key.scope and dispatch had no gate. Add resolve_bearer() (returns user_id + scope) and a scope gate in the /mcp ASGI wrapper that buffers the JSON-RPC body and rejects tools/call for any tool outside a read all-list when scope=='read' (default-deny for unknown/new tools). - Shared project notes/tasks panel was empty for non-owners: get_project_notes_route now queries notes/milestones with the project OWNER's uid (mirrors the already-fixed milestones route). - Shared editors couldn't save/delete shared NOTES (tasks worked): the three notes write routes now resolve via get_note_for_user, gate on can_write_note, and write as the owner — matching the tasks routes. - Event timezone drift: naive datetimes from the MCP date+time split are now localized to the user's tz at a single canonical service point (create_event /update_event), so MCP- and UI-created events agree. tz-aware inputs (REST/CalDAV) pass through untouched. - create_note validates status/priority (TaskStatus/TaskPriority), closing the MCP create_task path that let out-of-enum values persist (no DB CHECK). Tests cover resolve_bearer scope + the write-tool classifier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
38 lines
1.4 KiB
Python
38 lines
1.4 KiB
Python
"""MCP-side Bearer token resolution. Reuses the existing api_keys infrastructure."""
|
|
from __future__ import annotations
|
|
|
|
from fabledassistant.services.api_keys import lookup_key
|
|
|
|
|
|
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
|
|
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
|
|
|
|
Returns None if the header is missing, malformed, or the token is invalid
|
|
or revoked. The underlying lookup_key already updates last_used_at on hit.
|
|
"""
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
return None
|
|
raw_token = auth_header[len("Bearer "):].strip()
|
|
if not raw_token:
|
|
return None
|
|
api_key = await lookup_key(raw_token)
|
|
return api_key.user_id if api_key else None
|
|
|
|
|
|
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
|
|
"""Resolve a Bearer token to (user_id, scope).
|
|
|
|
scope is 'read' or 'write'. Returns None for a missing/malformed/invalid
|
|
token. The MCP dispatch layer uses scope to deny write-class tool calls
|
|
from read-only keys — the same read/write boundary the REST API enforces.
|
|
"""
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
return None
|
|
raw_token = auth_header[len("Bearer "):].strip()
|
|
if not raw_token:
|
|
return None
|
|
api_key = await lookup_key(raw_token)
|
|
if api_key is None:
|
|
return None
|
|
return api_key.user_id, (api_key.scope or "write")
|