Auth & Security
Security in RelataDB is in the query path. Every read runs through an ABAC engine, every write is audit-logged with a tamper-evident hash chain, and classified types are redacted at egress before serialisation — regardless of whether the query itself succeeded.
Dev mode warning
The free profile with no token set runs completely unauthenticated. pgwire is disabled, the admin surface (/admin/*) is open, and no auth is checked. This is intentional for local dev. If the node is reachable beyond localhost, secure it now:
# Minimum viable secure setup
RELATA_PROFILE=server \
RELATA_BEARER_TOKEN=$(openssl rand -hex 32) \
relata serveThe server and cluster profiles refuse to start without RELATA_BEARER_TOKEN. There is no way to accidentally run them unauthenticated.
Step 1 — Set a bearer token
Generate a token and set it before starting the server:
export RELATA_BEARER_TOKEN=$(openssl rand -hex 32)
export RELATA_ADMIN_TOKEN=$(openssl rand -hex 32)
RELATA_PROFILE=server relata serveEvery request to every endpoint (HTTP, gRPC, Arrow Flight, pgwire, all protocol doors) now requires:
Authorization: Bearer <token>
RELATA_BEARER_TOKEN itself never authenticates the HTTP data plane (/query, /ingest, /search, and every other non-admin HTTP route) — on any profile, with no opt-out. It's the standing bootstrap secret you use to start the server and reach unauthenticated-by-default surfaces like /health; for real data-plane traffic, mint a tenant-scoped token via POST /admin/tokens (see Step 1's "Provision a tenant token" below) and use that. gRPC, Arrow Flight, pgwire, and the protocol-compat doors (S3/ClickHouse/Neo4j/Redis/MongoDB/Bolt) are the exception: they still accept RELATA_BEARER_TOKEN directly as their shared wire-protocol secret.
Test that auth is working:
# Should return 401 — RELATA_BEARER_TOKEN is not a valid credential here
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $RELATA_BEARER_TOKEN" \
http://localhost:9090/query
# Should return 200 — /health does not require a data-plane token
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $RELATA_BEARER_TOKEN" \
http://localhost:9090/healthThe admin token gates all /admin/* management operations separately. On server and cluster profiles, RELATA_ADMIN_TOKEN is required — if unset, every /admin/* route returns 503 Service Unavailable. The regular bearer token (RELATA_BEARER_TOKEN) is not a fallback for admin access (privilege separation).
# Provision a tenant-scoped token via the admin API — tenant_id bootstraps it
# directly into that tenant (static admin only; omit tenant_id for a global,
# unscoped token). NOTE: /admin/* is loopback-only (RELATA_ADMIN_BIND,
# default 127.0.0.1:9091) and is NOT mounted on the data-plane port (9090) —
# a request to :9090/admin/tokens always 404s, by design. Run this from
# wherever :9091 is actually reachable (the node itself, a kubectl
# port-forward, or docker exec — see "Reaching the admin listener from a
# second container" below).
curl -X POST http://localhost:9091/admin/tokens \
-H "Authorization: Bearer $RELATA_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"description\":\"acme-prod\",\"tenant_id\":\"acme\",\"expires_at\":$(( $(date +%s) + 365*86400 ))}"
# → { "token": "rlt_7f3a9b2c...", "id": "tok_..." }expires_at is an absolute Unix-epoch-seconds timestamp — not a relative duration. Omit it entirely for a token that never expires (the field is nullable; there's no separate expires_in_days/TTL-style field, and an unrecognized field name is silently dropped rather than rejected, so a typo here fails silently into "never expires" rather than an error).
On the server and cluster profiles the data-plane HTTP listener (RELATA_HTTP_BIND) and gRPC listener (RELATA_GRPC_BIND) bind 0.0.0.0 by default — auth/TLS posture is uniform across every profile. RELATA_BEARER_TOKEN is not a valid credential for HTTP data-plane routes (see above) — only registry-minted, tenant-scoped tokens (POST /admin/tokens) authenticate /query, /ingest, /search, and the rest of the data plane. It remains the shared wire-protocol secret for gRPC, Arrow Flight, pgwire, and the protocol-compat doors; protect it with TLS (RELATA_TLS_CERT/RELATA_TLS_KEY) or a reverse proxy / sidecar on those surfaces. The admin surface (/admin/*, /platform/*) is on a separate, loopback-only listener (RELATA_ADMIN_BIND, default 127.0.0.1:9091 — Zero-Trust control plane) and is never mounted on the data-plane listener.
Token lifecycle
Tokens support expiry, self-service rotation/list/revoke, and per-tenant audit — all under /tokens/self/*, reachable from the network-facing listener with just the tenant's own token (no admin token needed):
# Create a token with a 30-day expiry (loopback admin listener, port 9091)
curl -X POST http://localhost:9091/admin/tokens \
-H "Authorization: Bearer $RELATA_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"description\":\"short-lived\",\"tenant_id\":\"acme\",\"expires_at\":$(( $(date +%s) + 30*86400 ))}"
# Rotate your own token (tenant self-service — no admin token needed)
curl -X POST http://localhost:9090/tokens/self/rotate \
-H "Authorization: Bearer rlt_7f3a9b2c..."
# → { "token": "rlt_new...", "old_token_revoked": true }
# List your own tenant-scoped tokens
curl http://localhost:9090/tokens/self \
-H "Authorization: Bearer rlt_7f3a9b2c..."
# View your token's own lifecycle audit trail
curl http://localhost:9090/tokens/self/audit \
-H "Authorization: Bearer rlt_7f3a9b2c..."
# Revoke one of your own tokens
curl -X DELETE http://localhost:9090/tokens/self/tok_abc123 \
-H "Authorization: Bearer rlt_7f3a9b2c..."
# Platform-wide audit (admin only, loopback listener, port 9091)
curl http://localhost:9091/admin/tokens/audit \
-H "Authorization: Bearer $RELATA_ADMIN_TOKEN"A sys-admin credential (RELATA_ADMIN_TOKEN) is not accepted on /tokens/self/* — that surface is intentionally tenant-only; use the loopbound /admin/tokens* routes above for cross-tenant management. Tokens within 30 days of expiry are logged at WARN on each use as a rotation reminder.
Reaching the admin listener from a second (sidecar) container
/admin/* is loopback-only by design (RELATA_ADMIN_BIND, default 127.0.0.1:9091, ADR-0261) and is never mounted on the data-plane port (9090) — a request to :9090/admin/tokens always 404s, with no 401/403 to hint an admin surface even exists there. This is a common trip-up for a separate, long-running service container (not a human operator, not Kubernetes) that needs a durable credential for /query, /mcp/tools/call, etc. — /admin/mint's break-glass credential caps out at a 15-minute TTL and is also loopback-only, so it isn't a fit either.
For Docker Compose / container run (non-Kubernetes) deployments, the supported pattern is:
-
Mint the token from inside the RelataDB container, where
localhost:9091is reachable —docker exec(or the Applecontainer execequivalent), not a call from the second container. Omitexpires_atfor a service credential that never expires (there's no 15-minute-style cap on this route, unlike/admin/mint):docker exec hm-relatadb curl -s -X POST http://localhost:9091/admin/tokens \ -H "Authorization: Bearer $RELATA_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"description":"hm-engine","tenant_id":"hypermind"}' # → { "token": "rlt_...", "id": "tok_..." } (no expiry set — valid until revoked) -
Hand the resulting token to the second container as a static env var or secret. It's a normal registry-minted, tenant-scoped token — non-expiring unless you set
expires_at— and it authenticates directly against the data-plane port (9090) —/query,/mcp/tools/call,/ingest, and the rest — like any other tenant credential. No refresh loop is needed for the sidecar's whole lifetime. -
Rotate later without re-execing in: the second container can call the dual-mounted, network-facing
POST /tokens/self/rotate(port 9090) using its own current token — loopback access is only needed for the initial mint, not for ongoing rotation.
There is no supported way for a different container to reach RELATA_ADMIN_BIND directly — only docker exec/kubectl exec into the RelataDB container, or sharing a network namespace (--network container:hm-relatadb, or a Kubernetes pod) reach it. If neither is available in your orchestrator, mint the bootstrap token as a one-time step in your deploy pipeline — wherever you already have exec access — and inject the result as a secret, rather than having the sidecar try to mint its own.
Step 2 — Wire OIDC (optional)
For production SSO, configure OIDC. The oidc mode trusts a front-proxy's verified principal; oidc-verify validates the JWT signature against the provider's JWKS endpoint in-process.
RELATA_AUTH_MODE=oidc \
RELATA_OIDC_ISSUER=https://auth.example.com \
RELATA_OIDC_CLIENT_ID=relata \
RELATA_OIDC_JWKS_URI=https://auth.example.com/.well-known/jwks.json \
RELATA_OIDC_AUDIENCE=relata \
RELATA_PROFILE=server \
relata serveFor in-process token signature verification (recommended):
RELATA_AUTH_MODE=oidc-verify \
RELATA_OIDC_ISSUER=https://auth.example.com \
RELATA_OIDC_JWKS_URI=https://auth.example.com/.well-known/jwks.json \
RELATA_OIDC_AUDIENCE=relata \
RELATA_PROFILE=server \
relata serveVerify OIDC is working by obtaining a token from your provider and querying:
TOKEN=$(curl -s -X POST https://auth.example.com/token \
-d "grant_type=client_credentials&client_id=relata&client_secret=$SECRET" \
| jq -r .access_token)
curl http://localhost:9090/health/ready \
-H "Authorization: Bearer $TOKEN"Step 3 — Wire mTLS (optional)
For service-to-service auth where client certificates are already managed by your mesh:
RELATA_AUTH_MODE=mtls \
RELATA_MTLS_CA_CERT_PATH=/etc/relata/tls/ca.crt \
RELATA_PROFILE=server \
relata servemTLS requires a CA cert (RELATA_MTLS_CA_CERT_PATH); client-cert requirement defaults to on (RELATA_MTLS_REQUIRE_CLIENT_CERT=true). To terminate TLS in-process on the listener, also set RELATA_TLS_CERT and RELATA_TLS_KEY.
Clients must present a certificate signed by the configured CA. No bearer token is required when mTLS is the auth mode — the client cert is the credential.
Test with curl:
curl --cert client.crt --key client.key --cacert ca.crt \
https://localhost:9090/health/readyPurpose enforcement
PURPOSE is optional at the SQL layer. When you declare it, it is recorded in the audit log and evaluated by the ACL engine.
-- With purpose (recorded in audit, ACL-evaluated)
PURPOSE 'analytics' SELECT name, email FROM Person LIMIT 10;
-- Without purpose (valid — purpose is optional)
SELECT name FROM Person LIMIT 10;In production, lock down to a registered list:
RELATA_PURPOSE_MODE=strict \
RELATA_PURPOSES=analytics,audit,compliance,security_incident \
relata serveQueries declaring an unregistered purpose return 403 (see Error codes — MissingPurpose/UnknownPurpose). Use open mode only in dev.
EXPLAIN POLICY
Before deploying a policy, validate what it does:
EXPLAIN POLICY FOR PURPOSE 'analytics' ON Person;The output shows which rows are visible, which columns are masked, and which deny rules fired. Run this whenever you change ACL policies — it catches overly broad denies before they hit production queries.
Policy engine (ABAC)
RelataDB ships its own Cedar-inspired ABAC engine — deny-wins semantics, bitmap row filtering, and cell masking.
| Rule type | Performance |
|---|---|
| Bitmap row filtering | ~1.0× raw-scan overhead (effectively free) |
| Conditional ACL | ~1.32× raw-scan p50 |
| Cell masking | ~2.6× raw-scan p50 — avoid on hot paths |
Policy example:
permit(
principal == user::"alice",
action == action::"read",
resource in department::"finance"
) when {
resource.purpose == "audit"
};
forbid(
principal,
action == action::"read",
resource
) when {
resource.classification == "restricted"
};Deny-wins means any matching forbid overrides all permit rules. Always test with EXPLAIN POLICY after adding a deny.
Egress filtering
Classified types are redacted at serialisation regardless of whether the query succeeded. This applies uniformly across HTTP, gRPC, Arrow Flight, pgwire, SPARQL, and every protocol door.
Redacted types include SourceTrueIdentity, SigintIntercept, AccessScopedIntercept, and LawfulInterceptRecord. These never appear in tool results, query rows, or SDK responses.
Rate limits
Rate limits are per-IP and enforced on every request path:
# Production defaults on server/cluster (per-IP token bucket)
# RELATA_RATE_LIMIT_RPS=100000 (default)
# RELATA_RATE_LIMIT_AUTH_FAIL_RPS=10 (default)On exhaustion the server returns 429 Too Many Requests with a Retry-After header. Setting AUTH_FAIL_RPS=0 is treated as 1 — use 99999 to effectively disable.
Auth-failure rate limiting is a brute-force guard. Keep it low in production.
GDPR Art. 17 erasure
The ERASE SUBJECT operator performs a governed right-to-erasure: shreds rows, destroys orphaned blobs, destroys the per-subject DEK via KMS, and returns a signed Art. 17 receipt.
ERASE SUBJECT 'person-42' REASON 'gdpr-art17' CERTIFY;The same operation is available via CLI, SDK, and MCP:
# CLI
relata query "ERASE SUBJECT 'person-42' REASON 'gdpr-art17' CERTIFY"
# SDK (Python)
client.identity.erase_subject("person-42", reason="gdpr-art17")
# MCP tool
# { "tool": "erase_subject", "subject_id": "person-42", "reason": "gdpr-art17" }The returned receipt is content-addressed and verifiable against the audit chain. Store it — regulators may ask for it.
Audit hash chain
Every write is recorded in an append-only log with principal, timestamp, purpose, cost units, and a hash linking each entry to the previous one.
# Check chain validity
curl -H "Authorization: Bearer $RELATA_BEARER_TOKEN" \
http://localhost:9090/audit/count
# { "entries": 1248, "chain_valid": true }
# Deep health check (chain + WAL + object-store)
relata checkchain_valid: false is a security event. Treat it as a potential breach: isolate the node, preserve the WAL, and investigate.
Protocol door security
The protocol-compatibility doors bind to 127.0.0.1 by default; override per-door with RELATA_<DOOR>_BIND on any profile (no license needed). pgwire fails closed without RELATA_BEARER_TOKEN. Put other doors behind a network policy or mTLS sidecar before exposing them beyond localhost. Every door presents a distinct Cedar principal (s3-client, pgwire-client, mongo-client, …) so you can grant least privilege per integration and audit-log which protocol wrote each row — see Per-Door ACL. For full Docker/Kubernetes door wiring, see Deploying Protocol Doors.
See also
- Per-Door ACL — least privilege per integration via Cedar door principals
- Multi-Tenancy — org isolation, per-tenant DRK, quotas
- Configuration — all auth env vars
- Observability — audit trail, correlation IDs