Skip to content

Tenancy and Auth

giulio d'erme edited this page Aug 10, 2026 · 2 revisions

Tenancy and Auth

Two separate mechanisms that are easy to conflate:

  • Tenancy isolates one caller's memories from another's, enforced in the database.
  • Authentication decides who a caller is and which tenant they get, enforced at the server.

They meet at one point: authentication does not just answer "may this caller in?" — it answers "which store is this caller allowed to touch?", and that has to be decided before any tool body runs.

Reference: docs/AUTH.md.


Multi-tenancy

Every row carries a tenant_id, every query filters on it, and a row-level security policy enforces the same boundary inside PostgreSQL — so a forgotten WHERE clause returns nothing rather than another tenant's memories.

A store is bound to one tenant for its lifetime. The connection sets a per-connection setting; the RLS policy compares against it. Tenancy is therefore a property of which store object you hold, not of an argument you pass to a query — which is exactly what makes it hard to get wrong by accident.

The policy is both ENABLEd and FORCEd. Without FORCE, the table's owner is exempt from its own policy, which is a common way for this protection to be silently absent.

⚠️ The superuser trap

RLS is bypassed by a superuser or a BYPASSRLS role — including the one in this repo's docker-compose.yml.

Connect as a privileged role and the database-level layer does nothing. Your queries still filter by tenant in application code, so nothing looks broken; you simply have one layer of defence instead of two, and no signal that this is the case.

Two things follow, and both are in the codebase deliberately:

  • store.check_rls_effective() tells you which situation you are actually in. Call it.
  • The server warns at startup when RLS is not effective.

This also shaped how RLS is tested. The tests connect as a role that cannot bypass RLS — because as a superuser they would pass while testing nothing. That distinction is the difference between a test suite and a green checkmark. → Contributing-and-Testing

Chunk ids and why the primary key changed

Chunk ids derive from the file path. Two tenants indexing the same directory layout therefore produced the same id — and under a single-column primary key, one tenant's re-index silently overwrote the other's row.

The key is now (tenant_id, id). The ordered migration path adopts an existing legacy table in place and assigns existing rows to the default tenant, so a single-tenant deployment upgrades without noticing. There is a test that builds an old-shape table, inserts a row, opens it with the current version, and asserts the row survives and is still retrievable — which is the only way to know a migration works.


Authentication

Two mechanisms ship:

Mechanism Use
Static bearer-token file Development and small local deployments. Refused when RECALL_ENV=production.
OIDC provider Production HTTP transports. Revocation, rotation and expiry belong to the identity provider.

In both cases the server maps an authenticated caller to a tenant and scopes before any tool body runs.

What it gives you

An unauthenticated network listener is impossible to create by accident. Starting an HTTP transport without configured authentication raises and refuses to boot. That is the property most often missing in systems like this, and it is the reason the module exists in the shape it does.

With OIDC, an HTTP transport also refuses when the tenant list is missing, when no subject-to-tenant decision has been configured, when symmetric or unsigned JWT algorithms are offered, or when both static and OIDC mechanisms are present without RECALL_AUTH_MODE.

What the static token file does not give you

  • No revocation without a restart. Tokens live in a file read at startup. Removing one takes effect on reload, not on save.
  • No rotation protocol. Overlapping validity is arranged by hand: add the new token, restart, migrate clients, remove the old one, restart again.
  • Bearer means bearer. A leaked token is full access for that principal until it is removed. No proof-of-possession, no audience binding.

For a deployment that needs those, use the OIDC path documented in docs/AUTH.md.

Tokens come from a file, never an environment variable

RECALL_AUTH_TOKENS_FILE is the only source. There is no RECALL_AUTH_TOKENS=<secret>, and the omission is the design.

Environment variables leak through /proc/<pid>/environ, ps e, container inspection APIs, crash dumps, and — worst — every child process the server ever spawns. A secrets file is what Kubernetes and Docker mount anyway, and unlike an environment variable it can be permission-checked. The server checks, and warns if the file is world-readable.

The file may hold plaintext tokens or their SHA-256 digests, so an operator provisioning access never has to write a live credential to disk in recoverable form.

Keep the token file outside RECALL_INDEX_ROOT. The index tool reads files as the server process itself, so a token file under the index root is in the same filesystem authority as user-indexable content. The server has additional corpus-glob checks, but path separation is the stronger boundary.

OIDC tenant binding

OIDC tokens must name a tenant, and that tenant must be in RECALL_OIDC_TENANTS. The identity provider vouches for identity; it does not know which tenant namespaces this deployment should serve.

One further decision is mandatory:

  • RECALL_OIDC_SUBJECT_TENANTS pins subjects to tenants in this service.
  • RECALL_OIDC_TRUST_TENANT_CLAIM=1 declares that the identity provider's tenant claim is minted from an authoritative subject-to-organization mapping.

Setting neither refuses startup. Setting both refuses startup. This is the cross-tenant boundary, so ambiguity is treated as a configuration error.

Scopes

Scope Grants
recall:read recall_search, recall_evidence, recall_stats
recall:write recall_index
recall:forget recall_forget

These mirror the tools' own MCP annotations — read-only, non-destructive, destructive — so a principal's scopes line up with the risk each tool actually carries.

Two defaults worth knowing:

  • A principal with no declared scopes gets recall:read and nothing else. Least privilege by default.
  • An unknown scope is refused, not ignored. A typo'd scope name silently dropped means a principal quietly has less access than intended, and the failure surfaces later as a confusing permission error rather than as a config error at startup.

Tokens may also carry an expiry.


Rate limits and the indexing budget

Per-tenant token buckets meter calls per tool class and an aggregate indexing byte budget. Configuration: Configuration-Reference.

Three design points:

Per tenant, not per principal. Two tokens issued to the same tenant are the same blast radius and the same bill; letting a tenant multiply its budget by minting another token would make the quota advisory. Rate-limiting the principal would isolate two clients from each other but would not cap the thing you actually pay for.

Bytes, not just calls. Request count is a poor proxy for spend when one call can carry twenty megabytes and the next two hundred bytes. The byte budget is the load-bearing one, and it is debited pre-flight — the tenant is charged for what is about to be embedded, before it is embedded, so a refusal has spent nothing.

Fails open only by configuration, never by accident. A limit can be switched off, but only by writing the literal off. Anything malformed falls back to its default rather than being read as unlimited. A typo must not silently remove a cap.

⚠️ Buckets are per process. Nothing is shared across workers, so N server processes admit roughly N times these rates. Honest for the deployment this targets — one process behind TLS — and the first thing to revisit before running a fleet. A shared limiter needs Redis or the database, and a network round trip on every call.

The store registry's database connection ceiling is process-wide, not tenant-count multiplied. Adding tenants does not silently multiply the configured maximum connections.


Deployment checklist

  1. Connect as an unprivileged role (NOSUPERUSER, NOBYPASSRLS). Verify with check_rls_effective(). Without this, RLS is decoration.
  2. Do not use the published default credentials against a non-local host. The server refuses to boot on this; do not reach for the override without meaning it.
  3. Choose auth mode. Use OIDC for production; use a static token file only where its restart based revocation is acceptable.
  4. Grant least privilege — most principals need recall:read only.
  5. Set RECALL_POOL_SIZE. Without it the server serves one request at a time.
  6. Set RECALL_STATEMENT_TIMEOUT_MS, or one runaway query holds a connection until the process dies.
  7. Set RECALL_INDEX_ROOT to exactly the directory you intend to expose.
  8. Check the byte budget is at or above the per-request byte cap, or requests sized between the two can never succeed.
  9. Put TLS in front. Bearer tokens over plaintext HTTP are credentials on the wire.
  10. Plan for restart-based revocation — there is no other kind here.

See also: Configuration-Reference · Python-API-and-MCP · SECURITY.md

Clone this wiki locally