Drafter reuse-recall (both halves) + the multi-user ACL work it exposed #78

Merged
bvandeusen merged 14 commits from dev into main 2026-07-26 00:25:27 -04:00
7 changed files with 141 additions and 123 deletions
Showing only changes of commit 8b069cc93f - Show all commits
+31 -20
View File
@@ -182,7 +182,17 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
# you has not earned that standing, so it waits until you go looking for it. # you has not earned that standing, so it waits until you go looking for it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def readable_notes_clause(user_id: int): def _my_group_ids(user_id: int):
"""The caller's group ids as a SUBQUERY, not a fetched list.
Keeping it in SQL is what makes the clause builders below pure functions —
no session, no await, nothing for a caller's unit test to mock — and it folds
the membership lookup into the one statement the caller was already running.
"""
return select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
def readable_notes_clause(user_id: int):
"""A SQLAlchemy WHERE predicate matching every note this user may READ. """A SQLAlchemy WHERE predicate matching every note this user may READ.
`get_note_permission` answers "may I read THIS note?" one row at a time, `get_note_permission` answers "may I read THIS note?" one row at a time,
@@ -199,17 +209,20 @@ async def readable_notes_clause(user_id: int):
too, or a note becomes findable but not openable (or the reverse — which is too, or a note becomes findable but not openable (or the reverse — which is
the bug this was written to fix). the bug this was written to fix).
""" """
async with async_session() as session: groups = _my_group_ids(user_id)
group_ids = await _user_group_ids(session, user_id)
note_share_conds = [NoteShare.shared_with_user_id == user_id] shared_note_ids = select(NoteShare.note_id).where(
project_share_conds = [ProjectShare.shared_with_user_id == user_id] or_(
if group_ids: NoteShare.shared_with_user_id == user_id,
note_share_conds.append(NoteShare.shared_with_group_id.in_(group_ids)) NoteShare.shared_with_group_id.in_(groups),
project_share_conds.append(ProjectShare.shared_with_group_id.in_(group_ids)) )
)
shared_note_ids = select(NoteShare.note_id).where(or_(*note_share_conds)) shared_project_ids = select(ProjectShare.project_id).where(
shared_project_ids = select(ProjectShare.project_id).where(or_(*project_share_conds)) or_(
ProjectShare.shared_with_user_id == user_id,
ProjectShare.shared_with_group_id.in_(groups),
)
)
return or_( return or_(
Note.user_id == user_id, Note.user_id == user_id,
@@ -269,7 +282,7 @@ async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
return items return items
async def browsable_notes_clause(user_id: int): def browsable_notes_clause(user_id: int):
"""A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus """A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus
notes in a project they can reach (owned or shared). notes in a project they can reach (owned or shared).
@@ -283,14 +296,12 @@ async def browsable_notes_clause(user_id: int):
project that you don't own therefore never matches — `NULL IN (...)` is not project that you don't own therefore never matches — `NULL IN (...)` is not
true — which is exactly the intent. true — which is exactly the intent.
""" """
async with async_session() as session: shared_project_ids = select(ProjectShare.project_id).where(
group_ids = await _user_group_ids(session, user_id) or_(
ProjectShare.shared_with_user_id == user_id,
project_share_conds = [ProjectShare.shared_with_user_id == user_id] ProjectShare.shared_with_group_id.in_(_my_group_ids(user_id)),
if group_ids: )
project_share_conds.append(ProjectShare.shared_with_group_id.in_(group_ids)) )
shared_project_ids = select(ProjectShare.project_id).where(or_(*project_share_conds))
owned_project_ids = select(Project.id).where(Project.user_id == user_id) owned_project_ids = select(Project.id).where(Project.user_id == user_id)
return or_( return or_(
+6 -6
View File
@@ -94,7 +94,7 @@ async def query_knowledge(
# No query = browsing. Narrower scope: a record shared directly with the # No query = browsing. Narrower scope: a record shared directly with the
# caller is search-only and must not appear in an ambient list. # caller is search-only and must not appear in an ambient list.
visible = await browsable_notes_clause(user_id) visible = browsable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
base = select(Note).where(visible) base = select(Note).where(visible)
@@ -152,7 +152,7 @@ async def _semantic_knowledge_search(
try: try:
# A typed query is an explicit act, so it reaches the caller's full read # A typed query is an explicit act, so it reaches the caller's full read
# scope — including records shared directly with them. # scope — including records shared directly with them.
visible = await readable_notes_clause(user_id) visible = readable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
pattern = f"%{q}%" pattern = f"%{q}%"
base = ( base = (
@@ -231,7 +231,7 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
Follows the browse list rather than the read scope: a facet is itself a Follows the browse list rather than the read scope: a facet is itself a
passive surface, and offering a tag that only a search-only record carries passive surface, and offering a tag that only a search-only record carries
would filter the visible list down to nothing.""" would filter the visible list down to nothing."""
visible = await browsable_notes_clause(user_id) visible = browsable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
base = ( base = (
select(func.unnest(Note.tags).label("tag")) select(func.unnest(Note.tags).label("tag"))
@@ -247,7 +247,7 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
"""Per-type counts for the sidebar, over what this user can BROWSE — so the """Per-type counts for the sidebar, over what this user can BROWSE — so the
numbers match the list they sit beside rather than promising rows that only a numbers match the list they sit beside rather than promising rows that only a
search would surface.""" search would surface."""
visible = await browsable_notes_clause(user_id) visible = browsable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
# Count non-task types # Count non-task types
stmt = ( stmt = (
@@ -316,7 +316,7 @@ async def query_knowledge_ids(
return [item["id"] for item in items], total return [item["id"] for item in items], total
# Browsing (see query_knowledge) — narrower scope. # Browsing (see query_knowledge) — narrower scope.
visible = await browsable_notes_clause(user_id) visible = browsable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
base = select(Note.id).where(visible) base = select(Note.id).where(visible)
@@ -347,7 +347,7 @@ async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
return [] return []
# Fetching specific ids is explicit, so this takes the full read scope — the # Fetching specific ids is explicit, so this takes the full read scope — the
# ids came from either a browse or a search, and both must resolve. # ids came from either a browse or a search, and both must resolve.
visible = await readable_notes_clause(user_id) visible = readable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
stmt = ( stmt = (
select(Note) select(Note)
+1 -1
View File
@@ -425,7 +425,7 @@ async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[d
matches. matches.
""" """
from scribe.services.access import readable_notes_clause from scribe.services.access import readable_notes_clause
visible = await readable_notes_clause(user_id) visible = readable_notes_clause(user_id)
async with async_session() as session: async with async_session() as session:
base = select(Note).where( base = select(Note).where(
visible, visible,
+24 -1
View File
@@ -13,11 +13,14 @@ def _bind_user():
_user_id_ctx.reset(token) _user_id_ctx.reset(token)
def _fake_note(id=1, title="Drift Audit", note_type="process"): def _fake_note(id=1, title="Drift Audit", note_type="process", user_id=7):
n = MagicMock() n = MagicMock()
n.id = id n.id = id
n.title = title n.title = title
n.note_type = note_type n.note_type = note_type
# Must be a real int, not an auto-attribute: the provenance check compares it
# against the bound caller (7) to decide whether the record is shared.
n.user_id = user_id
n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type} n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type}
return n return n
@@ -53,6 +56,26 @@ async def test_get_process_returns_body_and_candidates():
out = await get_process("drift") out = await get_process("drift")
assert out["id"] == 7 assert out["id"] == 7
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}] assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
# The caller's own process carries no provenance marker — absence of the flag
# is what makes it read as theirs.
assert "shared" not in out
@pytest.mark.asyncio
async def test_get_process_flags_another_users_process():
"""A shared Process must arrive labelled. get_process's contract is 'follow
the returned body', so an unlabelled one would put someone else's procedure
in charge of the session."""
note = _fake_note(id=7, user_id=9)
with patch("scribe.services.notes.resolve_process",
AsyncMock(return_value=(note, []))), \
patch("scribe.services.access.describe_provenance",
AsyncMock(return_value={"shared": True, "owner": "alex",
"permission": "viewer"})):
from scribe.mcp.tools.processes import get_process
out = await get_process("deploy")
assert out["shared"] is True
assert out["owner"] == "alex"
@pytest.mark.asyncio @pytest.mark.asyncio
+75 -81
View File
@@ -1,110 +1,104 @@
"""`readable_notes_clause` — the set-based ACL predicate behind list queries. """The two ACL predicates behind list queries.
It exists because `get_note_permission` answers "may I read THIS note?" one row `get_note_permission` answers "may I read THIS note?" one row at a time, which a
at a time, which a list query can't use. The two must stay in step: anything list query can't use. These express the same resolution as set membership. They
readable one way has to be findable the other, or a record shared with you is are pure SQL builders — group membership is a subquery rather than a fetched
openable by id but invisible in every list — the bug this predicate fixed. list, so they need no session and callers' unit tests need not know they exist.
No DB: the group lookup is stubbed and the resulting clause is compiled to SQL Two scopes, deliberately different (decision note 2094):
so the shape can be asserted directly. readable_* — everything the ACL permits, for explicit acts (a typed search, a
fetch by id).
browsable_* — owner + project access only, for passive surfaces (browse lists,
facet counts, the process→skill manifest).
Assertions compile each clause to SQL and inspect its shape.
""" """
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from scribe.services.access import browsable_notes_clause, readable_notes_clause
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: def _sql(clause) -> 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})) return str(clause.compile(compile_kwargs={"literal_binds": True}))
async def _compiled_browse_clause(group_ids: list[int]) -> str: def _read(user_id: int = 7) -> str:
from scribe.services.access import browsable_notes_clause return _sql(readable_notes_clause(user_id))
with patch("scribe.services.access.async_session") as cls:
cls.return_value = _session_returning_groups(group_ids)
clause = await browsable_notes_clause(user_id=7)
return str(clause.compile(compile_kwargs={"literal_binds": True}))
@pytest.mark.asyncio def _browse(user_id: int = 7) -> str:
async def test_clause_covers_ownership_and_both_share_paths(): return _sql(browsable_notes_clause(user_id))
sql = await _compiled_clause(group_ids=[])
# 1. ownership
assert "notes.user_id = 7" in sql # --- read scope --------------------------------------------------------------
# 2/3. a direct or group share on the note itself
assert "note_shares" in sql def test_read_scope_covers_ownership_and_every_share_path():
assert "shared_with_user_id = 7" in sql sql = _read()
# 4. inheritance from a shared project assert "notes.user_id = 7" in sql # 1. ownership
assert "note_shares" in sql # 2/3. direct or group note share
assert "notes.project_id IN" in sql # 4. inherited from a shared project
assert "project_shares" in sql assert "project_shares" in sql
assert "notes.project_id IN" in sql
@pytest.mark.asyncio def test_read_scope_resolves_group_membership_in_sql():
async def test_group_membership_widens_both_share_lookups(): """Group ids are a subquery, not a pre-fetched list — that's what keeps this
sql = await _compiled_clause(group_ids=[3, 9]) a pure function with no session of its own."""
assert "shared_with_group_id IN (3, 9)" in sql sql = _read()
# Both the note-level and project-level share lookups gain the group arm. assert "group_memberships.user_id = 7" in sql
assert sql.count("shared_with_group_id IN (3, 9)") == 2 # Both the note-level and project-level share lookups consult it. Count FROM
# clauses rather than bare occurrences — each rendered subquery names the
# table in SELECT, FROM and WHERE — and compare loosely, since the assertion
# is about the arms existing, not about SQLAlchemy's formatting.
assert sql.count("FROM group_memberships") >= 2
assert _browse().count("FROM group_memberships") >= 1
@pytest.mark.asyncio def test_read_scope_is_never_the_whole_table():
async def test_no_groups_means_no_group_arm(): """Guard against the predicate degrading to always-true, which would expose
"""A user in no groups must not produce an empty `IN ()`, which would be every user's notes to every other user."""
invalid SQL on some backends and always-false on others.""" sql = _read().lower()
sql = await _compiled_clause(group_ids=[]) assert " true" not in sql
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 assert "1 = 1" not in sql
# --- browse scope: the trust boundary --------------------------------------- # --- browse scope: the trust boundary ---------------------------------------
@pytest.mark.asyncio def test_browse_scope_excludes_direct_note_shares():
async def test_browse_scope_excludes_direct_note_shares(): """The whole point of the narrower scope. If `note_shares` leaks in here, a
"""The whole point of the narrower scope (decision note 2094): a record record someone shared one-to-one with the operator lands in their own browse
shared directly with you must NOT appear in a passive list. If `note_shares` list, facet counts and skill manifest as though they had recorded it."""
leaks in here, someone else's one-off lands in the operator's own browse assert "note_shares" not in _browse()
list, facet counts, and skill manifest as though they had recorded it."""
sql = await _compiled_browse_clause(group_ids=[3])
assert "note_shares" not in sql
@pytest.mark.asyncio def test_browse_scope_keeps_ownership_and_project_access():
async def test_browse_scope_keeps_ownership_and_project_access(): sql = _browse()
sql = await _compiled_browse_clause(group_ids=[]) assert "notes.user_id = 7" in sql # your own records
assert "notes.user_id = 7" in sql # your own
assert "project_shares" in sql # a project shared with you assert "project_shares" in sql # a project shared with you
assert "projects" in sql # a project you own assert "projects" in sql # a project you own
@pytest.mark.asyncio def test_browse_scope_is_strictly_narrower_than_read_scope():
async def test_browse_scope_is_strictly_narrower_than_read_scope(): """Browse must never surface something read scope wouldn't also allow, or a
"""Browse must never surface something read scope wouldn't also allow — list could show a record the caller cannot then open."""
otherwise a list could show a record the caller can't open.""" browse, read = _browse(), _read()
browse = await _compiled_browse_clause(group_ids=[3])
read = await _compiled_clause(group_ids=[3])
assert "note_shares" in read and "note_shares" not in browse assert "note_shares" in read and "note_shares" not in browse
for shared_arm in ("notes.user_id = 7", "project_shares"): for arm in ("notes.user_id = 7", "project_shares"):
assert shared_arm in browse and shared_arm in read assert arm in browse and arm in read
def test_browse_scope_is_never_the_whole_table():
sql = _browse().lower()
assert " true" not in sql
assert "1 = 1" not in sql
@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.
This is the property that keeps them usable: when they opened their own
session, every unrelated service test had to know they existed and stub them,
and four test modules broke the moment a service started calling one."""
import inspect
assert not inspect.iscoroutinefunction(clause_fn)
assert _sql(clause_fn(7)) # builds without touching a database
+1 -6
View File
@@ -32,12 +32,7 @@ async def test_counts_include_process_in_facet_and_total():
_scalar(1), # tasks _scalar(1), # tasks
_scalar(0), # plans _scalar(0), # plans
]) ])
from scribe.models.note import Note with patch("scribe.services.knowledge.async_session") as cls:
# The ACL predicate opens its own session; stub it so this test stays about
# the count facets.
with patch("scribe.services.knowledge.async_session") as cls, \
patch("scribe.services.knowledge.readable_notes_clause",
AsyncMock(return_value=Note.user_id == 1)):
cls.return_value = session cls.return_value = session
from scribe.services.knowledge import get_knowledge_counts from scribe.services.knowledge import get_knowledge_counts
counts = await get_knowledge_counts(user_id=1) counts = await get_knowledge_counts(user_id=1)
+1 -6
View File
@@ -68,12 +68,7 @@ async def test_list_projects_excludes_trashed():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_knowledge_excludes_trashed(): async def test_query_knowledge_excludes_trashed():
cap: list[str] = [] cap: list[str] = []
from scribe.models.note import Note with patch("scribe.services.knowledge.async_session") as cls:
# The ACL predicate opens its own session; stub it so this test stays about
# trash filtering.
with patch("scribe.services.knowledge.async_session") as cls, \
patch("scribe.services.knowledge.readable_notes_clause",
AsyncMock(return_value=Note.user_id == 1)):
cls.return_value = _capturing_session(cap) cls.return_value = _capturing_session(cap)
from scribe.services.knowledge import query_knowledge from scribe.services.knowledge import query_knowledge
await query_knowledge(user_id=1, note_type=None, tags=[], sort="modified", q=None, limit=10, offset=0) await query_knowledge(user_id=1, note_type=None, tags=[], sort="modified", q=None, limit=10, offset=0)