A SaaS product indexes tenant documents and support tickets into a search engine (Elasticsearch, OpenSearch, Algolia) so users get fast search. The index becomes a second copy of that data with its own access model, and that model drifts from the database that owns the permissions.
System description
A search subsystem indexes tenant records into an engine and tags each document with its tenant on write. Every query enforces that boundary. The primary database stays the authority for permissions and lifecycle; the index is a derived copy it keeps in sync.

Architecture choice
Two isolation models are common.
Index per tenant
Each tenant gets its own physical index. A query that targets tenant_42_docs cannot structurally reach tenant 43's data, and you can attach per-index permissions at the engine level.
Use this when:
Strong isolation matters more than dense packing
Tenant count is bounded and you can plan index capacity
Trade-off: every engine caps how many separate indexes it can hold, so past some tenant count you cannot onboard a new tenant without adding capacity or migrating. Schema changes also multiply by tenant count.
One index holds every tenant's documents, each tagged with tenant_id, and a filter on that field scopes every query. What matters is where the filter lives.
If the filter lives in application code, it has to be added to every query, and a query that omits it returns other tenants' data. The alternative is to keep the filter in the search engine: your code queries a filtered view, or a scoped key with the filter built in, so the engine applies it and the application never sends raw filter conditions.
Use this when:
Tenant count is high and onboarding must be cheap
Tenants vary widely in size and a per-tenant index would waste capacity
Trade-off: the boundary is only a filter, so a single mistake crosses tenants. Keep the filter in the search engine, so it cannot be dropped on one query path.
Golden path
Start with this path:
Indexer reads a source record → attaches `tenant_id` and access metadata → writes a version-stamped document → query builder derives tenant scope from the session → engine applies the filter inside the query before ranking → a permission change or delete propagates as a tombstone or reindexRelated patterns:
For the vector-store version of the same problem, see Threat Modeling RAG Access Control; the derived-index and propagation risks are shared, but faceted counts and autocomplete are search-specific and absent from a vector store
For the control-plane pattern the query builder mirrors, see Multi-Tenant File Sharing: Secure Control Plane Architecture; both keep the primary store as the authority and treat the derived layer as a projection
A hosted secured search key follows the same model as a pre-signed URL: a short-lived, scoped grant the backend mints and the client cannot widen
Minimal system context
Primary database (system of record): owns permissions and lifecycle; assigns each record its source version
Search index (data plane): a derived projection of records that powers fast search and the surfaces built on it, like autocomplete
Indexer / worker (control plane): moves source changes into the index and tags each document with its tenant and access metadata
Outbox / queue (control plane): carries change events from the database to the indexer in order, so permission changes and deletes are not lost
Query builder (control plane): the only approved read path; derives tenant scope from the session and applies the filter
Hosted search client (untrusted): when the browser queries a hosted engine directly, it holds only a scoped credential minted by the backend
Monitoring: watches indexing lag and flags any unscoped-query attempts
Core design
Indexed document (data plane)
Minimum fields:
tenant_id: the isolation boundary, indexed for exact-term matchingdoc_id: stable source identifierversion: source version at index time, used to order writesstatus:activeordeletedacl_principals/acl_groups: optional denormalized ACL for per-object access below the tenant boundarysuggest_scope: the tenant value carried into any separate autocomplete or suggestion structure
Query builder (control plane)
Every read path (search, suggest, autocomplete, export, aggregations) builds its query here. It reads the tenant scope from the session token, then applies that scope inside the search engine through a filtered view or a scoped key. One builder means one place to confirm that no surface issues an unfiltered query.
Threat model
Baseline assumptions
Clients are untrusted: they can hit any documented query surface and send arbitrary filter parameters
Control plane authority: the query builder derives tenant scope from the session token, not from request parameters or the query body
The primary database is the authority for permissions and lifecycle; the index is a derived copy
The engine can apply a hard filter to a query, and can enforce that filter server-side through a filtered view or a scoped key
Standard infra controls such as TLS, WAF, cluster network isolation, and database AuthN are assumed to be in place. This model focuses on tenant isolation through the search index
A note on risk
This table is not a checklist. Focus on preventing the highest-impact failures first. Detection and response are acceptable where prevention is impractical.
Phase 1: Indexing
Focus: Carrying tenant and access metadata into the index and keeping writes ordered
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Index metadata | Access metadata drop: Indexer writes a document with no | Source metadata extraction | 1. Reject any index write missing 2. Validate document schema in CI and at ingest 3. Dead-letter malformed records instead of indexing them | High |
Write order | Stale resurrect: In an event-driven indexing pipeline, events arrive out of order, so an old update lands after a newer delete and brings a removed document back into results | Internal last-write-wins versioning | 1. Stamp every write (index, update, delete) with the source's version number and reject an incoming write older than the stored version 2. Outbox pattern so the index event commits with the source transaction 3. Make writes idempotent so replays do not double-apply | Medium |
Phase 2: Query time
Focus: Keeping every read surface tenant-scoped and bounded in cost, including the surfaces that don't look like search
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
Tenant isolation | Forgotten filter: A secondary surface such as autocomplete or export issues a query without the tenant clause and returns other tenants' data | Tenant filter on the main search path | 1. Put the filter in the search engine (a filtered view or a scoped key) so a caller cannot omit it 2. Where the engine can bind a filter to a credential or role, use it as a floor beneath the app filter 3. Audit every read surface against the shared query builder; treat bypasses as the review question | High |
Tenant data | Count oracle: The UI shows facet counts ( | Documents correctly scoped in | 1. Apply the tenant filter to the facet counts as well as the returned hits 2. Forbid unscoped or global aggregations on a shared index 3. Restrict which fields are facetable | Medium |
Tenant data | Autocomplete leak: It is often served from a separate suggestion structure that the normal tenant filter does not reach, so even with search filtered, suggestions still span tenants | Main search path filtered | 1. Scope the suggestion structure to the tenant when it is built 2. Apply the tenant filter to term or value enumeration endpoints (the ones that populate filter dropdowns) 3. Serve autocomplete from a tenant-scoped query rather than the shared suggestion structure | Medium |
Client-side search key | Key scrape: The browser queries a hosted search service directly, and an unrestricted search key lets any visitor read the whole shared index (Algolia, Typesense Cloud, and similar) | Search-only (no write) key | 1. Server-mint a scoped, short-lived key with the tenant filter embedded and unmodifiable by the client (for example, an Algolia secured API key) 2. Derive the tenant id from the session, never from the client 3. Rotate the base key to revoke every derived key at once | Medium |
Cluster availability | Query-cost DoS: A tenant submits expensive query operators (leading wildcards, broad regex, open-ended fuzzy matching) that spike CPU on the shared index and starve every other tenant | None | 1. Build queries from safe, structured query types rather than exposing a raw query-language parser to user input 2. Disallow leading wildcards, unbounded regex, and open-ended fuzzy matching on user terms 3. Bound query cost with server-side timeouts and per-tenant concurrency limits | Low |
Phase 3: Propagation and reindex
Focus: Keeping the index consistent with the source as permissions change and indices are rebuilt
Asset | Threat | Baseline Controls | Mitigation Options | Risk |
|---|---|---|---|---|
ACL freshness | Stale ACL: To filter on per-user access, you copied the ACL into the document ( | Reindex-on-write pipeline | 1. Push each permission change as an event that reindexes the affected document 2. Prefer filtering on stable 3. Alert when reindex lag exceeds the freshness SLA | High |
Index freshness | Delete lag: A deleted document stays searchable for the index refresh interval plus the pipeline delay | Near-real-time index plus async pipeline | 1. Filter queries on the freshest 2. Bound and monitor pipeline lag; alert on backlog 3. Write a higher-version tombstone before any physical deletion, so a stale active copy loses to the delete state | Medium |
Propagation liveness | Poison pill: One malformed record keeps crashing the indexer and blocks the permission-change and delete events queued behind it, extending stale access | Queue retry policy | 1. Move a record to a dead-letter queue after bounded retries, keeping its 2. Process events so one bad record cannot stall the tenant or global stream 3. Alert on dead-letter backlog and the age of the oldest unprocessed event | Low |
Reindex integrity | Tenant merge: A backfill drops or mis-derives | None | 1. Assert the new index keeps the 2. Keep writes pointed at a single index during the rebuild; make the cutover atomic and retain the old index for rollback 3. Run a cross-tenant canary query against the new index before swapping traffic | Medium |
A note on per-object ACLs
When per-object ACLs change too often to trust inside the index, you have two options. Denormalize the ACL into the document and accept the sync lag from the rows above, or return candidate doc_id values from search and authorize each against the primary database before rendering. Post-search authorization keeps the index simple, but it breaks pagination and result counts: when the database drops results from a page, page sizes and total stop being reliable, so it suits small result sets or a final visibility check rather than deep pagination.
If you use index per tenant
Choosing separate indices instead of a shared one shifts the profile:
The forgotten-filter and count-oracle risks largely disappear: a missing filter just returns an empty result, since the index only holds that tenant's data
Onboarding becomes the hard limit. The index-count ceiling caps how many tenants you can add, and schema drift now multiplies per tenant
The merge risk moves to the indexer choosing the wrong index, replacing the forgotten-query-filter risk
FAQs
Should each tenant get its own search index?
It depends on tenant count. Index-per-tenant gives you the strongest isolation, since the boundary is the index itself. The catch is that every engine caps how many indexes it holds, and each small tenant still carries fixed overhead. With many small tenants, default to a shared index with a server-enforced filter; it sidesteps that ceiling. Keep separate indices for the few tenants big enough to justify the overhead.
Why does a deleted or unshared document still show up in search?
The index lags the database. A delete or permission change reaches the index only after the refresh interval and any async pipeline delay, and if you denormalized the ACL into the document, the old access stays in effect until that document is reindexed. Propagate permission changes as events, and filter queries on the current status. Where you can, filter on the stable tenant_id instead of per-object ACLs, which go stale every time membership changes.
Verification checklist
Indexing and metadata
Indexing a document without
tenant_idor a required access-scope field is rejected; no searchable document is writtenEvery write (index, update, delete) carries the source version, and a replayed older event does not overwrite a newer write
A reindex that drops or renames the
tenant_idfield fails validation before it runs
Query surfaces
Every read surface (search, autocomplete, export, aggregations, multi-search, pagination cursors) applies the tenant filter; the set that bypasses the shared query builder is known and empty
Autocomplete for tenant A never returns tenant B's terms
Facet and aggregation counts for tenant A exclude tenant B's documents
The tenant predicate is a hard filter on every query, never an optional or score-only clause, and it constrains facet counts as well as hits
Cross-tenant
total, pagination, and_idfetches return an identical empty or 404 shape regardless of what exists in other tenantsA user-supplied search string cannot reach a raw query-language parser; leading wildcards and regex are rejected or rewritten
Boundary placement
The tenant filter is enforced in the search engine, not by application code alone
No unrestricted search key reaches the browser; a hosted client key is a short-TTL secured key whose tenant filter is derived from the session
Propagation and freshness
Revoking access in the database removes the document from the tenant's results within the documented freshness SLA
A cross-tenant canary query against a freshly reindexed index returns zero before traffic is swapped to it
Per-tenant document counts match between source and index after a reindex
A record that fails to index lands in a dead-letter queue without stalling the permission-change and delete events behind it
Implementation & Review
The full threat model matrix, architectural diagrams, and a printable verification checklist for this pattern are available in the Secure Patterns repository. Use these artifacts to guide your design reviews and internal audits.
