Row-Level Security (RLS)
Declarative per-row policies compiled into query filters — the grammar, the context variables, how policies compose, and the fail-closed contract.
Row-Level Security (RLS)
RLS answers a question the other layers can't: which rows of an object may this user touch? Object permissions say "can read tasks"; the OWD baseline says "tasks are private"; RLS says "only the tasks assigned to you" — as a declarative policy, compiled into the query.
Two properties define how it behaves:
- It's a filter, not a row hook. Policies compile to a data filter that is pushed down into the query, so a restricted user's list is narrower — they don't get denied, they get fewer rows.
- It's fail-closed — where it applies. If policies apply and every one of them fails to compile, the query denies everything rather than admitting anything; an object with no applicable policy is left unfiltered, not hidden. See the contract.
RLS is the expert escape hatch. Reach for it when sharing rules, the OWD baseline, and scope depth can't express the rule — not before.
Anatomy of a policy
Policies live on a permission set under rowLevelSecurity:
import { definePermissionSet } from '@objectstack/spec';
export const ContributorAccess = definePermissionSet({
name: 'contributor',
label: 'Contributor',
objects: {
showcase_task: { allowRead: true, allowEdit: true },
},
rowLevelSecurity: [
{
name: 'task_own_rows',
label: 'Own Tasks Only',
object: 'showcase_task',
operation: 'select',
using: 'assignee == current_user.email',
positions: ['contributor'],
enabled: true,
},
],
});| Property | Type | Notes |
|---|---|---|
name | string | snake_case identifier |
label | string | Human-readable name |
object | string | Target object — or '*' to apply to every object |
operation | 'select' | 'insert' | 'update' | 'delete' | 'all' | Which operation the policy guards. A select policy also bounds writes when no write-class policy applies — see below |
using | string | Predicate for rows the user may see / act on (compiled into the query filter) |
check | string | Predicate rows must satisfy after a write. Omit it and using is reused |
positions | string[] | Which positions the policy applies to. Omit = everyone |
enabled | boolean | Default true. false switches the policy off — a disabled policy is not evaluated |
At least one of using / check is required.
Choosing operation: select narrows reads (the common case);
insert / update / delete guard the matching write; all applies to
everything. Internally find / findOne / count / aggregate all map to
select.
select also bounds writes when nothing else does. A write target must be
inside the caller's readable set, so when no write-class policy applies
to a caller on an object, the update / delete scope is derived from that
caller's select policies — a record they cannot read is one they cannot
modify, by id or in bulk. Authoring a write-class policy switches the
derivation off for that class: an authored update predicate then decides
update alone and widens exactly as written. Nothing is derived for insert
(there is no pre-existing row to be visible), and a caller holding the
read-side superuser bypass (viewAllRecords on a private or platform-global
object) is not narrowed, because their readable set is already unbounded.
The expression grammar
RLS predicates are canonical CEL, lowered into a query filter by the shared pushdown compiler — the same lowering sharing rules use. A broad subset compiles; anything the compiler can't lower fails closed (the policy matches zero rows) rather than raising at query time.
These all compile:
| Form | Example |
|---|---|
| Column equals a context value | created_by == current_user.id |
| Column equals a literal | status == 'active' |
Comparison (!=, >, >=, <, <=) | amount > 1000 |
| Set membership | owner_id in current_user.org_user_ids |
| Null check | deleted_at == null |
| Prefix / suffix / substring | code.startsWith('EU-') |
Boolean combination (&&, ||, !) | owner_id == current_user.id && status == 'open' |
| Always true | 1 == 1 |
What fails closed (never silently dropped, per the security contract): a
subquery, a cross-object / relation hop (account.region), arithmetic
(+ - * /), and any function call — including time functions like NOW() /
CURRENT_DATE. Express anything subquery-shaped as a pre-resolved
current_user.* array instead (see Context variables).
The legacy SQL-ish spellings (= for equality, IN (...)) still compile through
a transitional bridge that rewrites them to == / in, but canonical CEL is the
authoring form — the bridge logs a deprecation warning.
Two ways to combine conditions. Within one policy, use CEL && / || —
owner_id == current_user.id && status == 'open' compiles. Across policies,
separate policies on the same object also compose with OR (see below), so
"owner or team member" can be one || predicate or two policies. Reach for
multiple policies when the alternatives apply to different positions.
Context variables
Predicates reference the acting user through current_user, and record fields
as bare column names (no record. prefix — that's the UI/flow convention,
not this one):
| Variable | Source |
|---|---|
current_user.id | The acting user's id |
current_user.organization_id | The acting tenant |
current_user.positions | The user's positions (array) |
current_user.org_user_ids | User ids in the user's org (array) |
current_user.email | The user's email |
current_user.name is deliberately not exposed. Names aren't unique — a
policy keyed on a display name would hand a second "John Smith" someone else's
rows. Match on id or email, which are.
Deployments can inject additional membership sets (team ids, territory codes) into the evaluation context, which lets policies express set membership without subqueries the grammar doesn't allow.
How policies compose
Layer 0 (tenant isolation) ─┐
├── AND ──► the query's final filter
Layer 1 (business RLS) ─┘
policy A OR policy B OR …- Tenant isolation is a separate layer that always applies first and is never OR-ed away by a business policy.
- Multiple policies on the same object are OR-ed — a row is admitted if it matches any applicable policy. This is the intended way to express alternatives.
- The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline → depth → sharing → RLS. RLS narrows what the earlier layers already allowed; it never widens.
- A superuser bypasses business policies but not tenant isolation — crossing tenants requires a genuine platform admin.
- Children of a master-detail parent with
sharingModel: 'controlled_by_parent'derive access from the master record instead.
The fail-closed contract
Four ways a policy denies rather than leaks:
- A policy exists but every applicable expression fails to compile → a deny-everything filter.
- A referenced context variable is missing, null, or an empty array → that policy drops out (it cannot match).
- A policy references a column the object doesn't have → deny.
checkis omitted →usingis reused for writes, never "anything goes".
The one non-obvious case: no applicable policy means no restriction, not "deny". RLS only narrows what other layers granted — it is not what stops an un-permissioned user. If a user sees rows you expected RLS to hide, check whether any policy actually applies to their positions and the operation.
Verify it
Don't guess — ask the runtime:
curl -b cookies.txt \
"https://your-app.example.com/api/v1/security/explain?object=showcase_task&operation=read&userId=usr_123"The response walks the layers (tenant_isolation, object_crud, fls,
owd_baseline, depth, sharing, rls) with a verdict per layer —
grants, denies, narrows, widens, neutral, or not_applicable — and
tags each with kernelTier (layer_0_tenant vs layer_1_business). An RLS
policy that fired shows up as narrows on the rls layer; per-record
explanations mark rows admitted / excluded.
Explaining another user's access requires manage_users or delegated-admin
rights. Pair it with Impersonate in the Console to see the same rows they'd see.
Related
- Authorization Model — how all the layers fit together
- Sharing Rules — the declarative layer to try first
- Explain — the verdict vocabulary in full
- Access Recipes — requirement → layer mapping