feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
Closes #2092 and the Knowledge-browse provenance gap. The two halves of a hybrid search disagreed: the keyword half honoured shares while the semantic half was pinned to NoteEmbedding.user_id, so a shared record was findable by wording and invisible by meaning — the case a semantic search exists to serve. semantic_search_notes now scopes on Note via a `scope` parameter, and each of its five callers declares which kind of act it is: mcp/tools/search.py read the agent asked routes/search.py read the user typed it knowledge.py (semantic) read matches the keyword half beside it plugin_context.py browse nobody asked; never a one-to-one share dedup.py own a verdict that blocks a write must not hinge on another person's notes That last one is the reason this isn't a single global widening: the dedup gate returns "update the existing one instead", so matching a stranger's record would refuse a legitimate create and point at something the caller can't edit. Scope defaults to "own" so a caller that forgets is wrong in the safe direction, and an unknown scope raises rather than falling back — a typo there would be a data-exposure bug. Auto-inject keeps the browse scope, which still admits a collaborator's note via a shared project. Its menu line is the only provenance an agent sees, so a foreign hit now reads: #12 "Title" (0.71) - shared by alex, treat as a suggestion. MCP and REST search results carry shared/owner too. Knowledge browse: the feed hydrates cards from /api/knowledge/batch rather than the list route, so both paths label rows now, and KnowledgeView shows "by <owner>" on records the viewer doesn't own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
@@ -15,7 +15,11 @@ Assertions compile each clause to SQL and inspect its shape.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from scribe.services.access import browsable_notes_clause, readable_notes_clause
|
||||
from scribe.services.access import (
|
||||
browsable_notes_clause,
|
||||
notes_visibility_clause,
|
||||
readable_notes_clause,
|
||||
)
|
||||
|
||||
|
||||
def _sql(clause) -> str:
|
||||
@@ -92,6 +96,32 @@ def test_browse_scope_is_never_the_whole_table():
|
||||
assert "1 = 1" not in sql
|
||||
|
||||
|
||||
# --- the scope resolver ------------------------------------------------------
|
||||
|
||||
def test_own_scope_is_ownership_alone():
|
||||
"""The near-duplicate gate depends on this: its verdict must not turn on
|
||||
another person's records, or it would refuse a write and point the caller at
|
||||
something they may not be able to edit."""
|
||||
sql = _sql(notes_visibility_clause(7, "own"))
|
||||
assert sql == "notes.user_id = 7"
|
||||
|
||||
|
||||
def test_scope_resolver_maps_to_the_right_clauses():
|
||||
assert _sql(notes_visibility_clause(7, "browse")) == _browse()
|
||||
assert _sql(notes_visibility_clause(7, "read")) == _read()
|
||||
|
||||
|
||||
def test_scope_defaults_to_the_narrowest():
|
||||
"""A caller that forgets to choose must be wrong in the safe direction."""
|
||||
assert _sql(notes_visibility_clause(7)) == _sql(notes_visibility_clause(7, "own"))
|
||||
|
||||
|
||||
def test_unknown_scope_is_rejected_loudly():
|
||||
"""Silently falling back would turn a typo into a data-exposure bug."""
|
||||
with pytest.raises(ValueError):
|
||||
notes_visibility_clause(7, "everything")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("clause_fn", [readable_notes_clause, browsable_notes_clause])
|
||||
def test_clauses_are_pure_builders(clause_fn):
|
||||
"""Synchronous and side-effect free — no coroutine, no session of their own.
|
||||
|
||||
Reference in New Issue
Block a user