fix(retrieval): enforce group ACL in RAG retrieval (fail closed)#20
fix(retrieval): enforce group ACL in RAG retrieval (fail closed)#20yuzushi-dev wants to merge 2 commits into
Conversation
Group document ACL was enforced by the direct document endpoints (via RLS) but NOT by RAG retrieval, so a user in a group-enforced tenant could retrieve and get answers from documents their groups were never granted, then hit 404 when opening the same document directly. Root causes fixed here: 1. Group state never reached retrieval. AuthMiddleware computed group_ids / groups_enforced but built QueryScopes via resolve_query_scopes(tenant_id), leaving enforce_groups=False / group_ids=[]. resolve_query_scopes now accepts and carries them, and auth threads them through. Tenant admins and root/super_admin platform keys are exempted (enforce_groups=False), mirroring the RLS documents policy so their retrieval matches their direct document access (and root tenant-impersonation keeps working). 2. Own-tenant retrieval failed open. _resolve_vector_targets / _resolve_graph_targets applied the group allowlist only with a candidate set or a cross-tenant scope; the common own-tenant, no-candidate query left the target filter None and the backends (tenant-filter only) returned everything. They now resolve the allowlist for the viewer's own tenant when enforcement is on and skip the target when it is empty. 3. Scope not propagated to every retrieval entry point. The SSE /query/stream handler, the DRIFT inner retrieve() calls, and the agent search tool (create_retrieval_tool, both stream and non-stream agent branches) called retrieve() without query_scopes and fell back to open scopes. All now pass the authenticated scopes through. 4. Result cache ignored the ACL scope. The vector ResultCache key omitted the per-viewer allowlist, so same-tenant users with different grants could share an entry. The resolved ACL document scope is now part of the key. Behaviour is unchanged when group enforcement is off. Known follow-ups (non-interactive/system contexts with no request scope, do not carry query_scopes yet): the worker retrieval path (src/workers/tasks.py) and the eval harness. They need an explicit scope source and are tracked separately. Unit regression tests in tests/unit/test_retrieval_group_acl.py; the retrieval/visibility integration tests need a database and run in CI.
The RAG paths in this PR thread query_scopes and enforce the group ACL, but QueryUseCase.execute runs the STRUCTURED fast-path (structured_executor) as step 1, before any ACL, with tenant_id only. Its Cypher templates filter on tenant_id alone (no group join) and Neo4j has no Postgres-RLS backstop, so a group-restricted non-admin asking "list all documents" / "how many documents" / "list entities|relationships" could enumerate filenames, entities and relationship text from documents their groups were never granted — the same leak this PR claims to close for vector/graph retrieval. Skip the structured fast-path when group enforcement is active for the caller (query_scopes.enforce_groups) and fall through to the ACL-enforced RAG path. Behaviour is unchanged when enforcement is off (the common case), so admins / non-enforced tenants keep the fast-path. Regression test asserts structured_executor.try_execute is not awaited under enforcement and still runs when enforcement is off. Follow-up (not fixed here): resolve_query_scopes defaults enforce_groups=False, so any retrieval call site that forgets to pass query_scopes fails OPEN. The reachable API routes set request.state.query_scopes via auth, but the default should be hardened (derive enforcement from tenant config, or make scopes mandatory) so new/forgotten call sites fail closed.
|
Follow-up commit CRITICAL fix: Fix: skip the structured fast-path when Open follow-up (not fixed here): Deploy: pure code, no new deps → per the prod runbook, |
Context
Two negative prod feedbacks (2026-07-14) reported RAG answers whose sources 404'd on "View Document". Investigation showed the users (group Engineering, tenant
default,groups_enforced=true) had no folder grants, yet retrieval surfaced and answered from documents the direct document endpoint (correctly) denies via RLS. The missing prod grants were fixed operationally; this PR fixes the underlying code defect: group document ACL was enforced on direct document access but not in RAG retrieval.Security impact
On a
groups_enforcedtenant, any non-admin user could retrieve and receive answers from any document in their tenant via RAG (chat/stream, non-stream, DRIFT, agent tool), regardless of their group grants. Fail-open authorization bypass.Root causes fixed
AuthMiddlewarecomputedgroup_ids/groups_enforcedbut builtQueryScopesviaresolve_query_scopes(tenant_id), leavingenforce_groups=False.resolve_query_scopesnow accepts and carries them; auth threads them through. Tenant admins androot/super_adminplatform keys are exempted (mirrors the RLS documents policy; preserves root tenant-impersonation)._resolve_vector_targets/_resolve_graph_targetsapplied the group allowlist only with a candidate set or a cross-tenant scope; the common own-tenant, no-candidate query left the target filterNone→ backends (tenant-filter only) returned everything. Now they resolve the allowlist for the viewer's own tenant when enforcement is on and skip the target when empty (fail closed)./query/streamhandler, DRIFT's innerretrieve()calls, and the agentcreate_retrieval_tool(stream + non-stream agent branches) calledretrieve()withoutquery_scopes. All now pass the authenticated scopes.ResultCachekey omitted the per-viewer allowlist → same-tenant users with different grants could share an entry. The resolved ACL document scope is now part of the key.Behaviour is unchanged when group enforcement is off.
Peer review
Adversarially reviewed with Codex (GPT-5.5, xhigh) across 5 rounds; each round's findings (scope threading gaps, admin/root regressions, a broken test double) were fixed until the final review reported no regressions.
Tests
tests/unit/test_retrieval_group_acl.py(new): own-tenant enforced applies the allowlist without a candidate; empty allowlist yields no target (fail closed); enforcement-off keeps open scope; graph path mirrors vector;resolve_query_scopesthreads group state.tests/unit/test_query_stream_agent_mode.pystub to accept the new kwarg.Known follow-ups (out of scope)
Non-interactive/system contexts with no request scope do not carry
query_scopesyet: the worker retrieval path (src/workers/tasks.py) and the eval harness. They need an explicit scope source and are tracked separately.Deploy
No data migration. Group enforcement must already be intended for the tenant. Requires
apirestart to take effect (not performed).