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
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:
@@ -9,7 +9,7 @@ Permission rank: owner > admin > editor > viewer
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.group import GroupMembership
|
||||
@@ -161,3 +161,43 @@ async def can_read_note(user_id: int, note_id: int) -> bool:
|
||||
async def can_write_note(user_id: int, note_id: int) -> bool:
|
||||
perm = await get_note_permission(user_id, note_id)
|
||||
return perm in ("editor", "admin", "owner")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set-based visibility (for LIST queries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def readable_notes_clause(user_id: int):
|
||||
"""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,
|
||||
which a list query can't use — checking per row is O(n) round-trips. This is
|
||||
the same resolution expressed as set membership so it can go straight into a
|
||||
`select(Note).where(...)`:
|
||||
|
||||
1. ownership
|
||||
2. a direct note share
|
||||
3. a group note share
|
||||
4. inheritance from a shared project
|
||||
|
||||
Keep the two in step: a rule added here belongs in `get_note_permission`
|
||||
too, or a note becomes findable but not openable (or the reverse — which is
|
||||
the bug this was written to fix).
|
||||
"""
|
||||
async with async_session() as session:
|
||||
group_ids = await _user_group_ids(session, user_id)
|
||||
|
||||
note_share_conds = [NoteShare.shared_with_user_id == user_id]
|
||||
project_share_conds = [ProjectShare.shared_with_user_id == user_id]
|
||||
if group_ids:
|
||||
note_share_conds.append(NoteShare.shared_with_group_id.in_(group_ids))
|
||||
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(or_(*project_share_conds))
|
||||
|
||||
return or_(
|
||||
Note.user_id == user_id,
|
||||
Note.id.in_(shared_note_ids),
|
||||
Note.project_id.in_(shared_project_ids),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user