fix(acl): make shared records findable, not just openable (#2079)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m7s

Option A of the #2079 fork, per the operator's call: widen the scope in
query_knowledge for every caller rather than special-casing the snippet path.

Until now the list/search queries filtered on Note.user_id alone, while
get_note_permission resolved shares properly. The result: a record shared with
you could be OPENED by id but never FOUND — invisible in Knowledge browse, in
snippet and process lists, and in the facet counts beside them.

- services/access.py gains readable_notes_clause(user_id): the same resolution
  get_note_permission does per row (ownership, direct share, group share,
  inherited project share), expressed as set membership so a list query can use
  it in one statement instead of O(n) permission round-trips.
- services/knowledge.py routes every query through it — query_knowledge, the
  keyword half of the hybrid search, query_knowledge_ids, get_knowledge_by_ids,
  get_knowledge_tags and get_knowledge_counts. The facets follow the list, or a
  tag visible in the list would filter it down to nothing.
- list items now carry user_id, since these lists can be mixed-ownership and
  the client has no other way to mark what isn't yours.

Reaches four surfaces: the Snippets list (the original report), Knowledge
browse, list_processes, and the plugin's process manifest — so a Process shared
with you now also syncs as a local skill stub, which is the point of sharing one.

NOT widened: semantic_search_notes, which scopes by NoteEmbedding.user_id and
also backs auto-inject and the search MCP tool. Widening it would put another
user's content into your agent context automatically — a product decision, not
a bug fix. Consequence until that call is made, marked at the call site: a
shared record is findable by wording but not by meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-25 19:19:18 -04:00
parent b33e2a79c6
commit b7d6fc7e5d
5 changed files with 163 additions and 15 deletions
+71
View File
@@ -0,0 +1,71 @@
"""`readable_notes_clause` — the set-based ACL predicate behind list queries.
It exists because `get_note_permission` answers "may I read THIS note?" one row
at a time, which a list query can't use. The two must stay in step: anything
readable one way has to be findable the other, or a record shared with you is
openable by id but invisible in every list — the bug this predicate fixed.
No DB: the group lookup is stubbed and the resulting clause is compiled to SQL
so the shape can be asserted directly.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _session_returning_groups(group_ids: list[int]):
"""A stub async session whose only query — the caller's group ids — returns
`group_ids`."""
result = MagicMock()
result.scalars.return_value.all.return_value = group_ids
session = AsyncMock()
session.__aenter__ = AsyncMock(return_value=session)
session.__aexit__ = AsyncMock(return_value=False)
session.execute = AsyncMock(return_value=result)
return session
async def _compiled_clause(group_ids: list[int]) -> str:
from scribe.services.access import readable_notes_clause
with patch("scribe.services.access.async_session") as cls:
cls.return_value = _session_returning_groups(group_ids)
clause = await readable_notes_clause(user_id=7)
return str(clause.compile(compile_kwargs={"literal_binds": True}))
@pytest.mark.asyncio
async def test_clause_covers_ownership_and_both_share_paths():
sql = await _compiled_clause(group_ids=[])
# 1. ownership
assert "notes.user_id = 7" in sql
# 2/3. a direct or group share on the note itself
assert "note_shares" in sql
assert "shared_with_user_id = 7" in sql
# 4. inheritance from a shared project
assert "project_shares" in sql
assert "notes.project_id IN" in sql
@pytest.mark.asyncio
async def test_group_membership_widens_both_share_lookups():
sql = await _compiled_clause(group_ids=[3, 9])
assert "shared_with_group_id IN (3, 9)" in sql
# Both the note-level and project-level share lookups gain the group arm.
assert sql.count("shared_with_group_id IN (3, 9)") == 2
@pytest.mark.asyncio
async def test_no_groups_means_no_group_arm():
"""A user in no groups must not produce an empty `IN ()`, which would be
invalid SQL on some backends and always-false on others."""
sql = await _compiled_clause(group_ids=[])
assert "shared_with_group_id" not in sql
@pytest.mark.asyncio
async def test_owner_only_result_is_never_the_whole_table():
"""Guard against the predicate degrading to something always-true — that
would expose every user's notes to every other user."""
sql = await _compiled_clause(group_ids=[])
assert "true" not in sql.lower()
assert "1 = 1" not in sql