fix(contract-drift): MCP read-only scope, shared-note writes, event TZ
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 24s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped

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:
2026-06-02 19:02:19 -04:00
parent c363a5a6df
commit aef5009fc2
7 changed files with 218 additions and 10 deletions
+28 -5
View File
@@ -8,6 +8,7 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.services.access import can_write_note
from fabledassistant.services.notes import (
build_note_graph,
convert_note_to_task,
@@ -192,6 +193,15 @@ async def get_note_route(note_id: int):
@login_required
async def update_note_route(note_id: int):
uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service.
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
@@ -212,14 +222,14 @@ async def update_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(uid, note_id, **fields)
note = await update_note(owner_uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -227,6 +237,13 @@ async def update_note_route(note_id: int):
@login_required
async def patch_note_route(note_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
@@ -245,14 +262,14 @@ async def patch_note_route(note_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(uid, note_id, **fields)
note = await update_note(owner_uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
text = f"{note.title}\n{note.body}".strip() if note.body else (note.title or "")
if text:
asyncio.create_task(upsert_note_embedding(note.id, uid, text))
asyncio.create_task(upsert_note_embedding(note.id, owner_uid, text))
return jsonify(note.to_dict())
@@ -260,8 +277,14 @@ async def patch_note_route(note_id: int):
@login_required
async def delete_note_route(note_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
from fabledassistant.services.trash import delete as trash_delete
batch = await trash_delete(uid, "note", note_id)
batch = await trash_delete(note_obj.user_id, "note", note_id)
if batch is None:
return not_found("Note")
return "", 204
+5 -2
View File
@@ -116,6 +116,9 @@ async def get_project_notes_route(project_id: int):
if result is None:
return not_found("Project")
project, _ = result
# Use the project owner's uid so the ownership filter on notes/milestones
# matches for shared collaborators (who'd otherwise see an empty panel).
owner_uid = project.user_id or uid
# type filter: "note", "task", or None (both)
type_filter = request.args.get("type")
@@ -128,11 +131,11 @@ async def get_project_notes_route(project_id: int):
elif type_filter == "note":
is_task = False
ms_list = await list_milestones(uid, project_id)
ms_list = await list_milestones(owner_uid, project_id)
milestone_ids = [m.id for m in ms_list]
notes, total = await list_notes(
uid,
owner_uid,
is_task=is_task,
status=status_filter,
project_id=project_id,