Close permissioning-guide audit gaps: relationship privacy, anonymous extracts, group grants#1985
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.
…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 |
Code Review — PR #1985: Permissioning audit gap closureThis is a substantial, well-structured security PR. The three core changes — Potential Bugs / Correctness Concerns1. When has_permission = (
analysis.is_public
or analysis.creator_id == user.id # can be None == None for AnonymousUser + no-creator analysis
or analysis.user_can(user, PermissionTypes.READ, request=context)
)If the caller is an (analysis.creator_id is not None and analysis.creator_id == user.id)2. Structural-set relationships with The outer filter is The service path in Code Quality3. Repeated deferred imports of The function is imported inline inside 7 separate method bodies across 4. creator_exempt = Q(pk__in=[]) # always-false for anonymousThe 5. if getattr(user, "is_anonymous", True):
return False, None
extract = Extract.objects.get(id=extract_id) # bypasses visible_to_user intentionallyThe Performance Notes6. Nested subquery depth in Each invocation adds two 7. Unbatched privacy recursion now also applies to The Documentation / Test Gaps8. Annotation creator parity gap (#1986 item 1) not in CLAUDE.md pitfalls The asymmetry between 9. No direct test of
Positives Worth Calling Out
Summary
Items 1 and 2 are worth addressing before merge; everything else is clarity/maintainability work that could land in a follow-up. |
Code Review — PR #1985: Permissioning Audit Gap ClosureOverviewThis is a substantial security hardening PR that closes four distinct permissioning gaps surfaced by auditing the guide against live code. The scope — 28 files, 2822 additions, 585 deletions — is large but the changes are structurally coherent and well-tested. StrengthsExcellent DRY consolidation. Filter/check parity is rigorously maintained. The PR adds matrix invariant tests that mechanically pin Fail-closed throughout. Changelog fragments and documentation are in excellent shape. The security fragment correctly catalogues every fixed surface with full file paths and test locations. The guide overhaul is thorough. CI workflow changes are pragmatic. Selective service startup and disk pre-clean for the E2E workflow address real runner resource constraints. Issues / Questions1. Annotation creator asymmetry (issue #1986 item 1) — needs a tracked issueThe PR correctly pins the asymmetry with a sentinel test and documents it in CLAUDE.md pitfall #3. But the asymmetry is observable: an annotation's creator who has lost source access appears in
A user sees an edit button but gets a permission error when they click it. Relationships correctly resolve this with the creator short-circuit; annotations should match. Please either fix this in the same PR or open a concrete follow-up issue so it doesn't get lost. 2. Performance: new group-join subqueries now in 8 hot paths
Each invocation embeds two nested subqueries (one for analyses, one for extracts), each joining the group object-permission tables. The guide's performance note recommends indexing 3.
|
| Gap | Old behavior | New behavior | Assessment |
|---|---|---|---|
| Relationship privacy recursion | created_by_* fields present but ignored |
Full privacy recursion matching annotations | Correct |
| Extract anonymous visibility | is_public extracts visible to anonymous |
Extracts never anonymous-visible | Correct per guide contract |
| Group grants in list queries | User-table only; group grantee passed user_can but vanished from lists |
Group tables joined; filter/check parity | Correct |
| Anonymous corpus annotation leak | Privacy gate skipped for anonymous viewers | Gate applied unconditionally | Correct |
Structural myPermissions mask |
Annotations masked False for all; relationships unmasked |
Both mask to user.is_superuser |
Correct |
Test Coverage
New test files are thorough and well-structured:
test_relationship_privacy_scoping.py— service-level listingtest_extract_anonymous_lockdown.py— manager + service + filter/check paritytest_source_visibility_group_grants.py— group grant transitions before/aftertest_structural_mypermissions_breakglass.py— precomputed value vs mutation gate agreementtest_privacy_gate_regressions.py— aggregate surfaces (label distribution, relationship summary)test_analysis_listing_grant_parity.py— exact codename matching in the analysis listing
The sentinel test for the annotation creator gap in test_authorization_invariants_coverage.py is the right pattern for a documented known divergence.
Bottom Line
This is a high-quality security PR that does exactly what it says. The main ask before merge: either resolve the annotation-side creator asymmetry inline or open a concrete tracked issue (not just a CLAUDE.md pitfall) so it doesn't silently persist. The performance concern about new group-join subqueries should be addressed by confirming relevant guardian table indexes exist before the PR ships.
Code Review — PR #1985: Permissioning audit gap closureOverall assessment: Strong, security-critical implementation with no outright correctness bugs. The three primary gaps (relationship privacy recursion, extract anonymous lockdown, group-grant list/check parity) are each closed correctly. The architecture — a single What this PR does well
Issues and gapsMedium — Dangling comment reference in
|
Code Review — PR #1985: Permissioning audit gap closureThis is a well-scoped, security-critical PR. The core changes are correct and the test coverage is excellent. Below are observations ordered by severity. Security / Correctness
Anonymous extraction leak (
Code Quality
Missing type annotation on new # Current
def get_label_distribution_for_corpus(
visible_doc_ids,
top_n: int,
exclude_label_prefix: Optional[str] = None,
*,
user, # <-- no type
) -> list[dict]:
# Suggested
*,
user: Any,
Comment verbosity vs. CLAUDE.md — CLAUDE.md says "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." Several of the new docstrings and inline comment blocks in this PR are multi-paragraph. For security-critical permission boundaries, the "WHY is non-obvious" exception clearly applies — the reasoning chains here (structural-READ short-circuit, filter/check parity, deleted-source posture) are exactly what future maintainers need. Treat the verbosity as appropriate for this domain. Test CoverageExcellent. Specific strengths:
The pre-existing test modifications in PerformanceMigrations ( Subquery shape in Changelog / DocsThe changelog fragments ( SummaryThe PR is solid. The security fixes are real, the consolidation is clean, and the test coverage is thorough. The two items worth addressing before merge:
Everything else is either fine as-is or a minor style point. |
Code Review: Permissioning Audit Gap ClosureTL;DR: This is a well-executed security-critical PR that closes three real vulnerabilities and enforces long-deferred permissioning invariants. The DRY consolidation in OverviewThe PR closes four concrete security/correctness gaps found during a full guide-vs-code audit:
Security: What's Correct
Issues Worth Addressing1. The docstring correctly warns that FK descriptor accesses ( 2. The 3. In 4. user_id = getattr(user, "id", None)
has_permission = (
analysis.is_public
or (
analysis.creator_id is not None
and analysis.creator_id == user_id
)
or analysis.user_can(...)
)This block is only reached when filtering by analysis for a non-anonymous user (the surrounding code gates on 5. The function is defined at module level, between the class definitions and the Code Quality
Test CoverageGood:
Concern:
PerformanceThe four new concurrent indexes ( DocumentationThe guide overhaul is thorough. Highlights:
SummaryThree real security bugs fixed, thorough test coverage per bug, clean DRY consolidation. The main pre-merge asks are:
|
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
AnnotationManager.user_cannow mirrorsAnnotationQuerySet.visible_to_user'sQ(creator=user)source-privacy exemption. This fixes the Permissioning audit follow-ups (from PR #1985) #1986 item-1 edge where a row creator could see their own analysis-/extract-rooted annotation in lists but failuser_can(READ). The exemption only skips source recursion; document/corpus permissions still apply.GeographicAnnotationService.aggregate_for_corpusnow appliesapply_source_privacy_gateafter the visible-document corpus gate, so analysis-/extract-private annotations cannot leak through map pin aggregates. Regression coverage lives intest_geographic_annotation_service.py.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, and tests now pin structural_can_read=Trueas well.ExtractManager.visible_to_usernow resolves integer user ids before the anonymous guard, matchinguser_canand preserving the base manager's id shortcut.(user, permission, content_object)/(group, permission, content_object)unique indexes; this PR adds concurrent companion indexes for the source privacy subqueries:anl_uop_perm_user_obj_idx,anl_gop_perm_grp_obj_idx,ext_uop_perm_user_obj_idx, andext_gop_perm_grp_obj_idx.ExtractService.check_extract_permissionnow catches malformed ids (TypeError/ValueError) as denial, so an undecoded Relay id fails closed even though all production GraphQL callers already decode withfrom_global_id(...)before calling the service.analysis_id is not Nonecorrectness:_source_privacy_recursion_passesintentionally usesis not None, not truthiness, so a falsy-but-present id cannot skip the privacy recursion branch.test_annotation_creator_parity_gap_sentinelreference was removed fromsource_visibility.py; the comment now points attest_annotation_creator_source_private_row_has_filter_check_parity.*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.get_label_distribution_for_corpushas one production caller (config/graphql/corpus_queries.py), and it passesuser=info.context.user; the other call sites are tests.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,test_privacy_gate_regressions.py,test_analysis_listing_grant_parity.py,test_geographic_annotation_service.py, and the annotation creator parity coverage intest_authorization_invariants_coverage.py.git diff --checkpython -m compileall ...on touched Python modules, tests, and migrationsdocker compose -f test.yml run --rm django pytest --create-db opencontractserver/tests/permissioning/test_authorization_invariants_coverage.py::AnnotationUserCanLeafBranchesTestCase opencontractserver/tests/permissioning/test_extract_anonymous_lockdown.py -q-> 24 passeddocker compose -f test.yml run --rm django pytest opencontractserver/tests/permissioning/test_authorization_invariants.py::AnnotationAuthorizationInvariantsTestCase opencontractserver/tests/permissioning/test_source_visibility_group_grants.py opencontractserver/tests/permissioning/test_privacy_gate_regressions.py -q-> 29 passeddocker compose -f test.yml run --rm django pytest opencontractserver/tests/permissioning/test_relationship_privacy_scoping.py opencontractserver/tests/permissioning/test_structural_mypermissions_breakglass.py opencontractserver/tests/permissioning/test_extract_anonymous_lockdown.py opencontractserver/tests/test_geographic_annotation_service.py -q-> 40 passeddocker compose -f test.yml run --rm django isort --check-only ...on touched filesdocker compose -f test.yml run --rm django flake8 ...on touched filesFollow-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).Generated by Claude Code