Skip to main content

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

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