-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
RLS is bypassed by a superuser or a
BYPASSRLSrole — including the one in this repo'sdocker-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 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.
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.
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.
- 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.
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 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_TENANTSpins subjects to tenants in this service. -
RECALL_OIDC_TRUST_TENANT_CLAIM=1declares 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.
| 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:readand 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.
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.
The store registry's database connection ceiling is process-wide, not tenant-count multiplied. Adding tenants does not silently multiply the configured maximum connections.
-
Connect as an unprivileged role (
NOSUPERUSER,NOBYPASSRLS). Verify withcheck_rls_effective(). Without this, RLS is decoration. - 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.
- Choose auth mode. Use OIDC for production; use a static token file only where its restart based revocation is acceptable.
-
Grant least privilege — most principals need
recall:readonly. -
Set
RECALL_POOL_SIZE. Without it the server serves one request at a time. -
Set
RECALL_STATEMENT_TIMEOUT_MS, or one runaway query holds a connection until the process dies. -
Set
RECALL_INDEX_ROOTto exactly the directory you intend to expose. - Check the byte budget is at or above the per-request byte cap, or requests sized between the two can never succeed.
- Put TLS in front. Bearer tokens over plaintext HTTP are credentials on the wire.
- Plan for restart-based revocation — there is no other kind here.
See also: Configuration-Reference · Python-API-and-MCP ·
SECURITY.md
This wiki explains design and intent. Measured figures, deployment defaults and CLI flag values
live in the repository, versioned with the code that produced them:
FINDINGS ·
RESULTS ·
MIGRATIONS ·
.env.example · --help.
If a page here disagrees with the repo, the repo is right.
Concepts
Using it
- Installation-and-Setup
- Embedders-and-Rerankers
- Configuration-Reference
- CLI-Reference
- Python-API-and-MCP
- Tenancy-and-Auth
Evidence
Contributing
In the repo