feat(artifact): recipient-scoped private-children collections (#0009) — 0.21.112#1021
Merged
Conversation
….21.112)
A collection configured with
{"private_children": true, "recipient_field": "<manifest field>"}
becomes a cross-user inbox / drop-box: any writer may create a child
addressed to any recipient, but a non-admin caller listing/searching the
collection only ever sees the children addressed to their own email.
list()/search() forces WHERE manifest.<recipient_field> == caller.email:
- a client-supplied filter on the recipient field is ignored (cannot
widen the view);
- the scoping condition is AND-ed unconditionally, even under mode="OR",
so it cannot be escaped by combining it with a permissive keyword/
filter clause;
- workspace admins (the collection owner, root) bypass and see all.
Direct read(child_id) is intentionally unchanged: privacy is a discovery
boundary (unguessable UUID ids + scoped listing), analogous to "unlisted"
visibility — a recipient only ever learns the ids of their own messages.
The artifact permission model makes child-read == parent-read, so true
per-child read denial is not expressible via config; #9 solves the
enumeration half server-side (documented in CLAUDE.md).
Powers svamp notify-style cross-user notifications.
- hypha/artifact.py::search — scoping + docstring
- tests/test_artifact.py::test_private_children_recipient_scoping (no mocks,
sqlite backend): owner/admin sees all; recipient sees only own; client
filter ignored; scoping AND-ed with other filters; discovery boundary
- CLAUDE.md — two artifact permission-model constraints learned here
- version bump 0.21.111 -> 0.21.112 + CHANGELOG
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #9 — a server-enforced, recipient-scoped "private children" collection mode on the artifact-manager, powering
svamp notify-style cross-user inboxes.A collection configured with:
{ "private_children": true, "recipient_field": "recipientEmail" }becomes a cross-user inbox / drop-box: any writer may create a child addressed to any recipient, but when a non-admin caller lists or searches the collection, the server forces:
so a recipient only ever enumerates the children addressed to them.
Guarantees (all under test)
mode="OR"escape — the scoping condition is AND-ed unconditionally, kept out of the OR-ableconditionslist, so it can't be bypassed by combining it with a permissive keyword/filter clause.read(child_id)unchanged — privacy is a discovery boundary (unguessable UUID ids + scoped listing), analogous tounlistedvisibility. A recipient only learns the ids of their own messages.Why not enforce direct-read too?
The artifact permission model makes child-read ≈ parent-read:
read(child_id)hard-requiresreadon the parent collection, and when the parent grants read it acts as a fallback that overrides a stricter child ACL. So true per-child read denial isn't expressible via config —#0009solves the enumeration half server-side and leavesread()untouched. Both non-obvious constraints are now documented inCLAUDE.md.Changes
hypha/artifact.py::search— scoping injection (strip client filter on the recipient field; build a forcedmanifest.<field> == emailcondition; AND it unconditionally after the mode combination) + docstring.tests/test_artifact.py::test_private_children_recipient_scoping— real integration test (no mocks, sqlite backend, two distinct users): owner/admin sees all; recipient sees only own; client filter ignored; scoping AND-ed with other filters; discovery boundary; own-child read works.CLAUDE.md— two artifact permission-model constraints learned here.0.21.111 → 0.21.112+ CHANGELOG.Test
Also re-ran
test_sqlite_create_and_search_artifacts,test_order_by_custom_json_fields,test_versioning_artifacts— no regression in the search/list path.🤖 Generated with Claude Code