Implement security-filter trimming with Entra ID groups¶
Draft outline: not validated
Drafted with AI from official sources on 16 Oct 2026. Do not use on a live system until it is written, dry-run by someone other than the author and signed off (planned 2026-11-19). Author: Yogeshwaran · dry-run: Kranthi · sign-off: Deeparani. Work items: RAG-04-T05, RAG-04-T06, RAG-04-T07, RAG-04-T08.
1. Summary¶
Build the one shared retrieval function that every query path calls. It resolves the signed-in user's Entra ID groups through Microsoft Graph, including nested groups. It then adds a search.in security filter to every Azure AI Search request and returns no results when it cannot do that safely. Persona tests in CI prove it before release.
Use this after PB-RAG-04-01 has chosen security filters (Option A).
Risk: High (a bug in this function is a data leak). Typical duration: 2-3 days including tests. Facts checked against Microsoft Learn, AWS and OpenSearch documentation on 30 October 2026. Stable data-plane REST API: 2026-04-01.
2. When to use and when not to¶
Use this how-to when:
- The decision guide selected security filters, and the identities are Microsoft Entra ID users and groups.
- You are building or reviewing the retrieval layer of a RAG app, chat app or agent tool over permissioned content.
- You add a new query path (for example suggestions or an agent tool) and must route it through the shared function.
Do not use it when:
- You have not yet chosen between security filters and built-in ACL trimming. Use PB-RAG-04-01 first.
- You chose the built-in POSIX-like ACL, SharePoint ACL or Purview label approach (preview,
x-ms-query-source-authorization). The service checks the user token itself. Follow Document-level access control in Azure AI Search instead. - You must remove access now for a revoked user or document. Use PB-RAG-04-03.
- You suspect a leak in production. Use PB-RAG-04-04.
- The index lacks filterable
group_idsanduser_idsfields. Plan the schema with PB-RAG-03-01 and rebuild with PB-RAG-09-01.
3. What you must know first¶
- The filter only trims; it does not authenticate. A security filter is a plain string match on a field. There is no authentication or authorisation through the principal. Your app must sign the user in and take the user id from the validated token. Source: Security filters for trimming results.
- Use transitive membership. Microsoft Graph
GET /users/{id}/transitiveMemberOfreturns groups, directory roles and administrative units, direct and transitive. The default page size is 100; follow@odata.nextLink. An OData cast such as/microsoft.graph.groupneedsConsistencyLevel: eventualand$count, which read an index that might not be up to date. Call it without query parameters and keep only#microsoft.graph.groupobjects. Sources: transitiveMemberOf, Advanced query capabilities. - Do not trust the token's
groupsclaim for trimming. A JWT carries at most 200 groups (150 for SAML, 6 for the implicit flow). Above that, the token has an overage indicator (hasgroupsor_claim_names) instead of the list. The claim is current only when the token is issued. "Groups assigned to the application" excludes indirect membership. Source: Configure group claims and app roles in tokens. search.inis the efficient match, and it is case-sensitive.group_ids/any(g: search.in(g, '${IDS}', ','))counts as one filter clause. The docs promise sub-second response for hundreds or thousands of values, with latency growing as the list grows. The list is limited only by request size: 16 MB for POST, an 8 KB URL for GET. Matching is case-sensitive, likeeq. Sources: search.in, Service limits.retrievable: falseis not a security control. It hidesgroup_idsfrom results. It is not field-level security. Trimming works only because every query carries the filter. Source: Security filters for trimming results.- Filter construction can open a leak.
andbinds tighter thanor, so an unbracketed business filter can widen results. Escape a single quote in a string constant by doubling it. Escape any value that comes from user input, especially in security filters. Sources: OData $filter syntax, OData language overview. - Vector and hybrid queries use the same filter, with two traps.
vectorFilterModepreFilteris the default for indexes created after about 15 October 2023; older indexes default topostFilter. The previewvectorQueries.filterOverride(2026-08-01-preview) fully replaces the top-level filter, including security trimming. Sources: Add a filter to a vector query, Create a hybrid query.
4. Prerequisites and access¶
| Item | Detail |
|---|---|
| Decision | PB-RAG-04-01 decision record selects security filters and states the agreed revocation delay. |
| Index | Fields from snippets/security-fields.json: group_ids and user_ids as Collection(Edm.String), filterable: true, retrievable: false. |
| App identity roles | Search Index Data Reader on the search service. It covers search, lookup, autocomplete and suggestions. |
| Graph permission | Application permission User.Read.All for /users/{id}/transitiveMemberOf, with admin consent. Alternative: delegated User.Read on /me/transitiveMemberOf (on-behalf-of flow). |
| Operator roles | Search Service Contributor and Search Index Data Contributor on the sandbox search service; Owner or User Access Administrator to assign roles; an Entra role that can grant app role assignments. |
| Tools | Azure CLI (az), curl, jq, envsubst, Python 3.10+ with requests, azure-identity and pytest. |
| Test data | A non-production tenant with three test users and four groups: all-staff, finance, a legal parent group and a legal child group nested inside it. |
Set the environment. Never paste tokens into tickets or chat.
export SEARCH_ENDPOINT="https://${SEARCH_SERVICE_NAME}.search.windows.net"
export SEARCH_RESOURCE_ID="${SEARCH_SERVICE_RESOURCE_ID}"
export INDEX_NAME="${PRODUCTION_INDEX_NAME}"
export TEST_INDEX_NAME="${SANDBOX_INDEX_NAME}"
export APP_PRINCIPAL_ID="${APP_MANAGED_IDENTITY_OBJECT_ID}"
export SEARCH_TOKEN="$(az account get-access-token --resource https://search.azure.com --query accessToken -o tsv)"
cd playbooks/rag/rag-04-permission-aware-retrieval-and-security-
Placeholders: ${SEARCH_SERVICE_NAME} (search service), ${PRODUCTION_INDEX_NAME} (index the app queries), ${SANDBOX_INDEX_NAME} (throwaway test index), ${APP_MANAGED_IDENTITY_OBJECT_ID} (object id of the app's managed identity service principal).
5. Steps¶
Prepare¶
Step 1. Confirm the security fields exist with the right attributes.
curl -s "${SEARCH_ENDPOINT}/indexes/${INDEX_NAME}?api-version=2026-04-01" \
-H "Authorization: Bearer ${SEARCH_TOKEN}" |
jq '.fields[] | select(.name == "group_ids" or .name == "user_ids") | {name, type, filterable, retrievable}'
Verify: two objects print, each with "type": "Collection(Edm.String)", "filterable": true and "retrievable": false. If a field is missing or not filterable, stop. Adding filterable needs a rebuild (PB-RAG-09-01).
Changes who can see what
Writing group_ids or user_ids changes visibility at once for every user of ${INDEX_NAME}. Wrong ids hide documents from their owners or show them to the wrong people. Run step 2 on a new index or the sandbox first. To undo, push the previous ACL values again with mergeOrUpload, from the export your ingestion pipeline keeps (PB-RAG-04-03).
Step 2. Populate group_ids and user_ids on every chunk at index time.
Write the source document's Entra ID object ids, in lower case, onto every chunk of that document. Push pipelines send them in the same mergeOrUpload action as the chunk (snippets/trimming-seed-docs.json shows the shape). Indexer pipelines that chunk with skipIndexingParentDocuments must carry the fields through the skillset's indexProjections selector mappings, because the parent document is not indexed:
{
"mappings": [
{ "name": "group_ids", "source": "/document/group_ids" },
{ "name": "user_ids", "source": "/document/user_ids" }
]
}
Then count chunks with no ACL at all:
curl -s -X POST "${SEARCH_ENDPOINT}/indexes/${INDEX_NAME}/docs/search?api-version=2026-04-01" \
-H "Authorization: Bearer ${SEARCH_TOKEN}" -H "Content-Type: application/json" \
-d '{"search": "*", "filter": "not group_ids/any() and not user_ids/any()", "count": true, "top": 0}' |
jq '."@odata.count"'
Verify: the count is 0. A chunk with no ACL is never returned to anyone, which is safe but hides content. Fix the source mapping, not the filter.
Step 3. Grant the app identity Microsoft Graph User.Read.All.
Tenant-wide read permission
User.Read.All (application) lets the app read every user's profile and memberships in the tenant. Get security approval first. To undo, delete the assignment: az rest --method DELETE --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${APP_PRINCIPAL_ID}/appRoleAssignments/${ASSIGNMENT_ID}".
GRAPH_SP_ID="$(az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv)"
APP_ROLE_ID="$(az ad sp show --id 00000003-0000-0000-c000-000000000000 \
--query "appRoles[?value=='User.Read.All'].id" -o tsv)"
az rest --method POST \
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/${APP_PRINCIPAL_ID}/appRoleAssignments" \
--body "{\"principalId\": \"${APP_PRINCIPAL_ID}\", \"resourceId\": \"${GRAPH_SP_ID}\", \"appRoleId\": \"${APP_ROLE_ID}\"}"
Verify: az rest --method GET --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${APP_PRINCIPAL_ID}/appRoleAssignments" --query "value[].appRoleId" lists ${APP_ROLE_ID}.
Step 4. Assign Search Index Data Reader to the app identity.
az role assignment create --assignee-object-id "${APP_PRINCIPAL_ID}" \
--assignee-principal-type ServicePrincipal \
--role "Search Index Data Reader" --scope "${SEARCH_RESOURCE_ID}"
Verify: az role assignment list --assignee "${APP_PRINCIPAL_ID}" --scope "${SEARCH_RESOURCE_ID}" --query "[].roleDefinitionName" -o tsv prints Search Index Data Reader. Role assignments can take five to ten minutes to take effect. Role-based access must be enabled on the service (Enable or disable role-based access control).
Build¶
Step 5. Add the shared retrieval function to the app.
Copy snippets/secure_search.py into the app. It resolves transitive groups, caches them, builds the filter and fails closed. Set SEARCH_ENDPOINT, SEARCH_INDEX (index or alias), GROUP_CACHE_TTL_SECONDS and ALLOWED_FILTER_FIELDS. Create one retriever per process and pass the oid claim from the validated access token:
from secure_search import SecureRetriever, Settings
retriever = SecureRetriever(Settings.from_env()) # one per process
def grounding_chunks(question: str, user_oid: str) -> list[dict]:
# user_oid comes from the validated token's oid claim, never from the request body
return retriever.search(question, user_oid, business_filters={"category": "policy"}, top=5)
Verify: from the app host, a search for a sandbox test user returns results, and the log has one trim_applied line with groups= and filter_sha256=, but no group ids.
Step 6. Block every other path to the index.
Add this check to CI and to the code review checklist. Suggestions, autocomplete, document lookup by key and agent tools all count as query paths.
if grep -rnE "docs/(search|suggest|autocomplete)|SearchClient\(" "${APP_SOURCE_DIR}" --include="*.py" |
grep -v "secure_search.py"; then
echo "direct index calls found"
exit 1
else
echo "no direct index calls"
fi
Verify: the command prints no direct index calls. Lookup by key (GET /docs/{key}) takes no filter, so implement it as a filtered search on the key field inside the shared function.
Test¶
Use the sandbox index only
Step 7 creates ${TEST_INDEX_NAME} and writes canary documents into it. Pointing it at a production index would add those documents for real users. To undo, delete the test index: curl -X DELETE "${SEARCH_ENDPOINT}/indexes/${TEST_INDEX_NAME}?api-version=2026-04-01" -H "Authorization: Bearer ${SEARCH_TOKEN}".
Step 7. Create and seed the sandbox index.
Export GROUP_ALL_STAFF_ID, GROUP_FINANCE_ID, GROUP_LEGAL_PARENT_ID and DENIED_USER_OID (lower case) first. envsubst writes them into the seed documents.
jq --arg name "${TEST_INDEX_NAME}" '{name: $name, fields: .}' snippets/security-fields.json > test-index.json
curl -s -X PUT "${SEARCH_ENDPOINT}/indexes/${TEST_INDEX_NAME}?api-version=2026-04-01" \
-H "Authorization: Bearer ${SEARCH_TOKEN}" -H "Content-Type: application/json" -d @test-index.json | jq -r .name
envsubst < snippets/trimming-seed-docs.json > seed.json
curl -s -X POST "${SEARCH_ENDPOINT}/indexes/${TEST_INDEX_NAME}/docs/index?api-version=2026-04-01" \
-H "Authorization: Bearer ${SEARCH_TOKEN}" -H "Content-Type: application/json" -d @seed.json |
jq '[.value[].status] | all'
Verify: the first call prints ${TEST_INDEX_NAME} and the last prints true.
Step 8. Run the persona tests.
snippets/test_trimming.py has offline tests (fake Graph and fake search) and live tests against the sandbox. The personas are an allowed user, a nested-group user, a denied user, a direct user grant, an overage user with more than 200 groups, a Graph outage and Graph throttling. The tests also cover filter injection, the group cache and audit logging.
pytest -q snippets/test_trimming.py
SEARCH_INDEX="${TEST_INDEX_NAME}" GROUP_CACHE_TTL_SECONDS=60 RUN_LIVE_TRIMMING_TESTS=1 \
LIVE_ALLOWED_USER_OID="${ALLOWED_USER_OID}" LIVE_NESTED_USER_OID="${NESTED_USER_OID}" \
LIVE_DENIED_USER_OID="${DENIED_USER_OID}" pytest -q snippets/test_trimming.py -k live
Verify: the first run prints 26 passed, 4 skipped. The second prints 4 passed: the allowed and nested users see doc-restricted, the denied user does not, and a simulated Graph outage returns no results.
Step 9. Run the tests in CI on every pull request.
Add the offline tests to the app's pipeline, and the live tests where the pipeline can reach the sandbox:
steps:
- script: pip install pytest requests azure-identity
displayName: Install test tools
- script: pytest -q tests/security/test_trimming.py
displayName: Security trimming persona tests (offline)
- task: AzureCLI@2
displayName: Security trimming persona tests (sandbox)
inputs:
azureSubscription: ${SANDBOX_SERVICE_CONNECTION}
scriptType: bash
scriptLocation: inlineScript
inlineScript: RUN_LIVE_TRIMMING_TESTS=1 pytest -q tests/security/test_trimming.py -k live
Verify: on a throwaway branch, change f"{security} and ({business})" to or in secure_search.py. The pipeline fails on test_business_filter_is_anded_and_escaped.
Operate¶
Step 10. Send the audit log to your log store.
The function logs trim_applied (user id, group count, filter hash), trim_result (result count), trim_fail_closed (reason) and search_failed. It never logs the group list or the filter text. Route the secure_search logger to Application Insights or your log store.
Verify: one test query produces one trim_applied line and one trim_result line in the log store within the ingestion delay, and no line contains a group id.
6. Verify it works¶
Run all checks against the sandbox index after step 8, and again after any change to the retrieval code.
- Verify:
pytest -q snippets/test_trimming.pyprints26 passed, 4 skipped. - Verify: the live run prints
4 passed. The allowed user and the nested-group user seedoc-restricted. The denied user seesdoc-publicanddoc-personalbut neverdoc-restricted. - Verify: with Graph unreachable (the live outage test), the function returns
[]and logstrim_fail_closed. It never returns unfiltered results. - Verify: the step 6 check prints
no direct index calls. - Verify: the step 2 count of chunks with no ACL is
0on the production index. - Verify: audit log lines carry
filter_sha256=and contain no group ids.
Pass criteria: every allowed case returns the document, every denied case does not, and a Graph outage returns no results.
7. Known issues and fixes¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Users in nested groups get no results | Only direct memberships (memberOf) or the token's groups claim used |
Resolve with transitiveMemberOf, as the snippet does. |
| Users in many groups get no results, or results stop after a group change | Token groups claim used; it is missing on overage and current only at token issue |
Resolve groups from Graph, not from the token. |
| A user added to a group still sees nothing after the cache expires | OData cast or $filter on transitiveMemberOf with ConsistencyLevel: eventual, which reads an index that might not be up to date |
Call transitiveMemberOf without query parameters and drop non-group objects in code. |
| Filter returns nothing although the ids look right | search.in is case-sensitive; ids were indexed in a different case |
Store and query ids in lower case. Re-push the ACL fields with mergeOrUpload. |
| HTTP 400 on queries for users in many groups | Filter built from hundreds of eq and or clauses, or sent with GET (8 KB URL limit) |
Use one search.in clause and POST. Check current limits in the docs. |
| A restricted document appears after a business filter is added | Business filter concatenated as raw text, or joined with or without brackets |
Build business filters from allow-listed fields with escaped values, and AND them to the bracketed security filter. |
| Hybrid query returns documents the user cannot read | vectorQueries.filterOverride (preview) replaced the top-level security filter |
Never send filterOverride from the shared function. If you must, repeat the security filter in every override. |
Vector queries return fewer than k results for users with few documents |
Index created before about 15 October 2023, so postFilter is the default |
Set vectorFilterMode to preFilter (the snippet does); old indexes must be recreated to support it. |
| Suggestions or autocomplete show titles of restricted documents | That path calls the index directly without the filter | Route it through SecureRetriever.suggest; the step 6 check catches new direct calls. |
| Every query returns no results after deployment | Graph returns 403 (no User.Read.All consent) or 429 (throttling), so the function fails closed |
Check the trim_fail_closed reason. Grant the permission (step 3) or honour Retry-After and raise the cache TTL within the revocation delay. |
Chunks of permissioned files have empty group_ids |
ACL not projected to chunks; indexer field mappings are bypassed with skipIndexingParentDocuments |
Map the ACL fields in indexProjections (step 2) and rerun the indexer. |
| Revoked user still sees content for a while | Group cache TTL, or the index ACL not yet updated | Keep the TTL within the agreed delay; call GroupCache.evict on revocation events; run PB-RAG-04-03 for urgent cases. |
8. Monitoring and alerts¶
The thresholds are team defaults to tune against a measured baseline. They are not platform recommendations.
| Signal | Source | Alert when | Action |
|---|---|---|---|
| Direct index call check (step 6) | CI pipeline | Any match | Block the merge. Route the call through SecureRetriever. |
| Persona tests (step 8) | CI pipeline, nightly live run | Any failure | Block the release. Start PB-RAG-04-04 if the failure is a leak. |
trim_fail_closed log lines |
Application log | Above the team threshold for 5 minutes | Read the reason. Fix Graph permission or connectivity; users get no results until then. |
Graph 429 responses in trim_fail_closed |
Application log | Any sustained occurrence | Honour Retry-After; raise the cache TTL within the revocation delay. |
SearchLatency |
Platform metric | Above the team baseline for 10 minutes | Compare group counts in trim_applied; very large search.in lists add latency. |
ThrottledSearchQueriesPercentage |
Platform metric | Above 0 for 10 minutes | Add replicas or reduce query volume. |
9. Cost and capacity notes¶
- Graph calls. One call per page of memberships (100 objects by default) on each cache miss. Graph throttles on usage limits. The cache TTL controls the call rate. Check current limits in the Microsoft Graph throttling guidance.
- Filter size.
search.inhas no fixed value limit, but latency grows with the list, and the whole request must fit in 16 MB (POST). Check current limits in Service limits. - Vector prefiltering. With a highly selective filter,
preFiltercan traverse much of the HNSW graph, which raises latency and lowers throughput. The docs suggestexhaustive: truefor very selective filters. Measure with the persona users before go-live. - Price. Latency fixes such as extra replicas cost money. Check the current price before approval.
10. AWS and open-source equivalents¶
| Platform | Equivalent | Key differences |
|---|---|---|
| Amazon Bedrock Knowledge Bases | Store group ids as a string-list attribute in each file's .metadata.json; filter with listContains inside orAll on Retrieve or RetrieveAndGenerate |
andAll and orAll take up to 5 expressions each, with one level of nesting, so users in many groups need coarser groups. listContains is best supported on Amazon OpenSearch Serverless. Resolve the user's groups in your app and pass them in the filter. |
| Amazon OpenSearch Service and OpenSearch | Document-level security (DLS) on a role, with ${user.roles} or ${attr.jwt.<NAME>} substitution in the DLS query |
DLS is set on roles in the security plugin, not in each query. A missing substitution variable throws an error. Without DLS, the app adds a filter on a keyword field, as in this playbook. |
| Other open-source engines | A filter on a multi-value string field, built by the app | Same pattern and the same rules: one shared function, fail closed, persona tests. Check each engine's filter size limits. |
11. Validation record¶
Pending. Planned dry-run scenario: A second engineer runs the persona tests against a sandbox index with three users, one nested group and one restricted document. Pass if every allowed and denied case behaves as expected and a Graph outage makes the function return no results.
12. Sources¶
Checked on 30 October 2026.
- Microsoft Learn, Security filters for trimming results in Azure AI Search: pattern, field attributes,
retrievablecaveat,mergeOrUploadof ACL fields. - Microsoft Learn, List a user's memberships, direct and transitive: endpoint, permissions, page size,
ConsistencyLevelnote. - Microsoft Learn, Document-level access control in Azure AI Search: the four approaches and preview status of the built-in ones.
- Microsoft Learn, OData search.in function: syntax, delimiters, case sensitivity, performance.
- Microsoft Learn, OData $filter syntax: operator precedence, filter size limits,
not ... any(). - Microsoft Learn, OData language overview: escaping quotes, injection warning for security trimming.
- Microsoft Learn, Filters for keyword search: request size limits for filters, case-sensitive text filters.
- Microsoft Learn, Add a filter to a vector query:
vectorFilterModemodes and defaults, selective filter cost. - Microsoft Learn, Create a hybrid query:
filterOverride(preview) replaces security filters. - Microsoft Learn, Add autocomplete and search suggestions:
$filteron suggest and autocomplete. - Microsoft Learn, Service limits in Azure AI Search: 16 MB payload, 8 KB URL.
- Microsoft Learn, Connect to Azure AI Search using roles: Search Index Data Reader scope.
- Microsoft Learn, Enable or disable role-based access control: enabling roles, propagation delay.
- Microsoft Learn, Define an index projection: parent fields repeated on chunks.
- Microsoft Learn, Configure group claims and app roles in tokens: overage limits and indicators,
oidclaim. - Microsoft Learn, Advanced query capabilities on Microsoft Entra ID objects: OData cast on
transitiveMemberOfneeds advanced query parameters. - Microsoft Learn, Microsoft Graph throttling guidance: 429 and
Retry-After. - Microsoft Learn, Grant and revoke API permissions to managed identities: Microsoft Graph app id and
User.Read.Allapp role lookup. - Microsoft Learn, Grant an appRoleAssignment to a service principal:
principalId,resourceId,appRoleId. - AWS documentation, Configure and customise queries and response generation: metadata filter operators and limits.
- OpenSearch documentation, Document-level security: DLS on roles and substitution variables.
Related playbooks¶
- PB-RAG-04-01: decide between security filters and built-in ACL trimming first.
- PB-RAG-04-03: apply an urgent revocation to the index.
- PB-RAG-04-04: leakage tests and incident response.
- PB-RAG-03-01: design the index fields, including the security fields.
- PB-RAG-05-01: carry source permissions through ingestion.
- PB-RAG-09-01: rebuild the index if a security field is missing.