fix(acl): align MCP writes with the share model — editor edits, viewer doesn't
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 34s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 34s
CI & Build / Build & push image (push) Has been skipped
Option B, per the operator. Closes the last inconsistency from the ACL work.
The agent path had drifted into an indefensible position: delete_snippet honoured
editor shares (I made it share-aware so widening the read wouldn't let a VIEWER
trash things) while update_snippet still resolved through the owner-only
notes.get_note. So through an agent you could destroy a colleague's snippet but
not improve it — and the refusal claimed "not found" for a record you could
plainly open.
Now update_snippet, merge_snippets and update_process all resolve the read scope
and then require can_write_note, matching the REST routes and the sharing UI's
own promise that viewer / editor / admin are distinct grants. A viewer grant is
refused with the actual reason ("shared with you read-only — ask its owner for
edit access, or record your own version"), because not-found would send an agent
hunting for a missing id instead of recording its own copy.
Authorised writes are performed as the OWNER, since the underlying note update is
owner-scoped and a shared editor's own id would match nothing.
Merge additionally requires each source to share the TARGET'S owner and to be
writable by the caller — merging trashes the source, so read access isn't enough,
and cross-owner merge stays out of scope (#231). Sources failing either test are
skipped rather than half-merged.
A record the caller cannot read at all still returns not-found rather than
forbidden, so the error can't be used to confirm that an id exists.
Also fixed _fake_snippet's missing user_id proactively — the same
auto-MagicMock-reads-as-foreign trap that broke CI twice (see note 2109).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -180,6 +180,10 @@ async def update_snippet(
|
||||
tags: Replaces the extra-tag set (language + "snippet" are re-derived).
|
||||
project_id: 0 leaves it unchanged, -1 detaches it from its project, a
|
||||
positive id moves it.
|
||||
|
||||
Editing someone else's snippet requires an editor or admin share from them.
|
||||
A read-only share is refused with a message saying so — record your own
|
||||
version instead of trying to force it.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
if project_id == 0:
|
||||
@@ -189,21 +193,27 @@ async def update_snippet(
|
||||
else:
|
||||
project = project_id
|
||||
|
||||
note = await snippets_svc.update_snippet(
|
||||
uid, snippet_id,
|
||||
name=name, code=code, language=language,
|
||||
signature=signature, when_to_use=when_to_use,
|
||||
repo=repo, path=path, symbol=symbol,
|
||||
locations=locations, tags=tags, project_id=project,
|
||||
)
|
||||
try:
|
||||
note = await snippets_svc.update_snippet(
|
||||
uid, snippet_id,
|
||||
name=name, code=code, language=language,
|
||||
signature=signature, when_to_use=when_to_use,
|
||||
repo=repo, path=path, symbol=symbol,
|
||||
locations=locations, tags=tags, project_id=project,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
# Readable but not writable — surface the real reason, not "not found".
|
||||
raise ValueError(str(exc)) from exc
|
||||
if note is None:
|
||||
raise ValueError(f"snippet {snippet_id} not found")
|
||||
if system_ids is not None:
|
||||
await systems_svc.set_record_systems(uid, snippet_id, system_ids)
|
||||
await systems_svc.set_record_systems(note.user_id, snippet_id, system_ids)
|
||||
data = snippets_svc.snippet_to_dict(note)
|
||||
data.update(await access_svc.describe_provenance(uid, note))
|
||||
if system_ids is not None:
|
||||
data["systems"] = [
|
||||
s.to_dict() for s in await systems_svc.list_record_systems(uid, snippet_id)
|
||||
s.to_dict()
|
||||
for s in await systems_svc.list_record_systems(note.user_id, snippet_id)
|
||||
]
|
||||
return data
|
||||
|
||||
@@ -245,7 +255,10 @@ async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||
ids = [s for s in (source_ids or []) if s != target_id]
|
||||
if not ids:
|
||||
raise ValueError("merge_snippets requires at least one other source_id")
|
||||
result = await snippets_svc.merge_snippets(uid, target_id, ids)
|
||||
try:
|
||||
result = await snippets_svc.merge_snippets(uid, target_id, ids)
|
||||
except PermissionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if result is None:
|
||||
raise ValueError(f"snippet {target_id} not found")
|
||||
note, merged_ids = result
|
||||
|
||||
Reference in New Issue
Block a user