Access control, explained like you're new to it

Relata checks every request against a permission list before any data leaves the database. You can't accidentally skip it — and that includes requests that arrive through PostgreSQL, S3, or MongoDB clients rather than the normal API.

This page is the practical, copy-paste guide. It assumes no security background. If you want the design reasoning instead, see Governance.

The 60-second version

  1. Every connection arrives through a door (the HTTP API, the PostgreSQL wire door, the S3 door, …). Each door has its own built-in role, e.g. http-client, pgwire-client, s3-client.
  2. When a request comes in, Relata checks the role's permissions for the type (think: table) being touched — read or write.
  3. Deny always wins. If anything says no, the answer is no.
  4. You extend permissions with one environment variable (RELATA_ACL_GRANT) and hide columns with another one (RELATA_CELL_POLICIES). Both are read once at startup.
Note
Out of the box, every door role can read and write the built-in governed types — a local dev server "just works". The settings on this page are how you tighten or extend that for your own data.

"I registered my own type and now I get 403"

The most common first stumble. Built-in types (Person, Transaction, …) are pre-granted. Types you create yourself are not — until you grant them.

Add one environment variable and restart the server:

# Grant read+write on your custom types to every protocol door
RELATA_ACL_GRANT="Customer:read+write,Order:read+write" relata serve

That's it. The same grant automatically covers the internal system role (read only) so background features like materialized views keep working.

Tip

Shortcuts that save time:

  • RELATA_ACL_GRANT="Customer" (no :perms) means read only — the safe default when handing a type to an analytics tool.
  • Multiple types are one comma-separated list; + joins permissions: read+write.
  • The grant also registers the type, so a brand-new server won't return "unknown type" before your first write.
Warning
Env vars are read at startup. Editing them on a running server does nothing until you restart. There is currently no per-door variant of this variable — one grant covers every door equally (per-door narrowing is on the roadmap, see Per-Door ACL).

"I want to hide a column from everyone" (SSN, IBAN, salaries…)

Use the cell-policies variable. It takes a comma-separated list of Type.column=action entries:

RELATA_CELL_POLICIES="Person.ssn=mask,BankAccount.iban=redact" relata serve

Actions:

ActionWhat the caller sees
mask or redact[REDACTED] — the row still comes back, just with that cell blanked
tokenizeA fake-but-stable stand-in value (see next section)
allowNothing happens (explicit "leave this alone")

A query result then looks like this — the row is not dropped, only the cell is masked:

{"name": "Alice", "ssn": "[REDACTED]", "age": 30}
Tip
Masking applies on the way out of the database, no matter which door asked — HTTP, psql, the S3 door, an AI agent via MCP. Your source data stays intact, and exports/re-ingest still work on the unmasked values.
Warning
Typos are loud, on purpose. A malformed RELATA_CELL_POLICIES value makes the server refuse to start, with an error naming the bad entry. That's deliberate: a silently-ignored typo would mean you think a column is protected when it isn't. The format is strict — Type.column=action, not a file path or JSON.

"I want to hide a column but still join/count on it"

Masking destroys the value. Tokenizing replaces it with a stable fake: the same input always yields the same token, so equality checks, grouping, and dedup still work — but nobody can read the original.

RELATA_CELL_POLICIES="Person.ssn=tokenize"
RELATA_TOKENIZE_KEY="<your-32-byte-hex-key>" relata serve
Warning
Using tokenize without setting RELATA_TOKENIZE_KEY also refuses to start — a protected field is never silently left raw because of a config gap. Keep the key safe: it is what maps tokens back to values, and losing it makes tokenized columns permanently opaque.

"Why am I getting 403 / Forbidden?"

Work down this checklist — it covers essentially every case:

  1. Is it an admin-only endpoint? Registering or deleting types (POST /types, DELETE /types/{name}) needs the admin token, not the normal bearer token. On server/cluster profiles the admin token is RELATA_ADMIN_TOKEN.
  2. Is it your own type? Custom types need RELATA_ACL_GRANT (see above). Symptom: built-in types query fine, yours 403s.
  3. Did the env var actually load? All of these are read at startup — restart, and watch the first seconds of logs: each grant logs RELATA_ACL_GRANT: granted access.
  4. Still stuck? Ask the database to explain the decision:
EXPLAIN POLICY SELECT name, ssn FROM Person LIMIT 5

This returns which rules matched, in what order, and what each did (allow / deny / mask) — it's the single best debugging tool for access questions.

Note
Some security-classified types (like SourceTrueIdentity) are blocked at egress unconditionally, whatever your grants say. That's by design — see Governance.

"An auditor asked: who looked at this data?"

Two pieces, both already running:

1. Purpose tags. Queries may declare why they're reading. It's optional and per query:

PURPOSE 'analytics' SELECT name, revenue FROM Account LIMIT 100

2. The audit trail. Every query and write lands in a tamper-evident, hash-chained log. Page through it with filters:

# Everything for one purpose
curl 'http://localhost:9090/audit/entries?purpose=analytics&limit=50' \
  -H "Authorization: Bearer $RELATA_BEARER_TOKEN"
 
# Everything touching one type, newest first
curl 'http://localhost:9090/audit/entries?type=Person&limit=50' \
  -H "Authorization: Bearer $RELATA_BEARER_TOKEN"

Useful filters: purpose, principal (who), type, since/until (timestamps), outcome=ok|error. /audit/count gives totals; /audit/proof returns a cryptographic inclusion proof that an entry is really in the chain.

Tip
Want to require purposes instead of suggesting them? Set RELATA_PURPOSE_MODE=strict and list the allowed ones in RELATA_PURPOSES=analytics,audit,compliance. Queries with an unlisted purpose are then rejected before they run.

What you can't do (yet) — so you don't lose an afternoon

  • Per-door grants via env vars. RELATA_ACL_GRANT reaches every door. You cannot say "read-only for S3, read-write for HTTP" with today's env-var surface.
  • Cedar policy files. The engine has Cedar-inspired internals, but you cannot load a Cedar policy document on a running server today. If you've seen permit(...)/forbid(...) examples elsewhere, they don't apply to the current release.

Both are tracked; until they ship, the honest answer is: type-level grants + column masking (this page) are today's operator toolbox.

See also