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.
The four tiers
Section titled “The four tiers”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' }| Tier | Key | Question | Enforced by |
|---|---|---|---|
| 0 | services: | Is the calling service on the allow-list? | Query compiler |
| 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? | 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.
Deny-by-default
Section titled “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.
| 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.
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.
Keys that parse but do nothing
Section titled “Keys that parse but do nothing”| Key | Status |
|---|---|
rls.related-data.<name>.entity / .filter / .fields | Parses; the only consumer was the runtime filter hook, which is not wired. {{related.X.Y}} references cannot be satisfied. |
rowlevelsecurity: at entity level | An alias for the rls block, hoisted into access.rls at load. If both are present, this one wins and silently replaces 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.
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.
access-lock and final
Section titled “access-lock and final”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| Rule | Behaviour |
|---|---|
access-lock: values | services | 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 tier | Dropped. |
final: true | No layer contribution of any kind — stronger than locking all four tiers. |
| A tier the product layer supplies | Replaces the base’s wholesale. There is no per-rule merge. |
| A tier the product layer omits | Inherited. |
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 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.
| 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 |
accessgate: service "<svc>" not allowed on entity "<entity>" (allowed: […]) | 0 | 500 |
accessgate: action "<action>" on entity "<entity>" denied for principal "<uid>" | 1 | 500 |
safetyguard: field "<field>" on entity "<entity>" denied for principal "<uid>" | 3 | 500 |
safetyguard: aggregation <FUNC>(<field>) refused — column carries compliance tags <tags> … | — | 500 |
| An untranslatable RLS rule | 2 | 200 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.
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 · 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 enforcedaccess-lock: [services, actions, rls, fields]final: falseSharp edges:
- Every external-facing operation needs its own Tier-1 rule.
- A rule returning a non-empty string is an allow.
user.tenant_idis the fullcustomer:product:env:tenantkey.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/upsertneed their own keys; they do not inheritdelete/update/create. fields.readgates only an explicit projection;fields.writedoes 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.