Documentation
Security
Row-Level Security in Postgres, dual-key envelope encryption, audit log, API key rotation, rate limiting. Defense in depth, not theater.
Tenant isolation, layered
Every tenant-scoped row carries a tenant_id. Five layers enforce isolation so a single bug in any one of them doesn't leak data:
- API key → tenant resolution at the edge: bearer hashed (SHA-256), looked up in
api_keys, the tenant id is bound to the request context for everything downstream. - Application-layer scoping: every repo function in
@relayhq/dbtakes a tenant id and filters on it. The compiler enforces it. - Postgres Row-Level Security: even if the SQL forgets a
WHERE, the database itself denies the rows. See below. - Non-owner DB role: the control plane connects as
relay_appwhich has noBYPASSRLS. Admin scripts use a separate role. - Per-tenant encryption: provider credentials are AES-256-GCM sealed with the master key, with a dual-key rotation path.
Row-Level Security
Migration 004 enables RLS on every tenant-scoped table: tenants, api_keys, provider_credentials, runs, run_events, memories, audit_events. The policy is uniform:
-- Visible iff the row is yours OR the admin escape hatch is set.
USING (
coalesce(current_setting('app.bypass_rls', true), '') = 'on'
OR tenant_id::text = nullif(current_setting('app.tenant_id', true), '')
)The control plane wraps every tenant-scoped request in a transaction with SET LOCAL app.tenant_id = ?. Without that variable set, the policy hides all rows — safe by default. Admin contexts (signup, internal callbacks, migrations) set app.bypass_rls = 'on' explicitly.
Encryption at rest (BYOK)
Provider credentials never live in plaintext. The lifecycle:
- Seal: when you
PUT /v1/credentials/<provider>, the control plane encrypts the key with AES-256-GCM usingRELAY_MASTER_KEY, stores ciphertext + IV + auth tag. - Open: when a run needs to call OpenAI / Anthropic, the control plane decrypts on the fly and ships plaintext in the per-request payload to the runtime. The runtime never persists it.
- Rotate: dual-key envelope. Set
RELAY_MASTER_KEY_PREVIOUSto the old key, flipRELAY_MASTER_KEYto the new one.open()tries primary first, falls back to secondary — so old rows keep working through the cutover. - Re-encrypt:
pnpm db:rotate-master-keyscans every row, decrypts with whichever key works, re-encrypts with the new primary, writes anaudit_eventsrow per tenant. Once done, unsetRELAY_MASTER_KEY_PREVIOUS.
API key rotation
Tenants own their bearer tokens. The typical zero-downtime rotation:
# 1. Mint a new key
curl -X POST https://api.relaygh.dev/v1/keys \
-H "Authorization: Bearer $OLD_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"rotated 2026-05"}'
# Response: { "apiKey": "relay_live_...NEW", "descriptor": {...} }
# 2. Deploy the new key to your clients, verify it works
# 3. Revoke the old one
curl -X DELETE https://api.relaygh.dev/v1/keys/<old_key_id> \
-H "Authorization: Bearer $NEW_KEY"Audit log
Every security-relevant action lands in audit_events, scoped per tenant and RLS-protected like everything else. Indexed by (tenant_id, created_at) and (tenant_id, action, created_at).
| Action | Emitted on |
|---|---|
| tenant.signed_up | POST /v1/signup |
| api_key.created | POST /v1/keys |
| api_key.revoked | DELETE /v1/keys/:id |
| credential.created | PUT /v1/credentials/:provider (new) |
| credential.updated | PUT /v1/credentials/:provider (replace) |
| credential.deleted | DELETE /v1/credentials/:provider |
| master_key.rotated | pnpm db:rotate-master-key |
| memory.deleted | DELETE /v1/memories/:id |
| memory.namespace_cleared | DELETE /v1/memories?namespace=… |
| voice.transcribed | POST /v1/transcribe |
| voice.synthesized | POST /v1/synthesize |
# Read your audit log
curl -H "Authorization: Bearer $RELAY_API_KEY" \
"https://api.relaygh.dev/v1/audit?action=api_key.created&limit=50"Rate limiting
Per-tenant token-bucket on every /v1/* endpoint. Two classes:
| Class | Capacity | Refill | Applies to |
|---|---|---|---|
| default | 60 | 60/min | All authenticated reads |
| runs | 30 | 30/min | POST /v1/runs |
Backend: in-memory per replica by default; set REDIS_URL to enforce fleet-wide via an atomic Lua script (required once you scale the control plane to >1 instance).
Limit-exceeded responses are HTTP 429 with Retry-After (seconds), RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers.
Production checklist
RELAY_MASTER_KEYset (32 bytes, hex/base64). Never commit. Rotate every ~90 days using the dual-key procedure.DATABASE_URL_APPset to therelay_approle string. The adminDATABASE_URLstays scoped to migrations + bootstrap.REDIS_URLset if running >1 control plane replica (otherwise rate limits are per-replica only).NATS_URLset if running >1 control plane replica (the custom-tool broker has to be shared).- Cross-vendor backups of Postgres to S3 / R2 nightly, restore test quarterly.
RELAY_INTERNAL_SECRETset so only the runtime can call/internal/*.
See the full deploy runbook for the staged rollout.