Tenant isolation
How AACsearch keeps one organization's data invisible to every other organization.
Tenant isolation
AACsearch is multi-tenant. Many organizations share one search cluster. Cross-tenant reads cannot happen. Not under an admin API key, not under a misconfigured filter, not under a forged scoped token. This page explains how that guarantee is enforced and what your responsibilities are.
The hierarchy
flowchart TD
Org["Organization<br/>(top-level customer)"]
Proj["Project<br/>(prod / staging / …)"]
Idx["Index<br/>(Typesense collection)"]
Doc["Document<br/>(carries app-level tenantId for nested scoping)"]
Keys["API keys<br/>(scoped to indexes within the project)"]
Members["Members · Billing · Audit logs"]
Org --> Proj
Org --> Members
Proj --> Idx
Proj --> Keys
Idx --> Doc
classDef enforced fill:#1e293b,color:#fff,stroke:#0ea5e9,stroke-width:2px
classDef yours fill:#0f172a,color:#fde68a,stroke:#f59e0b,stroke-width:1px,stroke-dasharray:4
class Org,Proj,Idx,Keys,Members enforced
class Doc yoursThe dashed node (Document/tenantId field) is your application-level scoping — see Scoped tokens. The solid nodes are enforced by AACsearch.
| Level | What it is | Isolation |
|---|---|---|
| Organization | Top-level customer account. | Hard. All API keys, indexes, members, and billing live inside one org. |
| Project | Logical grouping within an org (e.g. prod, staging). | API keys belong to one project. Cross-project usage is rejected. |
| Index | A Typesense collection backing one searchable corpus. | API keys may be restricted to a subset of indexes within their project. |
| Tenant ID | Application-level field inside the document. | Your scoping field (e.g. customerId, accountId). Enforced by filter. |
The first three levels are enforced by AACsearch. The fourth is enforced by your filter, typically via a scoped token.
How org isolation is enforced
Every request that reaches search code carries a verified object built from your API key:
{
organizationId: "org_abc",
projectId: "prj_xyz",
keyId: "key_…",
scopes: ["search"],
// …
}Internally, every search call passes tenantId: verified.organizationId to the search layer. The search layer:
- Resolves the index name to the organization-prefixed physical collection name (e.g.
org_abc__products__v3). - Routes the query to that physical collection only.
- Returns results — there is no path that lets a query reach a collection belonging to another organization.
sequenceDiagram
autonumber
participant Client
participant API as public-handler
participant Auth as key-verify
participant SQL as Postgres (SearchIndex)
participant TS as Typesense
Client->>API: POST /search Bearer ss_search_***
API->>Auth: verify(token)
Auth-->>API: { organizationId, projectId, keyId, scopes }
API->>SQL: SELECT * FROM search_index<br/>WHERE id=? AND organization_id=verified.organizationId
alt mismatch (cross-tenant attempt)
SQL-->>API: 0 rows
API-->>Client: 404 not_found (NEVER 403 — no existence leak)
else match
SQL-->>API: index row
API->>TS: search(physical_collection_for_THIS_org_only)
TS-->>API: hits
API-->>Client: sanitized JSON
endThree layered checks make cross-tenant access fail closed:
- API gate: the bearer token resolves to one
organizationId. - SQL WHERE: every query that loads metadata pins
organization_id = verified.organizationId. - Physical isolation: Typesense collection names are namespaced by org short id — there is no shared collection.
This is Invariant 5 in agents.md: every search call passes tenantId: verified.organizationId. No cross-org reads, ever. A bug that bypasses this is a P0 security issue.
How project and index isolation are enforced
When you create an API key, you choose:
- Whether it can access all indexes in the project, or a specific list.
- Which scopes it has.
At verification time, the requested index must be in the key's allow-list (if the list is non-empty), and the operation must be permitted by the scopes. A search key calling index.delete is rejected before any database row is read.
How application-level isolation is enforced
For per-user or per-customer scoping inside a shared index, the document has a field you control — commonly tenantId, userId, accountId, or companyId. You enforce it by:
- Indexing that field on every document.
- Requiring the field in your filter on every search.
The robust way to "require" the filter is to mint a scoped token on the server with filterBy: "tenantId:=<value>". The server-side AND-combine means the user cannot opt out, even if they craft a clever client-side filter. The browser can ask for anything it likes; the result will always be the intersection with the token's filter.
Browser filter: stock:>0
Token filter: tenantId:=org_abc
Final (AND): tenantId:=org_abc && stock:>0This is Invariant 4: scoped tokens narrow, never widen.
What goes wrong if you skip this
A common mistake is to share one index across users without a scoped-token filter and rely on the application to "always send the right filter". If the application has any path where the filter is missing or attacker-controlled, every user can read every other user's data through that path. The scoped-token AND-combine exists so that no application bug can produce that outcome.
If you must run multi-tenant data in a single index, always use scoped tokens. If you cannot use scoped tokens (e.g. the search is fully server-side and never browser-side), enforce the filter in your server code and write a test that fails if the filter is dropped.
Cross-organization scenarios that do work
Some legitimate uses look like they need cross-org reads but don't:
- One company, two staging environments. Use two projects in the same organization, or two indexes with different aliases. No cross-org access needed.
- Reseller / agency model where the agency manages several merchants. Each merchant is its own organization. The agency staff is a member of each one. The dashboard switches "active organization" at the session level, and every API call uses that org's keys.
- Internal admin console aggregating metrics across orgs. Aggregate from each org's analytics endpoint with that org's admin key. Do not bypass the org-level filter in search.
If you have a use case that genuinely needs cross-org reads, that is a custom contract — talk to sales.
Verifying isolation in your own tests
When you build a feature on top of AACsearch, write at least one negative test:
test("user A cannot see user B's documents", async () => {
await indexAs(userA, { id: "1", tenantId: "tenant_a", title: "secret a" });
await indexAs(userB, { id: "2", tenantId: "tenant_b", title: "secret b" });
const tokenA = mintScopedTokenFor(userA); // filterBy: tenantId:=tenant_a
const res = await searchAs(tokenA, { q: "secret" });
expect(res.hits.map((h) => h.document.id)).toEqual(["1"]);
});If this test ever fails, treat it as a P0 incident.
See also
- Scoped tokens — the mechanism for AND-combined filters
- API keys — the org/project/index allow-list on each key
- Data residency — region-level isolation