relay

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:

  1. 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.
  2. Application-layer scoping: every repo function in @relayhq/db takes a tenant id and filters on it. The compiler enforces it.
  3. Postgres Row-Level Security: even if the SQL forgets a WHERE, the database itself denies the rows. See below.
  4. Non-owner DB role: the control plane connects as relay_app which has no BYPASSRLS. Admin scripts use a separate role.
  5. 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 using RELAY_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_PREVIOUS to the old key, flip RELAY_MASTER_KEY to 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-key scans every row, decrypts with whichever key works, re-encrypts with the new primary, writes an audit_events row per tenant. Once done, unset RELAY_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).

ActionEmitted 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_clearedDELETE /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:

ClassCapacityRefillApplies to
default6060/minAll authenticated reads
runs3030/minPOST /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_KEY set (32 bytes, hex/base64). Never commit. Rotate every ~90 days using the dual-key procedure.
  • DATABASE_URL_APP set to the relay_app role string. The admin DATABASE_URL stays scoped to migrations + bootstrap.
  • REDIS_URL set if running >1 control plane replica (otherwise rate limits are per-replica only).
  • NATS_URL set 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_SECRET set so only the runtime can call /internal/*.

See the full deploy runbook for the staged rollout.