The isolation is in the database, not the code
Application-level tenant filtering fails the day somebody writes one query without a WHERE clause. Here the floor is underneath the application: the database refuses to return another tenant's rows even when the query asks for them.
- ENABLE + FORCE RLS on every tenant table
- Auth0 RS256 against cached JWKS
- 6 roles · 12 permissions
- No certifications — stated below
The tenant floor
Every tenant table carries tenant_id with row-level
security both enabled and forced, and a policy whose
USING and WITH CHECK clauses compare it
against a transaction-local setting.
Connections set app.current_tenant with
SET LOCAL, so the scope belongs to the transaction and
cannot leak across a pooled connection into the next request. With
no setting at all, tenant tables return nothing — the failure mode is
an empty result, not somebody else's data.
FORCE matters as much as ENABLE. Without
it, the role that owns a table is exempt from its own policies. So
production requires a runtime role created
NOSUPERUSER NOBYPASSRLS, distinct from the migration
role that owns the schema. The bootstrap user in the pgvector image
is a superuser and is explicitly not a valid runtime identity.
This is checked rather than documented. CI creates the split roles,
runs the integration suite against them, and asserts that the
runtime connection is neither the owner, nor a superuser, nor
BYPASSRLS. DEPLOYMENT.md gives operators
the same query to run against their own database and is blunt about
the result: "Treat any other result as a deployment-blocking
security failure."
CREATE ROLE pcnaid_app LOGIN
NOSUPERUSER NOCREATEDB NOCREATEROLE
NOINHERIT NOBYPASSRLS;
// the assertion, run in CI and by you
SELECT current_user, rolsuper,
rolbypassrls FROM pg_roles …
rolsuper false
rolbypassrls false
table owner different role
// anything else blocks the deployment
How a bearer token becomes a tenant
The tenant is derived from something verified. It is never taken from a header a caller can set.
Auth0 access tokens
RS256 signatures are verified against the tenant's Auth0 JWKS, fetched from the configured domain and cached for one hour. Both the issuer and the audience are enforced during decode, not inspected afterwards.
Claims are read from the
https://pcnaid.com namespace:
tenant_id, roles and
plan. A token that verifies perfectly but carries no
tenant claim is rejected with 401 and the reason
missing tenant claim — there is no default tenant to
fall into.
If JWT verification libraries are unavailable, the middleware fails closed rather than degrading to unverified decoding.
Programmatic API keys
POST /v1/api-keys mints a key with the
pcnaid_ prefix and 256 bits of entropy. The plaintext
is returned once, at creation, and never again — listing shows the
name, scopes and timestamps only. DELETE revokes it
immediately by stamping revoked_at.
Scopes are validated against the real permission set at creation
time. memory:read and memory:* are
accepted; a scope that names no existing permission is a 400
rather than a key that silently grants nothing.
Keys are stored as an Argon2id verifier, with PBKDF2-SHA256 at 200,000 iterations as the fallback when argon2-cffi is absent. Neither is reversible.
The lookup problem, and how it is closed
An API key has to be authenticated before the tenant is
known, which is exactly the moment row-level security has nothing to
scope against. Accepting a bearer token because it starts with
pcnaid_ and then trusting whatever tenant it names would
hand any caller a choice of tenants.
So each key stores two independent values. A deterministic SHA-256
key_lookup_hash locates a candidate row, and that lookup
is guarded by a narrow policy bound to
app.api_key_lookup_hash — a transaction-local setting
that exposes exactly the row whose digest you already knew, and
nothing else. The Argon2id key_hash then verifies the
secret properly. Only after both steps does the tenant come from the
verified row.
The sixth role, cross_tenant_admin, holds no permissions
of its own. Its only effect is that a support operator carrying it may
override the token's tenant with an explicit
X-Tenant-ID header — a header that is otherwise ignored
when a verified token is present. The original user ID stays in the
request context, so the override appears in the logs and the audit
trail.
{ "name": "ingest-worker",
"scopes": ["memory:write", "memory:read"] }
"key": "pcnaid_qP7…" // shown once
// later, on every request
lookup sha256(token) → candidate row
RLS app.api_key_lookup_hash
verify argon2id(key_hash, token)
tenant from the verified row only
Twelve permissions, six roles
Permissions are checked per route, not per section of the dashboard. An API key carries its scopes directly and is evaluated against the same table.
| Permission | Held by | What it allows |
|---|---|---|
chat | owner · admin · member · service | Run a chat turn with retrieval and storage. |
memory:read | owner · admin · member · viewer · service | List, search and read memories. |
memory:write | owner · admin · member · service | Create, edit, pin, archive and delete memories. |
facts:read | owner · admin · member · viewer · service | Read the bitemporal fact store. |
facts:override | owner · admin | Replace the current value of a fact. |
kb:read | owner · admin · member · viewer · service | Read knowledge-base sources and documents. |
kb:crud | owner · admin | Add, refresh, approve and remove knowledge sources. |
audit:read | owner · admin | Read and export the audit log. |
pii:reveal | owner | Resolve a placeholder back to the real identifier. |
tenant:settings | owner · admin | Change tenant settings and mint or revoke API keys. |
billing | owner | Open Stripe checkout or the customer portal. |
admin:ops | owner · admin | Work the outbox, status page, alerts and support queue. |
Note what pii:reveal is missing from. An admin can work the
outbox, read the audit log and change tenant settings, and still cannot
resolve a placeholder back to somebody's phone number. That separation
is the point of having twelve permissions rather than three.
The rest of the surface
Smaller decisions, each one made because the obvious alternative fails in a specific way.
hmac.compare_digest. Hashing first means the comparison time reveals neither the length of the expected token nor how many leading characters matched.Host header. Loopback, private, link-local, multicast, reserved and unspecified ranges are refused while KB_BLOCK_PRIVATE_NETWORKS=true. Redirects are capped at five and responses at 2 MB.MAX_REQUEST_BYTES defaults to 1,048,576 and KB_MAX_REQUEST_BYTES to 10,485,760 for source uploads. Both are enforced in ASGI middleware that checks Content-Length and then counts the streamed bytes, so a chunked body with no declared length is refused at the moment it crosses the limit rather than after it is buffered.FERNET_KEY is set. If the key is rotated or the ciphertext is damaged, decryption returns the literal [DECRYPTION_FAILED]. It does not return the raw stored bytes, and it does not silently return an empty string that a caller might treat as "no credential configured".CORS_ORIGINS is a comma-separated list. When it is unset the CORS middleware is not installed at all, so no cross-origin request is granted. There is no wildcard default to forget to remove.What we do not have
Every security page should have this section. Most do not, which is why the ones that do are worth reading.
SECURITY.md asks you to keep passwords, API keys,
private keys, full card or bank details, government IDs and medical
records out of the system. The PII vault detects email addresses and
US-format phone numbers — two regular expressions — and nothing else.
Exactly what is and is not detected.
Give your agents a memory you can audit
Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.