Row-Level Security
PostgreSQL-inspired fine-grained access control. Policies define predicates that determine which rows a user or role can see and modify, enforced transparently by the query engine.
Enable RLS
-- Enable on a graph label (deny-by-default: no rows visible without policies) ENABLE ROW LEVEL SECURITY ON :Project -- Enable on a document collection ENABLE ROW LEVEL SECURITY ON COLLECTION profiles
One mask ships as a default: email on :User / auth.users is hidden from the reader role out of the box (remove or reshape it with UNHIDE PROPERTY - like the default policies, a fully removed mask is re-seeded on restart). HIDE PROPERTY masks are column-level security: the listed properties are removed from Cypher results, REST document reads and the graph endpoint for the listed roles (admins always see everything). Masks follow RLS sync pairs, so hiding email on :User also hides it on auth.users. Writes are unaffected.
CREATE POLICY
Syntax: CREATE POLICY name ON target FOR op [TO role] USING (predicate). The TO role clause is optional - omitting it applies the policy to all users.
-- Users see only their own data
CREATE POLICY own_data ON :Project FOR SELECT TO reader
USING (n.owner = current_user())
-- Policy without a role (applies to everyone)
CREATE POLICY public_read ON :Post FOR SELECT
USING (n.published = true)
-- Multi-tenant isolation
CREATE POLICY tenant ON :Project FOR ALL TO authenticated
USING (n.tenant_id = session('tenant_id'))
-- Restrictive policy (AND logic instead of OR)
CREATE POLICY no_secret ON :Document FOR SELECT TO reader
USING (n.classification != 'SECRET') AS RESTRICTIVE
-- Policy on document collection
CREATE POLICY doc_own ON COLLECTION auth.users FOR SELECT TO reader
USING (doc.owner = current_user())
-- Policy on relationships
CREATE POLICY knows_policy ON :FRIEND FOR SELECT TO reader
USING (r.visible = true)Policy Operations
| Operation | Description |
|---|---|
| SELECT | Filter rows on read |
| INSERT | Validate new rows |
| UPDATE | Filter rows eligible for update |
| DELETE | Filter rows eligible for delete |
| ALL | Applies to all operations |
Management
ALTER POLICY name ON :Label USING (predicate) -- give USING, WITH CHECK or both; the other is kept
ALTER POLICY name ON :Label WITH CHECK (predicate)
DROP POLICY name ON :Label
SHOW POLICIES
SHOW POLICIES ON :Label
ENABLE ROW LEVEL SECURITY ON :Label
DISABLE ROW LEVEL SECURITY ON :Label
FORCE ROW LEVEL SECURITY ON :Label
SIMULATE POLICY AS alice WITH ROLE reader ON :Project
-- A collection policy is addressed with ON COLLECTION wherever a label policy takes ON :Label
ALTER POLICY things_update ON COLLECTION app_crm.things WITH CHECK (d.organization_id = session('org'))
DROP POLICY things_update ON COLLECTION app_crm.things
SHOW POLICIES ON COLLECTION app_crm.things
-- Column-level security: hide properties from roles
HIDE PROPERTY email ON :User FROM reader
HIDE PROPERTY salary, ssn ON :Employee -- no FROM = every non-admin role
UNHIDE PROPERTY email ON :User FROM reader
SHOW HIDDEN PROPERTIESSession Functions
| Function | Description |
|---|---|
| current_user() | The authenticated user's UUID |
| current_username() | The authenticated login username |
| session('key') | Custom session variable (e.g., tenant_id) |
Looking Beyond the Row
A predicate normally sees only the row in front of it and the session. Two forms reach further - into another collection, or along a relationship - so a policy can say “only an admin of this row’s organisation may edit it” when the membership is kept somewhere else. Inside either form, d.field /n.field is a property of the row being judged, which is what joins the row to what is looked up.
-- Only an admin of this row's organisation may edit it; the membership lives elsewhere
CREATE POLICY things_update ON COLLECTION app_crm.things FOR UPDATE
USING (EXISTS DOCUMENT IN app_crm.organization_user_roles
WHERE user_id = current_user() AND organization_id = d.organization_id AND role = 'admin')
WITH CHECK (EXISTS DOCUMENT IN app_crm.organization_user_roles
WHERE user_id = current_user() AND organization_id = d.organization_id AND role = 'admin')
-- The same fact as a relationship
CREATE POLICY thing_update ON :Thing FOR UPDATE
USING (EXISTS ((:User {id: current_user()})-[:ADMIN_OF]->(:Organization {id: n.organization_id})))| Form | Meaning |
|---|---|
| EXISTS DOCUMENT IN <collection> WHERE <field> = <expr> [AND ...] | Some document in the collection has every listed field equal to its expression. Use the registered name (app_x.things; unqualified means public). Fields are bare names of the looked-up document. |
| EXISTS ((:Label {prop: <expr>})-[:TYPE]->(:Label {prop: <expr>})) | A relationship of TYPE runs from a node matching the left end to one matching the right end. Direction matters; one property per end. |
| <expr> | A literal, current_user(), session('key'), or d.field / n.field of the row being judged. |
A lookup form is one term: to combine it with another condition, put it in parentheses,(EXISTS DOCUMENT IN ... WHERE ...) OR d.public = true. A row that lacks the joined field is denied, as is a malformed form. Lookups bypass row-level security themselves - it is the policy engine asking, not the caller - so the membership row need not be readable by the person it is about, and a policy cannot recurse. UseWITH CHECK on updates or an admin could move a row into an organisation they do not run. They work on the documents API and on every Cypher statement that reads or writes under a policy; not on storage buckets. Each lookup runs per candidate row and is memoised within the request.
Write Enforcement
RLS policies are enforced on writes as well as reads. SET operations check the USING predicate per-node before applying changes. UPSERT DOCUMENT checks the USING predicate per-document. Write policies bypass the RBAC role check - any user whose data satisfies the predicate can write, regardless of role.
Behavior
Deny-by-default: when RLS is enabled on a target, queries with no matching policy are denied. RLS policies and the enabled/disabled state persist across server restarts (snapshot v5).
When a sync rule links a label to a collection, a single policy on the label automatically applies to the paired collection too.