Honour group object-permissions in QuerySet.visible_to_user (#1714)#1724
Conversation
Manager.user_can resolves group object-permissions (_default_user_can runs with include_group_permissions=True), but the visible_to_user filters in shared/QuerySets.py only joined the user object-permission table. A user whose only READ grant on a Document / Annotation / Note was via a group passed obj.user_can(user, READ) yet was excluded from Model.objects.visible_to_user(user) -- authorized for the object but unable to ever see it in a list query. Corpus was unaffected (its PermissionedTreeQuerySet resolves group perms via get_objects_for_user). PermissionQuerySet, DocumentQuerySet, and AnnotationQuerySet now OR in a subquery against the model's *groupobjectpermission table filtered by the requesting user's group ids. NoteQuerySet inherits the fix transitively via the Document/Corpus managers it composes. Group ids resolve as a lazy values_list subquery so the filter stays a single round-trip. New GroupObjectPermissionVisibilityTest pins filter/check equivalence for a group-shared user across Document, Annotation, Note, and the generic PermissionQuerySet body, plus the read/write asymmetry and the subquery-not-per-group-round-trip performance property. Closes #1714
Code Review — PR #1724: Honour group object-permissions in
|
| Test | What it pins |
|---|---|
test_document_group_grant_appears_in_visible_to_user |
Basic filter inclusion + control (unshared doc excluded) |
test_document_group_filter_check_equivalence |
user_can ↔ visible_to_user parity — the core invariant |
test_group_read_grant_does_not_widen_to_writes |
Security: READ grant ≠ UPDATE/DELETE |
test_permission_queryset_generic_body_honors_group_perms |
Generic fallback body tested directly |
test_annotation_group_grant_filter_check_equivalence |
Annotation filter/check parity |
test_note_group_grant_filter_check_equivalence |
Note transitive inheritance |
test_stranger_without_group_membership_stays_excluded |
Non-member exclusion across all three models |
test_group_lookup_uses_subquery_not_per_group_round_trips |
Performance: O(1) queries, not O(groups) |
Using TestCase (not TransactionTestCase) is appropriate here — no signals or async ORM operations involved. ✓
One suggestion: test_note_group_grant_filter_check_equivalence asserts self.assertTrue(check) and self.assertTrue(in_filter) without custom failure messages, unlike the document and annotation equivalents. Adding messages consistent with the other tests would make failures easier to diagnose.
Out-of-Scope Item (Acknowledged)
The PR correctly flags that BaseVisibilityManager.visible_to_user in Managers.py (lines ~238–248) has the structurally identical drift — it only queries *userobjectpermission, never *groupobjectpermission. Models that reach BaseVisibilityManager without a concrete queryset override (e.g., CorpusActionExecution) still have the bug. This is left for a separate change, which is a reasonable call for surgical scope. A follow-up issue to track this would be worth filing.
Relationship with PR #1712
The PR body correctly notes that #1712's test_group_shared_known_drift_user_can_vs_visible_to_user pins the drifted (broken) behaviour and will need converting to an equivalence assertion once one of the two PRs lands. The two PRs touch disjoint files, so merge order shouldn't create conflicts — but whoever merges second should verify that pinned-drift test is updated. Consider adding a comment or TODO in test_authorization_invariants.py now to make this dependency explicit for the reviewer who merges second.
Summary
This is a high-quality, well-scoped fix. The bug is real and the approach is correct. Tests are thorough and cover the critical security and performance invariants. No blockers; the notes above are all minor or informational.
Verdict: Approve with minor suggestions. The out-of-scope BaseVisibilityManager drift and the #1712 coordination are the most important follow-ups to track.
…-HAxeg # Conflicts: # CHANGELOG.md
The Phase F drift test test_group_shared_known_drift_user_can_vs_visible_to_user pinned the pre-#1714 behaviour where visible_to_user ignored group object-permissions. Issue #1714 closes that drift, so the test is renamed to test_group_shared_read_user_can_vs_visible_to_user_equivalence and now asserts filter/check equivalence; the file's audit comment is updated to match. Address review: extract user_group_ids to a local variable in PermissionQuerySet/DocumentQuerySet for parity with AnnotationQuerySet, and add custom failure messages to test_note_group_grant_filter_check_equivalence.
Code Review — PR #1724: Honour group object-permissions in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Adds a test that simulates a missing guardian *groupobjectpermission table so the except LookupError branches in AnnotationQuerySet.visible_to_user execute, closing the codecov patch coverage gap on lines 515 and 530.
…locks DocumentQuerySet and PermissionQuerySet resolved both the user-level and group-level guardian object-permission tables inside a single try block, so a LookupError raised resolving the group table silently discarded the already-resolved user-level grants. Split the lookups so each except LookupError branch zeroes only its own permitted-id set, matching the AnnotationQuerySet pattern. Extends the coverage test to exercise all three queryset fallback paths.
The perf pin test_group_lookup_uses_subquery_not_per_group_round_trips passes lightweight=True to skip heavy prefetch fan-outs; add a comment clarifying that the captured query count then reflects only the core visibility query that group resolution touches.
…-HAxeg # Conflicts: # CHANGELOG.md
Code Review — PR #1714: Honour group object-permissions in
|
Code Review: Honour group object-permissions in
|
Summary
Closes #1714.
Manager.user_canresolves group object-permissions —_default_user_canruns with
include_group_permissions=True— but thevisible_to_userfiltersin
opencontractserver/shared/QuerySets.pyonly joined the userobject-permission table (
*userobjectpermission), never*groupobjectpermission. A user whose only READ grant on a Document /Annotation / Note was via a group therefore passed
obj.user_can(user, READ)yet was excluded fromModel.objects.visible_to_user(user)— authorized for a single object butunable to ever see it in a list query.
Corpuswas already correct (itsPermissionedTreeQuerySetresolves group perms via guardian'sget_objects_for_user), which is what made this a genuine per-model drift.Changes
PermissionQuerySet.visible_to_user— OR in a subquery against{model}groupobjectpermissionfiltered by the user's group ids.DocumentQuerySet.visible_to_user— same, againstdocumentgroupobjectpermission.AnnotationQuerySet.visible_to_user— add group subqueries to both thedocument- and corpus-visibility predicates (annotations have no own
permission table; visibility is
MIN(doc, corpus)).NoteQuerySet.visible_to_user— no code change; it composesDocument.objects.visible_to_user/Corpus.objects.visible_to_user, so itinherits the fix transitively (docstring updated to say so).
Group ids resolve as a lazy
values_listsubquery, so the filter stays asingle round-trip — query count is independent of group-membership size.
Tests
New
GroupObjectPermissionVisibilityTestintest_permissioned_querysets.pypins, for a user whose sole grant is a group object-permission:
user_can(READ) == visible_to_user(...).exists())for Document, Annotation, and Note;
PermissionQuerySetfallback body honours group perms;round-trip.
Relationship to #1712
PR #1712 (Phase F, open) adds
DocumentAuthorizationInvariantsTestCase.test_group_shared_known_drift_user_can_vs_visible_to_user,which pins the current (drifted) behaviour. This PR closes that drift.
The two PRs touch disjoint files (#1712:
test_authorization_invariants*.pyQuerySets.py+test_permissioned_querysets.py), so theymerge without conflict — but whichever lands second, that one pinned drift
test must be converted into an equivalence assertion (its own docstring says
so). The equivalence is already covered here by
GroupObjectPermissionVisibilityTest.Test plan
GroupObjectPermissionVisibilityTest— 8/8 pass.test_permissioned_querysets.py+test_visibility_managers.py— fullygreen.
test_authorization_invariants.py— 34 pre-existing environment errors(fileless test documents trigger an eager
ingest_doctask on a freshclone); identical 34 on the pristine baseline with this PR's changes
stashed, so this change adds zero regressions. Unrelated to permission
filtering.
Out of scope
BaseVisibilityManager.visible_to_userinshared/Managers.pyhas thestructurally identical user-table-only pattern; it is only reached
un-overridden by
CorpusActionExecution, which #1714 did not flag. Left for aseparate change to keep this fix surgical.
Generated by Claude Code