Skip to content

Tenancy and Auth

GiulioDER edited this page Jul 23, 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). ensure_schema() migrates an existing 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

A static bearer-token scheme. An operator provisions tokens out of band, a client presents one, the server maps it to a principal, and the principal carries a tenant and a set of scopes.

It is deliberately not an OAuth authorisation server. That buys simplicity at a real cost, and the costs are stated plainly rather than left to be discovered in production.

What it gives you

An unauthenticated network listener is impossible to create by accident. Starting an HTTP transport without a token file 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.

What it 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 any of those, front this with a real identity provider and supply the MCP SDK's auth_server_provider instead.

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.

Scopes

Scope Grants
recall:read recall_search, 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.


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. Provision a token file, chmod 600, digests rather than plaintext where you can.
  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