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.

Shared index with a tenant filter

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 reindex

Related 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 matching

  • doc_id: stable source identifier

  • version: source version at index time, used to order writes

  • status: active or deleted

  • acl_principals / acl_groups: optional denormalized ACL for per-object access below the tenant boundary

  • suggest_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 tenant_id or required access-scope field, so it stays globally searchable inside a shared index

Source metadata extraction

1. Reject any index write missing tenant_id; write nothing rather than an unscoped document

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 (Status: Open (42)). The tenant filter scopes the returned documents but not the aggregation, so those counts are computed across all tenants and leak other tenants' category values and volumes

Documents correctly scoped in hits

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 (acl_principals); access is later revoked in the database, but the indexed copy keeps the old ACL until that document is reindexed, so the revoked user still finds it

Reindex-on-write pipeline

1. Push each permission change as an event that reindexes the affected document

2. Prefer filtering on stable tenant_id over churny per-object ACLs, and push fine-grained checks to a post-search database authorization step

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 status in addition to issuing the delete

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 doc_id, tenant_id, and failure reason

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 tenant_id, so the new index silently blends tenants

None

1. Assert the new index keeps the tenant_id field indexed for exact-term filtering; validate per-tenant document counts before and after

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_id or a required access-scope field is rejected; no searchable document is written

    • Every 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_id field 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 _id fetches return an identical empty or 404 shape regardless of what exists in other tenants

    • A 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.

Keep Reading