You're reading the v2.1.0 docs. View the latest (v2.2.0) →

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 serve

The 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 serve

Every 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/health

The 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:

  1. Mint the token from inside the RelataDB container, where localhost:9091 is reachable — docker exec (or the Apple container exec equivalent), not a call from the second container. Omit expires_at for 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)
  2. 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.

  3. 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 serve

For 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 serve

Verify 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 serve

mTLS 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/ready

Purpose 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 serve

Queries declaring an unregistered purpose return 403 (see Error codesMissingPurpose/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 typePerformance
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 check

chain_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