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.

The tenant floor and the audit chain A request reaches the application carrying a tenant claim. Before any query runs, the transaction sets app.current_tenant. Underneath the application every tenant table has row level security enabled and forced, so the current tenant's partition is readable and the other tenants' partitions return nothing at all. To the right, each governed operation appends an audit row whose row hash is computed over the previous row's hash, so any edit or deletion in the middle of the log breaks the chain. Application verified tenant_id · roles · plan, taken from the token — never a header SET LOCAL app.current_tenant = … ENABLE + FORCE ROW LEVEL SECURITY tenant A rows visible tenant B 0 rows returned tenant C 0 rows returned USING / WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid) Runtime role is NOSUPERUSER NOBYPASSRLS and does not own the tables. audit_events INGEST prev_hash — row_hash 7f2a41… FORGET prev_hash 7f2a41… row_hash 9c4108… MODIFY prev_hash 9c4108… row_hash b3d7e0… Break a row in the middle and every hash after it stops matching.

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."

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.

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.

PermissionHeld byWhat 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.

Constant-time token comparison
Admin, API and alert tokens are compared by hashing both sides to a fixed-length SHA-256 digest and calling hmac.compare_digest. Hashing first means the comparison time reveals neither the length of the expected token nor how many leading characters matched.
SSRF defence with DNS-rebinding mitigation
Knowledge-base fetches validate the scheme, check a domain allowlist, resolve DNS once and pin the resulting IP for the connection while preserving the original 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.
Body ceilings the proxy cannot skip
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.
Encryption that fails closed
Stored provider credentials are Fernet-encrypted when 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 is an allowlist, and it is empty by default
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.
Quotas fail closed outside a tenant context
Quota checks refuse rather than pass when they cannot establish the RLS context they need. An enforcement path that opens up when it is confused is not an enforcement path.

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.

No certifications
There is no SOC 2 report, no ISO 27001 certificate, no HIPAA attestation, no PCI assessment and no GDPR certification. There has been no third-party penetration test. If a procurement questionnaire needs one of those, the honest answer today is no — and self-hosting under the MIT licence means the controls above run inside your own compliance boundary instead.
Rate limiting does not span replicas
The limiter keeps its counters in process memory. Two replicas mean two independent sets of buckets and, in effect, twice the published ceiling. It is a useful floor and a poor perimeter; put the real limit at the edge. The buckets and their defaults.
Do not store regulated categories
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.
Reporting a vulnerability
Privately, to security@pcnaid.com. We aim to acknowledge within 72 hours and to fix critical issues within 14 days where the fix is under our control. Please do not open a public issue for a security report.

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.