Skip to content
Talk to our solutions team

Access rules

Every entity served by the Data block 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:
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 / write?
read: { language: expr, expression: 'field != "ssn" || "pii-reader" in user.roles' }
write: { language: expr, expression: 'field != "ssn" || "hr" in user.roles' }
TierKeyQuestionEnforced by
0services:Is the calling service on the allow-list?Refused at boot on data.svc: no HTTP caller carries a service identity
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 (read) or write it (write)?Query compiler (projection check); runtime hook per written field

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 block 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. On data.svc only the languages the deployment offers run — by default expr, cel, js / javascript, starlark and wasm (scripts.runtimes, see Configuration). lua cannot be offered yet and go is not available on data.svc; a schema that uses a language not offered is refused when the tenant’s engine is built.

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.

rowlevelsecurity: at entity level is an alias for the rls block, hoisted into access.rls at load. Declaring both is a load error naming the entity (declares row rules both as rowlevelsecurity: and as access.rls:; keep one); before 2026-09-16 the entity-level block silently replaced 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.

fields.write is the write side: on a create or an update it is evaluated once per field the payload carries, with the field name bound as field, in field-name order. The first denied field refuses the whole write with 403 access_denied naming the field; nothing is trimmed from the payload and the row is not touched. A payload that omits the field is admitted, so an hr-only salary rule still lets everyone else update grade:

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

Without a script runtime the rule cannot be decided and the write is refused, never admitted.

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 service declares an entity; the layers above it — the product, then the tenant overlay — may contribute to it. What they may contribute is the entity’s own decision.

Three keywords express it, from coarse to granular:

KeywordSays
final: trueNo layer may contribute anything: no field, no index, no access rule.
access-lock: [rls, actions]Those whole access tiers may not be overridden.
layering:Per element and per layer, one of final, additive or replace.

final: and access-lock: are the coarse forms of layering: and resolve through the same rules. Declaring access-lock: and layering: on one entity is refused — two vocabularies for one decision, and a reader would have to hold both to know what a tier permits.

ModeBehaviour
finalThe layer’s contribution is dropped and recorded.
additiveThe layer’s rule is stacked on the base’s: both are evaluated and both must pass. A layer can narrow, never widen.
replaceThe layer’s rule replaces the base’s. The only mode that can widen.

additive composes rather than merges, because two rules are scripts and may be in different languages. For an actions rule every stacked rule must also return true; for rls every stacked filter is AND-ed into the same WHERE, so the result can only be fewer rows.

layering: mirrors the block it governs, so the two read side by side. Under access.actions: every key is an action name — create, read, update, delete, purge, restore, unmask, decrypt — or the reserved default.

entities:
- name: users
layering:
default: final # anything not named below is sealed
structure:
fields: { product: additive, tenant: additive }
access:
actions:
default: final
read: { product: replace } # the product decides who may read
create: { product: additive } # it may narrow, not widen
unmask: final # nobody may grant it, ever
rls: { product: replace }
audit: { product: replace }

A rule is either a bare mode (final — that mode for every layer) or a map of layer to mode. The layers are product and tenant. There is no customer layer: customer is an axis of the tenant key, not a layer of the schema.

An identity entity seals actions for one reason — unmask, the rule that defeats a password hash’s redact — and in sealing the tier it also seals read, create, update and delete. The entity every product wants to join to becomes the one entity whose rules no product may write. layering: lets the service seal the one action and delegate the rest.

ElementModesWhy not the others
structure.fields, structure.indexesfinal, additivereplace would let a layer drop or retype columns the base declares — a migration, not a layer contribution.
access.actions.<name>all three
access.rlsall three
access.fieldsfinal, replaceComposing two field-permission rules means intersecting what each returns, at both evaluation sites. Not built.
access.servicesall threeadditive is the intersection of the two allow-lists.
auditfinal, replaceAn audit policy is a struct, not a rule; there is nothing to compose.

A combination outside this table is refused at load, naming the element and the reason. A mode that parses and then cannot be honoured is the failure this block exists to end.

ElementProductTenant
Structure (fields, indexes)additiveadditive
Access tiersreplacefinal
auditreplacefinal

So the tenant layer may add structure and may never touch access — unless an entity’s policy grants it. Adding layering: is opt-in; an entity without one behaves exactly as it did before the block existed.

An access: block on an augmenting entity is applied under the target’s policy, exactly as a layer’s would be. It used to be discarded silently, so rules written there were believed to be in force and never were.

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 a refusal by the compiler’s gate or guard is a canonical permission-denied error; both map to 403 whatever their text says. Anything untyped falls through to a substring match, which promotes access denied, not authorised, not authorized and forbidden to 403.

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
service "<svc>" not allowed on entity "<entity>": details.gate: "accessgate"0403
action "<action>" on entity "<entity>" denied for principal "<uid>": details.gate: "accessgate"1403
field "<field>" on entity "<entity>" denied for principal "<uid>": details.gate: "safetyguard"3403
aggregation <FUNC>(<field>) refused, column carries compliance tags <tags> …, details.gate: "safetyguard"—403
An untranslatable RLS rule (rls_rule_untranslatable)2500, and the schema is refused at boot
A rule that returns something other than a boolean (rule_not_boolean)1, 2500

Every refusal answers 403 with code access_denied, on every route. The details tell you which layer refused: a compile-time gate (tiers 0, 1 and 3, the access gate and the safety guard) sets details.gate to the gate’s name; the runtime access hook (tier 2, the action rules on writes and row-level security on create) sets details.hook, details.phase and details.tier.

Every compile-time denial. The access gate’s service and action refusals and the safety guard’s field and aggregation refusals, is also an access-denied event in the tenant’s data_audit_events table, carrying the principal, the action, the entity, the reason and which gate refused, hash-chained to the events before it.

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 · starlark · wasm on data.svc by default (scripts.runtimes); lua cannot be offered yet and go is not available on data.svc.

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 — refused at boot on data.svc
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: per projected column / per written field
access-lock: [services, actions, rls, fields]
final: false

Sharp edges:

  • Every external-facing operation needs its own Tier-1 rule.
  • A rule that returns anything but a boolean is refused as broken (rule_not_boolean).
  • user.tenant_id is the full customer:env:product:tenant key.
  • services: is refused at boot on data.svc: there is no service identity to match.
  • fields.write refuses the whole write on the first denied field; it never trims the payload.
  • RLS uses ==, not =; {{ … }} templates do not parse.
  • An untranslatable RLS rule refuses the schema at boot and the request with rls_rule_untranslatable.
  • RLS purge takes the delete rule and restore the update rule unless they declare their own; upsert still needs its own key.
  • fields.read gates only an explicit projection; fields.write does nothing.
  • 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.

The superadmin role is honoured on the superadmin routes only when the token was minted for the superadmin realm (realm: superadmin, which the identity service reserves for its operators). A token that carries the role without the realm answers 403 requires_superadmin with details.realm_required. The role alone is a bare string any trusted issuer could stamp; the realm is what binds it. properties.superadminrealm names the realm; empty honours the role alone.