Introduction
Retrieval-augmented generation changed how organizations expose internal knowledge to large language models, and GraphRAG - retrieval over a knowledge graph rather than a flat vector index - pushed that further by letting models reason over entities, relationships, and communities of information rather than isolated text chunks. Microsoft Research's GraphRAG approach, which builds hierarchical community summaries from an extracted knowledge graph, demonstrated that graph-structured retrieval can answer broad, thematic questions that plain vector similarity search struggles with. But every organization that has tried to put GraphRAG into production for internal data quickly runs into a problem that the original research papers mostly sidestep: real enterprise knowledge bases are not single-tenant, single-permission blobs of text. They are assembled from HR systems, legal repositories, customer records, and engineering wikis, each with its own access rules, and a retrieval layer that ignores those rules is a data breach waiting to happen.
This article is about the unglamorous but essential layer that sits between "we built a knowledge graph" and "we can safely let an LLM query it": authorization in the data path. We'll look at why bolting access control onto GraphRAG after the fact doesn't work, how attribute-based access control (ABAC) and access control lists (ACLs) map onto graph and relational retrieval, and the concrete mechanics of enforcing those policies - pre-filtering queries before they touch the graph or SQL engine, post-checking results after retrieval, and using Postgres row-level security (RLS) as a last line of defense. We'll also cover audit logging, because in regulated environments proving what was and wasn't disclosed is often as important as preventing the disclosure itself.
The Problem: Why RAG Authorization Is Different
Classic application authorization is comparatively simple: a user requests a specific resource - a document, a record, an API endpoint - and the application checks whether that user is allowed to see that specific resource before returning it. The check happens at a well-defined boundary, usually once per request. GraphRAG breaks this model in three ways. First, retrieval is generative and exploratory rather than targeted: the system doesn't know in advance which nodes, edges, or chunks it will need, because the LLM's query planner or the vector/graph search decides that dynamically based on semantic similarity and graph traversal. Second, a single answer is often synthesized from dozens of retrieved fragments pulled from multiple underlying sources - a support ticket system, a wiki, a codebase, a contract repository - each potentially governed by a different authorization model. Third, the output is natural language generated by a model, which means that even if you successfully filter out unauthorized source chunks, you still have to worry about the model inferring or leaking sensitive information indirectly through summarization or community-level abstractions that blend authorized and unauthorized content before the authorization check ever runs.
This last point deserves emphasis because it is the one most GraphRAG implementations get wrong first. In the Microsoft GraphRAG architecture, community detection algorithms (commonly Leiden) cluster entities into hierarchical groups, and an LLM generates a summary for each community during an offline indexing pass. If that summarization step runs over the entire graph without regard to document-level permissions, the resulting community summary is already a derived artifact that mixes restricted and unrestricted information. No amount of query-time filtering recovers isolation once two differently-permissioned facts have been compressed into a single generated sentence. This means authorization boundaries have to be respected not just at query time but at indexing time, which is a materially different requirement than classic document-level RAG.
The practical consequence is that "authorization for GraphRAG" is not one control but a chain of controls applied at different points in the pipeline: ingestion-time tagging, index-time partitioning or filtering, query-time pre-filtering, post-retrieval verification, and storage-level enforcement as a backstop. Each layer catches a different class of failure, and none of them is sufficient alone. A pre-filter that is misconfigured, for instance, is only caught if a post-check or RLS policy independently rejects the same unauthorized row - defense in depth matters here precisely because the query surface (arbitrary natural-language-derived graph traversals) is too dynamic to trust a single filtering pass.
ABAC and ACL in a Graph and Relational Context
Access control lists are the older, simpler model: each resource (a node, a document, a row) carries an explicit list of principals - users or groups - who may access it, typically with a permission level (read, write, admin). ACLs map naturally onto graph databases because the ACL itself can be modeled as a relationship: (:User)-[:CAN_READ]->(:Document) or (:Group)-[:HAS_ACCESS_TO]->(:Collection). This is straightforward to reason about and query, but it scales poorly when permissions depend on many combinations of context - department, data classification, project membership, time of access - because you end up materializing a combinatorial explosion of relationships or constantly rewriting them as organizational structure changes.
Attribute-based access control, formalized in NIST Special Publication 800-162, instead evaluates a policy function over attributes of the subject (role, clearance, department), the resource (classification, owner, project tag), and the environment (time, location, request context) at evaluation time, rather than enumerating permissions in advance. In a knowledge graph, this typically means every node and edge carries attribute properties - classification: "confidential", owner_dept: "legal", project: "project-falcon" - and the authorization layer evaluates a policy expression against the requesting user's attributes before or during traversal. The advantage is that policies compose: a single rule like "users may read nodes where node.classification <= user.clearance_level AND node.project IN user.project_memberships" covers an arbitrary and growing set of resources without per-resource bookkeeping.
In practice, most production systems use both models together rather than treating them as a binary choice. ACLs handle explicit, exceptional grants - "this contractor needs read access to exactly these three documents" - while ABAC handles the bulk of routine, rule-based access governed by organizational attributes. This hybrid is consistent with how frameworks like OpenFGA (an open-source implementation inspired by Google's Zanzibar paper) and Apache Ranger approach policy evaluation: Zanzibar-style systems express relationship-based access control (ReBAC), which is a generalization that can encode both ACL-style direct grants and ABAC-style attribute rules as relation tuples evaluated against a policy graph. For GraphRAG specifically, the authorization graph and the knowledge graph can be modeled as separate graphs that are joined at query time, which keeps the permission model independently auditable from the content model - a separation of concerns that matters a great deal when compliance teams need to review access policy without wading through domain data.
Distributed sourcing compounds this further. When a knowledge base ingests from multiple backend systems - each with its own native authorization model (Confluence space permissions, Jira project roles, a CRM's row-level ownership) - the ingestion pipeline has to translate each source's native permission model into a common attribute schema at ingest time. This normalization step is where many real-world leaks originate: if the Confluence connector fails to carry over a "restricted" label because the common schema has no equivalent field, that document becomes silently world-readable in the unified graph. Building an authorization schema expressive enough to represent the union of all source systems' permission primitives, and validating that translation with automated tests per connector, is unglamorous work, but it is the single highest-leverage investment for a multi-source GraphRAG deployment.
Enforcing Authorization in the Data Path
The core engineering question is where, mechanically, these policies get enforced. There are three complementary enforcement points: pre-filtering the query before it touches the data engine, post-checking results after retrieval but before they reach the LLM context, and enforcing policy inside the database itself via row-level security.
Pre-filtering means injecting the authorization predicate directly into the generated query - the Cypher traversal or SQL statement - before execution, so the database never even reads rows the user can't see. This is the most efficient approach because it reduces the actual working set and lets the query planner use indexes on the permission attributes. A parameterized Cypher pre-filter looks like this:
import neo4j, { Driver, Session } from "neo4j-driver";
interface UserContext {
userId: string;
clearanceLevel: number;
departments: string[];
projectMemberships: string[];
}
async function retrieveRelevantEntities(
driver: Driver,
ctx: UserContext,
seedEntityNames: string[],
maxHops: number = 2
) {
const session: Session = driver.session({ defaultAccessMode: neo4j.session.READ });
try {
// The authorization predicate is embedded in the MATCH/WHERE clause,
// not applied after the fact, so unauthorized nodes are never traversed.
const query = `
MATCH (seed:Entity)
WHERE seed.name IN $seedNames
CALL apoc.path.expandConfig(seed, {
maxLevel: $maxHops,
relationshipFilter: "RELATED_TO|MENTIONS|PART_OF",
labelFilter: "+Entity|+Document"
}) YIELD path
WITH nodes(path) AS pathNodes
UNWIND pathNodes AS n
WITH DISTINCT n
WHERE n.classification <= $clearanceLevel
AND (n.ownerDept IN $departments OR n.visibility = "org-wide")
AND (size($projectMemberships) = 0 OR
any(p IN n.projectTags WHERE p IN $projectMemberships))
RETURN n.id AS id, n.name AS name, n.classification AS classification
LIMIT 200
`;
const result = await session.run(query, {
seedNames: seedEntityNames,
maxHops,
clearanceLevel: ctx.clearanceLevel,
departments: ctx.departments,
projectMemberships: ctx.projectMemberships,
});
return result.records.map((r) => ({
id: r.get("id"),
name: r.get("name"),
classification: r.get("classification"),
}));
} finally {
await session.close();
}
}
Note that the WHERE clause on classification and department runs inside the same Cypher statement as the graph traversal, not as a separate filtering step in application code - this is deliberate, because filtering in application code after fetching unauthorized rows means those rows already crossed a trust boundary (they existed in process memory, potentially in logs, potentially in a stack trace) before being discarded.
Post-checking is the second layer, applied after retrieval and before the retrieved content is assembled into the LLM's context window. Even with a correct pre-filter, you want a second, independently-implemented check - ideally using a different code path or even a different service - that re-verifies every chunk's authorization before it's templated into the prompt. This catches bugs in the pre-filter logic, stale cache entries, and race conditions where permissions changed between the pre-filter query and context assembly:
from dataclasses import dataclass
from typing import Optional
@dataclass
class RetrievedChunk:
chunk_id: str
source_system: str
classification: int
owner_dept: str
project_tags: list[str]
text: str
class AuthorizationError(Exception):
pass
def post_check_and_assemble_context(
chunks: list[RetrievedChunk],
user_clearance: int,
user_departments: set[str],
user_projects: set[str],
audit_log,
) -> str:
"""
Independent re-verification of every retrieved chunk before it enters
the LLM prompt. This intentionally duplicates the pre-filter logic
using a separate policy evaluation function (see policy_engine.py)
so a bug in one layer doesn't silently bypass the other.
"""
approved_chunks = []
for chunk in chunks:
allowed = (
chunk.classification <= user_clearance
and (chunk.owner_dept in user_departments or chunk.owner_dept == "public")
and (not chunk.project_tags or set(chunk.project_tags) & user_projects)
)
audit_log.record(
chunk_id=chunk.chunk_id,
source=chunk.source_system,
decision="allow" if allowed else "deny",
reason="post_check",
)
if allowed:
approved_chunks.append(chunk)
# Deliberately do not raise on denial: silently drop the chunk
# rather than erroring, so a single over-broad pre-filter result
# doesn't crash the whole request for the user.
if not approved_chunks:
audit_log.record_event("empty_context_after_post_check")
return "\n\n---\n\n".join(c.text for c in approved_chunks)
The deliberate redundancy here - re-implementing the same logical check twice - is a defense-in-depth pattern, not an engineering oversight. Security reviews of RAG systems consistently find that the highest-severity data leaks occur when teams assume the first filter is sufficient and skip the second.
Row-level security inside Postgres is the third and most durable layer, because it moves enforcement into the database engine itself, where it applies regardless of which application code path issues the query - including ad hoc analyst queries, background jobs, and future code that a developer writes without remembering the authorization requirements. Postgres RLS, available since version 9.5, lets you attach policies directly to tables that the query planner enforces transparently:
-- Enable RLS on the table backing vector/text chunks used for retrieval
ALTER TABLE knowledge_chunks ENABLE ROW LEVEL SECURITY;
-- Policy: a session may only see rows matching its clearance and department,
-- using session variables set by the application after authenticating the user.
CREATE POLICY chunk_access_policy ON knowledge_chunks
FOR SELECT
USING (
classification <= current_setting('app.user_clearance')::int
AND (
owner_dept = ANY(string_to_array(current_setting('app.user_departments'), ','))
OR visibility = 'org-wide'
)
);
-- Force RLS even for the table owner role, which otherwise bypasses RLS by default
ALTER TABLE knowledge_chunks FORCE ROW LEVEL SECURITY;
The application sets the session variables per connection (or per transaction, using SET LOCAL inside a transaction to avoid leaking context across pooled connections):
BEGIN;
SET LOCAL app.user_clearance = '3';
SET LOCAL app.user_departments = 'legal,finance';
SELECT * FROM knowledge_chunks
WHERE embedding <-> '[0.012, -0.034, ...]' < 0.3
ORDER BY embedding <-> '[0.012, -0.034, ...]'
LIMIT 20;
COMMIT;
Because SET LOCAL variables are scoped to the transaction, this approach is safe to use with connection pooling (PgBouncer in transaction mode, for example) as long as every request wraps its reads in an explicit transaction that sets the context before querying - a detail that's easy to get wrong and worth covering explicitly in code review checklists and integration tests.
Implementation Patterns for Distributed, Multi-Source Knowledge Bases
When a GraphRAG system draws from genuinely distributed sources - separate databases, separate graph partitions, or federated services owned by different teams - the authorization design has to decide whether enforcement is centralized or pushed down to each source. A centralized policy decision point (PDP), in the style of the XACML (eXtensible Access Control Markup Language) architecture standardized by OASIS, evaluates every access request against a single policy store and returns an allow/deny decision that the retrieval layer enforces. This gives you one place to audit and change policy, but it requires every source connector to call out to the PDP for every candidate chunk, which adds latency proportional to the number of candidates - potentially hundreds per query in a graph traversal.
A more scalable pattern pushes policy evaluation down into each source's native query language wherever possible - Cypher WHERE clauses, SQL RLS policies, Elasticsearch document-level security - and reserves the centralized PDP for cross-cutting policy management and for resources where the native engine can't express the policy (for example, a policy that depends on a real-time external attribute like an active incident status). This hybrid is what most mature implementations converge on: policy is authored and versioned centrally, compiled down into source-native enforcement artifacts (RLS policies, Cypher predicates, OpenSearch role mappings), and a PDP handles the residual cases and acts as the audit log of record.
Audit logging deserves its own design attention because GraphRAG's non-deterministic retrieval makes after-the-fact investigation much harder than for a conventional document access log. You need to record not just "user X queried the system" but the full retrieval trace: which seed entities were used, which nodes and edges were traversed, which chunks were pre-filtered out, which were post-checked and dropped, and - critically - the final set of chunks that were actually included in the prompt sent to the model. Without that last detail, you cannot answer the compliance question that inevitably comes up after an incident: "what exactly did the model see when it generated this specific response?" A practical approach is to emit a structured, immutable audit event per request containing a content-addressed hash of each included chunk (not the chunk text itself, to avoid duplicating sensitive data into the log store) plus the policy decision metadata, and to retain these logs separately from the application database with its own, stricter access controls.
Trade-offs and Common Pitfalls
The most consequential trade-off in this design space is latency versus assurance. Every additional enforcement layer - pre-filter, post-check, RLS, PDP call - adds round trips and CPU cost to a retrieval path that is already latency-sensitive because it sits in front of an LLM call users are waiting on. Teams often respond by caching permission attributes aggressively, which is reasonable, but stale permission caches are one of the most common real-world causes of authorization drift: a user is removed from a project, but a 15-minute cache TTL on their project memberships means they can still retrieve project-restricted content from the knowledge graph for that window. Cache invalidation on permission changes (pushing an invalidation event on every ACL or attribute update, rather than relying purely on TTL expiry) closes this gap but adds operational complexity to every system that mutates permissions.
The second major pitfall, already touched on above, is permission leakage through derived artifacts - summaries, embeddings, and community detections computed over mixed-permission data during offline indexing. Because GraphRAG's value proposition depends heavily on these aggregate summaries, teams are tempted to compute them globally for efficiency and filter only at query time. This is a structural mistake: once a summary blends two differently-permissioned facts, you cannot un-blend them at query time, and the summary itself becomes a new artifact that needs its own classification, typically inherited as the maximum (most restrictive) classification of its inputs. This means indexing pipelines for multi-tenant or permission-segmented knowledge bases often need to build community summaries per-permission-partition rather than globally, which increases indexing cost and reduces the cross-cutting insight that made GraphRAG attractive in the first place - a real tension without a clean resolution, only a choice about where to draw partition boundaries.
Best Practices
Treat the authorization schema as a first-class artifact, versioned and reviewed with the same rigor as the knowledge graph schema itself. Attributes like classification levels and department codes should be defined in a shared, typed schema (not ad hoc string literals scattered across connectors) so that a new data source can be integrated against a known contract rather than reverse-engineered from existing code. Where possible, express ABAC policies in a declarative language - Open Policy Agent's Rego, Cedar (used by AWS Verified Permissions), or even well-structured Cypher/SQL templates - rather than embedding conditional logic directly in application code, since declarative policies are far easier to audit, test in isolation, and diff during security review.
Test authorization paths with the same seriousness as functional correctness: maintain a suite of integration tests that assert specific users cannot retrieve specific chunks, run against a seeded test graph with known permission boundaries, and run this suite on every change to the retrieval pipeline or connector code - not just on changes explicitly labeled as security-related, since regressions most often come from unrelated refactors. Combine this with periodic adversarial testing: have someone deliberately try to extract restricted information through indirect queries (asking about a topic rather than a specific document) to validate that community summaries and multi-hop traversals don't leak through aggregation.
Finally, instrument for observability from day one. Log every policy decision, not just denials, because understanding the shape of normal access patterns is what lets you detect anomalies later, and retain enough context in each log entry to reconstruct exactly what the model was shown without having to re-run the query. Pair this with regular reviews of the gap between your pre-filter, post-check, and RLS policies - in a well-functioning system these three should almost always agree, and any divergence is itself a signal worth investigating before it becomes an incident.
Mental Model: Authorization as a Pipeline, Not a Gate
It helps to stop thinking of authorization as a single gate a request passes through and instead think of it as a pipeline of independent, overlapping checks - similar to how network security rarely relies on a single firewall rule but layers network segmentation, host-based firewalls, and application-level authentication. Each layer in the GraphRAG authorization pipeline is built by a different team, tested differently, and fails differently: the pre-filter fails when a connector's schema translation is incomplete, the post-check fails when policy logic drifts out of sync between two implementations, and RLS fails when a session variable isn't set correctly in a pooled connection. None of these failure modes overlap, which is exactly why having all three materially reduces the chance that any single bug results in a disclosure.
A second useful mental model is to treat the knowledge graph's permission metadata the way you'd treat data lineage: every node, edge, and derived summary should be able to answer "where did I come from, and under what access constraints was I allowed to exist." This framing makes the index-time leakage problem - summaries blending permissions - intuitive, because it's the same failure mode as losing data lineage in an ETL pipeline: once two inputs are merged without tracking provenance, you cannot retroactively separate them.
Key Takeaways
- Enforce authorization at indexing time, not only at query time - offline summarization and community detection over mixed-permission data creates leakage that query-time filtering cannot undo.
- Implement pre-filtering directly inside the Cypher/SQL query (not in application code after fetching), so unauthorized rows never cross a trust boundary.
- Add an independent post-check as a second, differently-implemented policy evaluation before chunks enter the LLM prompt - treat it as defense in depth, not redundant work.
- Use Postgres row-level security (with
FORCE ROW LEVEL SECURITYand transaction-scopedSET LOCALsession variables) as a database-level backstop that applies even to code paths you didn't anticipate. - Log the actual chunks included in each generated response, not just access attempts, so you can answer "what did the model see" during a compliance review or incident investigation.
Conclusion
GraphRAG offers a genuine advance over flat vector retrieval for organizations that need an LLM to reason over interconnected, distributed knowledge, but the research literature that popularized it was largely written assuming single-permission, single-tenant data. Production deployments inside real organizations don't get that luxury - they have to reconcile HR records, legal documents, and engineering wikis, each governed by different rules, into a system an LLM can query freely and unpredictably. The engineering answer isn't a single clever filter; it's a layered pipeline of controls - ABAC and ACL policy models mapped into graph and relational schemas, pre-filtering baked into the query itself, independent post-checks before context assembly, and Postgres RLS enforcing the floor regardless of what the application layer does - each catching a different class of failure the others miss.
None of this is exotic technology. ABAC, RLS, and audit logging are decades-old, well-understood primitives; the work is in applying them consistently across a retrieval system whose query patterns are generated by an LLM rather than written by a developer, and in resisting the temptation to compute convenient global summaries that quietly erase the permission boundaries you spent so much effort enforcing everywhere else. Teams that treat authorization as integral to the indexing and retrieval architecture - rather than a compliance checkbox added after the prototype works - are the ones that get to keep using GraphRAG once it touches real, sensitive, distributed data.
References
- Edge, D., et al. "From Local to Global: A Graph RAG Approach to Query-Focused Summarization." Microsoft Research, 2024. https://arxiv.org/abs/2404.16130
- Microsoft GraphRAG Project Documentation. https://microsoft.github.io/graphrag/
- NIST Special Publication 800-162: "Guide to Attribute Based Access Control (ABAC) Definition and Considerations." National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/162/final
- PostgreSQL Documentation: "Row Security Policies." https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- OASIS. "eXtensible Access Control Markup Language (XACML) Version 3.0." https://docs.oasis-open.org/xacml/3.0/xacml-3.0-core-spec-os-en.html
- Pang, R., et al. "Zanzibar: Google's Consistent, Global Authorization System." USENIX ATC, 2019. https://research.google/pubs/pub48190/
- OpenFGA Documentation (Zanzibar-inspired open-source authorization). https://openfga.dev/docs
- Open Policy Agent Documentation (Rego policy language). https://www.openpolicyagent.org/docs/latest/
- Apache Ranger Documentation. https://ranger.apache.org/
- openCypher Project (Cypher query language specification). https://opencypher.org/
- Neo4j Documentation: "Access Control / Role-Based Security." https://neo4j.com/docs/operations-manual/current/authentication-authorization/
- OWASP. "Access Control Cheat Sheet." https://cheatsheetseries.owasp.org/cheatsheets/Access_Control_Cheat_Sheet.html