Skip to main content
Version: 0.3.36

Row-Level Security

A permission grant can carry a condition that is evaluated per node, and a list of properties that may be returned. Together they let one role see only its own drafts, another only published content, and a third only three fields of a profile. The checks run in the storage layer, so they apply the same way to REST, SQL, WebSocket subscriptions and psql.

What happens on a read

For every node that a query or a read touches:

  1. Callers with a system context or the system_admin role get the node unchanged.
  2. The caller's grants are matched on workspace, path, operation and node type, most specific path first.
  3. For each matching grant, its condition (if any) is evaluated against the node and the caller. The first grant whose condition holds allows the read. If none does, the node is left out of the result. A path read returns 404; a query simply has fewer rows.
  4. The fields or except_fields of the grant that allowed the read decide which properties are returned.

Writes use the same matching for update, delete, relate, unrelate and translate. A create is checked against the target path and node type, since the node does not exist yet: a condition on a create grant sees only node.path, node.name and node.node_type, so keep create grants free of conditions on properties or on created_by. A denied write fails with Permission denied: Cannot create raisin:Page at path '/carol' or Cannot update node at path '/hello'.

Conditions

A condition is a REL expression. It has two objects in scope.

auth, the caller:

VariableTypeDescription
auth.user_idStringGlobal identity id
auth.local_user_idStringId of the caller's raisin:User node in this repository
auth.homeStringPath of that node
auth.emailStringEmail address
auth.rolesArrayEffective role ids
auth.groupsArrayGroup ids
auth.is_anonymousBooleanSee the warning below -- not the test you want
auth.is_systemBooleanTrue for internal system calls
auth.is_anonymous does not detect the anonymous user

When anonymous access is enabled, an unauthenticated request runs as the built-in anonymous raisin:User node, and that is a resolved user like any other: auth.is_anonymous is false for it, and auth.user_id holds that node's id. The flag is true only for a context that was never resolved onto a user at all.

To recognise the anonymous caller in a condition, test the role instead:

auth.roles.contains('anonymous')

To require any signed-in caller, test auth.user_id != null together with that role check. Note also that REL treats null == null as true, so node.created_by == auth.user_id matches every unattributed node for a caller with no user id -- always guard an ownership test with auth.user_id != null.

node, the node being checked:

VariableTypeDescription
node.idStringNode id
node.nameStringNode name
node.pathStringFull path
node.node_typeStringNode type, e.g. blog:Article
node.created_by, node.updated_byStringActor ids
node.owner_idStringOwner id, if set
node.workspaceStringWorkspace name
node.<property>AnyEvery node property by key, e.g. node.status

Syntax:

  • Comparison ==, !=, <, <=, >, >=; logic &&, ||, !
  • String literals in single or double quotes, numbers, true, false, null, arrays
  • Methods on strings and arrays: contains(), startsWith(), endsWith(), length(), isEmpty(), toLowerCase(), indexOf(), first(), last()
  • Path helpers on strings: descendantOf(p), childOf(p), parent(), depth()
  • Graph: <a> RELATES <b> VIA 'TYPE' [DEPTH n | min..max] [DIRECTION OUTGOING|INCOMING|ANY]

A condition that does not parse, or that fails to evaluate (for example a missing property), is treated as false. A misconfigured grant therefore denies rather than allows.

Common patterns

Each example is one grant inside a role's PERMISSIONS (...) list, or the equivalent object in a role node's permissions property.

Only published content

CREATE ROLE 'article-reader' PERMISSIONS (
ALLOW READ ON 'articles' PATH '**' WHERE node.status == 'published'
);

With two pages in articles, one published and one draft:

-- as a user holding article-reader
SELECT path, properties->>'status'::String AS status FROM 'articles';
-- /hello | published

Own content only

ALLOW READ, UPDATE, DELETE ON 'content' PATH 'posts/**'
WHERE node.created_by == auth.user_id

Ownership or a role

ALLOW UPDATE, DELETE ON 'content' PATH '**'
WHERE node.created_by == auth.user_id || auth.roles.contains('admin')

Group membership

ALLOW READ, UPDATE ON 'projects' PATH '**'
WHERE auth.groups.contains('engineering')

Under the caller's home path

ALLOW READ, UPDATE ON 'raisin:access_control' PATH 'users/**'
WHERE node.path.startsWith(auth.home)

Property-based restriction

ALLOW READ ON 'documents' PATH '**'
WHERE node.classification != 'confidential' || auth.roles.contains('security-cleared')

Graph relationship

ALLOW READ ON 'raisin:access_control' PATH 'users/**/profile'
WHERE node.created_by RELATES auth.local_user_id VIA 'FRIENDS_WITH' DEPTH 2

Field-level filtering

After a grant allows a read, fields keeps only the listed properties and except_fields removes the listed ones. If both are set, fields wins.

CREATE ROLE 'title-only' PERMISSIONS (
ALLOW READ ON 'articles' PATH '**' FIELDS (title)
);
-- as a user holding title-only
SELECT path, properties FROM 'articles';
-- /hello | {"title":"Hello"}
-- /draft | {"title":"Draft"}
ALLOW READ ON 'articles' PATH '**' EXCEPT FIELDS (internal_notes, admin_comments)

Field filtering applies to reads. Whether a write is allowed is decided by the grant as a whole, not per property.

A complete setup

CREATE ROLE 'viewer-published' PERMISSIONS (
ALLOW READ ON 'articles' PATH '**'
WHERE node.status == 'published' || node.created_by == auth.user_id
);

CREATE ROLE 'author' INHERITS ('viewer-published') PERMISSIONS (
ALLOW CREATE ON 'articles' PATH '**',
ALLOW UPDATE ON 'articles' PATH '**'
WHERE node.created_by == auth.user_id,
ALLOW DELETE ON 'articles' PATH '**'
WHERE node.created_by == auth.user_id && node.status == 'draft'
);

CREATE ROLE 'editor' INHERITS ('author') PERMISSIONS (
ALLOW CREATE, READ, UPDATE, DELETE ON 'articles' PATH '**',
ALLOW READ ON 'raisin:access_control' PATH 'users/*/profile'
FIELDS (display_name, avatar_url, bio)
);
  • Viewers read published articles and their own.
  • Authors additionally create articles, edit their own, and delete only their own drafts.
  • Editors manage every article and can read three fields of any profile.

Anonymous access and defaults

With no matching grant the answer is always deny; there is no allow-by-default mode. Unauthenticated requests are either denied outright or run as the anonymous user (/users/system/anonymous, role anonymous), depending on whether anonymous access is enabled:

  1. per repository, by anonymous_enabled on a raisin:RepoAuthConfig node at /config/repos/{repo} in the raisin:system workspace;
  2. otherwise per tenant, by anonymous_enabled in PUT /api/tenants/{tenant}/auth/config;
  3. otherwise by the server configuration.

Locks and inventory are the exception to "anonymous runs as the anonymous user": the lock endpoints refuse that principal outright, whatever the anonymous role grants. See Locks & Inventory.

The raisin:SecurityConfig node at /config/default in raisin:access_control (managed with ALTER SECURITY CONFIG and SHOW SECURITY CONFIG) records a default_policy and anonymous_enabled, but in this release those values are not consulted by the enforcement path described above.

Next steps