feat(policy): ACL push-down at retrieval — AclPolicyEngine (Step 6.3) - #150
Merged
Merged
Conversation
Enforce label-based ACLs at retrieval. Step 6.1 resolved each tenant's acl_labels
onto the principal, but nothing enforced them — filter_pushdown scoped tenant_id
only, so any principal in a tenant could read every chunk. New AclPolicyEngine
(a decorator like QuotaPolicyEngine) And-merges
any_in("acl_labels", sorted(principal.acl_labels)) into every read_chunk
push-down: a chunk is retrievable only when its labels overlap the principal's.
- Overlap semantics via the existing AnyIn predicate — the semantics the codebase
already assumed and every backend translator already speaks (pgvector &&, Qdrant
MatchAny, Cypher ANY(), noop evaluate), so zero new predicate / translator work.
Injected at the canonical HybridRetriever read_chunk PDP site (no new call site).
- Fail-closed: any_in([]) matches nothing, so a label-less principal retrieves
nothing (model "public" as a shared label granted to all); each label-less
request emits one PII-free acl.egress_denied event (EVT_ACL_DENIED).
- Opt-in via cfg.acl.enabled (default off → pre-6.3 behaviour); build_app
(acl_enabled=...) wraps the engine, build_app_from_config from config. Decorates
without absorbing the inner engine.
Scope is push-down enforcement only — post-retrieval egress re-verification is 6.4,
graph edge ACLs deferred. ~17 new tests incl. an end-to-end ACL red-team through
HybridRetriever. All gates green (ruff, mypy --strict 294 files, RAG001,
schema-drift, policy-coverage, log-schema).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Step 6.3 — ACL push-down at retrieval: enforce label-based ACLs so a principal only retrieves chunks they're cleared for. Built as a single PR.
Step 6.1 resolved each tenant's
acl_labelsontoRequestContext.principal.acl_labels, but nothing enforced them —filter_pushdownscopedtenant_idonly, so any principal in a tenant could read every chunk. 6.3 fixes that.AclPolicyEngine(rag-policy) — a thin decorator (likeQuotaPolicyEngine, Step 4.5) that And-mergesany_in("acl_labels", sorted(principal.acl_labels))into everyread_chunkpush-down; a chunk is retrievable only when its labels overlap the principal's. Everything else delegates to the inner engine, so it composes with a production PDP rather than absorbing it.AnyInpredicate — the semantics the codebase already assumed (theFilterExprdocstring, the policy-engine extension example, the filter tests), and every backend translator already speaks it (pgvector&&, QdrantMatchAny, CypherANY(), the noopevaluate). So no new predicate and no translator changes. Injected at the canonicalHybridRetrieverread_chunkPDP site (which already mergesfilter_pushdown), so no new call site / coverage-linter entry.any_in([])matches nothing — so a label-less principal retrieves nothing rather than everything. Public data is modeled as a shared label (e.g.public) granted to all principals (no special predicate). Each label-less request emits one PII-freeacl.egress_deniedevent (the pre-registeredEVT_ACL_DENIED).cfg.acl.enabled(default false → pre-6.3 behaviour, tenant scoping only), because enforcement changes which chunks a principal can retrieve.build_app(acl_enabled=…)wraps the engine;build_app_from_configreadscfg.acl.AclConfig→rag.schema(config-only; nodist/schemasor proto churn).Scope & boundaries (see ADR-0035)
edge_filter) are deferred (onlynode_filtercarries the clause today).Documentation
Test plan
ruff check+ruff format --checkcleanmypy --strictclean (294 source files)HybridRetriever— overlap / disjoint-labels / fail-closed / public-label / no-tenant-bypass (tests/redteam/test_acl_isolation.py);AclPolicyEngineunit +acl.egress_deniedemission (tests/policy/test_acl_engine.py); config + gateway wiring (apps/gateway/tests/test_acl.py)🤖 Generated with Claude Code