Skip to main content

Access Control (RBAC)

OpenRegister provides a multi-layered Role-Based Access Control system that controls access at three levels:

LevelWhat it controlsWhere configured
OrganisationWho can manage registers, schemas, views, agents, and special actionsOrganisation.authorization
SchemaWho can create, read, update, delete objects of a given typeSchema.authorization
PropertyWho can read or update individual fields within an objectPer-property authorization inside the schema

All three levels integrate with Nextcloud groups — no separate role management is needed.


Core Concepts

Groups

RBAC rules reference Nextcloud groups by their group ID. Two special group names exist:

GroupMeaning
"admin"Nextcloud administrators — always bypass all RBAC checks
"public"Any logged-in user, regardless of group membership

Unauthenticated (anonymous) requests are evaluated against "public" rules only.

Evaluation Priorities

Permission checks follow this order (first match wins):

  1. Admin bypass — users in the admin group always have full access
  2. Owner bypass — the object owner always has full access to their own objects
  3. No authorization configured — if the authorization field is empty/missing, all users have all permissions
  4. Missing action — if a specific CRUD action is not listed, all users have that permission
  5. Rule matching — rules are evaluated in order; first matching rule grants access

RBAC Toggle

RBAC enforcement can be enabled or disabled globally via the admin settings API:

GET  /api/settings/rbac
PUT /api/settings/rbac

When disabled, all permission checks are bypassed (all users can do everything).


Level 1: Organisation Authorization

Organisations define what entity types their members can manage. This controls access to administrative resources like registers, schemas, views, and agents — not to individual data objects (that's schema-level authorization).

Configuration

The authorization field on an Organisation entity uses a hierarchical structure with CRUD permissions per entity type:

{
"authorization": {
"register": {
"create": ["admin-group"],
"read": ["staff", "viewers"],
"update": ["admin-group"],
"delete": ["admin-group"]
},
"schema": {
"create": ["admin-group"],
"read": ["staff", "viewers"],
"update": ["admin-group"],
"delete": ["admin-group"]
},
"object": {
"create": ["staff"],
"read": ["staff", "viewers"],
"update": ["staff"],
"delete": ["admin-group"]
},
"view": {
"create": ["admin-group"],
"read": ["staff", "viewers"],
"update": ["admin-group"],
"delete": ["admin-group"]
},
"agent": {
"create": ["admin-group"],
"read": ["staff"],
"update": ["admin-group"],
"delete": ["admin-group"]
}
}
}

Entity Types

TypeControls access to
registerRegister management
schemaSchema management
objectObject CRUD (general, overridden by schema-level auth)
viewView management
agentAgent management

Special Rights

In addition to entity-type CRUD, organisations can define special action permissions:

KeyControls
object_publishWho can publish/depublish objects
agent_useWho can execute agents
dashboard_viewWho can access the dashboard
llm_useWho can use LLM features

Nextcloud Groups on Organisations

Each organisation has a groups field — an array of Nextcloud group IDs associated with it:

{
"uuid": "org-uuid-123",
"name": "My Organisation",
"groups": ["staff", "editors", "viewers"]
}

Users who belong to these Nextcloud groups automatically get access based on the organisation's authorization rules.

Managing via UI

  1. Navigate to Organisation Details or Organisation List
  2. Click Edit on an organisation
  3. In the Edit Organisation modal:
    • Basic Information Tab: Use the "Nextcloud Groups" multi-select dropdown to assign groups
    • Security Tab: View and manage the authorization rules
  4. Click Save to persist changes

Managing via API

# Get organisation (includes groups and authorization)
GET /api/organisations/{uuid}

# Update organisation groups and authorization
PUT /api/organisations/{uuid}
{
"groups": ["staff", "editors"],
"authorization": {
"register": { "create": ["editors"], "read": ["staff"] }
}
}

# Join an organisation
POST /api/organisations/{uuid}/join

# Leave an organisation
POST /api/organisations/{uuid}/leave

# Get/set active organisation
GET /api/organisations/active
POST /api/organisations/{uuid}/set-active

Organisation Hierarchy

Organisations support parent-child relationships:

  • Set via the parent field (UUID of parent organisation)
  • Children can see all resources from parent organisations (including depublished items)
  • Parents cannot see child resources
  • Users can see depublished items from their own organisation

Level 2: Schema Authorization

Schema authorization controls who can create, read, update, and delete objects of a given type. This is the main RBAC mechanism for data access.

Configuration

The authorization field on a Schema entity maps CRUD actions to arrays of rules:

{
"authorization": {
"create": ["editors", "managers"],
"read": ["public"],
"update": ["editors", "managers"],
"delete": ["managers"]
}
}

Full Schema Examples

Below are complete schema JSON examples showing different authorization patterns. These are the same configurations used in the OpenRegister test suite.

Example 1: Open Access (No Restrictions)

Any user (including unauthenticated) can perform all operations. This is the default when no authorization is set.

{
"title": "Public Knowledge Base",
"description": "Open wiki-style content",
"properties": {
"title": { "type": "string", "required": true },
"content": { "type": "string" },
"category": { "type": "string" }
},
"authorization": {}
}
UserCreateReadUpdateDelete
AdminYesYesYesYes
Any logged-in userYesYesYesYes
AnonymousYesYesYesYes

Example 2: Public Read, Restricted Write

Anyone can read, but only specific groups can create, update, or delete. Good for catalogues, directories, and published content.

{
"title": "Software Module",
"description": "Published software catalogue entry",
"properties": {
"naam": { "type": "string", "required": true },
"beschrijving": { "type": "string" },
"versie": { "type": "string" },
"status": { "type": "string", "enum": ["concept", "actief", "ingetrokken"] }
},
"authorization": {
"create": ["editors", "managers"],
"read": ["public"],
"update": ["editors", "managers"],
"delete": ["managers"]
}
}
UserCreateReadUpdateDelete
AdminYesYesYesYes
editors groupYesYes*YesNo
managers groupYesYes*YesYes
viewers groupNoYesNoNo
AnonymousNoYesNoNo

*Editors and managers also get read access because logged-in users inherit public rights.

Example 3: Staff Only (Internal Data)

All operations restricted to a single group, with deletion reserved for managers. Good for internal records, HR data, or confidential information.

{
"title": "Medewerker",
"description": "Internal employee record",
"properties": {
"naam": { "type": "string", "required": true },
"email": { "type": "string", "format": "email" },
"afdeling": { "type": "string" },
"startdatum": { "type": "string", "format": "date" }
},
"authorization": {
"create": ["staff"],
"read": ["staff"],
"update": ["staff"],
"delete": ["managers", "staff"]
}
}
UserCreateReadUpdateDelete
AdminYesYesYesYes
staff groupYesYesYesYes
managers groupNoNoNoYes
Any other userNoNoNoNo
AnonymousNoNoNoNo

Example 4: Collaborative (Tiered Access)

Multiple groups with escalating privileges. Good for team workflows with viewers, editors, and administrators.

{
"title": "Zaak",
"description": "Case management record",
"properties": {
"onderwerp": { "type": "string", "required": true },
"beschrijving": { "type": "string" },
"status": { "type": "string", "enum": ["open", "in_behandeling", "afgerond"] },
"verantwoordelijke": { "type": "string" },
"deadline": { "type": "string", "format": "date" }
},
"authorization": {
"create": ["editors", "managers"],
"read": ["viewers", "editors", "managers"],
"update": ["editors", "managers"],
"delete": ["managers"]
}
}
UserCreateReadUpdateDelete
AdminYesYesYesYes
viewers groupNoYesNoNo
editors groupYesYesYesNo
managers groupYesYesYesYes
AnonymousNoNoNoNo

Example 5: Conditional Access (Organisation-Scoped)

Access depends on object data matching the user's context. Good for multi-tenant data where organisations should only see their own entries.

{
"title": "Gebruik",
"description": "Software usage record per organisation",
"properties": {
"module": { "type": "string", "required": true },
"aanbieder": { "type": "string", "description": "Organisation UUID of the provider" },
"status": { "type": "string" },
"geregistreerdDoor": { "type": "string" }
},
"authorization": {
"read": [
{ "group": "public", "match": { "geregistreerdDoor": "Leverancier" } },
"gebruik-beheerder"
],
"create": ["gebruik-beheerder"],
"update": [
{ "group": "gebruik-beheerder", "match": { "_organisation": "$organisation" } }
],
"delete": ["admin"]
}
}
UserCreateReadUpdateDelete
AdminYesYesYesYes
gebruik-beheerder (same org)YesYesYesNo
gebruik-beheerder (different org)YesYesNoNo
Any logged-in (object has geregistreerdDoor = "Leverancier")NoYesNoNo
Any logged-in (other objects)NoNoNoNo

Example 6: Full Schema with Property-Level Authorization

Combines schema-level and property-level authorization. Some fields have stricter access than the object itself.

{
"title": "Gebruik",
"description": "Usage record with restricted internal notes",
"properties": {
"module": {
"type": "string",
"required": true
},
"status": {
"type": "string",
"enum": ["aangevraagd", "actief", "beeindigd"]
},
"aanbieder": {
"type": "string"
},
"interneAantekening": {
"type": "string",
"title": "Interne Aantekening",
"description": "Only visible to users in the same organisation",
"authorization": {
"read": [
{ "group": "public", "match": { "_organisation": "$organisation" } }
],
"update": [
{ "group": "public", "match": { "_organisation": "$organisation" } }
]
}
},
"beoordeling": {
"type": "string",
"title": "Beoordeling",
"description": "Only managers can modify this field",
"authorization": {
"read": ["gebruik-beheerder"],
"update": ["managers"]
}
}
},
"authorization": {
"create": ["gebruik-beheerder"],
"read": ["gebruik-beheerder"],
"update": ["gebruik-beheerder"],
"delete": ["managers"]
}
}

What happens:

Fieldgebruik-beheerder (same org)gebruik-beheerder (different org)managers
moduleRead + WriteRead + WriteRead + Write
statusRead + WriteRead + WriteRead + Write
interneAantekeningRead + WriteHiddenRead + Write**
beoordelingRead onlyRead onlyRead + Write

** Managers also get interneAantekening access if they're in the same organisation.

Rule Types

Simple Rule (Unconditional)

A string representing a group name. Users in this group always have access:

{
"authorization": {
"read": ["admin", "editors"]
}
}

Conditional Rule (Object-Based)

An object with a group and optional match conditions. Access is granted only when the object matches the specified conditions:

{
"authorization": {
"read": [
{
"group": "public",
"match": { "status": "published" }
}
]
}
}

Multiple Rules (OR Logic)

Multiple rules in the array are evaluated with OR logic — access is granted if any rule matches:

{
"authorization": {
"read": [
"admin",
{ "group": "public", "match": { "geregistreerdDoor": "Leverancier" } },
{ "group": "gebruik-beheerder" }
]
}
}

This grants read access if:

  • User is in admin group, OR
  • User is logged in AND object has geregistreerdDoor = "Leverancier", OR
  • User is in gebruik-beheerder group

Dynamic Variables

Match conditions support dynamic variables that are resolved at query time:

VariableResolves to
$organisationCurrent user's active organisation UUID
$activeOrganisationAlias for $organisation
$userIdCurrent user's ID
$userAlias for $userId
{
"group": "public",
"match": { "aanbieder": "$organisation" }
}

This grants access when the aanbieder property matches the user's active organisation.

If a dynamic variable cannot be resolved (e.g., user has no active organisation), the condition is not met.

Match Operators

The match property supports various operators:

OperatorExampleDescription
(shorthand){ "field": "value" }Equals
$eq{ "field": { "$eq": "value" } }Equals (explicit)
$ne{ "field": { "$ne": "value" } }Not equals
$in{ "field": { "$in": ["a", "b"] } }In array
$nin{ "field": { "$nin": ["a", "b"] } }Not in array
$exists{ "field": { "$exists": true } }Not null
$gt / $gte{ "field": { "$gt": 5 } }Greater than (or equal)
$lt / $lte{ "field": { "$lt": 10 } }Less than (or equal)

Multiple Conditions (AND Logic)

When multiple properties are specified in match, all conditions must be met:

{
"group": "public",
"match": {
"status": "published",
"visibility": "public"
}
}

Special Fields

FieldMatches against
_organisationThe object's @self.organisation metadata field
(any other)The object's data properties

Complete Example

A module schema where:

  • Modules registered by "Leverancier" are publicly readable
  • Other modules require the gebruik-beheerder group
  • Only gebruik-beheerder can create/update
  • Only admin can delete
{
"authorization": {
"read": [
{ "group": "public", "match": { "geregistreerdDoor": "Leverancier" } },
"gebruik-beheerder"
],
"create": ["gebruik-beheerder"],
"update": ["gebruik-beheerder"],
"delete": ["admin"]
}
}

Level 3: Property Authorization

Property-level authorization controls access to individual fields within objects, independently from the schema-level RBAC.

Use Case

Consider a gebruik (usage) schema where most properties can be read by anyone with the gebruik-beheerder group, but the interneAantekening (internal notes) field should only be visible to users in the same organisation as the object.

Configuration

Property authorization is configured inside the schema's properties definition using an authorization key on individual properties:

{
"properties": {
"naam": {
"type": "string",
"title": "Naam"
},
"interneAantekening": {
"type": "string",
"title": "Interne Aantekening",
"authorization": {
"read": [
{ "group": "public", "match": { "_organisation": "$organisation" } }
],
"update": [
{ "group": "public", "match": { "_organisation": "$organisation" } }
]
}
}
}
}
  • naam: No property-level authorization — follows schema-level RBAC
  • interneAantekening: Only readable/writable if user's active organisation matches the object's organisation

Supported Actions

ActionEffect
readControls whether the property appears in API responses
updateControls whether the property can be modified

Rule Structure

Property authorization uses the exact same rule structure as schema authorization — simple rules, conditional rules, dynamic variables, and match operators all work identically.

Read Filtering (Outgoing Data)

When an API response is rendered, properties are filtered based on read rules:

GET /api/objects/register/schema/uuid

# User in matching organisation:
{ "naam": "Example", "interneAantekening": "Private note" }

# User in different organisation:
{ "naam": "Example" }

The interneAantekening field is silently removed from the response for unauthorized users.

Update Validation (Incoming Data)

When an API request modifies an object, property update rules are checked:

PUT /api/objects/register/schema/uuid
{ "naam": "Updated Name", "interneAantekening": "New note" }

# If unauthorized for interneAantekening:
{ "error": "You are not authorized to modify the following properties: interneAantekening" }

Unchanged properties are skipped during update validation — this allows PATCH-style updates without triggering authorization errors on fields the user didn't modify.

Object Creation

During object creation, property authorization rules apply except for organisation matching. This is because there is no existing object to match the organisation against yet. Other match conditions (like $userId) still apply on create.

Extended Objects

Property authorization is applied recursively to extended/nested objects. Each object is evaluated against its own schema's property authorization rules.


Enforcement Architecture

Handler Pipeline

RBAC is enforced by dedicated handlers in the object lifecycle:

Request → PermissionHandler → SaveObject/RenderObject → Response

PropertyRbacHandler
HandlerResponsibility
PermissionHandlerSchema-level RBAC — checks if user can perform CRUD action
PropertyRbacHandlerProperty-level RBAC — filters fields on read, validates fields on write
MagicRbacHandlerApplies RBAC filters directly in SQL for magic table queries

Where Checks Happen

OperationSchema RBACProperty RBAC
CreateSaveObject calls PermissionHandler.checkPermission()PropertyRbacHandler.getUnauthorizedProperties() validates incoming data
ReadPermissionHandler.hasPermission() filters the result setPropertyRbacHandler.filterReadableProperties() strips unauthorized fields from response
UpdateSaveObject calls PermissionHandler.checkPermission()PropertyRbacHandler.getUnauthorizedProperties() validates incoming data
DeleteSaveObject calls PermissionHandler.checkPermission()N/A
ListPermissionHandler.filterObjectsForPermissions() filters resultsProperty filtering applied per-object during rendering

Database-Level Enforcement

For magic table queries, MagicRbacHandler pushes RBAC filters into SQL WHERE clauses, ensuring unauthorized objects are never loaded from the database. This provides:

  • Better performance (no post-load filtering)
  • Correct pagination (filtered before limit/offset)
  • Publication-based public access controls

Multi-Tenancy Integration

RBAC works alongside the multi-tenancy system. They are complementary but independent:

SystemControlsToggle
Multi-tenancyUsers only see objects from their active organisation/api/settings/multitenancy
RBACUsers can only perform actions their groups allow/api/settings/rbac

Both can be enabled or disabled independently. When both are active:

  1. Multi-tenancy filters objects by organisation first
  2. RBAC filters the remaining objects by permission

Active Organisation

Users must have an active organisation set to access data (even admins). The active organisation:

  • Determines which objects are visible (multi-tenancy)
  • Resolves the $organisation / $activeOrganisation variable in match conditions (RBAC)
  • Is stamped on newly created objects as @self.organisation
# Get current user's active organisation
GET /api/organisations/active

# Set active organisation
POST /api/organisations/{uuid}/set-active

Published Object Bypass

When publishedObjectsBypassMultiTenancy is enabled in config, published objects (with a published date set and no depublished date, or depublished in the future) are visible across all organisations. Depublished objects remain restricted to their own organisation.


Validation

Schema Authorization Validation

When a schema is saved, the authorization structure is validated:

  • Actions must be one of: create, read, update, delete
  • Each action maps to an array of rules
  • Each rule must be either a string (group name) or an object with a group key
  • Conditional rules may include a match object

Invalid structures produce validation errors.

Property Authorization Validation

Property authorization is validated alongside the schema:

  • Only read and update actions are supported (not create or delete)
  • Same rule structure validation as schema-level
  • Invalid dynamic variable names are flagged

API Reference

Organisation Endpoints

GET    /api/organisations                     # List all organisations (admin)
POST /api/organisations # Create organisation (admin)
GET /api/organisations/{uuid} # Get organisation
PUT /api/organisations/{uuid} # Update organisation
PATCH /api/organisations/{uuid} # Partial update
POST /api/organisations/{uuid}/join # Join organisation
POST /api/organisations/{uuid}/leave # Leave organisation
GET /api/organisations/active # Get active organisation
POST /api/organisations/{uuid}/set-active # Set active organisation

Settings Endpoints

GET    /api/settings/rbac                     # Get RBAC settings
PUT /api/settings/rbac # Update RBAC settings
GET /api/settings/multitenancy # Get multi-tenancy settings
PUT /api/settings/multitenancy # Update multi-tenancy settings
GET /api/settings/organisation # Get organisation settings
PUT /api/settings/organisation # Update organisation settings

Test Coverage

The RBAC system has comprehensive test coverage:

Test FileTestsCoverage
RbacTest.php14Core Schema permission logic, admin/owner overrides
RbacComprehensiveTest.php79All 64 RBAC scenarios (4 schema types x 4 user types x 4 operations) + owner privileges + validation
ObjectServiceRbacTest.php13+Integration with ObjectService, Nextcloud dependency mocking

Tested Scenarios

The comprehensive test matrix covers:

Schema TypeDescription
OpenNo authorization — all access allowed
Public-readRead open, create/update/delete restricted
Staff-onlyAll actions restricted to staff group
CollaborativeDifferent groups for different actions

Each schema type is tested with 4 user types (admin, public, group1, group2) across all 4 CRUD operations, plus owner override tests.


Best Practices

  1. Start with schema authorization — most use cases only need object-level CRUD control
  2. Add property authorization sparingly — it adds processing overhead; only use when fields truly need different access rules
  3. Use "public" for open read access — rather than listing every group
  4. Leave actions unconfigured for open access — an action not listed in authorization allows all users
  5. Test with multiple user types — verify admin, owner, group member, and unauthenticated access
  6. Use descriptive Nextcloud group names — e.g., marketing-editors instead of group1

Troubleshooting

Users Can't Access Objects

  1. Check if RBAC is enabled: GET /api/settings/rbac
  2. Check the schema's authorization field — is the user's group listed for the action?
  3. Check if the user has an active organisation set: GET /api/organisations/active
  4. Check if multi-tenancy is enabled and the object belongs to the user's organisation

Admin Can't See Data

Admins bypass RBAC but still need an active organisation when multi-tenancy is enabled. Set one via:

POST /api/organisations/{uuid}/set-active

Property Fields Missing from Response

This is likely property-level authorization filtering. Check the schema's property definitions for authorization rules on the missing field.

Conditional Rules Not Matching

  1. Verify the dynamic variable resolves — does the user have an active organisation?
  2. Check the field name matches exactly (case-sensitive)
  3. For _organisation matches, the comparison is against @self.organisation, not a data field
  4. If the object is a resolved relation (array with id key), the system extracts the id automatically