Close permissioning-guide audit gaps: relationship privacy, anonymous extracts, group grants#1985
Close permissioning-guide audit gaps: relationship privacy, anonymous extracts, group grants#1985JSv4 wants to merge 21 commits into
Conversation
…acts, group grants 2026-06 permissioning audit of docs/permissioning/consolidated_permissioning_guide.md against the GraphQL + service layers. Four intent decisions were made and code/docs now agree: 1. Relationship privacy recursion (closes #1655 Phase-C deferral): RelationshipManager.user_can + visible_to_user now enforce created_by_analysis/created_by_extract source permissions exactly like annotations, via the shared module-level _source_privacy_recursion_passes (opencontractserver/shared/Managers.py). Non-creators need the requested permission on the source as well as the doc+corpus MIN; the relationship creator short-circuit is mirrored by Q(creator=user) in the queryset gate. 2. Extracts are never anonymous-visible: new ExtractManager denies anonymous on both manager surfaces; ExtractService.get_visible_extracts / check_extract_permission lock down the service boundary (previously exposed is_public extracts in public corpora to anonymous callers through the un-gated extracts resolver). 3. Group-granted analysis/extract permissions now unlock private rows in list queries: new opencontractserver/utils/source_visibility.py builders join the group object-permission tables and replace four divergent inline copies (AnnotationQuerySet.visible_to_user, AnnotationService x2, RelationshipService) — closing a filter/check parity drift vs user_can. Also fixes an anonymous privacy leak in get_corpus_annotations (privacy exclusion previously skipped for anonymous users). 4. Thread moderation: code semantics confirmed as intended; the guide's 'EDIT permission grants moderation' claim removed and the Conversation.can_moderate / Corpus.user_can_moderate divergence (#1450) documented. Also: pre-computed myPermissions now mirror the structural-write break-glass (superusers True / others False on structural rows) in both AnnotationService and RelationshipService (relationships previously reported raw doc+corpus writes on structural rows); stale superuser-bypass docstrings cleaned up (QuerySets/Managers/conversations/image_tools/documents services); guide corrected for stale optimizer names, dead query_optimizer.py paths, resolver locations, ws/agent-chat URL, awaited _check_user_permissions, real test paths, get_users_permissions_for_obj default, Pitfall 1, CorpusCategory management; documented #1682 corpus-as-gate vs MIN dual semantics, the opencontracts.E001 dual enforcement + scan scope, BaseService.filter_visible_qs, and missing reference-table rows; CLAUDE.md pitfall 2 updated to ConversationService. Tests: invariant suite extended (relationship privacy matrix incl. group grants, ExtractAuthorizationInvariantsTestCase, annotation group-grant parity; the test_no_privacy_widening_via_created_by_analysis sentinel that pinned the deferral is replaced by real privacy invariants per the agreed behavior change) plus four new files under tests/permissioning/.
- pyupgrade (--py39-plus): drop now-redundant quoted annotations in shared/Managers.py and utils/source_visibility.py (both files use from __future__ import annotations) - isort 6.0.1 (pre-commit rev): blank-line fix in extract_service.py - mypy: annotate Extract.objects override with type: ignore[misc] (house pattern used by Document/Relationship/Annotation managers) and narrow Optional[Extract] in test_extract_anonymous_lockdown.py
Code Review — PR #1985: Permissioning audit gap closureOverviewThis is a well-scoped security hardening PR that closes four real permission enforcement gaps. The code changes are conservative, the test additions are strong, and the documentation overhaul is genuinely valuable. Approachable to merge after addressing a few items below. Security Fixes — Confirmed Correct1. Relationship privacy recursion (closes #1655 Phase-C) The extraction of
2. Extract anonymous lockdown
3. Group-granted source permissions Centralizing into 4. The change from Issues🔴 Documentation bug: inverted boolean logic in the In # Single call handles all privacy/structural logic (falsy = granted)
if annotation_obj is None or BaseService.require_permission(
annotation_obj, info.context.user, PermissionTypes.DELETE,
request=info.context,
):
return RemoveAnnotation(ok=False, message="Permission denied")The comment "falsy = granted" contradicts standard patterns. If 🟡 N+1 query risk in The docstring correctly documents the risk: Bulk callers SHOULD The list-side paths (privacy gate as subqueries) are safe. The single-object 🟡 Missing test: explicit extract grant unlocking an extract-rooted relationship
🟡
Minor ObservationsPre-existing annotation-creator parity gap (noted in Follow-up observations): Guide Test CoverageThe new test suite is strong:
Only gap is the extract-positive case noted above. SummaryAll four security fixes are correctly implemented. The consolidation into |
… PERF note - Guide: expand the RemoveAnnotation example's comment to spell out BaseService.require_permission's truthiness inversion (returns '' on grant, denial string on deny; never raises) so the idiom cannot be misread or mis-transcribed to the legacy user_can direction - Managers.py: PERF pointer at the RelationshipManager privacy-recursion call site (select_related the created_by_* FKs in bulk user_can loops) - Invariants: symmetrical positive pin for the extract branch — explicit extract READ grant unlocks the extract-rooted relationship on both surfaces and does not bleed into the analysis-rooted sibling
|
Addressed the review in
Generated by Claude Code |
CI surfaced the two remaining tests that pinned the OLD anonymous-extract behavior the 2026-06 permissioning audit deliberately removed: - test_query_optimizer_methods.py::test_get_visible_extracts_anonymous_only_public asserted anonymous users see public extracts; renamed to ..._anonymous_sees_nothing and now pins the agreed semantic (extracts are never anonymous-visible at any layer; full coverage in test_extract_anonymous_lockdown.py). - test_stats.py::test_public_corpus_stats_query asserted identical corpusStats for anonymous and authenticated viewers; corpusStats counts are per-viewer by the resolver's own contract, so the anonymous branch now expects totalExtracts: 0 (other counts unchanged). Changelog fragment updated to document the user-visible stats consequence.
Code Review -- PR #1985: Close permissioning-guide audit gapsOverview: This is a well-structured permissioning security audit closing four real gaps: (1) relationship privacy recursion (closes #1655 Phase-C deferral), (2) anonymous extract lockdown, (3) group-granted source permissions for list queries, and (4) structural myPermissions break-glass alignment. The guide overhaul is thorough and changelog fragments are properly formatted. Security -- All four behavior changes look correctRelationship privacy recursion (Managers.py): The module-level Anonymous extract lockdown: Implementing the denial at the manager layer (ExtractManager) in addition to the service layer is good defense-in-depth -- agent tools and any future direct manager calls are covered without relying solely on service routing. Group-grant parity in list queries (source_visibility.py): The four divergent inline copies are eliminated and the shared builders now consult both user- and group-level guardian tables. The Structural myPermissions mask: The Bug FindingsNone found after reading the full diff. Two items to verify at CI time:
PerformanceN+1 potential in OR-union in source visibility builders: The three-queryset union produces separate SQL subqueries -- equivalent to the previous inline pattern and consistent with the rest of the codebase. No regression. Code QualityStrong points:
Minor nit (not blocking): the DocumentationThe guide overhaul is thorough. All stale QueryOptimizer references are updated. The new sections on The "Pitfall 1" correction in CLAUDE.md ("returns structural annotations only" instead of "returns empty") is a correctness fix -- good catch. Test Coverage
Coverage looks complete. The existing SummaryThe implementation is correct, well-tested, and the guide cleanup significantly improves maintainability. No blocking issues found. The main gate is CI confirmation that all new permissioning invariant tests pass against the live Postgres database. |
|
placeholder_review_body_v2 |
Reviewing the visible_extracts_for docstring surfaced a latent filter/check parity violation (same class as the group-grant drift this PR fixes): user_can's privacy recursion delegates to Extract.objects.user_can, which grants READ on is_public extracts for authenticated users, while the list-side privacy gates omitted the flag for extracts (analyses always included it). An extract-rooted row on a public extract therefore passed user_can(READ) but never surfaced in visible_to_user or service listings — a public-extract fixture would have failed the parity invariant. visible_extracts_for now includes is_public on the authenticated branch (anonymous still gets none — ExtractManager denies outright), mirroring visible_analyses_for. No practical exposure today: no user-facing flow sets Extract.is_public. Pinned by test_public_extract_source_passes_both_surfaces; changelog updated.
Code Review — Permissioning Audit Gap ClosureThis is a well-scoped, high-quality security hardening PR. The four agreed behavior changes are all correct, the test coverage is excellent, and the shared Security1.
user_grant_ids = AnalysisUserObjectPermission.objects.filter(user=user).values_list(...)
group_grant_ids = AnalysisGroupObjectPermission.objects.filter(
group_id__in=user.groups.values_list("id", flat=True)
).values_list(...)A user who holds only 2.
Potential Bugs3. If the source 4.
Performance5. The docstring correctly warns about prefetching requirements, but neither Code Quality6.
7. The old guard DocumentationThe guide overhaul is thorough and accurate. A few minor notes:
TestsTest coverage is comprehensive and well-structured. Specific positives:
One gap: there is no test for item 3 above (relationship/annotation with a hard-deleted source analysis). If permanently denying access is the intended posture, a test that deletes the Analysis and confirms SummaryThe core changes are correct and close real security gaps. The PR is in good shape to merge once:
Items 2, 4-7 are low-severity and can be addressed in follow-up. |
…r_can) Review follow-up that turned out to be a real parity bug, not a docstring question: the privacy-gate builders matched ANY guardian row on the source analysis/extract, while user_can recursion resolves READ via the read codename — so an update_analysis-only grantee saw private rows in lists while user_can(READ) denied them (the same filter/check violation class this PR closes elsewhere). visible_analyses_for / visible_extracts_for now filter permission__codename=read_analysis / read_extract on both the user- and group-level tables; pinned by test_update_only_source_grant_does_not_unlock_lists. Also from the review round: - _source_privacy_recursion_passes docstring documents the deleted-source posture (created_by_* FKs are on_delete=SET_NULL, so deletion turns rows into normal rows; the is-None branch is a fail-closed race guard, not a permanent lock) - ExtractManager.user_can carries an equivalence note for its None/anonymous guard vs the visible_to_user surface - get_corpus_annotations privacy exclusion now explains at the call site why it is intentionally unconditional (old anonymous guard was the leak) - changelog fragment updated for the grant-shape tightening
edc1f56 to
7b189db
Compare
|
Dispositions for the latest review (addressed in
Docs note on Generated by Claude Code |
|
Code Review - PR 1985: Permissioning Audit Gap Closure Summary: Security-focused PR closing four concrete gaps surfaced during a permissioning audit. Changes are well-scoped, well-documented, and backed by dedicated test files. WHAT THIS PR DOES WELL
ISSUES AND OBSERVATIONS
The docstring warns that instance.created_by_analysis / created_by_extract FK descriptors each hit the DB when relations are not prefetched. The user_can surface is called one instance at a time in mutations, so that is fine. But any future bulk caller that omits select_related will silently hit N+1 per row. A comment in RelationshipManager.user_can at the call site pointing to the select_related requirement would reduce risk for future callers.
The method calls resolve_user_for_user_can(user) itself, then passes resolved to super().user_can(resolved, ...), which calls resolve_user_for_user_can again. Idempotent for a concrete User instance so it is safe, but slightly cleaner to pass the original user to super() and use resolved only for the anonymous gate, or to add a brief comment explaining why resolved is forwarded. Not a bug.
Semantically correct (matches user_can recursion) and no user-facing flow currently sets the flag. However, if a future migration or admin action sets is_public=True on an extract it would silently become visible in privacy gates for all authenticated users without necessarily being visible in ExtractService.get_visible_extracts. A comment in visible_extracts_for noting this and pointing to ExtractService would help the two surfaces stay in sync.
Correctly documented in Follow-up observations: relationships have a creator short-circuit in _source_privacy_recursion_passes but annotations lack the corresponding creator exemption in AnnotationManager.user_can. A follow-up issue number in the docstring would help prevent this from being forgotten once an issue is filed.
test_stats.py correctly documents this with a BEHAVIOR CHANGE comment. For deployments with public corpora that anonymous users currently query this will be a visible GraphQL response change. Confirm downstream consumers (dashboards, embed widgets, public-facing pages) are aware before deploying. DOCUMENTATION QUALITY The permissioning guide update is thorough: stale QueryOptimizer references replaced, accurate code locations, get_corpus_documents dual-semantics documented, E001 scan-scope nuance explicit. The moderation section correction (EDIT does NOT grant moderation; CorpusModerator does) is particularly important since the guide was the source of the misconception. One doc nit: the Privacy Filtering in List Queries code example uses ... for the anonymous branch. A one-line note that the ellipsis is intentionally abbreviated would help readers who try to copy the pattern. VERDICT Core changes are correct. Security fixes close real vulnerabilities: group-grant parity drift, relationship privacy recursion gap, anonymous extract exposure, anonymous corpus annotation leak. Test coverage matches stated invariants. Changelog fragments follow project convention. Approve after confirming CI is green. |
Code Review — PR #1985: Permissioning audit gap closureOverviewThis is a high-quality, security-focused PR that closes four substantive gaps between the permissioning guide and the implementation. The changes are well-scoped and thoroughly documented. StrengthsDRY consolidation via Real security bugs fixed:
Structural myPermissions mask — Masking Test coverage — Four new test modules plus significant additions to the invariant matrix. Covering the full cross-product of (user grant type) x (surface) x (source type) is the right approach. Issues and Observations1.
|
|
[flake8] [pycodestyle] [isort] [end-of-file-fixer] Mypy configuration lives in mypy.ini (see issue #1331 anddocs/typing/README.md). It is split out because the per-modulebaseline allow-list is large.[coverage:run] django_coverage_plugin (template coverage) is intentionally not loaded:Coverage 7.x falls back to the legacy C trace function whenever any filetracer plugin is configured, which silently defeats COVERAGE_CORE=sysmon(a ~10x cheaper instrumentation path on Python 3.12+). The project owns 7Django templates total, all 4xx/5xx pages plus a couple of admin views —template coverage was a trivial slice of total coverage but the plugin wasforcing a ~150% test-suite wall-time tax. Restore the plugin if/whencoverage.py supports sysmon-compatible plugin tracers.[coverage:report] |
From the deep review round, verified findings (each confirmed against code before acting): Privacy gates added to three surfaces that skipped created_by_* checks: - AnnotationService.get_label_distribution_for_corpus (user-facing via corpusIntelligenceAggregates) leaked label names/counts of analysis-/extract-private annotations; now takes the requesting user (caller updated in config/graphql/corpus_queries.py) - RelationshipService.get_relationship_summary counted private rows in the document-type summary - RelationshipService.get_corpus_relationships (MCP corpus listing) listed private relationships; its corpus-as-gate document scope is retained deliberately per issue #1682 (MCP = pipeline-facing caller, the documented default) and now says so inline ExtractService.get_visible_extracts parity with check_extract_permission (pre-existing drift tightened while in the file): the extract leg consulted only USER-level guardian rows with any codename, the corpus leg used codename__contains=read substring matching; both legs now join the group object-permission tables and match read_extract / read_corpus exactly, mirroring user_can resolution. Reviewer claims rejected after verification (documented instead): - 'deleted source permanently locks rows' — created_by_* FKs are on_delete=SET_NULL; rows become normal rows. The source-is-None branch is a fail-closed race guard; docstring now says so. - get_corpus_relationships 'should use MIN semantics' — sole caller is MCP (pipeline-facing), where corpus-as-gate is the documented default. Also: AnnotationManager.user_can docstring no longer phrases the structural break-glass as a 'superuser bypass'; get_corpus_annotations documents why it returns an unannotated queryset (no per-row _can_* mask) and why the privacy exclusion is unconditional; guide's abbreviated AnnotationService block is labelled as such. Tests: new test_privacy_gate_regressions.py (anonymous corpus leak, label-distribution privacy, relationship aggregate/MCP listing privacy); ExtractListingGrantParityTestCase (group grants unlock listing, update-only extract grant does not); group-extract-grant counterparts in test_relationship_privacy_scoping and test_source_visibility_group_grants.
|
Dispositions for the deep review (addressed in 🔴 1 — Generated by Claude Code |
|
test comment |
Code Review: Permissioning Audit Gap ClosureSummary: This is a well-scoped, well-documented security fix that closes four documented permissioning gaps and fixes multiple information leaks across the annotation, relationship, extract, and corpus layers. The changes are correct, the tests are strong, and the documentation is being kept in sync. Recommend merging with a few minor observations addressed. What this PR does
Positive highlights
Observations and suggestionsMedium — Performance:
|
| Fix | Test coverage |
|---|---|
| Relationship privacy recursion | test_authorization_invariants.py (matrix + 6 new invariants), test_relationship_privacy_scoping.py |
| Extract anonymous lockdown | test_extract_anonymous_lockdown.py + updated test_query_optimizer_methods.py + test_stats.py |
| Group-grant parity | test_source_visibility_group_grants.py + invariant fixtures |
Structural myPermissions break-glass |
test_structural_mypermissions_breakglass.py |
| Aggregate privacy leaks | test_privacy_gate_regressions.py |
Coverage is excellent. The invariant suite is the right place for matrix tests and is extended correctly.
Minor nits
_source_privacy_recursion_passesdocstring is thorough but long for an internal helper. The "Deleted-source posture" and "Performance note" paragraphs would read better as inline comments at the specific branches they describe.- The
# type: ignore[attr-defined]oninstance.created_by_analysis/instance.created_by_extractare correct. A brief note that callers are alwaysAnnotation/Relationshipinstances (which declare these FKs) would clarify why the ignores are safe.
|
Re the latest review round — no code changes warranted, two requests fulfilled:
Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Codecov flagged one uncovered line in shared/Managers.py: the extract-side fail-closed branch of _source_privacy_recursion_passes (created_by_extract_id non-null but the dereference yields None during the read-vs-SET_NULL race window). The analysis-side twin was already pinned in test_authorization_invariants_coverage.py via the descriptor-cache simulation; this adds the mirrored extract-side test using the same mechanism. Also serves as the correctly-framed deleted-source-posture test requested in review (deletion itself nulls the FK via SET_NULL and rows become normal rows — only the in-memory race window hits this branch, and it fails closed).
|
test |
Code Review - Permissioning Audit Gap ClosureThis is a substantial, high-quality security PR that closes real information-disclosure vulnerabilities. The changes are well-structured, test coverage is comprehensive, and the PR description gives an accurate account of what changed and why. SummaryWhat this PR does:
The DRY refactor is particularly good: IssuesMedium —
|
- visible_extracts_for now uses the same single-filter OR shape as visible_analyses_for (the review-round conversion only updated the analyses builder — behavior-identical, but the two security-critical builders should not diverge in shape) - get_label_distribution_for_corpus docstring states explicitly that user=None is the most-restrictive anonymous shape (omission under-counts, never leaks) and names the current call sites - get_corpus_relationships corpus-as-gate caveat now names its sole caller (opencontractserver/mcp/tools.py) so the #1682 constraint is searchable
|
Both mediums addressed in
The two observations (annotation-creator parity, Generated by Claude Code |
Code ReviewOverviewThis PR closes substantive permissioning audit gaps across four agreed behavior changes: relationship privacy recursion, anonymous extract lockdown, group-grant list parity, and structural Positives
Issues1. The docstring says "every user-facing caller MUST pass the requesting user" and the GraphQL resolver was correctly updated ( 2. Double resolved = resolve_user_for_user_can(user)
if resolved is None or getattr(resolved, "is_anonymous", True):
return False
return super().user_can(resolved, ...) # super() calls resolve_user_for_user_can(resolved) againThe docstring notes this is idempotent for a User instance — true. The minor maintenance risk is that the anonymous guard above is only effective because 3. def get_relationship_summary(cls, document_id: int, corpus_id: int, user) -> dict:Every other parameter is type-annotated. Missing annotation reduces mypy coverage on the privacy gate path added in this PR. 4. Exclusion pattern repeated across six call sites (low priority) The privacy-gate exclusion triple: .exclude(Q(created_by_analysis__isnull=False) & Q(structural=False) & ~Q(created_by_analysis__in=visible_analyses_for(user)))
.exclude(Q(created_by_extract__isnull=False) & Q(structural=False) & ~Q(created_by_extract__in=visible_extracts_for(user)))appears in Verification request (pre-merge)The follow-up observations section notes that SummaryApprove with the items above addressed or acknowledged. Issue 1 ( |
…quire user on label aggregate Review round follow-ups: - The created_by_* exclusion triple appeared at six call sites (annotation document/corpus listings, label distribution, relationship document listing/summary/MCP corpus listing). It now has one home: apply_source_privacy_gate(qs, user) in utils/source_visibility.py, alongside the source builders. The positive-Q composition inside AnnotationQuerySet.visible_to_user keeps its own shape (needs the creator disjunct and single-WHERE composition with the doc/corpus EXISTS predicates) and the helper docstring cross-references it. - get_label_distribution_for_corpus's user parameter is now REQUIRED and keyword-only: omission is a TypeError at the call site instead of a silent anonymous-shaped under-count. Both existing callers already passed it by keyword. Declined from the same review, with rationale in the PR thread: forwarding the original (unresolved) user to super().user_can in ExtractManager — forwarding the resolved User is the robust direction if the base class ever stops resolving; and annotating just one user parameter in relationship_service.py, where the file convention leaves user untyped throughout.
|
placeholder |
Code Review — PR #1985: Permissioning Audit Gap ClosureThis is a substantial and well-structured security PR. The audit surfaced four real bugs, each addressed with deliberate intent and matched invariant tests. Overall quality is high. Findings below are in descending priority. Critical / Correctness1. Creator parity gap in user_can vs visible_to_user for relationships (and annotations) The PR body acknowledges this as a follow-up observation, but it deserves explicit attention: RelationshipManager.visible_to_user adds Q(creator=user) to the privacy gate so the row's creator always sees it in list queries. But _source_privacy_recursion_passes — shared by AnnotationManager.user_can and RelationshipManager.user_can — has no creator exemption. A user who created a privacy-rooted relationship but later lost access to the source analysis/extract would see it in lists (via visible_to_user's Q(creator=user) disjunct) but be denied READ on the single-object check (via user_can). The invariant tests check shared_reader (never the creator) against privacy-rooted rows, so this parity gap is currently undetected by the test suite. In practice the scenario is unlikely (these rows are system-created via pipeline), but the filter/check parity contract the PR itself establishes makes this a documented gap. A follow-up issue or sentinel test would close this cleanly. Security Bugs Fixed — Confirmed2. Extract anonymous visibility leak The previous get_visible_extracts exposed public extracts in public corpora to anonymous users — a clear drift from the guide's documented "Extract — never" contract. The three-layer fix (manager, service, privacy gate in visible_extracts_for) is correct and consistent. The test_stats.py change capturing the behavioral change to totalExtracts: 0 for anonymous users is the right way to document it. 3. Group-grant filter/check parity The prior inline copies of the source-visibility gate only consulted UserObjectPermission tables, so a viewer whose analysis/extract READ grant came via a Django group passed user_can (group-resolving by default) but never saw the private rows in lists. The new visible_analyses_for / visible_extracts_for builders correctly join both user and group tables. Consolidating the four inline copies into one module is the right DRY call here. 4. Label distribution privacy leak get_label_distribution_for_corpus previously counted analysis-/extract-private annotation labels for any viewer with doc+corpus READ, leaking label names and counts for rows they could not actually see. Applying apply_source_privacy_gate before aggregation is correct. The new user parameter is keyword-only and required (TypeError at the call site, no silent under-count) — good API design. Minor Concerns5. Breaking signature change — verify no other callers get_label_distribution_for_corpus(user=...) is now required-keyword. The PR updates config/graphql/corpus_queries.py (the only apparent call site), but the E001 scanner only covers config/graphql/. Worth confirming no Celery tasks or test helpers call the old positional signature: grep -r "get_label_distribution_for_corpus" opencontractserver/ 6. Double resolution in ExtractManager.user_can ExtractManager.user_can calls resolve_user_for_user_can and then forwards the resolved user to super().user_can(resolved, ...). The comment notes this is an idempotent no-op for a resolved User instance (correct — BaseVisibilityManager would run the same resolver again). This is safe but a slight code smell. Not a bug — flagged for consideration. 7. apply_source_privacy_gate sync comment is load-bearing The exclude-pair shape in apply_source_privacy_gate must stay in sync with AnnotationQuerySet.visible_to_user's positive-Q shape. These two paths express the same logic in different syntactic forms and are not type-checked against each other. A test that runs both paths on the same queryset state would pin this more tightly — test_source_visibility_group_grants.py covers the service path but not the queryset path directly. Positive Observations
SummaryFour real security gaps closed with good DRY consolidation and well-scoped invariant tests. The main open item is the creator parity gap in user_can (acknowledged in the PR body as out-of-scope; recommend a follow-up issue). The get_label_distribution_for_corpus caller check is worth a quick grep before merge. Everything else is either correct or low-risk. |
item 1) The known annotation-side divergence — a row's creator without source access appears in visible_to_user (the queryset gate's creator disjunct) but is denied by user_can(READ) (no creator exemption in the privacy recursion) — was documented in the PR, issue #1986, and an in-code pointer, but nothing executable pinned it. This sentinel asserts the CURRENT divergent behavior with both assertions referencing the issue, mirroring the retired Phase-C deferral sentinel's idiom: whoever fixes the asymmetry must consciously flip both assertions together. It also encodes why the invariant matrix excludes creator-of-row-without-source fixtures (they would fail read-equivalence by design until the fix). Locally verified: the coverage file passes 51/51 with the sentinel.
|
Latest round addressed in
With this, every distinct suggestion across all twelve review rounds has been either implemented, pinned by a test, tracked in #1986, or declined with evidence on this thread. Generated by Claude Code |
Code Review — PR #1985: Permissioning Audit Gap ClosureOverall Assessment: Strong security PR. Recommend merge with minor follow-up tracking. This PR closes a cluster of real security vulnerabilities surfaced during an audit: relationship privacy bypass, extract anonymous access leak, group-grant filter/check parity drift, and several aggregate/listing surfaces that skipped the privacy gate entirely. The audit was thorough and the implementation is high quality. Findings below are roughly ordered by severity. Issues worth tracking (not blockers)1. Annotation creator asymmetry is now explicitly documented but still a real parity violation ( The code at the 2. # test_stats.py (after PR)
for client in [self.owner_client, self.collaborator_client, self.regular_client]:
# anonymous_client removed — correct, it now sees 0
...There's no assertion anywhere in the test that anon_result = self.anonymous_client.execute(query, variable_values=variables)
self.assertEqual(anon_result["data"]["corpusStats"]["totalExtracts"], 0)3. The docstring says: "Callers that invoke Code quality observations (positive)
The The Test suite design is strong. Five new test files each with a single responsibility, plus invariant matrix extension, plus updated existing tests. The Replacing Minor style observations
Pre-merge checklist
|
|
No changes needed for this round — all three tracked items predate it:
Checklist status: CI was fully green on the Generated by Claude Code |
|
Code Review This is a well-structured security audit PR that closes four real information-disclosure vulnerabilities. The changes are correct, consistently applied at all enforcement layers, and thoroughly tested. Bugs / Correctness IssuesNone blocking. The four targeted vulnerabilities are correctly fixed:
Minor Issues Worth Noting1. Deleted-source exposure is underdocumented (low)
2. Acknowledged annotation-creator parity asymmetry (issue #1986 item 1) The 3.
4. Double The manager resolves the user then passes Pre-existing Issue (not introduced by this PR)
Strengths
Overall: Approve with the suggestion to add a note to the permissioning guide about the deleted-source exposure behavior (item 1 above). The rest are optional clean-ups. |
…visible_extracts Review round follow-ups (the round's remaining items were already done or are tracked in #1986): - The guide's Source Privacy banner now states the on_delete=SET_NULL consequence explicitly: deleting a private Analysis/Extract un-gates its rows (they become ordinary doc+corpus-governed rows) — an operator deleting a source expecting continued privacy would be surprised. Previously documented only in the _source_privacy_recursion_passes docstring. - get_visible_extracts now uses the same fail-closed None/anonymous guard shape as check_extract_permission and ExtractManager (both reads: the top lockdown branch and the corpus-scope branch — a bare user.is_anonymous would AttributeError on None rather than fail closed). - #1986 gains item 6: DocumentRelationshipService.get_visible_relationships pre-computes _can_update/_can_delete as creator-only while user_has_permission computes MIN(source, target, corpus) — a pre-existing myPermissions under-report verified during this round, out of scope here.
|
Round addressed in
Generated by Claude Code |
Code Review — Permissioning Audit Gap ClosureOverviewThis is a security-critical PR that closes multiple real vulnerabilities found during an audit of the permissioning guide against the actual code. Four behavior changes were agreed with the maintainer; the rest is doc/comment cleanup. The scope is large (24 files, +2479/-534) but well-organized: each fix has its own invariant tests and a dedicated changelog fragment. Security Fixes — Correct and Necessary1. Relationship privacy recursion (closes #1655 Phase-C) 2. 3. Group-granted source permissions — 4. 5. Aggregate surfaces ( 6. Structural Issues and Concerns[SECURITY] Known annotation creator parity gap remains open (issue #1986 item 1)
Relationships resolved this with an explicit creator short-circuit. Annotations have not. This means an annotation's own creator who has no source access appears in list queries but gets mutation errors — inconsistent UX at minimum, and a parity violation that could produce surprising behavior depending on how the list results are used downstream. The PR correctly pins and tracks this rather than leaving it undocumented, but it warrants explicit attention in follow-up. [PERF] N+1 risk from [DESIGN] [DESIGN] Test CoverageThe test coverage is exceptionally thorough:
DocumentationThe guide overhaul is comprehensive and accurate. Notable additions:
SummaryApprove with follow-up items noted. This PR closes real security vulnerabilities (anonymous annotation leak, group-grant parity, relationship privacy bypass, anonymous extract exposure, aggregate leaks) with comprehensive tests and thorough documentation. The code is clean, DRY, and follows project conventions. Worth verifying before merge:
Track as follow-up:
|
…te context Review follow-through: the #1682 caller-intent warning now exists on BOTH sides of the boundary — the service docstring names this caller, and the call site itself warns against copying the pattern into a user-facing GraphQL resolver (which requires the MIN-semantics variant). Also verified this round's pre-merge ask: no loop callers of RelationshipManager.user_can exist anywhere (config/graphql, llms/tools, mcp) — the AnnotatePermissionsForReadMixin fallback never calls user_can (it reads prefetched guardian metadata), GraphQL relationship lists flow through the annotated services, and every mutation gate is single-object — so the privacy recursion's per-call FK dereference has no N+1 surface today.
|
Round addressed in
Generated by Claude Code |
|
test |
|
Code Review - PR 1985: Permissioning audit gap closure Overview This PR closes four security gaps surfaced during a permissioning audit: relationship privacy recursion, anonymous extract lockdown, group-granted source permissions, and myPermissions accuracy for structural rows. The approach - consolidated utility builders, shared manager function, per-surface guards, and a saturating test matrix - is the right architecture for this class of security invariant. Security - Confirmed fixes 1. Relationship privacy recursion - The shared _source_privacy_recursion_passes function correctly places the privacy check after the creator short-circuit in RelationshipManager.user_can. The structural-READ short-circuit and its interaction with the list-side Q(structural=True) disjunct are explicitly documented and well-reasoned. 2. Anonymous extract lockdown - ExtractManager denies anonymous on both surfaces with fail-closed default (getattr(user, 'is_anonymous', True)). Consistent with the rest of the codebase. 3. Group grants in privacy gates - The read_analysis / read_extract codename-exact match closes the old any-codename parity drift (an update_analysis-only grantee was clearing list gates while user_can(READ) denied them). Pinned by test_update_only_source_grant_does_not_unlock_lists. 4. myPermissions structural mask - Both services now mask structural _can_update/_can_delete to user.is_superuser via Case/When. Correctly mirrors what user_can will enforce in mutations. Issues to Address [Bug - dead code] get_visible_extracts corpus-id branch (extract_service.py ~line 182): The outer anonymous guard already sets qs = Extract.objects.none() before the corpus block runs. The inner anonymous check inside the corpus_id block is therefore unreachable for anonymous callers and can never return a non-empty queryset. The dead branch will mislead future maintainers. Suggest removing the inner anonymous guard and leaving a comment that anonymous is handled above. [Minor - double resolution] ExtractManager.user_can (extracts/models.py): resolve_user_for_user_can(user) is called and the resolved value is passed to super().user_can(resolved, ...). However, BaseVisibilityManager.user_can will call resolve_user_for_user_can again on the already-resolved value. Idempotent on a User instance, but asymmetric with visible_to_user (which calls super() without pre-resolution). A brief inline comment noting the double-resolution is intentionally safe would be sufficient. [Maintenance concern] apply_source_privacy_gate vs AnnotationQuerySet.visible_to_user - two shapes must stay in sync: The note at the top of apply_source_privacy_gate documents this. The test_gate_matches_queryset_visibility_for_non_creator test helps but is scoped to non-creators by design. A future contributor changing apply_source_privacy_gate without updating the positive-Q shape inside AnnotationQuerySet.visible_to_user could silently break parity. Stronger fix: have AnnotationQuerySet.visible_to_user call visible_analyses_for / visible_extracts_for (it already imports from source_visibility) rather than building its own Q objects - eliminating the divergent shape entirely. [Follow-up, not a blocker] Annotation-side creator asymmetry (issue 1986 item 1): The sentinel test in test_authorization_invariants_coverage.py correctly documents the known divergence. Noted here for visibility only. Test Coverage Thorough overall. Specific observations:
One gap: LabelDistributionPrivacyRegressionTestCase covers anonymous and ungranted-authenticated callers but not a group-granted source. The gate goes through apply_source_privacy_gate (tested elsewhere) so risk is low - but a group-grant case would complete the coverage for the aggregate surface. Documentation The permissioning guide update is accurate and substantially improves navigability. The two-semantics section for CorpusDocumentService (get_corpus_documents vs get_corpus_documents_visible_to_user) is particularly clear. Small suggestion: the SOURCE PRIVACY banner callout deleted-source note could link to _source_privacy_recursion_passes, which has the fuller explanation including the race-window detail. Summary The security fixes are correct. The test coverage is strong. There is one dead-code issue to clean up before merge, and a maintenance-risk concern about the two-shape sync between apply_source_privacy_gate and AnnotationQuerySet.visible_to_user. The rest are low-priority suggestions. This is a well-executed security PR. |
…ate grant matrix Review round follow-ups: - The anonymous guard in get_visible_extracts now EARLY-RETURNS Extract.objects.none() instead of falling through with an empty queryset, which made the corpus-scope block's inner anonymous check unreachable-in-effect (it gated a queryset that was already empty). The inner branch is removed; only authenticated users reach the corpus permission check, and the comment says so. - LabelDistributionPrivacyRegressionTestCase gains the group-granted case, completing the grant matrix (anonymous / ungranted / owner / group-granted) for the aggregate surface end-to-end. - The guide's deleted-source note now cross-links _source_privacy_recursion_passes for the full mechanics including the race-window guard. Premise correction recorded for the same round: the suggested 'stronger fix' (have AnnotationQuerySet.visible_to_user consume the shared builders) has been in place since the first group-grant commit — the queryset already calls visible_analyses_for/visible_extracts_for; only the gate COMPOSITION differs (positive-Q with creator disjunct vs exclude pair), which is necessary and pinned by test_gate_matches_queryset_visibility_for_non_creator.
|
Round addressed in
Generated by Claude Code |
Code Review — Permissioning Audit Gap ClosureThis is a substantial, security-focused PR that closes real authorization gaps. The approach is sound and the test coverage is excellent. Below are findings organized by severity. OverviewFour concrete behavior fixes:
Bugs / Correctness Issues1. The PR documentation in
But the current This is flagged in the PR body as a "follow-up observation (out of scope)", but given that the relationship creator exemption is explicitly documented as part of the new design, it would be cleaner to either:
2.
grep -rn "get_label_distribution_for_corpus" opencontractserver/ config/Similarly, Security Observations3. The new anonymous guard is placed correctly before the DB lookup, which is the right IDOR posture. Good. 4. The manager resolves the user, checks anonymous, then calls 5. Deleted-source race window (documented, correct behavior) The Performance Considerations6. Nested IN subqueries in The gate adds two On large annotation tables, this could be slow. Consider profiling on a realistic dataset. If it becomes a problem, the typical remedy is to use 7. N+1 in The docstring correctly warns callers to Code Quality8. A module-level private function placed between two class definitions (after 9. This is correct per the documented parity with Documentation / GuideThe guide updates are thorough and accurate. Notable improvements:
One minor nit: the inline code snippet in the guide for Test CoverageThe new test files are comprehensive:
The replaced One gap: no test directly asserts that the creator of a private-source annotation/relationship sees their own row in service-level list queries ( SummaryThis is well-designed, well-documented work on a genuinely complex security surface. The critical items to address before merge:
The group-grant parity fix, the extract anonymous lockdown, and the relationship privacy recursion are all correct and important. The |
…y fix) Review round 17 surfaced a real inconsistency this PR's own manager changes made visible: after relationships gained the creator short-circuit on BOTH manager surfaces, the service listings (which flow through apply_source_privacy_gate) became the odd surface out — a relationship's creator who lost source access kept READ via user_can AND visible_to_user yet vanished from the document view. The gate now carries the same authenticated creator exemption as the queryset gate's Q(creator=user) disjunct; anonymous callers get an always-false predicate instead (Q(creator=<anonymous>) is not a valid lookup, and anonymous users can never be a row's creator). Surface alignment after this change: - Relationship: all three surfaces (user_can, manager visible_to_user, service listings/aggregates) honour the row's creator. - Annotation: both LIST surfaces honour the creator; user_can remains the single tracked divergence (issue #1986 item 1, sentinel-pinned). Tests: creator-retention pins for both models (test_creator_retains_own_privacy_rooted_row_in_listing, test_row_creator_exempt_from_gate_in_document_listing); the scoped cross-shape parity test's rationale updated (its non-creator scope is now a focus choice, not a necessity). Changelog updated.
|
Round 17 addressed in
Full Generated by Claude Code |
Code Review: Permissioning Audit Gap ClosureThis is a well-executed, high-quality security-hardening PR. The changes are correct, DRY, well-documented, and thoroughly tested. Here are my observations. What this does wellArchitecture / DRY
Correctness of the anonymous always-false predicate Fail-closed race window Guard-shape tightening Issues and observations1. Known annotation-creator parity gap (issue #1986, acknowledged) The docstring on 2. analysis_id = getattr(instance, "created_by_analysis_id", None)
if analysis_id: # <-- falsy for 0 or NoneDjango PKs are always positive integers so 3. Nested subquery depth in Each call to 4. Behavior change for anonymous users on extracts is a breaking change for running apps
5. The comment in
This is the corpus-as-gate vs. MIN semantics from issue #1682 documented in CLAUDE.md. The comment is good, but I'd suggest promoting it to a 6. Looking at the service: qs = apply_source_privacy_gate(
apply_source_privacy_gate(...), user
)If the gate is applied twice in some code path (one in a helper, once in the caller), there's no functional harm but extra query complexity. Worth a quick scan to confirm this isn't accidentally double-applied via 7. if resolved is None or getattr(resolved, "is_anonymous", True):
return FalseThe Test coverage assessmentThe test coverage is strong:
One gap: there is no test verifying that passing DocumentationThe guide overhaul is excellent. The dual-semantics table for SummaryApprove with minor follow-ups. The four security issues being closed (relationship privacy, anonymous extract lockdown, group-grant list parity, anonymous annotation leak) are real and the fixes are correct. Test coverage is thorough. The main actionable items from this review are small: the |
Style follow-up from review: the truthiness checks were moved verbatim from the original annotation-side implementation; Django PKs are positive so behavior is identical, but the explicit form is the standard idiom for FK id fields.
|
Round 18 (third consecutive approval) — one nit adopted in
Generated by Claude Code |
Code Review: Permissioning Audit Gap ClosureThis is a substantial, security-focused PR. The overall approach is sound — DRYing up duplicated privacy gate logic, enforcing a consistent anonymous-extract lockdown, and closing the relationship privacy deferral from #1655. Here is a structured review. OverviewThe PR makes four agreed behavior changes:
Strengths
Issues and Suggestions1. Potential double-resolution in
|
One-test follow-up from review round 19: the service's fail-closed getattr(user, 'is_anonymous', True) default for a literal None user is now pinned directly (the manager surfaces' None cases were already covered).
|
Round 19 — one test adopted in 1/4. Double-resolution & call-site N+1 notes — both comments already exist at the call sites (rounds 10–14); With three consecutive approvals and the deltas reduced to single-test/single-idiom items, treating the review loop as converged; the gate to merge is the routine CI pass on the tip. Generated by Claude Code |
Code ReviewOverviewThis PR closes four substantive permissioning gaps found during a guide/code audit: relationship privacy recursion (Phase-C deferral from #1655), anonymous-visible extracts, group-granted source permissions not unlocking list queries, and Security AnalysisGroup-grant fix (item 3) is the most impactful security correction. The prior inline implementations used only user-level guardian tables, so a group-granted analysis/extract viewer passed Codename exactness improvement. Changing
Specific Observations1.
This is pre-existing and out of this PR's scope, but given the audit context it seems worth tracking as a follow-up (the same filter/check parity class as what was fixed here). 2. The docstring correctly warns that bulk 3. Annotation-side creator asymmetry (issue #1986 item 1) — sentinel is the right choice The 4. Verified: only two callers exist — 5. The 6. The docstring notes this was a latent parity drift (original inline omitted 7. The modification is correctly handled — anonymous clients now expect Minor Nits
SummaryThis is a well-scoped security PR with clean implementation, good test coverage, and honest documentation of known asymmetries. The core changes (shared |
Review round 20 caught the asymmetry left by round 6: the extract listing received group-grant legs and exact read_* codenames, but its sibling get_visible_analyses kept the identical pre-fix drift — user-table-only guardian Exists (any codename) on the analysis leg and a codename__contains='read' substring match on the corpus leg, while check_analysis_permission resolves both through user_can (group grants + exact codename). Both legs now join AnalysisGroupObjectPermission / CorpusGroupObjectPermission and match read_analysis / read_corpus exactly. A group-granted analysis reader previously passed this PR's privacy gates yet could not see the analysis row itself in the list view. Anonymous semantics unchanged (public analyses in public corpora remain visible — the documented contract, unlike extracts). Tests: test_analysis_listing_grant_parity.py (group grants on both legs unlock the listing + single-object surface agreement; update-only analysis grant does not unlock). Changelog bullet extended to cover both siblings.
|
Round 20 addressed in Remaining items were confirmations (2, 3, 4, 5, 7 — agreed on all counts) or already covered: item 6's release-notes ask is carried by the security fragment's Generated by Claude Code |
Code Review: Permissioning Audit Gap ClosureOverviewThis is a well-executed, security-critical PR that closes several real vulnerabilities uncovered during a systematic audit of the consolidated permissioning guide. The changes are substantial (27 files, +2741/-564) but tightly scoped: every behavioral change is tied to a documented decision, and the guide, code, and tests are all updated together. Security Findings AddressedAll four are genuine vulnerabilities, well-scoped and correctly fixed:
Code Quality: Strengths
Issues and Concerns1. Annotation-creator parity gap (issue #1986 item 1) — UX consequence The sentinel test documents this clearly, but the concrete UX consequence is worth flagging: an annotation's creator who loses source access will still see their annotation appear in list results (via 2. The 3. Performance: subqueries now run on every annotation/relationship list path
4. resolved = resolve_user_for_user_can(user)
...
return super().user_can(resolved, ...) # base class calls resolve again (no-op)The base-class second call is idempotent on an already-resolved 5. CI status The PR description explicitly notes CI was not complete at submission time. Given the scope (4 new security behaviors, 6 new test files, changes to shared manager/queryset infrastructure), CI is the real gate here. Do not merge until the full suite passes. Documentation ReviewThe
One minor note: Test Coverage AssessmentStrong overall. New files:
Extended One gap to verify: the changelog lists Pre-merge Checklist
|
|
Round 21 — no changes warranted; every item verifies against the branch:
With rounds 14–21 producing three approvals and otherwise only items that already exist on the branch by name, I'll respond to future automated rounds only if they contain new, verifiable findings — the disposition ledger above and #1986 cover everything else. Generated by Claude Code |
Permissioning audit gap closure
A full audit of
docs/permissioning/consolidated_permissioning_guide.mdagainst the GraphQL and service layers surfaced substantive guide/code divergences. Four intent decisions were made with the maintainer, and this PR makes code and docs agree.Agreed decisions → behavior changes
1. Relationship privacy recursion implemented (closes the #1655 Phase-C deferral)
RelationshipManager.user_canandRelationshipManager.visible_to_usernow enforcecreated_by_analysis/created_by_extractsource permissions exactly like annotations, via a shared module-level_source_privacy_recursion_passes(opencontractserver/shared/Managers.py). Previously a user with doc+corpus grants could see and write analysis-/extract-private relationships through the manager surfaces. The relationship creator keeps access (creator short-circuit, mirrored byQ(creator=user)in the queryset gate) so filter/check parity holds.2. Extracts are never anonymous-visible
The guide's documented contract ("Extract — never") is now enforced everywhere: a new
ExtractManager(opencontractserver/extracts/models.py) denies anonymous users on bothvisible_to_useranduser_can, andExtractService.get_visible_extracts/check_extract_permissionlock down the service boundary. Previously the service exposedis_publicextracts in public corpora to anonymous callers (drift copied from the analysis service, reachable through the un-gatedextractsresolver).3. Group-granted source permissions unlock private rows in lists
The
created_by_*privacy gates consulted only the USER object-permission tables, so a viewer whose analysis/extract grant arrived via a Django group passeduser_can(groups resolve by default) but never saw the rows in lists — a filter/check parity drift. New shared builders inopencontractserver/utils/source_visibility.pyjoin the group tables and replace four divergent inline copies (AnnotationQuerySet.visible_to_user,AnnotationService.get_document_annotations/get_corpus_annotations,RelationshipService.get_document_relationships). Bonus fix found during consolidation:get_corpus_annotationsskipped privacy exclusion entirely for anonymous users — anonymous viewers of a public corpus could see analysis-/extract-private annotations on public documents. The exclusion now applies unconditionally.4. Thread moderation: code is right, guide was wrong
Conversation.can_moderatedoes NOT grant moderation via EDIT permission on the corpus/document; the guide claimed it did. Guide corrected; the deliberateConversation.can_moderatevsCorpus.user_can_moderatedivergence (#1450) is now documented.Also in this PR
myPermissionsmirrors the structural-write break-glass:AnnotationServicemasked structural_can_update/_can_deletetoFalsefor everyone (under-reporting for superusers — a stale comment claimed superusers were "handled upstream", untrue since scoped admin 2026-05);RelationshipServicehad no structural mask (over-reporting doc+corpus writes for normal users). Both now mask touser.is_superuser.*QueryOptimizername → current per-app service; dead<app>/query_optimizer.pypaths →<app>/services/…; resolver locations after thequeries.pysplit;ws/agent-chat/URL; awaited_check_user_permissions+ issue Agent Tools Not Fault Tolerant #820 fault-tolerance split; real agent-test paths;get_users_permissions_for_objgroup default (True); Pitfall 1 (structural-only, not empty); CorpusCategory GraphQL mutations; structural Enforcement Locations →Managers.py. Newly documented: the Reconcile CorpusObjsService corpus-as-gate vs GraphQL MIN-permission semantic #1682get_corpus_documents(corpus-as-gate) vsget_corpus_documents_visible_to_user(MIN) dual semantics,opencontracts.E001dual enforcement + scan-scope nuance,BaseService.filter_visible_qs, and reference-table rows for Note / UserFeedback / Embedding / CorpusAction / Notification.shared/QuerySets.py,shared/Managers.py,conversations/models.py,llms/tools/image_tools.py,documents/services/relationships.py; CLAUDE.md pitfall 2 →ConversationService.changelog.d/perm-audit-2026-06.security.md+.fixed.md.Tests
test_authorization_invariants.py: relationship matrix extended with analysis-/extract-rooted fixtures + user- and group-granted readers; thetest_no_privacy_widening_via_created_by_analysissentinel (which pinned the old deferral) is replaced by six real privacy invariants per the agreed behavior change; newExtractAuthorizationInvariantsTestCase; annotation group-grant parity test.test_relationship_privacy_scoping.py,test_extract_anonymous_lockdown.py,test_source_visibility_group_grants.py,test_structural_mypermissions_breakglass.py(all underopencontractserver/tests/permissioning/).black/isort/flake8clean on all touched files. The targeted dockerized test run is in flight in the session environment (Docker Hub rate limiting slowed image pulls); any failures will be fixed in follow-up commits on this branch — CI should be treated as the gate.Follow-up observations (out of scope, flagged during audit)
ExtractTypehas noget_querysetoverride; related-field traversal (e.g. datacell → extract) relies on parent gating. Pre-existing.get_objects_for_userinconfig/graphql/search_queries.py(legal under E001's three-token scan; now documented in the guide as a known exception; service migration is a candidate follow-up).user_canprivacy recursion has no creator exemption whileAnnotationQuerySet's gate hasQ(creator=user)— a latent annotation-creator parity edge not covered by current fixtures (relationships handle this explicitly via the creator short-circuit).Generated by Claude Code