Skip to main content
Version: 0.1.0

Row-Level Security

RaisinDB enforces permissions at query time through the RLS (Row-Level Security) filter. Every node read from storage passes through this filter before being returned to the caller. This means access control is enforced consistently regardless of whether data is accessed via REST, SQL, WebSocket, or PGWire.

How the RLS Filter Works

Node from storage


System context? ──── yes ──→ Return node (bypass)
│ no

Permissions resolved? ──── no ──→ Deny
│ yes

is_system_admin? ──── yes ──→ Return node (bypass)
│ no

Find matching permission
(scope + path + operation + node_type)

┌─────┴─────┐
│ no match │ match found
│ │
▼ ▼
Deny Evaluate REL condition

┌─────┴─────┐
│ false │ true
│ │
▼ ▼
Deny Apply field filtering


Return filtered node

The filter evaluates permissions in order: scope match, path match, operation match, node type match, then condition evaluation. Among all matching permissions, the most specific path pattern wins.

REL Conditions

REL (Raisin Expression Language) conditions are string expressions attached to permissions that must evaluate to true for the permission to apply. They enable dynamic, runtime access control.

Available Variables

Two objects are available in REL conditions:

auth.* — the authenticated user:

VariableTypeDescription
auth.user_idStringGlobal identity ID
auth.local_user_idStringWorkspace-specific user node ID
auth.emailStringUser's email address
auth.is_anonymousBooleanWhether this is an anonymous request
auth.rolesArrayEffective role IDs
auth.groupsArrayGroup IDs
auth.homeStringUser's home path in the repository

node.* — the node being accessed:

VariableTypeDescription
node.idStringNode UUID
node.nameStringNode name
node.pathStringFull path in the hierarchy
node.node_typeStringNode type (e.g., blog:Article)
node.created_byStringIdentity ID of the creator
node.updated_byStringIdentity ID of the last updater
node.owner_idStringNode owner identity ID
node.workspaceStringWorkspace the node belongs to
node.<property>AnyAny node property, accessed by key

Node properties are automatically converted to REL values: strings, numbers, booleans, arrays, and objects are all supported.

REL Syntax

REL supports standard expression syntax:

  • Comparison: ==, !=, >, <, >=, <=
  • Logical: &&, ||, !
  • Property access: node.status, auth.email
  • Array indexing: node.tags[0]
  • Functions: contains(), startsWith(), endsWith()

Fail-Closed Evaluation

If a REL condition fails to parse or evaluate (e.g., referencing a non-existent variable), the result is false — access is denied. This is a deliberate security choice to ensure misconfigurations never result in open access.

Common Patterns

Users Can Only See Their Own Content

{
"path": "posts/**",
"operations": ["read", "update", "delete"],
"condition": "node.created_by == auth.user_id"
}

With this permission, a user can only read, update, or delete posts they created.

Editors See Everything, Viewers See Published Only

Define two roles:

{
"name": "editor",
"permissions": [
{
"path": "articles/**",
"operations": ["read", "update", "create", "delete"]
}
]
}

{
"name": "viewer",
"permissions": [
{
"path": "articles/**",
"operations": ["read"],
"condition": "node.status == 'published'"
}
]
}

Editors see all articles. Viewers only see articles where status is published.

Ownership OR Admin Access

{
"path": "content/**",
"operations": ["update", "delete"],
"condition": "node.created_by == auth.user_id || auth.roles.contains('admin')"
}

Users can modify their own content, and admins can modify anything.

Group-Based Access

{
"path": "projects/**",
"operations": ["read", "update"],
"condition": "auth.groups.contains('engineering')"
}

Only members of the engineering group can access project content.

Home Directory Access

{
"path": "users/**",
"operations": ["read", "update"],
"condition": "node.path.startsWith(auth.home)"
}

Users can access content under their own home path.

Property-Based Restrictions

{
"path": "documents/**",
"operations": ["read"],
"condition": "node.classification != 'confidential' || auth.roles.contains('security-cleared')"
}

Confidential documents are only visible to users with the security-cleared role.

Field-Level Filtering

After a matching permission is found and the REL condition passes, field-level filtering controls which properties are visible.

Field Whitelist

Only the listed fields are returned — all others are stripped:

{
"path": "users/**",
"operations": ["read"],
"fields": ["display_name", "avatar_url", "bio"]
}

A viewer with this permission can see user profiles but only the display_name, avatar_url, and bio fields. Sensitive fields like email, phone, or internal_notes are hidden.

Field Blacklist

All fields are returned except the listed ones:

{
"path": "articles/**",
"operations": ["read"],
"except_fields": ["internal_notes", "admin_comments"]
}

Everything is visible except internal_notes and admin_comments.

If both fields and except_fields are somehow set, the whitelist takes precedence.

Structured Conditions

In addition to REL string expressions, RaisinDB supports structured conditions for programmatic use:

Condition TypeExample
PropertyEqualsauthor == $auth.user_id
PropertyInstatus IN ['draft', 'review']
PropertyGreaterThanpriority > 5
PropertyLessThanage < 18
UserHasRoleCheck if user has a specific role
UserInGroupCheck if user is in a specific group
AllAND composition of sub-conditions
AnyOR composition of sub-conditions

Condition values can be literals or auth variable references ($auth.user_id, $auth.email).

Write Operation Checks

The RLS filter also applies to write operations. Before a node can be created, updated, or deleted, the system checks:

  • Update/Delete: can_perform(node, operation, auth) — same matching logic as read filtering but for the requested operation
  • Create: can_create_at_path(path, node_type, auth) — checks permissions against the target path and node type (since no node exists yet)

Putting It All Together

Here's a complete example of a multi-role setup:

[
{
"name": "viewer",
"permissions": [
{
"path": "**",
"operations": ["read"],
"condition": "node.status == 'published' || node.created_by == auth.user_id"
}
]
},
{
"name": "author",
"inherits": ["viewer"],
"permissions": [
{
"path": "articles/**",
"operations": ["create", "update"],
"condition": "node.created_by == auth.user_id",
"except_fields": ["featured", "editor_pick"]
},
{
"path": "articles/**",
"operations": ["delete"],
"condition": "node.created_by == auth.user_id && node.status == 'draft'"
}
]
},
{
"name": "editor",
"inherits": ["author"],
"permissions": [
{
"path": "articles/**",
"operations": ["create", "read", "update", "delete"]
},
{
"path": "users/*/profile",
"operations": ["read"],
"fields": ["display_name", "avatar_url", "bio"]
}
]
}
]

This setup provides:

  • Viewers can read published content and their own drafts
  • Authors inherit viewer access, can create and edit their own articles (but not set featured or editor_pick), and can only delete their own drafts
  • Editors inherit everything, can manage all articles, and can view basic user profile info

Security Configuration

The raisin:SecurityConfig controls the default security posture:

security:
workspace: "*" # Applies to all workspaces
default_policy: "deny" # Deny when no permission matches
anonymous_enabled: false # No unauthenticated access

Per-interface overrides let you allow anonymous REST access (for a public website) while requiring authentication for PGWire (internal analytics):

security:
workspace: "content"
default_policy: "deny"
anonymous_enabled: true
anonymous_role: "anonymous"
interfaces:
rest:
anonymous_enabled: true
pgwire:
anonymous_enabled: false
websocket:
anonymous_enabled: false

The default out-of-the-box configuration is deny-all with no anonymous access, ensuring a secure starting point.

Next Steps