Home / AI & GenAI / Vector Databases

AI & GenAIHow-toAdvanced

Permission-aware RAG: document-level security trimming with ACL metadata and identity filters

Make a RAG assistant return only what each user may read. Capture ACLs at ingestion, store them as filterable metadata, apply identity-based filters at query time, keep permissions fresh, and test for leaks before and after go-live.

Deepak Bhardwaj5 min read

Applies toRAG over SharePoint, file shares, Confluence, wikis or ticket systems; Azure AI Search, pgvector, Qdrant, OpenSearch and similar stores with metadata filtering; Entra ID group claims

A RAG assistant indexed with a service account can read everything. If retrieval does not filter by the asking user's permissions, the assistant becomes the easiest way to read the CFO's folder. Security trimming has to be designed in, not added later.

The model

Ingestion: document -> chunks -> each chunk carries allowed_groups / allowed_users / classification
Query:     user identity -> group IDs -> filter(allowed_groups ∩ user_groups ≠ ∅) -> vector/hybrid search

The filter must be applied inside the search, before ranking, not afterwards in the application. Post-filtering returns fewer (or zero) results and is easy to get wrong.

Step 1: capture ACLs at ingestion

For each source, read effective permissions and resolve them to stable IDs (Entra object IDs, not display names):

Source Where the ACL comes from
SharePoint / OneDrive Microsoft Graph permissions on the item (and inherited site/library permissions)
File shares (SMB) NTFS ACLs (SIDs), mapped to Entra objects if synced
Confluence / wikis Space and page restrictions via API
Ticket systems Project / queue membership
Databases Row ownership columns

Store on every chunk:

{
  "id": "hr-policy-2026.pdf#12",
  "content": "...",
  "embedding": [ ... ],
  "allowed_groups": ["6f1c...-hr-team", "a92b...-all-managers"],
  "allowed_users": [],
  "classification": "confidential",
  "source_url": "https://contoso.sharepoint.com/...",
  "acl_synced_at": "2026-10-29T06:00:00Z"
}

Warning

"Everyone except" style permissions (deny entries) do not map cleanly to allow-list filters. Either resolve them at ingestion into explicit allowed principals, or do not index those documents.

Step 2: index fields as filterable

Azure AI Search field definition:

{ "name": "allowed_groups", "type": "Collection(Edm.String)", "filterable": true, "retrievable": false }

pgvector:

ALTER TABLE chunks ADD COLUMN allowed_groups uuid[] NOT NULL DEFAULT '{}';
CREATE INDEX chunks_allowed_groups_gin ON chunks USING gin (allowed_groups);

Qdrant: create a payload index on allowed_groups (keyword type) so filtering is efficient.

Step 3: filter at query time from the token, not the request

Get the user's groups from the validated access token (or a server-side Graph lookup), never from a parameter the client sends.

def user_groups(token_claims) -> list[str]:
    groups = token_claims.get("groups", [])
    if token_claims.get("_claim_names", {}).get("groups"):   # group overage: too many groups for the token
        groups = graph_member_of(token_claims["oid"])          # transitive membership via Graph
    return groups

Azure AI Search:

groups = user_groups(claims)
flt = "allowed_groups/any(g: search.in(g, '{}'))".format(",".join(groups))
results = search_client.search(search_text=q, vector_queries=[vq], filter=flt, top=8)

pgvector:

SELECT id, content
FROM chunks
WHERE allowed_groups && $1::uuid[]          -- overlap with user's groups
ORDER BY embedding <=> $2
LIMIT 8;

With HNSW indexes, restrictive filters can return fewer than LIMIT rows; on pgvector 0.8+ enable iterative scans (SET hnsw.iterative_scan = relaxed_order;). In Qdrant and Azure AI Search, filtered vector search is applied during the search.

Azure AI Search also has built-in document-level access control features for some sources (for example permission filtering with Entra tokens in recent API versions). Check current documentation; when available, it reduces custom code.

Step 4: keep permissions fresh

Permissions change more often than content. Two sync loops:

Loop Frequency What
Content On change New and changed documents, re-chunk and embed
ACL Frequent (minutes to an hour) Update allowed_groups on existing chunks without re-embedding

Track acl_synced_at and alert when a source has not synced within its window. When a document is deleted or a user is removed from a group, the next query must reflect it; group membership changes are picked up at query time automatically because you filter on current token groups.

Step 5: protect the rest of the pipeline

  • Caches: a semantic or response cache keyed only by question leaks across users. Include a hash of the user's group set in the cache key, or cache retrieval results per permission set.
  • Logs and traces: retrieved chunks in traces are as sensitive as the documents. Restrict trace access.
  • Citations: only show links the user can open (they can, if trimming is right, which doubles as a check).
  • Agents and tools: tools that fetch documents must use the user's identity (on-behalf-of), not the service account.

Step 6: test for leaks

Create test users in groups with known access, and documents with known permissions:

CASES = [
    ("user_hr", "What is the 2026 salary band for L5?", "must_find", "hr-salary-bands-2026"),
    ("user_eng", "What is the 2026 salary band for L5?", "must_not_find", "hr-salary-bands-2026"),
]
for user, q, expect, doc in CASES:
    got = {r["doc"] for r in retrieve_as(user, q, k=20)}
    assert (doc in got) == (expect == "must_find"), (user, q)

Run them in CI and after every ACL sync change. Also test the answer, not just retrieval: the model should say it has no information, not hint at restricted content.

Caution

Never test leakage only with admin accounts. Admins can read everything, so every test passes.

Prevent it next time

  • No source goes into the index without an ACL mapping and an owner.
  • ACL sync monitored with an alert on staleness.
  • Leak test suite in CI with representative users.
  • Periodic access review of what the RAG service account can read; remove what it does not need.