Skip to content
Talk to our solutions team

Access rules

Every entity served by the Data API can carry an access: block that governs which services may call it, which operations a principal may run, which rows those operations touch, and which columns come back. All four tiers use one rule shape and one binding vocabulary, so a rule reads the same wherever it appears.

entities:
- name: invoice
access:
services: [billing.svc] # Tier 0 — which SERVICES may call at all
actions: # Tier 1 — may this principal do this OPERATION?
read: { language: expr, expression: "true" }
create: { language: expr, expression: '"admin" in user.roles' }
rls: # Tier 2 — which ROWS may they see / touch?
read: { language: expr, expression: "row.tenant_id == user.tenant_id" }
fields: # Tier 3 — which COLUMNS may they project?
read: { language: expr, expression: 'field != "ssn" || "pii-reader" in user.roles' }
TierKeyQuestionEnforced by
0services:Is the calling service on the allow-list?Query compiler
1actions:May this principal run this operation?Runtime hook + query compiler
2rls:Which rows does the operation apply to?Query compiler (WHERE injection); in-process on create
3fields:May this principal project this column?Query compiler (projection check)

A tier you omit is not applied — except that a missing Tier-1 rule is a denial for external callers. See Deny-by-default.

The Data API denies any operation on an entity that no Tier-1 rule governs, when the request is external. Three shapes count as “no rule”: no access: block at all, an access: block with no actions: map, and an actions: map with no key for this operation.

# No access block → every external GET / POST / PUT / DELETE returns 403.
- name: currency
fields: [ ... ]
# Read is opted in. create / update / delete remain denied — they have no rule.
- name: currency
access:
actions:
read: { language: expr, expression: "true" }
fields: [ ... ]

The denial message is access denied (tier 1): entity "<name>" has no access rule for <op> (deny-by-default); declare an access.actions rule to permit it.

When the hook fires, and when it is bypassed

Section titled “When the hook fires, and when it is bypassed”

A request is external when the HTTP auth layer stamped an auth level on it. Both the authenticated middleware and the anonymous middleware do that — the anonymous one stamps anonymous even when the caller sends no header at all, so /anon/rest/* traffic is fully gated.

CallerGated by deny-by-default
Authenticated HTTP request (/rest/*, /graphql)Yes
Anonymous HTTP request (/anon/rest/*, /anon/graphql)Yes — principal has an empty user id and no roles
CLI seeding, bootstrap, migrationsNo — no auth level on the context
Background and refresh runnersNo
In-process calls from the hosting serviceNo
Engine-managed system entities (migration ledger, locks, plans)No — exempt even for external callers

Every rule — an action guard, an RLS predicate, a field rule — is a script reference with exactly these five keys:

KeyMeaning
language:Which engine evaluates the body. Defaults to expr when empty.
expression:An inline expression. The normal case.
script:An inline script (a full program body). Same destination as expression:; if both are set, expression: wins.
script-file:External script file URI. Does not execute — see below.
function:Entry function name for a module script. Consumed only on the file path, so it does not execute either.

Accepted language: values: expr (the default), cel, js, javascript, lua, starlark, go, wasm. Anything else fails the rule at evaluation time. ts / typescript is not accepted.

read: { language: expr, expression: '"admin" in user.roles' }

One function builds the bindings for every rule, so the variable set is identical across tiers and engines.

BindingIs
user.idThe caller’s user id
user.user_idAlias of user.id
user.tenant_idThe caller’s tenant key
user.tenantAlias of user.tenant_id
user.rolesThe caller’s roles (a list)
user.serviceThe calling service identity
user.session_idThe session id
tenantTop-level alias of user.tenant_id
entityThe entity name (not the row)
actionThe operation: create / read / update / delete
operationAlias of action
user_id, rolesFlat back-compat aliases; prefer the nested form
row.<column>The row under evaluation
payloadThe mutation body (runtime hook only, on writes)
fieldThe column name — field rules only, one evaluation per projected column
extended_actionpurge / restore / unmask / decrypt — extended-action guards only
entities, paramsNamed-action guards only

Nothing else resolves.

entity is the entity name; row.* is the row data. Reach for row.status, never entity.status.

Rule bodies run without the request context: they cannot be cancelled by a request timeout and cannot read anything from the request.

services: is an allow-list of calling service identities. Empty or absent means no service restriction.

access:
services: [billing.svc, admin.svc]

The compile-time gate denies when the list is non-empty and the caller’s service identity is not on it. An empty service identity matches nothing, so it is refused rather than waved through.

SlotEvaluated on
createCreate
readRead, list, search
updateUpdate, patch
deleteDelete
purgeDelete and ?purge — stacked on top of delete
restoreA restore mutation — stacked on top of update
unmaskRead and ?unmask — stacked on top of read
decryptRead and ?untokenize — stacked on top of read

The four extended guards are AND-stacked: they are evaluated in addition to the base CRUD guard, never instead of it. A caller who passes read but fails unmask is denied when they ask for unmasking. An extended flag outside its operation is ignored — ?purge on a read evaluates nothing.

This is the access block from the shipped end-to-end product schema, the one the PostgreSQL-backed access suite runs against:

entities:
- name: customer
description: "Customer master data"
fields:
- name: code
type: string
modifiers: [required, unique]
- name: name
type: string
modifiers: [required]
- name: tenant_id
type: string
nullable: true
- name: owner_id
type: string
nullable: true
access:
actions:
read: { language: expr, expression: "true" }
create: { language: expr, expression: "true" }
update: { language: expr, expression: "true" }
# Only an admin may delete. An anonymous caller carries no roles,
# so this denies.
delete: { language: expr, expression: '"admin" in user.roles' }

An anonymous DELETE /anon/rest/customer/id/<id> against that entity returns 403 with access denied and tier 1 in the body, and the row survives.

Action guards are identity decisions — use user.*, user.roles, tenant. For which rows a principal may touch, use RLS.

actions: also accepts a rule: (plus optional language:) that applies one body to all eight slots at once. A per-action key overrides it for that slot.

access:
actions:
language: expr
rule: '"staff" in user.roles' # applies to all eight slots
delete: { language: expr, expression: '"admin" in user.roles' } # overrides for delete

Each Tier-1 guard is evaluated once in the runtime hook and once when the query is compiled. A rule body with side effects runs twice per request, and the two evaluations build their bindings from different sources.

Row-level security restricts which rows an operation applies to. Write it as a boolean predicate over row.* and user.*.

access:
rls:
read: { language: expr, expression: "row.tenant_id == user.tenant_id" }
update: { language: expr, expression: "row.tenant_id == user.tenant_id" }
delete: { language: expr, expression: "row.tenant_id == user.tenant_id" }
create: { language: expr, expression: "row.tenant_id == user.tenant_id" }

The nested rules: map form parses too, and the two merge with the top-level keys winning:

access:
rls:
rules:
read: { language: expr, expression: "row.owner_id == user.id" }

The four top-level shortcut keys are create, read, update, and delete. The nested rules: map takes any key, and the compiler looks up whichever key names the operation it is compiling — including purge, restore, and upsert.

OperationMechanism
read / update / deleteThe predicate is compiled into the SQL WHERE — the database filters, and non-matching rows are never read or touched
createAn INSERT has no WHERE, so the new row is checked in-process against the rule; failure returns 403 access denied (tier 2): row denied by RLS create rule on entity <name>
purge / restore / upsertLooked up under their own key — see the caution below

Values from a rule are parameterised, so no rule value can be injected as SQL. Column names come from the rule text and are emitted as quoted identifiers.

user.* and tenant are known when the query is compiled, so any subtree that references no row.* is evaluated against the principal and folded to a constant. That makes the role-bypass pattern work exactly as written:

rls:
read: { language: expr, expression: '"admin" in user.roles || row.owner_id == user.id' }
  • An admin — the left branch folds to true, so the whole rule folds to true, so no predicate is added and every row is visible.
  • A non-admin — the left branch folds to false and drops, leaving owner_id = <their id>.

A rule that folds to false produces the deny-all predicate id = NULL, which is never true for a persisted row.

CategoryAccepted
Logical&&, and, ||, or, !, not
Comparison==, !=, <, >, <=, >= — one side must be row.<column>; sides may be in either order
Column to columnrow.a == row.b — compiled as a real column reference, not a parameter
Membershiprow.<column> in [ … ] where the right side resolves to a list; "<const>" in user.roles is folded
Literalsstring, integer, float, bool, nil, array
Foldable identifierstenant, true, false, and user. + id / user_id / userid / tenant / tenant_id / tenantid / service / session_id / sessionid / session / roles

Anything outside this set is untranslatable: an unsupported operator, a bare row.x used as a term, an in whose right side is a row.*, an unknown identifier, or a user.<field> outside the foldable set.

KeyStatus
rls.related-data.<name>.entity / .filter / .fieldsParses; the only consumer was the runtime filter hook, which is not wired. {{related.X.Y}} references cannot be satisfied.
rowlevelsecurity: at entity levelAn alias for the rls block, hoisted into access.rls at load. If both are present, this one wins and silently replaces access.rls.

fields.read is evaluated once per projected column, with the column name bound as field, and its result is coerced to a bool. Write it as a boolean over field:

access:
fields:
read: { language: expr, expression: 'field != "salary" || "hr" in user.roles' }

A denied column fails the whole query rather than dropping the column from the result.

Read-side masking, redaction and hiding are declared per field under compliances:, not under access.fields. The two are separate mechanisms: access.fields decides whether a column may be projected at all, compliances: decides what the value looks like once it is. Field protection documents the directive vocabulary, the mask-pattern grammar, and the pii / phi / pci / gdpr default bundle.

A product layer may override a service’s access block. access-lock: names the tiers it may not touch, and final: seals the entity outright.

entities:
- name: audit_event
access-lock: [rls, actions] # a product layer may not replace these two tiers
access:
actions:
read: { language: expr, expression: '"auditor" in user.roles' }
rls:
read: { language: expr, expression: "row.tenant_id == user.tenant_id" }
- name: platform_key
final: true # no layer may add fields or touch access at all
RuleBehaviour
access-lock: valuesservices | actions | rls | fields; lower-cased at parse, so [RLS, Actions] works. Unknown names are inert.
Tenant-layer access:Always dropped, locked or not. Only the product layer may override access.
Product override of a locked tierDropped.
final: trueNo layer contribution of any kind — stronger than locking all four tiers.
A tier the product layer suppliesReplaces the base’s wholesale. There is no per-rule merge.
A tier the product layer omitsInherited.

An impersonated request keeps the target user as the principal, with the operator recorded alongside it. Every rule therefore keeps working unchanged — no per-entity rules are needed to support impersonation, and user.id / user.roles are the impersonated user’s.

The HTTP layer classifies by error type first: a denial raised by the runtime hook is a typed access-denied error and maps to 403 whatever its text says. Anything untyped falls through to a substring match, which promotes access denied, not authorised, not authorized and forbidden to 403. The query compiler’s gate and guard return plain errors whose text matches none of those, so they land on the default — 500.

MessageTierStatus
access denied (tier 0): service "<svc>" not allowed on entity "<entity>"0403
access denied (tier 1): entity "<entity>" has no access rule for <op> (deny-by-default); …1403
access denied (tier 1): action guard denied <op> on "<entity>"1403
access denied (tier 1): action guard denied <purge|restore|unmask|decrypt> on "<entity>"1403
access denied (tier 1): action "<action>": entity "<entity>" rule denied <op>1403
access denied (tier 2): row denied by RLS create rule on entity <entity>2403
access guard eval (<action>): <err>1500
accessgate: service "<svc>" not allowed on entity "<entity>" (allowed: […])0500
accessgate: action "<action>" on entity "<entity>" denied for principal "<uid>"1500
safetyguard: field "<field>" on entity "<entity>" denied for principal "<uid>"3500
safetyguard: aggregation <FUNC>(<field>) refused — column carries compliance tags <tags> …500
An untranslatable RLS rule2200 with zero rows

The five CRUD handlers carry the codes v2.query_failed, v2.create_failed, v2.update_failed, v2.delete_failed, and v2.restore_failed in the JSON envelope regardless of which tier denied; the status, not the code, tells you it was an authorization failure.

Compile-time denials are also emitted to the audit sink as an access-denied event carrying the action and which gate refused.

The fastest loop is a local service against PostgreSQL, hitting the anonymous mirror so no token is needed — an anonymous request is an external request, subject to every tier.

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" -H 'X-Customer: dev' -H 'X-Product: dev' -H 'X-Env: dev' -H 'X-Tenant: dev' http://localhost:8099/anon/rest/customer
SituationResult
No rule for the operation403, access denied … tier 1
read: { expression: "true" }200, all rows
A read RLS rule that compiles200, matching rows only
A read RLS rule that does not compile200, zero rows
A failing create guard403, nothing inserted
A failing create RLS rule403 tier 2, nothing inserted
A rule using script-file:500
A field rule returning a list500

Confirm a denial actually stopped the write by checking the row directly, not just the status code.

Rule keys: language: · expression: · script: · script-file: (does not run) · function: (does not run). Plus rule: at actions: level as an all-slots shorthand.

Languages: expr (default) · cel · js · javascript · lua · starlark · go · wasm.

Bindings: user.id · user.user_id · user.tenant_id · user.tenant · user.roles · user.service · user.session_id · tenant · entity · action · operation · user_id · roles · row.<column> · payload · field (field rules) · extended_action (extended guards).

access:
services: [ ... ] # Tier 0 — breaks HTTP callers
actions: { create:, read:, update:, delete:, purge:, restore:, unmask:, decrypt: } # Tier 1
rls: # Tier 2
create:, read:, update:, delete: # top-level shortcut keys
rules: { purge:, restore:, upsert: } # any operation name
fields: { read:, write: } # Tier 3 — write is not enforced
access-lock: [services, actions, rls, fields]
final: false

Sharp edges:

  • Every external-facing operation needs its own Tier-1 rule.
  • A rule returning a non-empty string is an allow.
  • user.tenant_id is the full customer:product:env:tenant key.
  • services: takes an entity off the air over HTTP — there is no service identity to match.
  • RLS uses ==, not =; {{ … }} templates do not parse.
  • An untranslatable RLS rule returns zero rows with no error.
  • RLS purge / restore / upsert need their own keys; they do not inherit delete / update / create.
  • fields.read gates only an explicit projection; fields.write does nothing.
  • Read-side masking from compliances: is not wired — a tagged column returns unmasked.
  • A product layer replaces an access tier wholesale; a tenant layer’s access block is always dropped.

The endpoints these tiers apply to are documented in the Data API HTTP reference; the block overview is at Data API.