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

OperationDescription
SELECTFilter rows on read
INSERTValidate new rows
UPDATEFilter rows eligible for update
DELETEFilter rows eligible for delete
ALLApplies 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 PROPERTIES

Session Functions

FunctionDescription
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})))
FormMeaning
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.