Enterprise Access Control: Securing RAG with RBAC and ABAC

Naive RAG collapses data at different access levels into one index. Here is how to build identity-aware filtering so an employee sees through the assistant exactly what they can access in the source systems.

Enterprise Access Control: Securing RAG with RBAC and ABAC

# Enterprise Access Control: Securing RAG with RBAC and ABAC

An operations clerk asks the assistant: "What were the terms on the last deal with this customer?" The assistant searches the RAG index, finds a document from the legal department, and hands it over. The clerk has no access to that document in SharePoint. They didn't hack anything; they just asked the bot, and the bot isn't a rights subject. The retriever found the most relevant hit, the model summarized it. This is how AI produces **privileged leakage**: data the person has no access to at the source flows out through the assistant anyway.

<!--more-->

This is a fundamental defect of naive RAG. Indexing collapses data at different access levels into a single vector index, and semantic search doesn't know about permissions. The fix is an **identity plane**: RAG must return answers strictly within the rights the user actually has in the source systems — Confluence, Jira, SharePoint, PostgreSQL — and that must be provable.

## The business goal

A user gets from RAG exactly what they're allowed to see in the source systems — no more. Three promises anchor the work:

1. No answer contains data the asker lacks access to at the source.
2. Access revocation at the source propagates into RAG within minutes, not days.
3. When a regulator or auditor asks "why did this employee see this," the answer lies in the permissions, not in the retriever's luck.

## What drives the work: threat and regulator

- **Confused deputy**: the AI acts under its own identity with the full permissions of the index, rather than on behalf of the user. A classic internal-leak vector.
- **OWASP LLM**: LLM02 (Sensitive Info Disclosure), LLM06 (Excessive Agency — when RAG tools reach into systems).
- **GDPR / banking secrecy**: access to PII without a legal basis is a violation even inside the company.
- **AI Act audit**: access to high-risk data must be logged and justified.

## The architectural pattern: Identity-Aware Hybrid Filtering

The 2026 enterprise-architecture consensus: don't choose between fast pre-filtering and precise authz — combine both.

JWT(user: groups, roles, tenant_id) │ ▼ [1] Pre-filter in the vector DB by ACL metadata (recall, cheap) │ candidate chunks ▼ [2] FGA post-check: CheckBulkPermissions against each chunk's source document │ permitted chunks (precision, ReBAC/ABAC) ▼ [3] LLM → [4] OPA post-filter on the answer (field masking, forbidden combinations)


The pre-filter cheaply cuts out the bulk. The FGA service gives a precise check where the payload filter isn't enough — complex relationships, ABAC context. The user's rights penetrate **the retrieval itself**, rather than being layered onto the output after the fact.

## Engineering stack

- **Vector DB with payload filters**: Qdrant, Pinecone, PostgreSQL (pgvector).
- **Identity**: OpenID Connect (OIDC), Keycloak; JWT with `groups`, `roles`, `tenant_id`.
- **Fine-Grained Authorization (ReBAC/ABAC)**: OpenFGA, SpiceDB, Cerbos, Auth0 FGA — `CheckBulkPermissions` in the retrieval path.
- **Output-side policy**: OPA (Rego).
- **Orchestration**: LlamaIndex / LangChain.

## Implementation

### Step 1. ACL as chunk metadata + version

At indexing time, each chunk's payload gets a normalized ACL array and an `acl_version`. The version is the key to instant invalidation.

payload = { "tenant_id": doc.tenant_id, "allowed_groups": doc.acl_groups, # from the source system "source_doc_id": doc.id, # for FGA post-check "acl_version": doc.acl_version, }


### Step 2. Pre-filter from the JWT security context

flt = {"must": [ {"key": "tenant_id", "match": {"value": user.tenant_id}}, {"key": "allowed_groups","match": {"any": user.groups}}, ]} candidates = qdrant.search(query_vec, query_filter=flt, limit=50)


### Step 3. FGA post-check against the source

allowed = fga.batch_check( user=f"user:{user.id}", relation="viewer", objects=[f"doc:{c.payload['source_doc_id']}" for c in candidates], ) chunks = [c for c in candidates if allowed[c.payload["source_doc_id"]]]


### Step 4. ACL sync — event-driven, not batch

Subscribe to permission-change webhooks from the source systems; permission changes reconcile within minutes. Bump the `acl_version` → stale chunks are invalidated. A nightly batch sync is unacceptable here: a day with a revoked access still valid is a day of leakage.

### Step 5. OPA at the output

The final answer and its citations pass through policy: masking of individual fields, banning dangerous combinations, redacting reference-link metadata.

## Where it breaks

- **ACL drift is the main risk.** Permissions change at the source, the index lags → you hand over what's already forbidden. The only fix is event-driven sync plus real-time FGA checking. Pre-filtering against the index always lags by the sync delay.
- **Leakage through aggregation.** Chunks permitted individually reveal something forbidden in aggregate. Neither pre-filter nor FGA sees this — it needs OPA/business-rule logic on top.
- **Citation metadata.** Content is filtered, but the source's title or link in the answer reveals the mere existence of the document. Clean up citations too.
- **FGA latency.** `CheckBulkPermissions` across dozens of chunks adds delay; balance pre-filter depth against post-check volume.
- **ABAC context.** "Access only during business hours / only for your own region / only with a business justification" doesn't fit cleanly into a payload — it moves into FGA/OPA, adding complexity.

## Standards and mapping

- **ISO/IEC 42001**: access controls for AI system data and systems.
- **EU AI Act**: Art. 10 (data governance), access logging for high-risk.
- **OWASP LLM**: LLM02, LLM06.
- **NIST AI RMF**: Govern/Map — data and access control.

## Lab and artifact

Set up Qdrant with payload ACLs and `acl_version`, OIDC via Keycloak, pre-filter by `tenant_id`+`groups`, an OpenFGA relationship model + `batch_check` in the retrieval path, webhook-based access-revocation sync, OPA at the output. Test: two users with different permissions ask the same question from the introduction — compare the results; revoke access and measure reconciliation time. **Artifact**: FGA model + rego policies + a "role × document → access" test matrix + a sync-lag metric.

## Maturity checklist

- **L1**: a single index filtered by `tenant_id`.
- **L2**: pre-filter by groups from the JWT + FGA post-check against the source, OPA at the output.
- **L3**: event-driven ACL sync (minutes), protection against aggregation and citation leaks, ABAC context, an audit trail for every access.

## Sources

- [Document-Level RBAC for RAG (2026 guide, Truto)](https://truto.one/blog/how-to-maintain-document-level-rbac-in-enterprise-rag-pipelines/)
- [Fine-Grained Authorization for RAG with OpenFGA (Auth0)](https://auth0.com/ai/docs/intro/authorization-for-rag)
- [The Right Approach to Authorization in RAG (Oso)](https://www.osohq.com/post/right-approach-to-authorization-in-rag)
- [RAG with Access Control (Pinecone)](https://www.pinecone.io/learn/rag-access-control/)

---

If your RAG assistant returns an answer the asker could not have seen in the source system, you have built a privilege-escalation tool with a chat interface.

Leave a Reply

Your email address will not be published. Required fields are marked *