Ga naar hoofdinhoud

Access Control

Access Control provides enterprise-grade permissions management through integration with Nextcloud RBAC (Role-Based Access Control) and Keycloak.

Overview​

The access control system integrates with:

  • ADFS (Active Directory Federation Services) for user and group management via Keycloak
  • Nextcloud RBAC for role-based permissions
  • FCS (Federal Cloud Services) compliance requirements
  • Verwerkingen registers for process tracking

Permission Levels​

Access can be controlled at multiple levels:

  • Register level - Control access to entire registers
  • Schema level - Manage permissions for specific register/schema combinations
  • Object level - Set permissions on individual objects, make one object private, and invite a principal to it (see Object Sharing)
  • Property level - Fine-grained, conditional control over specific object properties (see Property Authorization)

Permission Types​

Permissions are granted through:

  1. User Rights

    • CRUD (Create, Read, Update, Delete) operations
    • Inherited from ADFS groups via Keycloak
    • Role-based access control through Nextcloud
  2. Contract Rights

    • Application-level permissions
    • Process-specific authorizations
    • Compliance with FCS requirements
    • Integration with verwerkingen registers

Implementation​

Access control is implemented through:

  1. User Authentication

    • Direct integration with Keycloak for identity management
    • ADFS synchronization for user and group information
    • Single Sign-On (SSO) capabilities
  2. Permission Management

    • CRUD-level permissions for all system entities
    • Hierarchical permission inheritance
    • Fine-grained access control at multiple levels
  3. Process Integration

    • Compliance with FCS guidelines
    • Integration with verwerkingen registers for process tracking
    • Application-specific permission contracts

Technical Implementation​

Architecture Overview​

Authorization Flow​

Schema Authorization Configuration​

A schema with no authorization block is readable by every account that can log in​

Read this before you decide your schema does not need a block.

An absent authorization block does not mean "no access". It means open. A schema that declares nothing answers GET /api/objects/{register}/{schema} with every non-private row, to every caller the login accepted. There is no group check, because there is no rule to check against.

This surprises people, so here is the shape of it:

The schema declaresA list read returns
No authorization at allEvery non-private row, to anyone authenticated
"read": ["some-group"]Rows only to members of that group
"read": []Nothing. An empty list means "grant to nobody"
A block with no read keyNothing, beyond the caller's own rows

The last two rows are the fail-closed half. Once your block is non-empty, OpenRegister denies any action you did not name. So {"read": ["admins"]} on its own does not leave create, update and delete as they were. It closes them to owners and admins. Name all four actions, or accept that you changed the write posture too.

Why the anonymous check reassures you and should not​

Point an anonymous request at an unprotected schema and you get an empty list. That looks like a working boundary. It is not one. Anonymous callers are empty for a different reason: they own no rows and belong to no organisation. Log in as any account, including one in no groups at all, and the same endpoint returns everything.

Measured on a fleet app in September 2026: an account created with occ user:add and given no group membership read ten rows of admin-only integration status. A wrong password returned 401 and anonymous returned an empty list, so the instance looked correctly locked from both ends anyone had checked. Access control held at the login line and nowhere past it.

Test the boundary with an authenticated account that should not pass it. An anonymous probe cannot tell an enforced schema from an open one.

"rbac": true in the response is not evidence of filtering​

The @self.rbac field echoes what was asked for, not what happened. It reads true whenever the caller is not an admin. A schema with no block returns "rbac": true alongside every row it holds.

To find out whether a schema is actually protected, read the schema itself. The schemas API returns the stored block, and null there is the open case:

curl -s -u admin:… -H 'OCS-APIRequest: true' \
'https://your-instance/index.php/apps/openregister/api/schemas?_limit=500' \
| jq '.results[] | select(.slug=="yourSchema") | {slug, authorization}'

See Check Authorization Config for the same lookup through SchemaMapper when you are already inside the container.

A guard on the page does not guard the data​

Leaf apps put admin-only configuration in a register and then hide the page behind a route guard or a permission: "admin" manifest entry. Both stop the page from rendering. Neither narrows a list read, because the rows come from OpenRegister and a client cannot restrict a server. If the page is admin-only, the schema behind it needs read restricted too.

The list path evaluates read, never list​

list is a real action in the vocabulary, but GET /api/objects/{register}/{schema} does not use it. It filters on read. A block that grants {"list": [...]} and omits read falls into the fail-closed branch and returns the caller's own rows only.

A denial on a list is HTTP 200 with an empty results array, not a 403. OpenRegister narrows a list in SQL rather than refusing it, so that enumerating a schema cannot tell you what you were not allowed to see.

This default is scheduled to change​

The rbac-default-authenticated change proposes flipping the absent-block default, and rbac-unmarked-schema-audit.md tracks which schemas still declare nothing. Do not wait for it. A schema that names its groups today keeps the same behaviour after the flip; one that says nothing gets whatever the flip decides.

Next: pick the groups your schema should answer to, then write the block using the samples under Code Examples below.

The action vocabulary: closed by default, extensible by declaration​

An authorization block may name create, read, update or delete, plus any action the schema declares for itself:

"x-openregister-action": {
"sendMail": {
"name": "Send mail",
"description": "Send a message as the acting user."
}
}

With that declaration in the schema's configuration, "sendMail": ["staff"] is a legal authorization rule. Without it, the import fails, naming the offending action and listing what is allowed.

That refusal is the point, not a safety rail bolted onto it. An open vocabulary would let a typo — raed for read — save cleanly as a permission that is never granted and never errors: a rule that appears to protect something and protects nothing. A schema that declares a permission nobody can satisfy is the bug being prevented.

Declaring an action grants and enforces nothing. It defines a name that an authorization block, an event listener and the grantable-rights index can all refer to; the app still enforces its own operation. A declaration with no description is ignored, because an action that reaches a permission matrix without telling the person granting it what they are agreeing to is worse than no entry at all.

The mcp scope: what may be OFFERED, never what is HELD​

mcp sits beside public, authenticated and admin, and means: this action, on this schema, may be offered to an agent.

It does not mean an agent has it. RBAC resolves through Nextcloud groups, which are per user — two agents owned by one person are indistinguishable to it. Whether a specific agent holds a right stays resolved in Hermiq against that agent's own grants, where the request-and-approve flow already lives. The relationship is: mcp bounds the menu, Hermiq picks from it.

It never grants. An administrator can create a real Nextcloud group called mcp; without a guard, its members would inherit every action any schema ever documented as an agent surface. The evaluator refuses the match outright, in both the bare-string and {"group": "mcp"} rule forms, in both rule interpreters.

⚠️ Writing mcp into authorization DOES opt the schema into authorization. A block is fail-closed once non-empty, so {"read": ["mcp"]} denies every action it does not list — including read itself, since the scope satisfies nobody. If you want to record an agent surface without changing enforcement, use the x-openregister-mcp dialect instead. That is where the surface belongs, and where every live declaration on this instance already is.

That rule was chosen after the alternative was measured and rejected. Treating an offers-only block as absent — so the annotation changed nothing — sounds like the right reading of a descriptive token, but "absent" means default-open: the schema became readable by every authenticated user and by anonymous callers. An author documenting an agent surface would have published the schema instead of restricting it. Between a fail-safe and a fail-open reading of an ambiguous block, the block gets the fail-safe one: a denial is loud and quickly fixed, a silent grant to anonymous is a breach.

An explicitly empty rule list ("read": []") means grant this action to nobody and is preserved for the same reason — it is the strictest rule the grammar can express, and reading it as "no rule" would flip it to default-open.

Authorization Exception System​

RBAC Query Filtering Process​

Database Schema​

Authorization Exception Table: oc_openregister_authorization_exceptions

ColumnTypeDescription
idINTEGERPrimary key
uuidVARCHAR(36)Unique identifier
typeVARCHAR(20)Exception type: inclusion or exclusion
subject_typeVARCHAR(20)Subject type: user or group
subject_idVARCHAR(255)User ID or Group ID
schema_uuidVARCHAR(36)Schema UUID (nullable for global)
register_uuidVARCHAR(36)Register UUID (nullable)
organization_uuidVARCHAR(36)Organisation UUID (nullable)
actionVARCHAR(20)CRUD action: create, read, update, delete
priorityINTEGERPriority for resolution (higher = more important)
activeBOOLEANWhether exception is active
descriptionTEXTHuman-readable description
created_byVARCHAR(255)User who created the exception
created_atDATETIMECreation timestamp
updated_atDATETIMELast update timestamp

Schema Authorization Field:

  • Stored in oc_openregister_schemas.authorization (JSON)
  • Format:
{
"create": ["admin", "editors"],
"read": ["admin", "editors", "viewers", "public"],
"update": ["admin", "editors"],
"delete": ["admin"],
"inheritFromPublic": true
}

The optional inheritFromPublic boolean controls whether authenticated users qualify for public rules on this schema. It defaults to true (the pre-change behaviour). See Disabling public-group inheritance for authenticated users below.

Object Authorization Field:

  • Stored in oc_openregister_objects.authorization (JSON)
  • Inherits from schema but can be overridden per-object
  • Same format as schema authorization

Permission Resolution Algorithm​

Code Examples​

Schema Authorization Configuration​

// Setting authorization on a schema
$schema->setAuthorization([
'create' => ['admin', 'editors'],
'read' => ['admin', 'editors', 'viewers', 'public'],
'update' => ['admin', 'editors'],
'delete' => ['admin']
]);

// Checking if a user has permission
$hasPermission = $schema->hasPermission('read', $userGroups);

Creating Authorization Exceptions​

use OCA\OpenRegister\Db\AuthorizationException;

// Create an inclusion exception (grant extra permission)
$inclusion = new AuthorizationException();
$inclusion->setType(AuthorizationException::TYPE_INCLUSION);
$inclusion->setSubjectType(AuthorizationException::SUBJECT_TYPE_USER);
$inclusion->setSubjectId('user123');
$inclusion->setSchemaUuid($schemaUuid);
$inclusion->setAction(AuthorizationException::ACTION_UPDATE);
$inclusion->setPriority(10);
$inclusion->setActive(true);
$inclusion->setDescription('Allow user123 to update objects in this schema');

// Create an exclusion exception (deny permission)
$exclusion = new AuthorizationException();
$exclusion->setType(AuthorizationException::TYPE_EXCLUSION);
$exclusion->setSubjectType(AuthorizationException::SUBJECT_TYPE_GROUP);
$exclusion->setSubjectId('restricted_group');
$exclusion->setRegisterUuid($registerUuid);
$exclusion->setAction(AuthorizationException::ACTION_DELETE);
$exclusion->setPriority(20);
$exclusion->setActive(true);
$exclusion->setDescription('Prevent restricted_group from deleting objects in this register');

// Check if an exception matches criteria
$matches = $exception->matches(
subjectType: 'user',
subjectId: 'user123',
action: 'update',
schemaUuid: $schemaUuid
);

RBAC Query Filtering (MagicMapper)​

use OCA\OpenRegister\Service\MagicMapperHandlers\MagicRbacHandler;

// Apply RBAC filters to a dynamic table query
$rbacHandler->applyRbacFilters(
qb: $queryBuilder,
register: $register,
schema: $schema,
tableAlias: 't',
userId: $currentUserId,
rbac: true
);

// Check if current user is admin
$isAdmin = $rbacHandler->isCurrentUserAdmin();

// Get current user's groups
$userGroups = $rbacHandler->getCurrentUserGroups();

Object-Level Authorization​

// Get object authorization (inherits from schema if not set)
$objectAuth = $object->getAuthorization();

// Override schema authorization for specific object
$object->setAuthorization([
'read' => ['admin', 'special_viewers'],
'update' => ['admin']
]);

Conditional rules work identically at every level​

Schema-level, object-level, and property-level authorization blocks all accept the same conditional rule grammar. A rule of the form { "group": "...", "match": { ... } } evaluates the same way whether it sits on a schema, an object, or a single property, and whether it is enforced at list time (SQL WHERE via MagicRbacHandler), at single-object fetch time (PermissionHandler::hasPermission), or during property filtering (PropertyRbacHandler).

All three enforcement points route conditional match evaluation through the shared ConditionMatcher service. The operator set and dynamic-variable set listed below therefore apply uniformly:

  • Operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists.
  • Dynamic variables resolved at evaluation time: $organisation / $activeOrganisation, $userId / $user, $now.

A schema authored with { "read": [{ "group": "public", "match": { "publishDate": { "$lte": "$now" } } }] } returns the same object set from GET /api/objects/{register}/{schema} (list) and GET /api/objects/{register}/{schema}/{id} (find). List-vs-find drift caused by differing grammar is no longer possible.

Disabling public-group inheritance for authenticated users (inheritFromPublic)​

By default, authenticated users qualify for any rule that targets the public group — they inherit at least the rights of an anonymous visitor. This is convenient for most schemas, but it gets in the way of two patterns:

  1. Privacy-strict schemas where authentication is meant to be a strict gate, not a superset of public access. For example: a schema where the public group can only see redacted/anonymised rows via a conditional rule, but logged-in users should be channelled through a different curated view rather than seeing the same redacted set.
  2. Tiered visibility flows where a public catalogue uses a date-windowed match (e.g. publishedAt $lte $now) and a separate authenticated curated view uses its own group rule. With public inheritance on, the authenticated view leaks the public catalogue rows.

The optional inheritFromPublic boolean on the authorization block of a schema or register lets a tenant opt out of the inherit-from-public behaviour. When false, authenticated users no longer qualify for public rules on that schema/register; they must qualify via their own group memberships. Anonymous users are unaffected — the flag does not change what an unauthenticated visitor sees.

Cascade​

The effective value is resolved per schema, walking the cascade until the first explicitly-set value is found:

  1. The schema's own authorization.inheritFromPublic.
  2. The parent register's authorization.inheritFromPublic.
  3. The tenant-wide IAppConfig key openregister.rbac.inherit_from_public_default (read via IAppConfig::getValueBool, which accepts true/false/"true"/ "false"/"1"/"0"/1/0 at the storage layer).
  4. Hard-coded true (preserves pre-change behaviour).

Strict-boolean check at schema and register levels. Steps 1 and 2 require the stored value to be a literal true or false — anything else (string forms, integers, mistyped JSON) is rejected as "unset" and the cascade falls through, with a warning logged. This closes a foot-gun where a seed write or migration that bypasses the schema validator could store the string "false" and silently invert the gate ((bool) "false" is true). Always store real JSON booleans on schema/register authorization blocks. The IAppConfig layer keeps its broader tolerance because the occ / operator surface intentionally accepts those forms.

null at any level is treated as "unset" — the cascade falls through to the next level. The first explicit boolean wins; a schema that sets the flag overrides its register and the tenant default.

The tenant default can be flipped from the OpenRegister settings UI under RBAC Configuration → Authenticated users inherit public group rights (default), or via occ config:app:set openregister rbac.inherit_from_public_default --value=false --type=boolean.

Four-state matrix​

For a schema with read: [{ "group": "public", "match": <m> }]:

UserinheritFromPublicResult
anonymoustrue (default)granted when <m> matches (pre-change behaviour)
anonymousfalsegranted when <m> matches — anonymous users are unaffected
authenticatedtrue (default)granted when <m> matches (authenticated user inherits public)
authenticatedfalsedenied unless the user qualifies via another rule (own group / owner / admin)

Owner-shortcut and admin-bypass paths are unaffected by the flag — an object's owner and any user in the admin group always have access regardless of inheritFromPublic.

The PHP-side per-object check (PermissionHandler::hasPermission) and the SQL-side listing filter (MagicRbacHandler::applyRbacFilters / buildRbacConditionsSql) both honour the flag identically; per-object checks and listing membership cannot drift.

Worked example: a publication-style schema with a curated authenticated view​

A publication schema needs to be visible to anonymous visitors only after its publishedAt timestamp, but logged-in editors should see the curated in-progress queue (which is a different rule) instead of the public-time window. Authenticated users without editors membership should see nothing — they should not inherit the public window.

{
"authorization": {
"inheritFromPublic": false,
"read": [
{ "group": "public", "match": { "publishedAt": { "$lte": "$now" } } },
{ "group": "editors", "match": { "status": { "$in": ["draft", "review"] } } }
]
}
}

Behaviour:

  • An anonymous visitor sees rows where publishedAt <= now (public match applies; the flag does not affect anonymous users).
  • An editors member sees rows where status is draft or review (their group rule applies). They do NOT inherit the public time-window because inheritFromPublic is false.
  • A logged-in user who is not in editors sees nothing on this schema. They have no qualifying rule, and the flag prevents them from falling back to the public match.
  • An owner of a row sees it via the owner shortcut, regardless of the flag.

If the same schema were authored with inheritFromPublic: true (or the field omitted), the third case would change: the non-editors logged-in user would inherit the public window and see the same rows the anonymous visitor sees.

When to reach for 'authenticated' instead of 'public'​

inheritFromPublic governs the public group only. The simple-string rule 'authenticated' is a separate construct — it grants access to any logged-in user, independent of the flag. If you want "any logged-in user, no group filtering, regardless of inheritFromPublic", use { "read": ["authenticated"] } rather than relying on public-inheritance.

Property-Level Authorization​

In addition to the schema- and object-level rules above, individual properties can carry their own authorization block with conditional rules. This is covered in depth in Property Authorization; this section is a short map into that feature.

Property-level rules support the same grammar as schema-level (listed above):

  • Group checks — the same groups used at schema level (including "public").
  • match conditions on the object, with the operators listed above.
  • Dynamic variables — the same set listed above.

Example — a publishedAt field that is only visible after its own timestamp:

{
"publishedAt": {
"type": "string",
"format": "date-time",
"authorization": {
"read": [
{ "group": "public", "match": { "publishedAt": { "$lte": "$now" } } }
]
}
}
}

Example — internal notes scoped to the owning organisation:

{
"interneAantekening": {
"type": "string",
"authorization": {
"read": [{ "group": "public", "match": { "_organisation": "$organisation" } }],
"update": [{ "group": "editors", "match": { "_organisation": "$organisation" } }]
}
}
}

Reads that fail property authorization strip the field from the response; writes that fail produce a validation error rather than a silent drop. Schemas with no authorization blocks on any property skip property-RBAC evaluation entirely.

See Property Authorization for the full operator catalogue, variable reference, edge cases, and more examples.

RBAC Configuration​

RBAC can be configured in Nextcloud app settings:

{
"enabled": true,
"adminOverride": true,
"inheritFromPublicDefault": true
}
  • enabled: Master switch for RBAC system
  • adminOverride: Allow users in 'admin' group to bypass all RBAC checks
  • inheritFromPublicDefault: Tenant-wide default for the schema-level inheritFromPublic flag. When true (default), authenticated users qualify for public rules on any schema that does not explicitly set the flag. When false, authenticated users must qualify via their own group memberships unless a schema or register opts back in. Persisted to the IAppConfig key openregister.rbac.inherit_from_public_default. See Disabling public-group inheritance for authenticated users.

Performance Optimizations​

  1. Lazy Group Loading

    • User groups are only fetched when RBAC is enabled
    • Results are cached within the request lifecycle
  2. Admin Fast Path

    • Admin users bypass permission checks entirely when override is enabled
    • Reduces database queries for administrative operations
  3. Query-Level Filtering

    • RBAC filters are applied at the database query level
    • Prevents loading unauthorized objects into memory
  4. Authorization Config Caching

    • Schema authorization configs are cached with schema entities
    • Reduces redundant JSON parsing
  5. Exception Priority Indexing

    • Database index on priority field for fast exception sorting
    • Composite index on (subject_type, subject_id, action, active) for fast matching

Integration Points​

1. ObjectEntityMapper​

  • Applies RBAC filters to all object queries via findAll(), find(), count()
  • Respects rbac parameter (default: true)

2. MagicMapper (Dynamic Tables)​

  • Uses MagicRbacHandler for schema-specific table filtering
  • Consistent security across schema-generated tables
  • RBAC filtering applied via applyAdditionalFilters() in GuzzleSolrService
  • Currently logs RBAC application but full implementation pending

4. API Controllers​

  • RBAC checks in ObjectsController, RegistersController, SchemasController
  • Validates permissions before CRUD operations

Best Practices​

  1. Use Schema-Level Authorization

    • Define authorization at the schema level for consistency
    • Only override at object level when necessary
  2. Leverage Group-Based Permissions

    • Use Nextcloud groups for role management
    • Avoid user-specific permissions unless absolutely required
  3. Authorization Exceptions as Last Resort

    • Use exceptions sparingly for edge cases
    • Document the reason for each exception
    • Set appropriate priorities to avoid conflicts
  4. Test Permission Scenarios

    • Test unauthenticated access
    • Test group membership changes
    • Test admin override behavior
    • Test exception priority resolution
  5. Monitor Authorization Exceptions

    • Regularly audit active exceptions
    • Deactivate or delete obsolete exceptions
    • Review exception conflicts (overlapping priorities)

Debugging & Monitoring​

Enable Debug Logging​

// In GuzzleSolrService
$this->logger->debug('[SOLR] RBAC filtering applied');

// In MagicRbacHandler
$this->logger->debug('Applying RBAC filters', [
'user_id' => $userId,
'user_groups' => $userGroups,
'schema_uuid' => $schema->getUuid()
]);

Check Authorization Config​

# Query schema authorization
docker exec -u 33 master-nextcloud-1 php -r "
\$schema = \OC::$server->get(\OCA\OpenRegister\Db\SchemaMapper::class)->find(1);
var_dump(\$schema->getAuthorization());
"

Query Authorization Exceptions​

# List active exceptions
docker exec -it master-database-mysql-1 mysql -u nextcloud -pnextcloud nextcloud -e "
SELECT type, subject_type, subject_id, action, priority, description
FROM oc_openregister_authorization_exceptions
WHERE active = 1
ORDER BY priority DESC;
"

Security Considerations​

  1. Default Deny

    • When authorization is configured, default behavior is to deny access
    • Explicitly configure 'public' in read permissions for public access
  2. Admin Override

    • Admin override can be disabled for high-security environments
    • When disabled, even admins must have explicit permissions
  3. Authorization Inheritance

    • Objects inherit authorization from schemas
    • Object-level overrides take precedence
  4. Exception Priority

    • Exclusions should have higher priority than inclusions to ensure security
    • Use priority > 50 for security-critical exclusions
  5. Unauthenticated Access

    • Unauthenticated users only see objects with 'public' in read permissions
    • Fallback to published object filtering can be enabled (currently disabled)

Restricting OpenRegister to a user group​

Nextcloud administrators can limit OpenRegister to specific groups via Apps → OpenRegister → Limit to groups (or occ app:enable openregister --groups <group>). Because Nextcloud gates app routes by IAppManager::isEnabledForUser(), this blocks every non-public route for any user outside those groups — including admins who are not members, and other apps calling OpenRegister on a user's behalf. Only routes marked #[PublicPage] bypass the restriction.

OpenRegister is a data platform whose register, schema, and object read endpoints are consumed by other apps for all users. To keep those reachable while limiting management to the restriction group, the read surface is public-by-route and write/management endpoints are not:

OperationRoute visibilityBehaviour under app-group restriction
Read registers / schemas / objects (index, show)#[PublicPage]Reachable by all users (and consuming apps), in or out of the group
Create / update / delete registers & schemasnot publicBlocked for users outside the group (and additionally gated to admin / manage-permission)

Anonymous access to the public read endpoints is limited to published resources. A register or schema is only returned to an unauthenticated caller when its published field is set and it has not been depublished; an anonymous request for an unpublished register/schema returns 401. Authenticated users are unaffected and continue to receive results scoped by the RBAC rules described above. Object reads remain governed by ObjectService / PermissionHandler regardless of the resolved identity.

Net effect: group-restricting OpenRegister hides the management UI and write operations from non-group users while leaving the consumed read APIs (and published catalogue data) available. See also the register/schema write-authorization rules below and in RegistersController / SchemasController.

Authorization Exceptions​

The Authorization Exception System provides a flexible way to override the standard Role-Based Access Control (RBAC) system. It allows for fine-grained control over permissions by defining specific inclusions and exclusions that take precedence over normal authorization rules.

Exception Types​

  1. Inclusions: Grant additional permissions to users or groups that they wouldn't normally have
  2. Exclusions: Deny permissions to users or groups even if they would normally have access through RBAC

Subject Types​

  • User: Exceptions that apply to specific individual users
  • Group: Exceptions that apply to all members of a specific group

Priority System​

Authorization exceptions use a priority system to resolve conflicts:

  1. Exclusions (highest priority) - Always deny access if applicable
  2. Inclusions (medium priority) - Grant access if no exclusions apply
  3. Normal RBAC (lowest priority) - Default system behavior

Within each type, exceptions with higher numerical priority values take precedence.

Database Schema​

The oc_openregister_authorization_exceptions table stores authorization exceptions with the following key fields:

  • type: 'inclusion' or 'exclusion'
  • subject_type: 'user' or 'group'
  • subject_id: The actual user ID or group ID
  • action: CRUD operation ('create', 'read', 'update', 'delete')
  • schema_uuid: Optional - limits exception to specific schema
  • register_uuid: Optional - limits exception to specific register
  • organization_uuid: Optional - limits exception to specific organization
  • priority: Integer priority for conflict resolution
  • active: Boolean to enable/disable exceptions
  • description: Human-readable description

Usage Examples​

Example 1: Group Cross-Organization Access​

Allow users in the 'ambtenaar' group to read 'gebruik' objects from all organizations:

$exception = new AuthorizationException();
$exception->setType(AuthorizationException::TYPE_INCLUSION);
$exception->setSubjectType(AuthorizationException::SUBJECT_TYPE_GROUP);
$exception->setSubjectId('ambtenaar');
$exception->setAction(AuthorizationException::ACTION_READ);
$exception->setSchemaUuid('gebruik-schema-uuid');
$exception->setPriority(20); // High priority to override multi-tenancy
$exception->setDescription('Allow ambtenaar group to read gebruik objects from all organizations');

$authService->createException(/* parameters */);

Example 2: User Exclusion​

Deny a specific user update access despite group membership:

$exception = new AuthorizationException();
$exception->setType(AuthorizationException::TYPE_EXCLUSION);
$exception->setSubjectType(AuthorizationException::SUBJECT_TYPE_USER);
$exception->setSubjectId('problematic-user');
$exception->setAction(AuthorizationException::ACTION_UPDATE);
$exception->setSchemaUuid('sensitive-schema-uuid');
$exception->setPriority(15);
$exception->setDescription('Deny user update access due to security concerns');

API Usage​

Creating Exceptions​

# Create user inclusion
curl -X POST http://localhost/index.php/apps/openregister/api/authorization-exceptions \
-u 'admin:admin' \
-H 'Content-Type: application/json' \
-H 'OCS-APIREQUEST: true' \
-d '{
"type": "inclusion",
"subject_type": "user",
"subject_id": "special-user",
"action": "read",
"schema_uuid": "confidential-schema-uuid",
"priority": 10,
"description": "Allow special user to read confidential data"
}'

Listing Exceptions​

# List all exceptions
curl http://localhost/index.php/apps/openregister/api/authorization-exceptions \
-u 'admin:admin' \
-H 'OCS-APIREQUEST: true'

# Filter by criteria
curl 'http://localhost/index.php/apps/openregister/api/authorization-exceptions?type=inclusion&active=true' \
-u 'admin:admin' \
-H 'OCS-APIREQUEST: true'

Integration with RBAC​

The authorization exception system integrates seamlessly with the existing RBAC system:

  1. Query Level: The ObjectEntityMapper::applyRbacFilters() method checks for exceptions before applying normal RBAC rules
  2. Object Level: The ObjectEntityMapper::checkObjectPermission() method evaluates exceptions first, then falls back to standard permission checks
  3. Evaluation Order: Exclusions → Inclusions → Normal RBAC → Object ownership → Publication status

Best Practices​

1. Use Specific Scope​

Always limit exceptions to the most specific scope possible:

// Good - specific to schema and organization
$exception->setSchemaUuid('specific-schema');
$exception->setOrganizationUuid('specific-org');

// Avoid - too broad, affects everything
// (leaving schema_uuid and organization_uuid as null)

2. Set Appropriate Priorities​

Use priority levels consistently:

  • 1-10: Low priority inclusions
  • 11-20: Medium priority inclusions
  • 21-30: High priority inclusions
  • 31-40: Low priority exclusions
  • 41-50: Medium priority exclusions
  • 51+: High priority exclusions

3. Document Exceptions​

Always provide clear descriptions explaining why the exception exists:

$exception->setDescription('Allow support team to read customer data for troubleshooting purposes - ticket #12345');

4. Regular Audits​

Regularly review authorization exceptions to ensure they're still needed:

// Find old exceptions
$oldExceptions = $mapper->findByCriteria([
'created_at' => '<' . (new DateTime('-6 months'))->format('Y-m-d'),
'active' => true
]);

Troubleshooting​

Exception Not Working​

  1. Check if exception is active:

    $exception = $mapper->findByUuid($uuid);
    var_dump($exception->getActive());
  2. Verify priority is high enough:

    $exceptions = $service->getUserExceptions($userId);
    usort($exceptions, fn($a, $b) => $b->getPriority() <=> $a->getPriority());
  3. Check scope matching:

    $result = $exception->matches($subjectType, $subjectId, $action, $schemaUuid);

Security Considerations​

  1. Audit Trail: All exception creation/modification is logged with user information
  2. Admin Only: Only administrators should create/modify authorization exceptions
  3. Regular Review: Exceptions should be reviewed quarterly to ensure they're still appropriate
  4. Principle of Least Privilege: Use the most restrictive scope possible for each exception

Future Enhancements​

  1. Audit Logging

    • Log all authorization decisions
    • Track permission changes over time
  2. Permission Testing Tool

    • UI for testing user permissions
    • Visualize effective permissions for users/groups
  3. RBAC Analytics

    • Permission usage statistics
    • Identify over-privileged users
    • Suggest permission optimizations