fix(contract-drift): MCP read-only scope, shared-note writes, event TZ
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>
This commit is contained in:
@@ -68,6 +68,19 @@ def _normalize_duration(
|
||||
return None
|
||||
|
||||
|
||||
async def _localize_naive(user_id: int, dt: datetime | None) -> datetime | None:
|
||||
"""Anchor a naive datetime in the user's timezone; pass tz-aware through.
|
||||
|
||||
Naive datetimes are the user's local wall-clock time (the MCP create/update
|
||||
tools combine date+time without a zone). Attaching the user's tzinfo lets
|
||||
asyncpg store the correct UTC instant, matching the REST/UI path.
|
||||
"""
|
||||
if dt is not None and dt.tzinfo is None:
|
||||
from fabledassistant.services.tz import get_user_tz # noqa: PLC0415
|
||||
return dt.replace(tzinfo=await get_user_tz(user_id))
|
||||
return dt
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
@@ -97,6 +110,13 @@ async def create_event(
|
||||
"""
|
||||
if duration is not None and duration_minutes is None:
|
||||
duration_minutes = duration
|
||||
# Canonical localization point: a naive datetime (e.g. from the MCP tool's
|
||||
# date+time split) is the user's wall-clock time, so anchor it in their
|
||||
# timezone before storage. tz-aware inputs (REST, CalDAV pass-through) are
|
||||
# left untouched. Without this, MCP-created events landed at the same
|
||||
# wall-clock numerals in UTC and drifted from UI-created ones by the offset.
|
||||
start_dt = await _localize_naive(user_id, start_dt)
|
||||
end_dt = await _localize_naive(user_id, end_dt)
|
||||
duration_minutes = _normalize_duration(
|
||||
start_dt=start_dt, end_dt=end_dt, duration_minutes=duration_minutes,
|
||||
)
|
||||
@@ -271,6 +291,11 @@ async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
||||
return None
|
||||
old_title = event.title # capture before mutation for CalDAV lookup
|
||||
|
||||
# Localize a naive start_dt patch to the user's timezone (same canonical
|
||||
# rule as create_event) before it's used or persisted.
|
||||
if fields.get("start_dt") is not None:
|
||||
fields["start_dt"] = await _localize_naive(user_id, fields["start_dt"])
|
||||
|
||||
# Resolve any end_dt/duration_minutes inputs against the
|
||||
# post-update start_dt. If neither is in the patch, leave the
|
||||
# existing duration_minutes alone.
|
||||
|
||||
@@ -66,6 +66,20 @@ async def create_note(
|
||||
entity_meta: dict | None = None,
|
||||
task_kind: str = "work",
|
||||
) -> Note:
|
||||
# Validate status/priority here so the MCP create_task path (which passes
|
||||
# them straight through) can't persist an out-of-enum value that the REST
|
||||
# route would have rejected — there's no DB CHECK on notes.status.
|
||||
if isinstance(status, str):
|
||||
try:
|
||||
status = TaskStatus(status).value
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid status: {status!r}. Must be one of: {[s.value for s in TaskStatus]}")
|
||||
if isinstance(priority, str):
|
||||
try:
|
||||
priority = TaskPriority(priority).value
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid priority: {priority!r}. Must be one of: {[p.value for p in TaskPriority]}")
|
||||
|
||||
# Auto-populate project_id from milestone when not explicitly provided
|
||||
if milestone_id is not None and project_id is None:
|
||||
from fabledassistant.models.milestone import Milestone
|
||||
|
||||
Reference in New Issue
Block a user