Skip to content

feat(policy): ACL push-down at retrieval — AclPolicyEngine (Step 6.3) - #150

Merged
officialCodeWork merged 1 commit into
mainfrom
build/phase-6/step-6.3-acl-pushdown
Jun 7, 2026
Merged

feat(policy): ACL push-down at retrieval — AclPolicyEngine (Step 6.3)#150
officialCodeWork merged 1 commit into
mainfrom
build/phase-6/step-6.3-acl-pushdown

Conversation

@officialCodeWork

Copy link
Copy Markdown
Owner

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_labels onto RequestContext.principal.acl_labels, but nothing enforced them — filter_pushdown scoped tenant_id only, so any principal in a tenant could read every chunk. 6.3 fixes that.

  • AclPolicyEngine (rag-policy) — a thin decorator (like QuotaPolicyEngine, Step 4.5) that 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. Everything else delegates to the inner engine, so it composes with a production PDP rather than absorbing it.
  • Overlap via the existing AnyIn predicate — the semantics the codebase already assumed (the FilterExpr docstring, the policy-engine extension example, the filter tests), and every backend translator already speaks it (pgvector &&, Qdrant MatchAny, Cypher ANY(), the noop evaluate). So no new predicate and no translator changes. Injected at the canonical HybridRetriever read_chunk PDP site (which already merges filter_pushdown), so no new call site / coverage-linter entry.
  • Fail-closed: the clause is uniform — 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-free acl.egress_denied event (the pre-registered EVT_ACL_DENIED).
  • Opt-in via new 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_config reads cfg.acl.

AclConfigrag.schema (config-only; no dist/schemas or proto churn).

Scope & boundaries (see ADR-0035)

  • Overlap (not subset) semantics — a principal needs one matching label, not all of a chunk's.
  • Push-down enforcement only — post-retrieval egress re-verification is Step 6.4; graph edge ACLs (edge_filter) are deferred (only node_filter carries the clause today).

Documentation

Test plan

  • ruff check + ruff format --check clean
  • mypy --strict clean (294 source files)
  • RAG001 logging gate clean
  • schema-drift regenerated (committed); policy-coverage + log-schema/event-registry pass
  • full suite 2546 passed, 4 skipped
  • ~17 new tests: an end-to-end ACL red-team through HybridRetriever — overlap / disjoint-labels / fail-closed / public-label / no-tenant-bypass (tests/redteam/test_acl_isolation.py); AclPolicyEngine unit + acl.egress_denied emission (tests/policy/test_acl_engine.py); config + gateway wiring (apps/gateway/tests/test_acl.py)

🤖 Generated with Claude Code

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>
@officialCodeWork
officialCodeWork merged commit fb3646b into main Jun 7, 2026
12 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant