Skip to main content

Implement secure document-level access in Azure AI Search for multi-tenant apps using enforced security filters, tenant isolation, and protected vector search retrieval.

Security ArchitectureAzure AI SearchbaseFilterfilterAddOnEntra ID Groups

Strict Isolation: Implementing Document-Level Security
in Azure AI Search for Multi-Tenant Apps

A shared vector index is a shared vector index, regardless of how confidently your application code claims to filter it. Relevance ranking doesn't know about tenants. Semantic reranking doesn't know about tenants. The only thing standing between User A and User B's confidential documents is whichever filter your code remembered to attach on that particular request — and "remembered to attach, every time, on every code path" is not a security boundary. It's a hope.

The failure signature this guide resolves
# The incident report that starts this conversation, every time:

  User:     alice@tenant-acme.com  (Tenant: ACME Corp)
  Query:    "What's in our Q3 termination severance policy?"
  Response: Grounded in document "hr-policy-2024.pdf"
  Actual owner of that document: Tenant "Globex Industries" — a
            DIFFERENT customer of the same multi-tenant SaaS product.

# The application code, on inspection, looked completely reasonable:

async def retrieve_context(query: str, user_id: str):
    results = search_client.search(
        search_text=query,
        query_type="semantic",
        top=5,
        # filter=f"tenant_id eq '{get_tenant(user_id)}'"   <-- commented
        #                                                       out during
        #                                                       a debugging
        #                                                       session six
        #                                                       weeks ago,
        #                                                       never restored
    )
    return [r["content"] for r in results]

# No error. No exception. No log line. The query executes perfectly —
# against the ENTIRE shared index, across every tenant, because nothing
# at the SERVICE layer enforces the boundary. The application was the
# only thing standing between tenants, and the application forgot.

# What SHOULD have caught this, at the platform level:
PUT /knowledgesources/tenant-docs-ks?api-version=2026-05-01-preview
{
  "searchIndexParameters": {
    "baseFilter": "tenant_id eq @@CALLER_TENANT@@"   # persisted, cannot
  }                                                    # be omitted per-call

Symptom: A user's RAG chatbot response is grounded in a document belonging to a different tenant — data the user was never granted access to.  Failure point: Tenant isolation was implemented entirely in application code as a per-query filter, with no enforcement at the search-service layer. A missed filter on one code path is a full cross-tenant data leak.  Default platform behaviour: Azure AI Search returns whatever a query asks for, from whatever documents are in the index, regardless of who's asking — unless a filter constrains it. The service has no innate concept of "tenant" or "user" until you build one into the schema and the query.

baseFilter
Persisted on the knowledge source itself. Applies to every retrieve request automatically — cannot be forgotten per-call
filterAddOn
Supplied at runtime, per request. Composes with baseFilter using AND logic — narrows, never widens
Two layers
Tenant isolation belongs in baseFilter (fail-safe). Group-level trimming belongs in filterAddOn (per-query)
AND, not OR
The composition model is additive restriction — a runtime filter can only narrow what the base filter already allows, never expand it

Multi-tenant RAG has a specific failure mode that doesn't exist in single-tenant search: the index itself doesn't care who's asking. A vector similarity search finds the nearest neighbors to a query embedding regardless of which customer uploaded them. Semantic reranking scores relevance regardless of ownership. If tenant isolation lives only in a filter parameter that some code path forgot to attach — a new endpoint added in a hurry, a debugging session that commented it out, a background job that reused a client without the filter — the result isn't a bug users notice. It's a silent, undetected leak, because the query succeeds, the response looks plausible, and nothing throws an exception. The fix that actually holds under this pressure separates the two things that were tangled together in application code: a tenant boundary that is structurally difficult to omit, and a user-level trim that composes on top of it without ever being able to loosen it.

Figure 1 — Two layers of filtering, composed with AND: a request can only get narrower
baseFilter (persisted, knowledge-source-level) AND filterAddOn (runtime, per-request)SHARED VECTOR INDEX — all tenants, all users, every documentACME Corp docs · Globex Industries docs · Initech docs · ... (structurally, one big pool)LAYER 1 — baseFilter: "tenant_id eq 'acme-corp'"Persisted on the knowledge source. Applied to EVERY retrieve request automatically.No application code path can accidentally omit it — it isn't a parameter they pass.ANDLAYER 2 — filterAddOn: "group_ids/any(g: search.in(g, 'grp-hr,grp-mgr'))"Runtime, per request — built from the CALLER's actual Entra ID group token claims.Composes with baseFilter using AND — can only narrow further, never escape Layer 1.RESULT: ACME docs, HR/Manager-visible only — the caller's exact scope
Every retrieve request passes through both layers, and the composition is always AND — never OR. Even if the runtime filterAddOn is built incorrectly, malformed, or missing entirely on a rushed code path, the persisted baseFilter still enforces the tenant boundary. That structural property — the boundary living somewhere a per-request bug cannot reach — is what makes this a fix rather than a slightly-better version of the same fragile pattern.
01Why "Filter in Application Code" Is Not a Security BoundaryRoot Cause

The pattern that leaks is almost always the same shape: a developer adds a filter parameter to the search call, tests it, ships it, and it works — for that code path. The problem surfaces later, on a code path nobody was thinking about when the filter was designed: a new admin dashboard that reuses the same search client without remembering the filter; a background enrichment job that queries the index directly; a debugging session where someone commented the filter out "just to see the full result set" and forgot to restore it; a second developer, six months later, adding a new retrieval function and copying an older one that predates the filter.

None of these are exotic mistakes. They are the ordinary failure mode of a security control that lives as a convention rather than a structural property — every single call site has to remember to do the right thing, correctly, forever, and the control fails silently the moment any one of them doesn't.

Failure modeWhy it happensWhy "just review the code" doesn't fully fix it
New code path added without the filterDeveloper copies an older query pattern, or writes a new one from scratch, unaware the filter convention existsCode review catches what reviewers know to look for. A missing filter often doesn't look wrong — it looks like a working query
Filter commented out during debuggingDeveloper wants to see unfiltered results to diagnose a relevance issue, forgets to restore itThe change is small, easy to miss in a diff, and the code still runs without error
Background job bypasses the request-scoped filter logicBatch/cron jobs often construct their own search client, outside the normal request pipeline where the filter convention livedThese jobs are less frequently reviewed and rarely covered by the same integration tests as user-facing paths
Third-party integration calls the index directlyA partner service or internal tool is given search access and builds its own queriesThe filter convention is tribal knowledge that doesn't travel with API access
The test that reveals whether you actually have a security boundary

Ask a specific question about your current architecture: if a brand-new engineer, on their first day, writes a search query against your index without reading any internal documentation, does the query come back correctly scoped to one tenant — or does it return everyone's data by default? If the honest answer is "it returns everyone's data unless they remember to add the filter," you don't have a security boundary. You have a convention that has not failed yet. The fix in this article moves the boundary to a place a new engineer's first query cannot bypass by omission.

02The Two-Layer Model: baseFilter vs filterAddOnConcept

Azure AI Search's search index knowledge sources support exactly the split this problem needs, and the distinction between the two properties maps directly onto the distinction between tenant isolation and user-level trimming.

PropertyWhere it livesWhen it appliesUse it for
baseFilterPersisted on the searchIndexParameters of the knowledge source itselfEvery single retrieve request that uses this knowledge source, automaticallyTenant isolation. The boundary that must never be optional
filterAddOnSupplied in knowledgeSourceParams at retrieve time, per requestOnly on requests where the caller explicitly supplies itUser/group-level trimming. The narrower, per-query scope on top of the tenant boundary

The composition model is what makes this architecturally sound rather than just organizationally tidier: filterAddOn combines with the stored baseFilter using AND logic. A request can only ever narrow what the base filter already permits — it has no mechanism to widen it. Even a badly-constructed or missing runtime filter cannot cause a query to escape the tenant boundary, because that boundary isn't expressed as a parameter the request controls at all.

This is a precedence model, not just two independent filters

Microsoft's own release notes describe a full precedence model governing service defaults, knowledge source defaults, and per-request overrides — baseFilter and filterAddOn are the concrete expression of that hierarchy for search index knowledge sources specifically. Understanding it as a precedence chain, not two unrelated settings, is what makes the design choice obvious: put the thing that must never be skipped at the layer nearest the data, and the thing that legitimately varies per-caller at the layer nearest the request.

03Designing the Security Fields: tenant_id and group_idsDesign

Both filters need something to filter on. Before writing either baseFilter or filterAddOn expressions, the index schema needs two filterable fields designed for exactly this purpose.

Index schema — the two security fields, side by side{ "name": "docs-index", "fields": [ { "name": "id", "type": "Edm.String", "key": true }, { "name": "content", "type": "Edm.String", "searchable": true }, { "name": "contentVector", "type": "Collection(Edm.Single)", "dimensions": 1024, "vectorSearchProfile": "default" }, // TENANT ISOLATION FIELD — single value, every document has exactly one { "name": "tenant_id", "type": "Edm.String", "filterable": true }, // GROUP-LEVEL TRIM FIELD — collection, a document can be visible to // multiple groups within the same tenant { "name": "group_ids", "type": "Collection(Edm.String)", "filterable": true } ] }
FieldCardinalityPopulated fromUsed in
tenant_idOne value per documentYour application's tenant/customer identifier at ingestion timebaseFilter — the persistent boundary
group_idsCollection — a document can belong to several groupsEntra ID group object IDs the document should be visible to, set at ingestionfilterAddOn — the runtime, per-caller trim
group_ids is the documented default field name — for a reason

Microsoft's own security filter guidance for Azure OpenAI On Your Data uses group_ids as the default field name specifically for this pattern: a Collection(Edm.String), filterable, holding the Entra ID group object IDs permitted to see that document. You can rename it, but keeping the convention makes onboarding new engineers and support staff faster — and every internal Microsoft doc, sample, and troubleshooting guide assumes it by default.

Recommend group access over individual user access

Microsoft's document-level access guidance is explicit on this point: for ACL-secured content, prefer group access over individual user access, for ease of management. A document tagged with 3-4 group IDs that map cleanly to Entra ID security groups (e.g., grp-hr-benefits, grp-managers) is dramatically easier to keep correct over time than a document re-tagged every time an individual employee joins or leaves a team. Group membership changes happen in Entra ID; your index doesn't need to know about it at all if the filter is built from the token's current group claims at query time.

Architectural Topology: Failing vs Remediated

LayerFailing configuration (current)Remediated configuration (fix)
Tenant boundaryA filter string built and passed per-call in application codebaseFilter persisted on the knowledge source — not a per-call parameter
Group-level trimMixed into the same ad-hoc filter string, if present at allfilterAddOn built from the caller's live Entra ID group token claims
New code pathsMust remember to reconstruct the full filter from scratchAutomatically tenant-scoped via baseFilter; only need filterAddOn for finer trim
Background jobs / direct API callersBypass the filter convention entirely if they build their own clientStill tenant-scoped — the boundary lives on the knowledge source, not the caller's code
SchemaNo dedicated security fields, or an inconsistent ad-hoc fieldtenant_id (single) + group_ids (collection), both filterable
Failure mode of a bugMissing filter = full cross-tenant leakMissing filterAddOn = over-broad within one tenant, never across tenants
Group membership changesRequires re-tagging documents or re-indexingResolved live from the caller's token at query time — no index changes needed
Native ACL optionNot evaluated — custom filter is the only mechanism consideredConsidered alongside filters for ADLS Gen2 / SharePoint sources with existing ACLs (Section 7)
05Fix 1 — Persist Tenant Isolation in baseFilterThe Fix

This is the structural fix — the one that turns tenant isolation from a convention into a property of the knowledge source itself. Set it once, at knowledge source creation (or update), and every subsequent retrieve request through that knowledge source is bound by it, regardless of which code path issued the request.

REST — create a knowledge source with a persisted tenant baseFilterPUT {{search-url}}/knowledgesources/tenant-acme-ks?api-version=2026-05-01-preview Content-Type: application/json api-key: {{search-api-key}} { "name": "tenant-acme-ks", "kind": "searchIndex", "searchIndexParameters": { "searchIndexName": "docs-index", "baseFilter": "tenant_id eq 'acme-corp'" } }
One knowledge source per tenant is the pattern this design implies

Notice the shape: baseFilter is a fixed string on the knowledge source, not a template with a placeholder that gets filled per-request. That means the clean architecture is one knowledge source per tenant, provisioned when the tenant onboards, each with its own hardcoded tenant_id eq '...' baseFilter pointing at the shared underlying index. Your application looks up which knowledge source belongs to the authenticated caller's tenant and always retrieves through that one. There's no runtime string interpolation of the tenant ID into a filter — the tenant boundary is provisioned infrastructure, not request-time logic.

Python — provisioning a new tenant's knowledge source at onboarding timefrom azure.search.documents.indexes import SearchIndexClient from azure.search.documents.indexes.models import SearchIndexKnowledgeSource, SearchIndexKnowledgeSourceParameters from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() index_client = SearchIndexClient(endpoint=SEARCH_ENDPOINT, credential=credential) def provision_tenant_knowledge_source(tenant_id: str): """Called once, at tenant onboarding. Not called per-request.""" ks = SearchIndexKnowledgeSource( name=f"tenant-{tenant_id}-ks", search_index_parameters=SearchIndexKnowledgeSourceParameters( search_index_name="docs-index", base_filter=f"tenant_id eq '{tenant_id}'", # fixed, not runtime ), ) index_client.create_or_update_knowledge_source(ks) return ks.name # At retrieve time, the application only needs to know WHICH knowledge # source to call - tenant scoping is already baked into it. knowledge_source_name = f"tenant-{caller_tenant_id}-ks"
Sanitize tenant_id before it ever touches an OData filter string

Even though baseFilter is set at provisioning time rather than per-request, it is still constructed by string interpolation somewhere in your onboarding code. Validate that tenant_id is drawn from your own system (a GUID or slug you generated, not arbitrary user input) before it goes into the filter string, to avoid OData filter injection at the one place this pattern still builds a string dynamically.

06Fix 2 — Map Entra ID Group Tokens to filterAddOnThe Fix

With the tenant boundary structurally guaranteed by baseFilter, filterAddOn handles the narrower, legitimately-per-request concern: which groups within that tenant the specific caller belongs to. This is where the caller's Entra ID group membership token claims get translated directly into an OData filter expression.

Python — build filterAddOn from the caller's Entra ID token group claimsfrom azure.search.documents.knowledge import KnowledgeBaseClient from azure.search.documents.knowledge.models import ( KnowledgeBaseRetrievalRequest, SearchIndexKnowledgeSourceParams ) def build_group_filter(user_groups: list[str]) -> str: """ user_groups comes from the caller's Entra ID token — the 'groups' claim, or a Microsoft Graph memberOf lookup if the token uses the overage indicator (more groups than fit in the token directly). """ if not user_groups: # No group membership resolved -> fail CLOSED, not open. # An empty filter here would mean "everything visible" by # OData semantics on some constructions - be explicit instead. return "group_ids/any(g: g eq '__no_access__')" quoted = ", ".join(f"'{g}'" for g in user_groups) return f"group_ids/any(g: search.in(g, '{quoted}'))" async def retrieve_context(query: str, caller_tenant_id: str, caller_groups: list[str]): kb_client = KnowledgeBaseClient(endpoint=SEARCH_ENDPOINT, credential=credential) request = KnowledgeBaseRetrievalRequest( messages=[{"role": "user", "content": query}], knowledge_source_params=[ SearchIndexKnowledgeSourceParams( knowledge_source_name=f"tenant-{caller_tenant_id}-ks", # baseFilter lives HERE filter_add_on=build_group_filter(caller_groups), # composes with AND ) ], ) return await kb_client.retrieve(request)
Resolve group membership from the token, not from a client-supplied field

The group list passed into build_group_filter must come from a source the caller cannot influence — the validated Entra ID access token's groups claim, or a server-side Microsoft Graph memberOf call keyed off the token's verified subject. It must never be a field the client sends in the request body ("here are my groups: ..."). If your application accepts client-supplied group lists, the entire filter — however cleanly built — is decorative, because any caller can simply claim membership in whatever group unlocks the data they want.

Handle the group overage claim

Entra ID tokens have a practical limit on how many groups can be embedded directly in the groups claim. When a user belongs to more groups than fit, the token instead includes an overage indicator, and your application must call Microsoft Graph's memberOf endpoint to resolve the full list server-side. If your build_group_filter function only ever reads the token's inline groups claim, users in many groups will silently see an incomplete filter — narrower than intended, which fails safe in this direction, but will also generate confusing "why can't I see this document I should have access to" support tickets. Check for the overage indicator and fall back to Graph explicitly.

07Fix 3 — Native Token-Based ACL Enforcement (Where It Fits)Alternative

The filter-based pattern above is the general-purpose fix, and it works for any data source. But if your documents originate from a system that already has real ACLs — ADLS Gen2 containers and files, or SharePoint in Microsoft 365 — Azure AI Search has a more native mechanism worth knowing about: it can validate the caller's Microsoft Entra token directly against synchronized document ACL metadata, trimming results without you writing any filter expression at all.

The native pattern — attach the token, let the service enforceGET {{search-url}}/indexes/docs-index/docs/search?api-version=2026-05-01-preview Content-Type: application/json x-ms-query-source-authorization: Bearer {{caller-entra-id-token}} { "search": "termination severance policy", "top": 5 } # No filter expression needed. Azure AI Search evaluates the token # against the document ACL metadata (synced from ADLS Gen2 or # SharePoint) and trims results to only what the caller can read.
AspectCustom filter (baseFilter/filterAddOn)Native token-based ACL
Data source fitAny source — you own the schema and tagging entirelyADLS Gen2 (native), SharePoint (native) — sources with real, syncable ACLs
Setup effortDesign fields, build filter logic, maintain group tagging at ingestionLower — permissions sync from the source system automatically
Permission driftYou must keep group_ids current with reality yourselfSynced from the source of truth (ADLS Gen2 ACLs, SharePoint permissions)
Custom access modelsFull flexibility — model tenant + group + any custom dimension you wantLimited to what the source system's native ACL model expresses
Multi-tenant SaaS with app-level tenancyThe right fit — "tenant" usually isn't a concept ADLS Gen2 or SharePoint ACLs express directlyAwkward fit unless tenancy maps cleanly onto folder/site structure
For most multi-tenant SaaS RAG, the custom filter pattern is still the right default

The native ACL path is compelling when your documents genuinely originate from ADLS Gen2 or SharePoint with real, actively-maintained ACLs — you inherit correctness from a system that's already the source of truth for permissions. But "tenant" in a multi-tenant SaaS product is usually an application-level concept that doesn't map cleanly onto blob container ACLs or SharePoint site permissions. For that shape of problem — which is what this article is centrally about — the baseFilter/filterAddOn pattern remains the more direct fit, because you're modeling a concept (tenant) the underlying storage system doesn't natively have.

Figure 2 — Defense in depth: three independent layers, any one of which stops a leak
A REQUEST HAS TO PASS ALL THREE LAYERS — a bug in any ONE does not equal a cross-tenant leakLAYER 1 — Knowledge source selection: caller routed to THEIR tenant's knowledge sourceBug here: wrong knowledge source picked. Layer 2's baseFilter still enforces THAT source's tenant.LAYER 2 — baseFilter (persisted): tenant_id eq 'acme-corp'Bug here: cannot be omitted per-call — it's not a request parameter. Structural, not conventional.LAYER 3 — filterAddOn (runtime): group_ids/any(g: search.in(g, caller_groups))Bug here: over-broad WITHIN the tenant only. Layer 2 still prevents ANY cross-tenant exposure.A single-layer bug degrades scope. It does not breach the tenant boundary.
The point of layering isn't redundancy for its own sake — it's that each layer fails differently. A routing bug at Layer 1 still lands inside a correctly tenant-scoped knowledge source. A missing or malformed filterAddOn at Layer 3 makes results too broad within the tenant, but the tenant boundary at Layer 2 was never a parameter that bug could touch. This is the architectural difference between "usually works" and "cannot silently fail in the worst way."
08Fix 4 — Defense in Depth: Index-Per-Tenant vs Shared IndexArchitecture

The baseFilter/filterAddOn pattern assumes a shared index across tenants, which is the right default for most multi-tenant SaaS — one index, filtered per tenant, is dramatically simpler to operate than provisioning and maintaining an entire index per customer. But it's worth being explicit about when the shared-index assumption stops being the right one, because for a subset of customers it isn't.

ApproachBest forTrade-off
Shared index, per-tenant knowledge source + baseFilterMost multi-tenant SaaS — many small-to-medium tenants, self-serve onboardingOperationally simple; isolation is logical (filter-enforced), not physical
Index-per-tenantA small number of large, high-value, or regulatorily sensitive tenants (e.g. a healthcare or financial services customer with contractual data-isolation requirements)Physical isolation — no shared storage at all. Higher operational overhead: N indexes to manage, monitor, and scale
Hybrid — shared index for most, dedicated index for a fewSaaS products with a tiered customer base and a genuine enterprise/regulated tierMost complexity, but matches risk to isolation cost — don't pay physical-isolation overhead for every tenant if only a few need it
Physical isolation is a contractual answer, not usually a technical necessity

The honest engineering answer is that a correctly-implemented baseFilter boundary provides strong logical isolation — a bug cannot make one tenant's data appear in another tenant's results, for the structural reasons Figure 2 lays out. Index-per-tenant is usually not chosen because the filter pattern is insecure; it's chosen because a specific customer's contract, compliance framework, or internal risk policy requires physical rather than logical separation, and no amount of correct filter logic satisfies that requirement on paper. Know which of your tenants actually need that guarantee before defaulting everyone into the higher-overhead architecture.

09Anti-Patterns: Isolation That Looks Solid and Isn'tTraps

Because tenant isolation bugs are invisible until a specific unlucky query surfaces them, teams often ship patterns that look secure in code review and hold up fine until the one code path nobody checked.

Anti-patternWhy it feels rightWhy it isn't
Build the tenant filter as a per-request string in application code"It's just one more parameter on the query"Every new code path has to remember to build it correctly. This is the exact pattern that leaked in the opening incident
Accept client-supplied group IDs in the request body"The frontend already knows the user's groups"Any caller can claim membership in any group. The filter becomes decorative — it enforces whatever the client says, not reality
Empty group filter defaults to "show everything""If we can't determine groups, don't block the user"Fails open. A token parsing bug or a Graph API outage becomes a full permission bypass instead of a visible error
One knowledge source, tenant ID passed as filterAddOn instead of baseFilter"Same effect, one less thing to provision"Moves the tenant boundary back into per-request logic — exactly the structural weakness baseFilter exists to remove
Trust the group claim in an unvalidated or expired token"The token has a groups field, that's enough"An unvalidated token's claims are just strings an attacker can forge. Verify signature, issuer, audience, and expiry before trusting any claim inside it
Test only the happy path (correct tenant, correct groups)"The main flow works, ship it"The dangerous bugs live in the paths nobody tests: missing groups, malformed tokens, background jobs, admin overrides. Test those deliberately

Validation & Verification: Confirm the Fix

The only test that actually proves tenant isolation holds is an adversarial one: deliberately try to see another tenant's data, from a legitimate user account, and confirm it's impossible — not just unlikely.

Step 1 — Confirm baseFilter is actually persisted on the knowledge sourceGET {{search-url}}/knowledgesources/tenant-acme-ks?api-version=2026-05-01-preview api-key: {{search-api-key}} # PASS: response includes "baseFilter": "tenant_id eq 'acme-corp'" # FAIL: baseFilter missing or empty - the tenant boundary does not # exist at the service layer. Stop and fix this before anything else.
Step 2 — The adversarial test: try to retrieve cross-tenant using a valid ACME token# Authenticate as a REAL Acme Corp user. Deliberately query for content # you know only exists in a DIFFERENT tenant's documents. async def test_cross_tenant_isolation(): acme_token = get_valid_token(user="alice@tenant-acme.com") kb_client = KnowledgeBaseClient(endpoint=SEARCH_ENDPOINT, credential=acme_token) # "globex-only-secret-project" is a phrase that ONLY appears in # Globex Industries' documents, seeded specifically for this test. request = KnowledgeBaseRetrievalRequest( messages=[{"role": "user", "content": "globex-only-secret-project"}], knowledge_source_params=[ SearchIndexKnowledgeSourceParams( knowledge_source_name="tenant-acme-ks", # ACME's knowledge source ) ], ) response = await kb_client.retrieve(request) # PASS: zero references, zero grounding data returned - the query # ran against ACME's scope and found nothing, because there IS # nothing Globex-related in ACME's scope. assert len(response.references) == 0, "CROSS-TENANT LEAK DETECTED"
Step 3 — Confirm filterAddOn actually narrows within the tenant# Two ACME users, different groups. Same tenant, different scope. async def test_group_level_trimming(): hr_user_groups = ["grp-hr-benefits"] exec_only_doc_marker = "board-compensation-2026" # in group_ids: [grp-exec] request = KnowledgeBaseRetrievalRequest( messages=[{"role": "user", "content": exec_only_doc_marker}], knowledge_source_params=[ SearchIndexKnowledgeSourceParams( knowledge_source_name="tenant-acme-ks", filter_add_on=build_group_filter(hr_user_groups), ) ], ) response = await kb_client.retrieve(request) # PASS: zero references - the HR-only user cannot see the exec-only doc, # even though both are in the SAME tenant's knowledge source. assert len(response.references) == 0, "GROUP-LEVEL TRIM FAILED"
Step 4 — Confirm the fail-closed behaviour for missing/malformed group data# Simulate a token with no resolvable groups (e.g. Graph lookup failed). filter_expr = build_group_filter([]) assert filter_expr == "group_ids/any(g: g eq '__no_access__')", \ "Empty group list must fail CLOSED, not return an unfiltered query" # PASS: the function returns a filter matching nothing, not an empty # string that would silently mean "no restriction" in some # OData constructions.
What "fixed" actually means here

Three conditions must hold together. One: every knowledge source used in production has a non-empty baseFilter scoping it to exactly one tenant, verified by reading the knowledge source configuration directly, not by trusting application code comments. Two: the adversarial cross-tenant test — a real, authenticated user from one tenant querying for content known to exist only in another tenant — returns zero results, every time, including after code changes. Three: the group-membership resolution path fails closed on missing or unresolvable data, verified by a dedicated test, not assumed. Miss any of the three and the isolation is real only by accident, which is the same condition that caused the original incident.

Key Takeaways

A filter that lives only in application code is a convention, not a security boundary. One forgotten code path is a full cross-tenant leak, and it fails silently — no exception, no log line.
Put the tenant boundary in baseFilter, not filterAddOn. One knowledge source per tenant, with the tenant ID baked in at provisioning time — not interpolated per request.
filterAddOn composes with AND, never OR. A malformed or missing runtime filter narrows less than intended within a tenant — it can never widen scope across tenants.
Resolve group membership from the validated token, never from client-supplied data. A group list the caller can set themselves makes the entire filter decorative.
Fail closed on missing group data. An empty or unresolvable group list should filter out everything, not default to unrestricted access.
For ADLS Gen2 or SharePoint sources, consider native token-based ACL enforcement. It inherits correctness from a system that's already the source of truth for permissions — but doesn't map cleanly onto app-level "tenant" concepts.
Test adversarially, not just the happy path. A real user, a real token, deliberately searching for another tenant's content, expecting and verifying zero results — run this as a standing test, not a one-time audit.

Frequently Asked Questions

What's the difference between baseFilter and filterAddOn in Azure AI Search?
baseFilter is a persisted property on a search index knowledge source's searchIndexParameters — it's set once, at knowledge source creation or update, and applies automatically to every subsequent retrieve request that uses that knowledge source. filterAddOn is supplied at runtime, per request, in the retrieve call's knowledgeSourceParams. The two compose using AND logic — the runtime filter can only narrow what the base filter already permits, never widen it. For multi-tenant applications, this maps cleanly onto two different concerns: baseFilter is the right place for the tenant boundary, because it can't be accidentally omitted on a per-request basis; filterAddOn is the right place for group- or user-level trimming that legitimately varies per caller within that tenant.
Why not just build the tenant filter in application code like the group filter?
Because the two have very different failure costs. A missing or malformed group filter, built at request time in application code, results in a user seeing more documents than intended — but still only within their own tenant, which is a real but bounded problem. A missing tenant filter, built the same way, results in a user seeing another customer's confidential data entirely — an unbounded, often contractually and legally serious problem. Putting the tenant boundary in baseFilter means it isn't a parameter any request-time code path can omit, because it isn't a parameter of the request at all — it's a property of which knowledge source you're even calling. That structural difference is the entire point of using two separate mechanisms rather than one filter string built the same way every time.
How do I map a user's Entra ID group membership into the filterAddOn expression?
Resolve the caller's groups from their validated Entra ID access token — either directly from the token's groups claim, or via a server-side Microsoft Graph memberOf call if the token shows the group overage indicator (meaning the user belongs to more groups than fit inline in the token). Build an OData filter expression against your index's group_ids collection field, typically using group_ids/any(g: search.in(g, '<comma-separated-group-ids>')), and pass it as the filterAddOn on the knowledge source params for that retrieve request. Critically, the group list must come from the validated token or a server-side Graph call — never from a value the client supplies directly in the request — or the filter provides no real security benefit, since any caller could simply claim whichever group unlocks the content they want.
Should I use a shared index with filters, or a separate index per tenant?
A shared index with a correctly-implemented baseFilter per tenant is the right default for most multi-tenant SaaS products — it's operationally simpler, avoids managing N indexes, and provides strong logical isolation: a bug cannot cause one tenant's data to appear in another's results, because the boundary isn't a request-time parameter that a bug could omit. Index-per-tenant is a physical isolation model, and it's usually adopted not because the filter approach is insecure, but because a specific customer's contract, regulatory framework, or internal risk policy explicitly requires physical rather than logical separation — a requirement that no amount of correct filter logic satisfies on paper, regardless of its actual security properties. If you have a mix of typical tenants and a small number with genuine physical-isolation requirements, a hybrid — shared index for most, dedicated index for the few that need it — matches the isolation cost to the actual risk and contractual need.

Popular posts from this blog

Learn how to use Azure Chaos Studio to simulate data center outages, test Azure OpenAI failover, and validate AI app resiliency using KQL and CLI workflows

Resiliency Testing Chaos Studio Zone Down Azure OpenAI Failover Testing AI Resiliency: Using Azure Chaos Studio to Simulate Data Center Outages on Your LLM Every multi-region Azure OpenAI architecture diagram has a failover arrow drawn on it. Almost none of them have ever actually been triggered. The arrow is a hypothesis, confirmed only by a real outage — unless you deliberately cause a controlled one first, on your own schedule, with a rollback plan, instead of finding out during an incident that the failover you designed never quite worked the way the diagram promised. The failure signature this guide resolves # The gap this article closes — a real architecture review finding: Design doc, page 4: "In the event of a regional outage, Azure Front Door automatically routes traffic to the secondary Azure OpenAI deployment in West Europe, with an expected failover time under 60 seconds." Verification performed to support this claim: NONE. Last time this path was ac...

Improve AI application performance by reducing latency, optimizing embeddings, and lowering cloud inference costs

Performance Fix Foundry Local 1.2 Linux ARM64 Embeddings Offline ASR The Edge Latency Drop: Fixing Latency Spikes by Offloading Embeddings to Foundry Local 1.2 You are paying a full cloud round trip — network, TLS, queue, throttle risk — to turn a twelve-word search query into a vector. That is the most expensive way possible to do one of the cheapest computations in your stack. Foundry Local 1.2 now runs on Linux ARM64, which means embeddings and speech recognition can happen on a Raspberry Pi, a Jetson, or a Graviton instance — offline, unmetered, and in single-digit milliseconds. The failure signature this guide resolves # Application Insights — the embedding call, not the LLM, is your tail latency: name p50 p95 p99 calls/day POST /embeddings (cloud) 89 ms 412 ms 3,847 ms 1,240,000 POST /chat/completions (cloud) 940 ms 1,720 ms 2,910 ms 38,000 ^^^^^^^^ ...

Learn how to select Azure Files and Blob storage tiers, avoid early deletion fees, model costs, and automate lifecycle management for large file migrations.

Choosing the Right Azure Storage Tier for Large File Migrations The complete decision framework for storage tier selection during large file migrations — Azure Files tiers, Blob tiers, cost modelling, early deletion traps, lifecycle automation, and the 2026 changes that affect every migration running today. By Francis Avorgbedor | Azure Engineer  ·  July 14, 2026  ·  18 min read  ·  Storage Tiers · Cost Optimisation · Migration FA Francis Avorgbedor Azure Engineer  ·  SEVENAI  ·  Azure Field Notes 9 Distinct Azure storage tiers across Files and Blob — most engineers know only 3 15hrs Archive tier rehydration time at standard priority — the delay teams forget to plan for 128KB Minimum billable object size for Cool/Cold/Archive from July 2026 — a 32× trap for small files 70% How much retrieval and transaction fees add to a theoretical Archive storage bill The most expensive mistake I see on large Azure file migrations is not choosing the w...

Find the hidden Windows 10 system files consuming up to 500GB, including hibernation, shadow copies, backups, and WinSxS, with safe cleanup steps.

  The 500GB System File That Eats Your Hard Drive Something on your Windows 10 drive is consuming hundreds of gigabytes and the normal tools cannot find it. This guide identifies every known culprit — from hibernation files and shadow copies to runaway backups and the Windows component store — and tells you exactly what is safe to delete, what to leave alone, and what the commands actually do.

Learn safe methods to reset Azure virtual machines using managed disks while preserving critical workloads

How to Reset an Azure Virtual Machine to Factory Settings Using a Managed Disk Azure does not have a single "factory reset" button. What it does have is something better: the OS Disk Swap — a method that swaps out the corrupted or misconfigured OS disk for a clean Windows Server managed disk without deleting the VM, its NICs, its IP addresses, or any attached data disks. Here is how it works, when to use it, and the exact steps to execute it safely. FA Francis Avorgbedor Azure Engineer July 16, 2026 15 min read Azure VMs · Windows Server · Real-World Fix 3 Methods to achieve a clean Windows Server installation on an existing Azure VM ~15min Typical OS Disk Swap duration — VM retains its NICs, IPs, and data disks throughout 0 Data disks affected by an OS Disk Swap — data disks remain attached and untouched 1 Snapshot of the original OS disk you must take before starting — no exceptions Introduction Why Azure Does Not Have a Simple Factory Reset — and What to Do Instead On a ph...

Determine Windows 11 compatibility, upgrade requirements, costs, and performance expectations on older hardware

Can I Update My Old Computer to Windows 11 — and How Much Will It Cost? Your i7, 16GB RAM, 512GB SSD machine is powerful enough to run Windows 11 comfortably. The TPM 2.0 and Secure Boot wall is a security checkbox, not a performance ceiling. Here are two proven ways to get past it, what each one costs, and what you are trading away by doing so. $0 Cost of the Windows 11 licence if your existing Windows 10 is genuine — the upgrade remains free in 2026 2 Proven methods to bypass TPM 2.0 and Secure Boot — Rufus (easy) and Registry edit (manual) 25H2 Current Windows 11 version — all known bypass methods tested and confirmed working as of July 2026 Oct 2025 Windows 10 end of life — no more security updates. Staying on Windows 10 now carries real risk. First — Check Your BIOS Before Anything Else You Might Not Actually Need a Bypass Before running any bypass, open your BIOS and look at two settings. Many computers that fail the Windows 11 compatibility check have TPM 2.0 present in the hard...

Solve common AKS issues with practical troubleshooting techniques for networking, scaling, upgrades, and workloads

Troubleshooting Guide AKS Kubernetes Real Solutions kubectl Azure Kubernetes Service (AKS) Troubleshooting Guide: Real Solutions to Common Problems CrashLoopBackOff at 2am. Pods stuck Pending with no obvious cause. Nodes going NotReady mid-deployment. DNS resolution silently failing in production. Every AKS engineer encounters these — the difference between engineers who panic and engineers who stay calm is knowing the exact sequence of diagnostic commands to run. This guide gives you that sequence, the root cause analysis for each failure mode, and the fix. 3 commands 90% of AKS problems are diagnosed with the same three kubectl commands: describe pod, logs --previous, and get events — in that order, every time Exit 137 The exit code that tells you everything: container killed by SIGKILL — either the Linux OOM killer (memory limit exceeded) or kubelet after grace period expired 5 min The CrashLoopBackOff ceiling: Kubernetes applies exponential backoff (10s → 20s → 40s → 80s → 160s → 3...

Step-by-step guide to deploying scalable AI chatbots on Azure with OpenAI and App Service

Step-by-Step Guide Azure OpenAI App Service Production Python How to Deploy an AI Chatbot on Azure Using Azure OpenAI and App Service From zero to a production-grade AI chatbot: provision Azure OpenAI, write a streaming Flask API backend, deploy it on Azure App Service with Managed Identity, wire in conversation history and content safety, and instrument it with Application Insights — all with complete code and Terraform IaC. No API keys in environment variables. No hardcoded secrets. No half-finished PoC patterns. 7 phases This guide covers the full deployment lifecycle: architecture design → resource provisioning → backend code → App Service deployment → streaming → security → monitoring Zero keys The chatbot authenticates to Azure OpenAI using Managed Identity and DefaultAzureCredential — no API keys stored in environment variables, Key Vault, or code SSE Server-Sent Events stream GPT tokens to the browser as they generate — the same token-by-token typing effect users expect from pr...