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.
Where a rule is declared
Section titled “Where a rule is declared”| Key | Meaning |
|---|---|
access.rls.read | Predicate ANDed into the SELECT WHERE |
access.rls.update | Predicate ANDed into the UPDATE WHERE |
access.rls.delete | Predicate ANDed into the (soft) DELETE WHERE |
access.rls.create | Boolean 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[].rowlevelsecurity | Entity-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.
How an expression becomes SQL
Section titled “How an expression becomes SQL”For read, update, and delete the rule is compiled at query-build time:
| Stage | What happens |
|---|---|
| 1. Parse | The rule body is parsed by the expression parser |
| 2. Fold | Every subtree that does not mention row.* is evaluated against the request principal and collapses to true or false |
| 3. Translate | row.<column> OP <value> becomes a column predicate; the value is a bind parameter, row.a == row.b a column reference |
| 4. Simplify | Folded 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. Inject | The 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 dropsSELECT ... FROM "erptest"."sales_order" WHERE "owner_id" = $1-- args: [u-alice]
-- caller u-admin, roles ["admin"] — the whole rule folds to true, no predicateSELECT ... 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.
Bindings
Section titled “Bindings”The user side of a rule resolves against the request principal. These identifiers fold; nothing else does.
| Binding | Resolves to |
|---|---|
user.id, user.user_id, user.userid | Caller’s user id (empty string on /anon/rest) |
user.tenant_id, user.tenantid, user.tenant | Caller’s tenant key |
user.roles | Caller’s roles, as a list |
user.service | Calling service identity |
user.session_id, user.sessionid, user.session | Session id |
tenant | Top-level alias of user.tenant_id |
true, false | Boolean 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.
Grammar
Section titled “Grammar”The translator accepts exactly this subset. Everything else is a rejection.
| Category | Accepted |
|---|---|
| Logical | &&, and, ||, or, !, not |
| Comparison | ==, !=, <, >, <=, >= — the row side may be on either side of the operator |
| Membership | row.<col> in [ ... ], where the right side resolves to a list (literals or user.roles) |
| Column to column | row.a == row.b — emitted as a real column reference, not a parameter |
| Folded membership | "<const>" in user.roles |
| Literals | string, 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 rolerow.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.
Per-tenant and per-user predicates
Section titled “Per-tenant and per-user predicates”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.
create is enforced differently
Section titled “create is enforced differently”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 / delete | create | |
|---|---|---|
| Enforced by | The SQL compiler, as a WHERE predicate | An in-process hook, as a boolean |
row.* is | The stored row | The request payload, after defaults |
language: | Ignored (expression syntax only) | Honoured |
| Failure | Rows silently excluded | 403, 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.
Which rule an operation looks up
Section titled “Which rule an operation looks up”The compiler looks up the rule by the action token it compiles, with no fallback between tokens.
| Request | Token | Rule consulted |
|---|---|---|
GET /rest/:entity, GET /rest/:entity/id/:id, POST /rest/:entity/search | read | rls.read |
POST /rest/:entity | create | rls.create (in-process) |
PUT / PATCH | update | rls.update |
DELETE /rest/:entity/id/:id | delete | rls.delete |
DELETE ...?purge=true | purge | rls.rules.purge |
POST /rest/:entity/id/:id/restore | restore | rls.rules.restore |
/rest bulk envelope, "action": "upsert" | upsert | rls.create, in-process only |
Relations, grouping, and aggregates
Section titled “Relations, grouping, and aggregates”| Shape | Behaviour |
|---|---|
?include=rel | The 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 id | The 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 functions | An 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 response | The 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:
| Operation | Rule excludes the row (or fails to compile) |
|---|---|
| List / search | 200 with fewer rows, or none |
| Read by id | 404 |
| Update | 200, empty data, nothing written |
| Delete | 200, 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.
Injection safety
Section titled “Injection safety”| Element | Handling |
|---|---|
| Literals in the rule | Bind parameters |
Folded user.* values | Bind parameters — token contents never reach the SQL text |
row.<column> names | Taken from the rule text (author-controlled, not request-controlled) and quoted as SQL identifiers |
row.a == row.b | Emitted 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.
Testing a rule
Section titled “Testing a rule”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.
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'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:
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;| Check | Expected |
|---|---|
| Owner reads the list | Only their rows; the count in the database is higher |
| Admin reads the list | Every row |
| Non-owner reads by id | 404, while the row is still present in the table |
| Owner updates their row | 200, row changed |
| Non-owner updates the same row | 200, empty data, row unchanged in the table |
Create violating rls.create | 403, no row inserted |
| Every caller sees nothing | Suspect 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.
Limits
Section titled “Limits”| Item | State |
|---|---|
related-data: prefetch and {{related.x.y}} references | Parse, but nothing consumes them on data.svc |
Database-native CREATE POLICY emission | Not reachable from a schema file; predicate injection is the enforcement path |
language: on read / update / delete rules | Ignored — expression syntax only |
script-file: / function: rule bodies | Never resolved — the script runtime does not load an external body. A read / update / delete rule with no inline body denies every row |
IS NULL tests | Not expressible |
| Boot-time rule validation | Not run — a broken rule surfaces only as missing rows |
| Backends | Predicate injection is compiled for the tenant datastores data.svc wires, which are PostgreSQL |