Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
dc06f32
Close permissioning-guide audit gaps: relationship privacy, anon extr…
claude Jun 12, 2026
b89daa9
Fix linter findings: pyupgrade quote removal, isort, mypy narrowing
claude Jun 12, 2026
7b1069b
Address review: clarify require_permission idiom, extract-grant test,…
claude Jun 12, 2026
3b7df6f
Update two legacy tests pinning pre-lockdown anonymous extract semantics
claude Jun 12, 2026
eec7f04
Close is_public-extract parity gap in source visibility builders
claude Jun 12, 2026
7b189db
Tighten source privacy gates to READ-codename grants (parity with use…
claude Jun 12, 2026
bdef2f2
Close privacy-gate gaps in aggregates/listings + extract listing parity
claude Jun 12, 2026
8912c91
Pin extract-side orphan-source race guard (last uncovered patch line)
claude Jun 12, 2026
f614ed9
Align visible_extracts_for SQL shape; harden user=None docs
claude Jun 12, 2026
e4a0fbe
Consolidate privacy-gate exclusion into apply_source_privacy_gate; re…
claude Jun 12, 2026
0a7ea9c
Pin gate/queryset privacy parity for non-creators; tighten cross-path…
claude Jun 12, 2026
2f2e250
Dequote annotations in apply_source_privacy_gate (pyupgrade)
claude Jun 12, 2026
66d267f
Annotate the creator-disjunct asymmetry at the queryset privacy gate
claude Jun 12, 2026
4198242
Add sentinel test pinning the annotation-creator parity gap (issue #1…
claude Jun 12, 2026
344d608
Document deleted-source semantics in guide; align None guards in get_…
claude Jun 12, 2026
902a3fb
Mark the MCP corpus-relationships call as the sanctioned corpus-as-ga…
claude Jun 12, 2026
37d8f3c
Remove dead anonymous branch in get_visible_extracts; complete aggreg…
claude Jun 12, 2026
f8a2b6e
Extend creator exemption to apply_source_privacy_gate (round-17 parit…
claude Jun 12, 2026
68679d2
Use is-not-None idiom for created_by_* id checks in privacy recursion
claude Jun 12, 2026
f4374fd
Pin check_extract_permission(None) at the service layer
claude Jun 12, 2026
1c88b6b
Mirror listing grant parity onto AnalysisService.get_visible_analyses
claude Jun 12, 2026
d7ae2e4
Fix extract E2E workflow disk usage
JSv4 Jun 13, 2026
bb64688
Address Claude permissioning review notes
JSv4 Jun 13, 2026
669ab32
Resolve Claude permissioning follow-ups
JSv4 Jun 13, 2026
29fd00a
Address latest Claude privacy review
JSv4 Jun 13, 2026
b90df2f
Apply black formatting from CI
JSv4 Jun 13, 2026
ec6ef0e
Address Claude extract follow-ups
JSv4 Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/frontend-e2e-extract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ jobs:
working-directory: ./frontend
run: yarn playwright install --with-deps chromium

- name: Free runner disk for Docker stack
run: |
df -h
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc || true
docker system prune -af || true
df -h

# ────────────────────────────────────────────────────────────────
# Materialize `.envs/.local/*` from the committed `.envs/.test/*`
# templates. Real `.envs/.local/*` files are gitignored because
Expand Down Expand Up @@ -142,7 +149,16 @@ jobs:
# `local.e2e-coverage.yml` swaps the Django start command for
# `/start-with-coverage` (coverage.py wrapping `manage.py runserver
# --noreload`). All other services keep their default commands.
docker compose -f local.yml -f local.e2e-coverage.yml up -d
#
# Start only the services this spec needs. A bare `up -d` starts
# every service in local.yml, including heavyweight optional parser /
# embedder images and operator services; hosted runners can exhaust
# Docker disk before the app stack even boots.
docker compose -f local.yml -f local.e2e-coverage.yml up -d \
postgres redis docling-parser vector-embedder
docker compose -f local.yml -f local.e2e-coverage.yml up -d \
--no-deps django celeryworker

echo "Waiting for django to become healthy…"
for i in {1..60}; do
state=$(docker inspect -f '{{.State.Health.Status}}' \
Expand Down Expand Up @@ -221,8 +237,12 @@ jobs:
- name: Export backend coverage to XML
if: success() || failure()
run: |
docker compose -f local.yml run --rm --no-deps django \
coverage xml -o /app/coverage-backend-e2e-extract.xml || true
if docker compose -f local.yml ps --status running --services | grep -qx django; then
timeout 120s docker compose -f local.yml exec -T django \
coverage xml -o /app/coverage-backend-e2e-extract.xml || true
else
echo "django container is not running; skipping backend coverage export"
fi
ls -la coverage-backend-e2e-extract.xml || true

- name: Capture backend logs on failure
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ Run manually: `pre-commit run --all-files`
## Common Pitfalls

1. **Frontend tests hanging**: Always use `--reporter=list` flag
2. **Permission N+1 queries**: Use `.visible_to_user()` NOT individual permission checks. For **Conversation list queries** in GraphQL resolvers, use `ConversationQueryOptimizer` from `opencontractserver.conversations.query_optimizer` - it provides request-level caching to avoid repeated corpus/document visibility subqueries
2. **Permission N+1 queries**: Use `.visible_to_user()` NOT individual permission checks. For **Conversation list queries** in GraphQL resolvers, use `ConversationService` from `opencontractserver.conversations.services` - it provides request-level caching to avoid repeated corpus/document visibility subqueries. For annotation/relationship source privacy, looping `user_can` over rows dereferences `created_by_analysis` / `created_by_extract`; use list/queryset surfaces or `select_related("created_by_analysis", "created_by_extract")`.
3. **Missing GraphQL mocks**: Check variables match exactly (null vs undefined matters), add duplicates for refetches
4. **Notification duplication in tests**: Moderation methods auto-create ModerationAction records
5. **Structural annotation editing**: Always read-only except for superusers
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/perm-audit-2026-06.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **Pre-computed `myPermissions` now mirror the structural-write break-glass.** `AnnotationService.get_document_annotations` masked structural rows' `_can_update`/`_can_delete` to `False` for everyone — under-reporting for superusers, whose structural writes are allowed by the break-glass in `AnnotationManager.user_can` (a stale comment claimed superusers were "handled upstream", which stopped being true with scoped admin access 2026-05). `RelationshipService.get_document_relationships` had no structural mask at all — over-reporting doc+corpus write permissions on structural relationships for normal users. Both services now mask structural rows to `user.is_superuser`. Tests: `opencontractserver/tests/permissioning/test_structural_mypermissions_breakglass.py`.
- **Consolidated permissioning guide audited against the GraphQL + service layers** (`docs/permissioning/consolidated_permissioning_guide.md`): corrected the Discussion-Thread moderation rules (EDIT permission on corpus/document does NOT grant moderation; `CorpusModerator` with a non-empty permissions list does; `Corpus.user_can_moderate` divergence documented per #1450), the `DocumentActionsService` permission bullets (no superuser branch — the old `_check_document_permission` "User is superuser" list contradicted scoped admin access), anonymous conversation visibility (THREADs inherit public corpus/document context), the `get_users_permissions_for_obj` group-permission default (`True`, not `False`), Pitfall 1 (`allAnnotations` without `corpusId` returns structural-only, not empty), CorpusCategory management (superuser-gated GraphQL mutations exist alongside Django Admin), and the agent section (`ws/agent-chat/` URL, awaited `_check_user_permissions`, real test-file paths, issue #820 fault-tolerance split). Renamed every stale `*QueryOptimizer` reference to the current per-app services and dead `opencontractserver/<app>/query_optimizer.py` paths to `opencontractserver/<app>/services/…`. Documented previously-missing behavior: the issue #1682 `get_corpus_documents` vs `get_corpus_documents_visible_to_user` dual semantics, the `opencontracts.E001` system check (dual enforcement + scan-scope nuance), `BaseService.filter_visible_qs`, and reference-table rows for Note / UserFeedback / Embedding / CorpusAction / Notification.
- **Stale "superuser bypass" code comments removed** (scoped admin access 2026-05 left contradicting docstrings behind): `PermissionQuerySet.visible_to_user` + `BaseVisibilityManager` + `PermissionManager` (`opencontractserver/shared/`), `ChatMessageQuerySet.visible_to_user` (`opencontractserver/conversations/models.py`), `get_annotation_images_with_permission` (`opencontractserver/llms/tools/image_tools.py`), and `DocumentRelationshipService.user_has_permission` (`opencontractserver/documents/services/relationships.py`). Also fixed CLAUDE.md's pitfall #2 (`ConversationQueryOptimizer` → `ConversationService`).
6 changes: 6 additions & 0 deletions changelog.d/perm-audit-2026-06.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- **Relationship privacy recursion implemented (closes the Phase-C deferral from issue #1655).** `RelationshipManager.user_can` and `RelationshipManager.visible_to_user` (`opencontractserver/shared/Managers.py`) now enforce `created_by_analysis` / `created_by_extract` source permissions exactly like annotations: non-creators need the requested permission on the source Analysis/Extract in addition to the doc+corpus MIN. Previously a user with doc+corpus grants could see and write analysis-/extract-private relationships through the manager surfaces. The recursion logic is shared with `AnnotationManager` via the new module-level `_source_privacy_recursion_passes`. Parity pinned by new fixtures/tests in `opencontractserver/tests/permissioning/test_authorization_invariants.py` (`RelationshipAuthorizationInvariantsTestCase` — the old `test_no_privacy_widening_via_created_by_analysis` sentinel that documented the deferral is replaced by real privacy invariants) and `opencontractserver/tests/permissioning/test_relationship_privacy_scoping.py`.
- **Extracts locked down to never be anonymous-visible.** `ExtractService.get_visible_extracts` / `check_extract_permission` (`opencontractserver/extracts/services/extract_service.py`) previously exposed `is_public` extracts in public corpora to anonymous callers (drift copied from the analysis service, reachable through the un-gated `extracts` GraphQL resolver), contradicting the documented "Extract — never" anonymous contract. A new `ExtractManager` (`opencontractserver/extracts/models.py`) denies anonymous users on both `visible_to_user` and `user_can` so the rule holds at the manager layer too (e.g. agent tools listing extracts). User-visible consequence: anonymous viewers of a public corpus now see `totalExtracts: 0` in `corpusStats` (the resolver's counts are per-viewer by contract). Two legacy tests that pinned the old semantic were updated to the agreed behavior: `test_query_optimizer_methods.py::test_get_visible_extracts_anonymous_only_public` (now `..._anonymous_sees_nothing`) and `test_stats.py::test_public_corpus_stats_query` (anonymous branch). New coverage: `opencontractserver/tests/permissioning/test_extract_anonymous_lockdown.py`.
- **Group-granted analysis/extract permissions now unlock private rows in list queries.** The `created_by_*` privacy gates in `AnnotationQuerySet.visible_to_user` (`opencontractserver/shared/QuerySets.py`), `AnnotationService.get_document_annotations` / `get_corpus_annotations`, and `RelationshipService.get_document_relationships` consulted only the USER object-permission tables, so a viewer whose source grant arrived via a Django group passed `user_can` (which resolves group grants by default) but never saw the rows in lists — a filter/check parity drift. All gates now route through the new shared builders in `opencontractserver/utils/source_visibility.py`, which join the group object-permission tables; the exclusion shape itself is also consolidated there as `apply_source_privacy_gate(qs, user)` (one home instead of six call-site copies), and the gate carries the same authenticated creator exemption as the queryset disjunct — without it the service listings were the odd surface out for relationships (both manager surfaces honoured a creator who lost source access while the document view hid the row). Two more drifts in the same parity class were closed in the builders: (a) `Extract.is_public` — the inline gates omitted the flag for extracts (while honouring it for analyses) even though `user_can`'s recursion grants READ on public sources; `visible_extracts_for` now includes `is_public` on the authenticated branch (no practical exposure today: no user-facing flow sets the flag); (b) grant shape — the inline gates matched ANY guardian row on the source, so an `update_analysis`-only grantee saw private rows in lists while failing `user_can(READ)` (which resolves the read codename); the builders now filter `permission__codename="read_analysis"` / `"read_extract"`. Tests: `test_source_visibility_group_grants.py` + new invariant fixtures incl. `test_public_extract_source_passes_both_surfaces` and `test_update_only_source_grant_does_not_unlock_lists`.
- **Anonymous privacy leak in corpus-wide annotation listing fixed.** `AnnotationService.get_corpus_annotations` applied the `created_by_*` privacy exclusion only to authenticated users — anonymous viewers of a public corpus could see analysis-/extract-private annotations on public documents. The exclusion now applies unconditionally (anonymous resolves to public-analyses-only / no extracts via `source_visibility`). Regression pins: `opencontractserver/tests/permissioning/test_privacy_gate_regressions.py`.
- **Privacy gate added to three aggregate/listing surfaces that skipped it** (2026-06 audit review rounds): `AnnotationService.get_label_distribution_for_corpus` (powers the user-facing `corpusIntelligenceAggregates` label panel — leaked label names/counts of private-source annotations; now takes a REQUIRED keyword-only `user` (omission is a `TypeError`, never a silent under-count)), `RelationshipService.get_relationship_summary` (leaked counts of private relationships into the document-type summary), and `RelationshipService.get_corpus_relationships` (the MCP corpus listing exposed private relationships; its corpus-as-gate document scope is retained deliberately per issue #1682 — MCP is a pipeline-facing caller — and now documented inline). Tests: `test_privacy_gate_regressions.py`.
- **`ExtractService.get_visible_extracts` filter/check parity with `check_extract_permission`** (pre-existing drift tightened while hardening the file): the listing consulted only USER-level guardian rows (any codename) on the extract leg and a `codename__contains="read"` substring match on the corpus leg, while the single-object check resolves both through `user_can` (group grants + exact read codename). The listing now joins `ExtractGroupObjectPermission` / `CorpusGroupObjectPermission` and matches `read_extract` / `read_corpus` exactly; the sibling `AnalysisService.get_visible_analyses` carried the identical drift (user-table-only Exists + `codename__contains="read"` corpus leg) and received the same fix. Tests: `ExtractListingGrantParityTestCase` in `test_extract_anonymous_lockdown.py` and `test_analysis_listing_grant_parity.py`.
1 change: 1 addition & 0 deletions config/graphql/corpus_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ def resolve_corpus_intelligence_aggregates(self, info, corpus_id) -> Any:
visible_doc_ids=visible_doc_ids,
top_n=CORPUS_INTELLIGENCE_LABEL_DISTRIBUTION_TOP_N,
exclude_label_prefix=OC_RESERVED_LABEL_PREFIX,
user=info.context.user,
)
label_distribution = [
LabelDistributionEntryType(
Expand Down
Loading