Skip to content
Talk to our solutions team

Row-level security

Row-level security (Tier 2 of an entity’s access rules) restricts which rows an operation applies to. A rule is a boolean expression over row.* and user.*; data.svc compiles it into a predicate and ANDs it into the SQL the database runs. Filtering happens in the database, not after the fact — a row the rule excludes is never read, never updated, never deleted.

KeyMeaning
access.rls.readPredicate ANDed into the SELECT WHERE
access.rls.updatePredicate ANDed into the UPDATE WHERE
access.rls.deletePredicate ANDed into the (soft) DELETE WHERE
access.rls.createBoolean checked in-process against the new row (an INSERT has no WHERE)
access.rls.rules.<action>The same rules in map form. Keys are free-form; the compiler looks up the action token it is compiling
access.rls.related-data.<name>Parses, but has no effect on data.svc — see Limits
entities[].rowlevelsecurityEntity-level alias for the whole rls: block

Both forms merge, and the shortcut keys win over the same key inside rules:.

entities:
- name: sales_order
fields:
- name: order_no
type: string
modifiers: [required, unique]
- 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" }
delete: { language: expr, expression: "true" }
rls:
read: { language: expr, expression: '"admin" in user.roles || row.owner_id == user.id' }
update: { language: expr, expression: '"admin" in user.roles || row.owner_id == user.id' }
delete: { language: expr, expression: '"admin" in user.roles || row.owner_id == user.id' }

RLS never grants: a caller still needs a Tier-1 actions: rule for the operation, and the Data API is deny-by-default for external requests. An entity with rls: and no actions: returns 403 to every external caller.

For read, update, and delete the rule is compiled at query-build time:

StageWhat happens
1. ParseThe rule body is parsed by the expression parser
2. FoldEvery subtree that does not mention row.* is evaluated against the request principal and collapses to true or false
3. Translaterow.<column> OP <value> becomes a column predicate; the value is a bind parameter, row.a == row.b a column reference
4. SimplifyFolded arms drop out of and / or; a whole rule folding to true yields no predicate at all, folding to false yields a deny-all predicate
5. InjectThe result is ANDed into the query’s WHERE, alongside the caller’s own filters and the soft-delete filter

The same rule therefore produces different SQL per caller. Given '"admin" in user.roles || row.owner_id == user.id':

-- caller u-alice, roles ["user"] — the role test folds to false and drops
SELECT ... FROM "erptest"."sales_order" WHERE "owner_id" = $1
-- args: [u-alice]
-- caller u-admin, roles ["admin"] — the whole rule folds to true, no predicate
SELECT ... FROM "erptest"."sales_order"

That is what makes the role-bypass pattern work as written: an admin gets no row restriction, everyone else gets an owner filter.

The user side of a rule resolves against the request principal. These identifiers fold; nothing else does.

BindingResolves to
user.id, user.user_id, user.useridCaller’s user id (empty string on /anon/rest)
user.tenant_id, user.tenantid, user.tenantCaller’s tenant key
user.rolesCaller’s roles, as a list
user.serviceCalling service identity
user.session_id, user.sessionid, user.sessionSession id
tenantTop-level alias of user.tenant_id
true, falseBoolean literals
row.<column>A column of the row under evaluation — never folded, always translated

Member access on user.* is case-insensitive on the field name; a row.<column> name is taken verbatim and must match the field as the entity declares it. Any other identifier — including entity, action, and payload, which the Tier-1 guards do see — is rejected by the translator and takes the rule down the fail-closed path.

The translator accepts exactly this subset. Everything else is a rejection.

CategoryAccepted
Logical&&, and, ||, or, !, not
Comparison==, !=, <, >, <=, >= — the row side may be on either side of the operator
Membershiprow.<col> in [ ... ], where the right side resolves to a list (literals or user.roles)
Column to columnrow.a == row.b — emitted as a real column reference, not a parameter
Folded membership"<const>" in user.roles
Literalsstring, integer, float, bool, nil, array
row.tenant_id == user.tenant_id && row.name in ["open","review"]
→ WHERE ("tenant_id" = $1 AND "name" IN ($2, $3))
row.name in user.roles → WHERE "name" IN ($1, $2) -- one param per role
row.owner_id == row.author_id → WHERE "owner_id" = "author_id"
!(row.name == "x") → WHERE NOT ("name" = $1)

Not accepted, and therefore deny-all: function calls, ternaries, arithmetic, string methods, in with row.* on the right, a bare row.<col> used as a boolean, and {{ ... }} template syntax.

rls:
# per-tenant isolation
read: { language: expr, expression: "row.tenant_id == user.tenant_id" }
rls:
# per-user ownership, with a role escape hatch
read: { language: expr, expression: '"admin" in user.roles || row.owner_id == user.id' }
rls:
# row visible when its required role is one the caller holds
read: { language: expr, expression: "row.required_role in user.roles" }

An anonymous caller on /anon/rest has user.id == "" and no roles, so an ownership rule compiles to "owner_id" = '' and returns nothing. That is the intended posture — an anonymous request is an external request and every tier applies to it.

An INSERT has no WHERE, so the create rule is evaluated in-process against the new row after validation and before the write. It returns 403 with code v2.create_failed and access denied (tier 2) in the message when false.

read / update / deletecreate
Enforced byThe SQL compiler, as a WHERE predicateAn in-process hook, as a boolean
row.* isThe stored rowThe request payload, after defaults
language:Ignored (expression syntax only)Honoured
FailureRows silently excluded403, nothing inserted

row.* on create is the payload as it stands at that point in the chain: the request body plus the schema defaults (defaultvalue:, except on array fields, which default database-side) and the audit columns createdon / createdby / updatedon / updatedby / version, which are filled in earlier. Materialised computed values are written after this check, so a rule over a materialised column tests a field that is not there yet and denies the write.

The compiler looks up the rule by the action token it compiles, with no fallback between tokens.

RequestTokenRule consulted
GET /rest/:entity, GET /rest/:entity/id/:id, POST /rest/:entity/searchreadrls.read
POST /rest/:entitycreaterls.create (in-process)
PUT / PATCHupdaterls.update
DELETE /rest/:entity/id/:iddeleterls.delete
DELETE ...?purge=truepurgerls.rules.purge
POST /rest/:entity/id/:id/restorerestorerls.rules.restore
/rest bulk envelope, "action": "upsert"upsertrls.create, in-process only
ShapeBehaviour
?include=relThe included relation is compiled as its own query, so the child entity’s own rls.read applies. Nested includes are filtered at every level
Read by idThe predicate is ANDed with the id filter. A row excluded by RLS yields 404, not 403
?groupby=The predicate lands in WHERE, which runs before grouping — aggregates only ever see rows the caller may read
Aggregate functionsAn aggregate over a column carrying compliance tags is refused unless the principal passes the entity’s unmask guard. COUNT(*) is always allowed
count in the responseThe number of rows returned after filtering — a caller cannot infer how many rows the predicate removed

A child entity with no rls.read of its own is not filtered when included — the parent’s rule does not propagate. Declare the rule on every entity that holds restricted rows.

Failure mode: a rule that does not compile denies every row

Section titled “Failure mode: a rule that does not compile denies every row”

data.svc is the only enforcement point for RLS — there is no runtime fallback evaluator. It therefore runs the fail-closed posture, and this is not configurable from a schema or a config file:

A rule the translator cannot compile is replaced by a deny-all predicate. It never becomes a request error, and it never runs unfiltered.

That covers read, update, and delete. A create rule the script runtime cannot evaluate fails the request instead of denying the row.

The deny-all predicate compares the row’s id column to NULL, which no row satisfies. The column is always id, whatever the entity calls its primary key:

SELECT ... FROM "erptest"."sales_order" WHERE "id" = NULL

"id" = NULL is the signature of a rule that failed to compile. Add ?stats=true to a read and the response envelope carries the compiled stats.sql (and stats.include_sql for each included relation), so you can see the injected predicate without touching the database.

What the caller observes:

OperationRule excludes the row (or fails to compile)
List / search200 with fewer rows, or none
Read by id404
Update200, empty data, nothing written
Delete200, empty data, nothing deleted
Create (rls.create false)403, code v2.create_failed, message ending access denied (tier 2): row denied by RLS create rule on entity <name>

A rule that names a column the entity does not have is a different case: the query fails to compile and the request errors instead of returning rows.

ElementHandling
Literals in the ruleBind parameters
Folded user.* valuesBind parameters — token contents never reach the SQL text
row.<column> namesTaken from the rule text (author-controlled, not request-controlled) and quoted as SQL identifiers
row.a == row.bEmitted as a quoted column reference, not a string parameter

Nothing a caller sends — token claims, query parameters, request body — is ever concatenated into the predicate. The rule text itself comes from the schema, which only the service and product layers can author; a tenant layer’s access: block is dropped before it reaches the compiler.

Run data.svc locally against Postgres and read the same endpoint as different principals. The tenant routing headers are always required; the token supplies user.id and user.roles. The full endpoint list, flags, and response envelope are in the HTTP reference.

Terminal window
curl -s -H 'X-Customer: dev' -H 'X-Product: dev' -H 'X-Env: dev' -H 'X-Tenant: dev' -H 'Authorization: Bearer <alice>' 'http://localhost:8099/rest/sales_order'
Terminal window
curl -s -H 'X-Customer: dev' -H 'X-Product: dev' -H 'X-Env: dev' -H 'X-Tenant: dev' -H 'Authorization: Bearer <admin>' 'http://localhost:8099/rest/sales_order'

Ask for the compiled SQL to see exactly what the rule became for that caller:

Terminal window
curl -s -H 'X-Customer: dev' -H 'X-Product: dev' -H 'X-Env: dev' -H 'X-Tenant: dev' -H 'Authorization: Bearer <alice>' 'http://localhost:8099/rest/sales_order?stats=true'
{
"data": [ ... ],
"count": 2,
"stats": {
"sql": "SELECT ... FROM \"erptest\".\"sales_order\" WHERE \"owner_id\" = $1 ORDER BY \"id\" ASC",
"duration_ms": 3
}
}

Then check ground truth in the database, so a filtered read cannot be confused with an empty table:

SELECT count(*) FROM erptest.sales_order WHERE order_no LIKE 'RLS-%' AND deletedon IS NULL;
CheckExpected
Owner reads the listOnly their rows; the count in the database is higher
Admin reads the listEvery row
Non-owner reads by id404, while the row is still present in the table
Owner updates their row200, row changed
Non-owner updates the same row200, empty data, row unchanged in the table
Create violating rls.create403, no row inserted
Every caller sees nothingSuspect the rule did not compile — check the SQL for "id" = NULL

Test the anonymous mirror (/anon/rest/:entity) too when the entity is reachable without a token: it carries no user id and no roles, which is the harshest input an ownership rule receives.

ItemState
related-data: prefetch and {{related.x.y}} referencesParse, but nothing consumes them on data.svc
Database-native CREATE POLICY emissionNot reachable from a schema file; predicate injection is the enforcement path
language: on read / update / delete rulesIgnored — expression syntax only
script-file: / function: rule bodiesNever resolved — the script runtime does not load an external body. A read / update / delete rule with no inline body denies every row
IS NULL testsNot expressible
Boot-time rule validationNot run — a broken rule surfaces only as missing rows
BackendsPredicate injection is compiled for the tenant datastores data.svc wires, which are PostgreSQL