Multi-tenant search architecture: how tenant isolation works in AACSearch
Collection namespacing on the engine proxy, full versus search key scopes, short-lived browser keys, per-organization quotas in a Durable Object and organization-scoped events — what a platform operator gets when embedding search for many customers.
A search engine has one flat namespace: collections, aliases, synonym sets, curation sets, presets, stopwords, stemming dictionaries, analytics rules and conversation models all live side by side, and the engine's admin key reaches any of them. That is fine for one application and unworkable for a platform hosting search for many customers: the moment two tenants both want a collection called products, you have a data-isolation problem, not a search problem.
AACSearch puts a tenant proxy in front of the engine and makes isolation a property of that proxy, enforced on every request, rather than a convention each integration is trusted to follow. Here are the layers, top to bottom.
Collection namespacing on the engine proxy
Every tenant-API request is authenticated by an X-NOVA-API-KEY header that resolves to an organization. From there, the proxy prefixes every engine resource name with that organization's prefix on the way in and strips it on the way out.
The prefix is derived from the organization id (t_<organization id>__), and it is applied wherever a resource name can appear: path parameters (collectionName, aliasName, synonymSetName, curationSetName, presetId, stopword setId, stemming dictionaryId, analytics ruleName, conversation and NL modelId), comma-separated search parameters such as synonym_sets, stopwords and preset, JOIN references like $orders(...) inside filter_by and include_fields, and JSON bodies including each entry of a multi_search request. Responses are rewritten in reverse, and list responses drop entries that belong to other tenants entirely. Documents themselves are never touched; only the names around them are.
Two details matter for security. Per-request engine credential headers a caller might try to smuggle through are stripped before the request is forwarded. And the rewrite does not depend on a content-type header the caller chooses — a body that claims to be something other than JSON still gets its collection names namespaced.
The result is that products is products to every tenant, and no tenant can name, list or JOIN against anything outside its prefix.
Key scopes: full versus search
An API key carries a scope, full or search, and the scope rides along with the tenant on every request. A full key is the server-side key: it can do everything the contract exposes to a tenant.
A search key is the one that ships inside a browser widget, so it is treated as the abuse surface. The gate is declarative: the OpenAPI contract marks operations with x-nova-key-scope: search, and a search-scoped request is allowed only if the matched operation carries that marker — searching, federated search, recommendations, event ingestion, and the visitor-facing AI operations (semantic and image search, voice, the assistant and the visitor's own conversation). Anything else — writing documents, reading or changing configuration, minting keys — is refused with a 403. The gate is fail-closed: a path with no entry in the contract is refused rather than passed on, so a route added to the Worker without a contract entry is never reachable with a widget key.
Restrictions narrow a key further, whichever scope it has:
allowedCollections: the collections the key may name at all.forcedFilterBy: afilter_byfragment per collection (or*) ANDed into every search — document-level ACL and row-level security without a separate index per customer.allowedFields:include_fieldsis forced to the allowed set, and a request that names a forbidden field inquery_by,facet_by,sort_by,highlight_fields,filter_byorgroup_byis refused rather than silently rewritten.allowedIps,allowedOrigins,rateLimitPerMinuteand a bound searchprofile.
Browser keys are how a server hands a visitor something safe. POST /browser-keys mints an HMAC-signed nssk_… token from a parent key: it is never stored, its restrictions are intersected with the parent's so it can only narrow, and it expires on its own (at most seven days).
The same mechanism is what lets a server-side integration such as the Bitrix module hand a browser a key that can search and talk to the assistant but never write.
Per-organization quotas in a Durable Object
Isolation is also about capacity: one tenant must not be able to consume another's budget. Quota and rate accounting lives in a TenantQuota Durable Object with exactly one instance per organization (the object id is derived from the organization id), so concurrent requests for the same tenant serialize through a single object. That is also what makes the organization's wallet and ledger — the balance AI operations spend credits from — safe to read-modify-write without a database transaction.
On each request the proxy reads the tenant's status (plan, remaining requests, period reset). If remaining is zero the request is refused with a 429 and quota headers; the period is the subscription's billing period when one is set, otherwise the calendar month, and crossing it resets the counter.
Rate limiting is layered in front of the quota, and only for search keys, because they are the ones that live in browsers:
- a per-visitor bucket (key id plus client IP), sized so one autocomplete session at a normal debounce never trips it;
- a per-key ceiling scaled to the plan, which still catches a leaked key hammered from many addresses.
full server keys are exempt from both; they sit behind the organization-wide quota check alone. And the extra engine calls the zero-hit rescue makes are not billed against the tenant: one request is one unit, whatever the proxy had to do to answer it.
Event and analytics scoping
Every event row carries the organization id. Server-side ingestion (POST /events) and the storefront batch (POST /analytics/events/batch, up to 20 search, click or conversion events per call) both write into the same table under the calling tenant, and a search key is allowed to send them so a widget can report clicks without a server hop.
Everything derived from events inherits that scope. Funnel, query, zero-result and revenue-attribution metrics are computed for one organization; document ranking signals — the behavioural counts that feed trending and recommendation models — are aggregated per organization and collection; personalization profiles are updated from a tenant's own events only. A request that sets X-NOVA-SESSION-ID attributes events and experiment enrolment to that session, again inside the organization.
Inside an organization, people are scoped too: the control plane has a permission matrix (collections:write, search_rules:write, keys:write, analytics:read, audit:read and so on) with owner, admin and member built in, and custom roles may be limited to a list of collections for writes that name one.
What this means for a platform operator
If you are embedding search for many customers — a marketplace, a CMS vendor, an agency running dozens of storefronts — each customer gets an organization; each organization sees a private namespace on a shared engine, its own quota and wallet, its own events, and its own keys. Your servers hold full keys; every browser gets a short-lived search key bound to a collection list, an origin list and a rate limit; and the contract, not your integration code, decides what that key can reach.
None of it requires a dedicated engine per customer: the isolation is in the proxy, and the proxy is the only door. The console's API keys, Quota and Usage screens show a tenant its own side of it; organizations, billing and the audit log sit behind the platform operator's admin surface.
For the wider picture of what sits on top of this layer, read What is a Search OS?. For the operator-facing summary, see Enterprise and Security; for the SDKs and the contract, Developers.
Related Posts
Faceted search that scales to millions of SKUs
Why facet counts get slow as a catalogue grows, the difference between an exact and an estimated count, and how to keep faceted navigation fast without hiding filters shoppers need.
Webhook delivery: signatures, retries and safe replays
How AACSearch signs outbound webhooks, what happens when your endpoint is briefly down, and how an Idempotency-Key keeps a retried write from landing twice.
What is a Search OS?
A search box answers one query. A DIY engine gives you a retrieval primitive. A Search OS is the layered system around it — query understanding, indexing, synonyms, ranking rules, curation, results and analytics — that a tenant actually controls. Here's how AACSearch implements each layer.