AACsearch
Security & Compliance

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 yours

The dashed node (Document/tenantId field) is your application-level scoping — see Scoped tokens. The solid nodes are enforced by AACsearch.

LevelWhat it isIsolation
OrganizationTop-level customer account.Hard. All API keys, indexes, members, and billing live inside one org.
ProjectLogical grouping within an org (e.g. prod, staging).API keys belong to one project. Cross-project usage is rejected.
IndexA Typesense collection backing one searchable corpus.API keys may be restricted to a subset of indexes within their project.
Tenant IDApplication-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:

  1. Resolves the index name to the organization-prefixed physical collection name (e.g. org_abc__products__v3).
  2. Routes the query to that physical collection only.
  3. 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
    end

Three 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:

  1. Indexing that field on every document.
  2. 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:>0

This 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

On this page