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.
The four tiers
Section titled “The four tiers”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' }| Tier | Key | Question | Enforced by |
|---|---|---|---|
| 0 | services: | Is the calling service on the allow-list? | Refused at boot on data.svc: no HTTP caller carries a service identity |
| 1 | actions: | May this principal run this operation? | Runtime hook + query compiler |
| 2 | rls: | Which rows does the operation apply to? | Query compiler (WHERE injection); in-process on create |
| 3 | fields: | 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.
Deny-by-default
Section titled “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.
| Caller | Gated 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, migrations | No. No auth level on the context |
| Background and refresh runners | No |
| In-process calls from the hosting service | No |
| Engine-managed system entities (migration ledger, locks, plans) | No: exempt even for external callers |
The rule shape
Section titled “The rule shape”Every rule. An action guard, an RLS predicate, a field rule, is a script reference with exactly these five keys:
| Key | Meaning |
|---|---|
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' }The binding vocabulary
Section titled “The binding vocabulary”One function builds the bindings for every rule, so the variable set is identical across tiers and engines.
| Binding | Is |
|---|---|
user.id | The caller’s user id |
user.user_id | Alias of user.id |
user.tenant_id | The caller’s tenant key |
user.tenant | Alias of user.tenant_id |
user.roles | The caller’s roles (a list) |
user.service | The calling service identity |
user.session_id | The session id |
tenant | Top-level alias of user.tenant_id |
entity | The entity name (not the row) |
action | The operation: create / read / update / delete |
operation | Alias of action |
user_id, roles | Flat back-compat aliases; prefer the nested form |
row.<column> | The row under evaluation |
payload | The mutation body (runtime hook only, on writes) |
field | The column name: field rules only, one evaluation per projected column |
extended_action | purge / restore / unmask / decrypt: extended-action guards only |
entities, params | Named-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.
Tier 0: services
Section titled “Tier 0: services”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.
Tier 1: action guards
Section titled “Tier 1: action guards”| Slot | Evaluated on |
|---|---|
create | Create |
read | Read, list, search |
update | Update, patch |
delete | Delete |
purge | Delete and ?purge: stacked on top of delete |
restore | A restore mutation: stacked on top of update |
unmask | Read and ?unmask: stacked on top of read |
decrypt | Read 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.
The all-slots shorthand
Section titled “The all-slots shorthand”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 deleteGuards run twice
Section titled “Guards run twice”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.
Tier 2: RLS
Section titled “Tier 2: RLS”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.
How each operation is enforced
Section titled “How each operation is enforced”| Operation | Mechanism |
|---|---|
read / update / delete | The predicate is compiled into the SQL WHERE. The database filters, and non-matching rows are never read or touched |
create | An 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 / upsert | Looked 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.
The user side folds
Section titled “The user side folds”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.
The grammar the translator accepts
Section titled “The grammar the translator accepts”| Category | Accepted |
|---|---|
| Logical | &&, and, ||, or, !, not |
| Comparison | ==, !=, <, >, <=, >=. One side must be row.<column>; sides may be in either order |
| Column to column | row.a == row.b: compiled as a real column reference, not a parameter |
| Membership | row.<column> in [ … ] where the right side resolves to a list; "<const>" in user.roles is folded |
| Literals | string, integer, float, bool, nil, array |
| Foldable identifiers | tenant, 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.
The entity-level spelling
Section titled “The entity-level spelling”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.
Tier 3: fields
Section titled “Tier 3: fields”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.
Field masking is not Tier 3
Section titled “Field masking is not Tier 3”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.
Layering: who may change a rule
Section titled “Layering: who may change a rule”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:
| Keyword | Says |
|---|---|
final: true | No 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.
The three modes
Section titled “The three modes”| Mode | Behaviour |
|---|---|
final | The layer’s contribution is dropped and recorded. |
additive | The layer’s rule is stacked on the base’s: both are evaluated and both must pass. A layer can narrow, never widen. |
replace | The 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.
The shape
Section titled “The shape”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.
Why per-action matters
Section titled “Why per-action matters”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.
What each element accepts
Section titled “What each element accepts”| Element | Modes | Why not the others |
|---|---|---|
structure.fields, structure.indexes | final, additive | replace would let a layer drop or retype columns the base declares — a migration, not a layer contribution. |
access.actions.<name> | all three | |
access.rls | all three | |
access.fields | final, replace | Composing two field-permission rules means intersecting what each returns, at both evaluation sites. Not built. |
access.services | all three | additive is the intersection of the two allow-lists. |
audit | final, replace | An 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.
Defaults, when no policy is declared
Section titled “Defaults, when no policy is declared”| Element | Product | Tenant |
|---|---|---|
| Structure (fields, indexes) | additive | additive |
| Access tiers | replace | final |
audit | replace | final |
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.
Augments carry access too
Section titled “Augments carry access too”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.
Impersonation
Section titled “Impersonation”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.
Denials and status codes
Section titled “Denials and status codes”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.
| Message | Tier | Status |
|---|---|---|
access denied (tier 0): service "<svc>" not allowed on entity "<entity>" | 0 | 403 |
access denied (tier 1): entity "<entity>" has no access rule for <op> (deny-by-default); … | 1 | 403 |
access denied (tier 1): action guard denied <op> on "<entity>" | 1 | 403 |
access denied (tier 1): action guard denied <purge|restore|unmask|decrypt> on "<entity>" | 1 | 403 |
access denied (tier 1): action "<action>": entity "<entity>" rule denied <op> | 1 | 403 |
access denied (tier 2): row denied by RLS create rule on entity <entity> | 2 | 403 |
access guard eval (<action>): <err> | 1 | 500 |
service "<svc>" not allowed on entity "<entity>": details.gate: "accessgate" | 0 | 403 |
action "<action>" on entity "<entity>" denied for principal "<uid>": details.gate: "accessgate" | 1 | 403 |
field "<field>" on entity "<entity>" denied for principal "<uid>": details.gate: "safetyguard" | 3 | 403 |
aggregation <FUNC>(<field>) refused, column carries compliance tags <tags> …, details.gate: "safetyguard" | — | 403 |
An untranslatable RLS rule (rls_rule_untranslatable) | 2 | 500, and the schema is refused at boot |
A rule that returns something other than a boolean (rule_not_boolean) | 1, 2 | 500 |
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.
Testing a rule
Section titled “Testing a rule”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.
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| Situation | Result |
|---|---|
| No rule for the operation | 403, access denied … tier 1 |
read: { expression: "true" } | 200, all rows |
A read RLS rule that compiles | 200, matching rows only |
A read RLS rule that does not compile | 200, zero rows |
A failing create guard | 403, nothing inserted |
A failing create RLS rule | 403 tier 2, nothing inserted |
A rule using script-file: | 500 |
| A field rule returning a list | 500 |
Confirm a denial actually stopped the write by checking the row directly, not just the status code.
The other YAML front end
Section titled “The other YAML front end”Quick reference
Section titled “Quick reference”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 fieldaccess-lock: [services, actions, rls, fields]final: falseSharp 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_idis the fullcustomer:env:product:tenantkey.services:is refused at boot ondata.svc: there is no service identity to match.fields.writerefuses 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
purgetakes thedeleterule andrestoretheupdaterule unless they declare their own;upsertstill needs its own key. fields.readgates only an explicit projection;fields.writedoes 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 realm
Section titled “The superadmin realm”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.