Skip to main content

RBAC and Multi-Tenancy Implementation

This guide explains how to implement multi-tenancy and Role-Based Access Control (RBAC) in OpenRegister mappers using the 'MultiTenancyTrait'.

Overview​

OpenRegister uses a trait-based approach to provide consistent multi-tenancy and RBAC functionality across all mappers. This ensures that:

  • Users only see data from their active organisation
  • Permissions are checked before CRUD operations
  • Code duplication is minimized
  • Security is enforced at the database layer

Architecture​

Components​

  1. MultiTenancyTrait ('lib/Db/MultiTenancyTrait.php'): Reusable trait providing:

    • Organisation filtering on reads
    • Auto-set organisation on creates
    • Organisation verification on updates/deletes
    • RBAC permission checking
  2. OrganisationService ('lib/Service/OrganisationService.php'): Manages:

    • Active organisation in user session
    • Organisation membership
    • Default organisation
  3. Organisation Entity ('lib/Db/Organisation.php'): Contains:

    • RBAC roles configuration
    • User membership
    • Permission definitions

Implementation Steps​

Step 1: Add Organisation Property to Entity​

Each entity must have an 'organisation' property storing the organisation UUID:

<?php
namespace OCA\OpenRegister\Db;

use OCP\AppFramework\Db\Entity;

/**
* @method string|null getOrganisation()
* @method void setOrganisation(?string $organisation)
*/
class YourEntity extends Entity
{
/**
* Organisation UUID this entity belongs to
*
* @var string|null
*/
protected ?string $organisation = null;

public function __construct() {
$this->addType('organisation', 'string');
}
}

Step 2: Add Database Column​

Create a migration to add the 'organisation' column:

<?php
namespace OCA\OpenRegister\Migration;

use OCP\DB\ISchemaWrapper;
use OCP\Migration\SimpleMigrationStep;
use OCP\Migration\IOutput;

class VersionXDateYYYYMMDDHHIISS extends SimpleMigrationStep
{
public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('openregister_your_table')) {
$table = $schema->getTable('openregister_your_table');

if (!$table->hasColumn('organisation')) {
$table->addColumn('organisation', 'string', [
'notnull' => false,
'length' => 255,
'default' => null,
]);

// Add index for faster filtering
$table->addIndex(['organisation'], 'your_table_organisation_idx');
}
}

return $schema;
}
}

Step 3: Update Mapper to Use Trait​

<?php
namespace OCA\OpenRegister\Db;

use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
use OCP\IUserSession;
use OCP\IGroupManager;
use OCP\IAppConfig;
use OCA\OpenRegister\Service\OrganisationService;
use Psr\Log\LoggerInterface;

class YourMapper extends QBMapper
{
use MultiTenancyTrait;

private OrganisationService $organisationService;
private IUserSession $userSession;
private IGroupManager $groupManager;

// Optional: Define these properties if you need advanced multi-tenancy features.
// The trait does not declare these to avoid conflicts.
private IAppConfig $appConfig;
private LoggerInterface $logger;

public function __construct(
IDBConnection $db,
OrganisationService $organisationService,
IUserSession $userSession,
IGroupManager $groupManager,
IAppConfig $appConfig = null,
LoggerInterface $logger = null
) {
parent::__construct($db, 'openregister_your_table', YourEntity::class);
$this->organisationService = $organisationService;
$this->userSession = $userSession;
$this->groupManager = $groupManager;
$this->appConfig = $appConfig;
$this->logger = $logger;
}

public function insert(Entity $entity): Entity
{
// Verify RBAC permission to create
$this->verifyRbacPermission('create', 'your_entity_type');

// Auto-set organisation from active session
$this->setOrganisationOnCreate($entity);

return parent::insert($entity);
}

public function update(Entity $entity): Entity
{
// Verify RBAC permission to update
$this->verifyRbacPermission('update', 'your_entity_type');

// Verify user has access to this organisation
$this->verifyOrganisationAccess($entity);

return parent::update($entity);
}

public function delete(Entity $entity): Entity
{
// Verify RBAC permission to delete
$this->verifyRbacPermission('delete', 'your_entity_type');

// Verify user has access to this organisation
$this->verifyOrganisationAccess($entity);

return parent::delete($entity);
}

public function find(int $id): YourEntity
{
// Verify RBAC permission to read
$this->verifyRbacPermission('read', 'your_entity_type');

$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->tableName)
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));

// Apply organisation filter (all users including admins must have active org)
$this->applyOrganisationFilter($qb);

return $this->findEntity($qb);
}

public function findAll(int $limit = 50, int $offset = 0): array
{
// Verify RBAC permission to read
$this->verifyRbacPermission('read', 'your_entity_type');

$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->tableName)
->setMaxResults($limit)
->setFirstResult($offset)
->orderBy('created', 'DESC');

// Apply organisation filter
$this->applyOrganisationFilter($qb);

return $this->findEntities($qb);
}
}

RBAC Configuration​

Organisation Roles Structure​

Organisations store RBAC configuration in their 'roles' JSON field:

{
"admin": {
"name": "Administrator",
"permissions": {
"*": ["*"]
}
},
"editor": {
"name": "Editor",
"permissions": {
"schema": ["create", "read", "update"],
"register": ["create", "read", "update"],
"configuration": ["read"]
}
},
"viewer": {
"name": "Viewer",
"permissions": {
"schema": ["read"],
"register": ["read"],
"configuration": ["read"]
}
}
}

Entity Types​

Supported entity types for RBAC:

  • 'schema'
  • 'register'
  • 'configuration'
  • 'application'
  • 'agent'
  • 'view'
  • 'source'
  • 'organisation'

Actions​

Supported CRUD actions:

  • 'create'
  • 'read'
  • 'update'
  • 'delete'

Wildcard '*' grants all permissions.

Trait Methods Reference​

getActiveOrganisationUuid()​

Gets the active organisation UUID from the session.

Returns: 'string|null'

getCurrentUserId()​

Gets the current logged-in user ID.

Returns: 'string|null'

isCurrentUserAdmin()​

Checks if the current user is in the admin group.

Returns: 'bool'

applyOrganisationFilter()​

Applies organisation filtering to a query builder.

Parameters:

  • '$qb': The query builder to modify
  • '$columnName': The column name for organisation (default: 'organisation')
  • '$allowNullOrg': Whether to include entities with null organisation
  • '$tableAlias': Optional table alias for published/depublished columns
  • '$enablePublished': Whether to enable published entity bypass (default: false)
  • '$multiTenancyEnabled': Whether multitenancy is enabled (default: true)

Behavior:

  • All users (including admins) see entities from their active organisation AND parent organisations
  • Children can see ALL items from parent organisations (including depublished items)
  • Users can see their own organisation's depublished items
  • Published entities can bypass organisation filtering if 'publishedObjectsBypassMultiTenancy' is enabled in config
  • Depublished entities from OTHER organisations are excluded from published bypass
  • Admins must set an active organisation to access data

setOrganisationOnCreate()​

Auto-sets the organisation UUID on entity creation.

Usage: Call in 'insert()' method before 'parent::insert()'

verifyOrganisationAccess()​

Verifies that the entity belongs to the active organisation.

Throws: '\Exception' if organisation doesn't match (HTTP 403)

Usage: Call in 'update()' and 'delete()' methods

hasRbacPermission()​

Checks if the current user has RBAC permission.

Returns: 'bool'

verifyRbacPermission()​

Verifies RBAC permission and throws exception if denied.

Throws: '\Exception' if permission denied (HTTP 403)

Usage: Call at the start of CRUD methods

Best Practices​

1. Always Inject Dependencies​

Ensure your mapper constructor injects required dependencies:

  • 'OrganisationService' (required)
  • 'IUserSession' (required)
  • 'IGroupManager' (required)

Optional dependencies for advanced features:

  • 'IAppConfig' - For multitenancy config settings (published bypass, admin override, etc.)
  • 'LoggerInterface' - For debug logging

Note: The 'MultiTenancyTrait' does not declare the '$appConfig' and '$logger' properties to avoid conflicts. Classes using the trait should declare these properties themselves if needed. The trait methods check 'isset()' before using them.

2. Apply Organisation Filter on All Reads​

Apply 'applyOrganisationFilter()' to all query builders that fetch data.

3. Verify Permissions on All Operations​

Call 'verifyRbacPermission()' at the start of:

  • 'insert()' → 'create'
  • 'find()'/'findAll()' → 'read'
  • 'update()' → 'update'
  • 'delete()' → 'delete'

4. Verify Organisation on Modifications​

Call 'verifyOrganisationAccess()' in:

  • 'update()'
  • 'delete()'

5. Auto-Set Organisation on Create​

Call 'setOrganisationOnCreate()' in 'insert()' before 'parent::insert()'.

6. Handle Exceptions in Controllers​

Wrap mapper calls in try-catch blocks:

try {
$entity = $this->mapper->update($entity);
return new JSONResponse($entity, Response::HTTP_OK);
} catch (\Exception $e) {
if ($e->getCode() === Response::HTTP_FORBIDDEN) {
return new JSONResponse(['error' => $e->getMessage()], Response::HTTP_FORBIDDEN);
}
return new JSONResponse(['error' => 'Internal error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}

7. Admin Privileges​

Admins (users in the 'admin' group) have special privileges:

  • RBAC Bypass: Admins bypass all RBAC permission checks (create, read, update, delete)
  • Organisation Access: Admins can see ALL organisations and set ANY organisation as active
  • Data Filtering: Once an admin sets an active organisation, they see data from that organisation AND its parent organisations
  • NULL Org Access: Admins can access entities with NULL organisation (legacy data) if '$allowNullOrg' is true

This ensures admins work within an organisational context while having full permissions within that context.

8. Organisation Hierarchy​

OpenRegister supports hierarchical organisation structures:

  • Parent-Child Relationships: Organisations can have parent organisations
  • Child Access: Children can see ALL items from parent organisations (including depublished items)
  • Own Organisation: Users can see ALL items from their own organisation (including depublished items)
  • Published Bypass: Published (and not depublished) items from ANY organisation are visible if 'publishedObjectsBypassMultiTenancy' is enabled

9. Published/Depublished Entities​

Entities (objects, schemas, registers) can have 'published' and 'depublished' date fields:

  • Published Entities: Can bypass organisation filtering if 'publishedObjectsBypassMultiTenancy' is enabled in config
  • Depublished Entities: Are excluded from published bypass (not visible from other organisations)
  • Own Organisation: Users can always see depublished items from their own organisation
  • Parent Organisations: Children can always see depublished items from parent organisations

10. Default Organisation​

Users without organisations are automatically added to the default organisation on first access.

Testing​

Test Cases to Cover​

  1. Create: Entity gets organisation UUID set automatically (from active org)
  2. Read (Admin with Active Org): Admin sees only data from active organisation
  3. Read (User): User sees only data from their active organisation
  4. Update (Same Org): Succeeds (admin bypasses RBAC, user needs permission)
  5. Update (Different Org): Fails with 403 (for both admin and user)
  6. Delete (Same Org): Succeeds
  7. Delete (Different Org): Fails with 403
  8. RBAC Create: Only users with 'create' permission can create
  9. RBAC Read: Only users with 'read' permission can read
  10. RBAC Update: Only users with 'update' permission can update
  11. RBAC Delete: Only users with 'delete' permission can delete

Troubleshooting​

Issue: Organisation filter not applied​

Cause: Missing 'applyOrganisationFilter()' call in query builder

Solution: Add '$this->applyOrganisationFilter($qb)' to all find methods

Issue: Users can't see their own entities​

Cause: Organisation UUID not set on entity creation

Solution: Ensure 'setOrganisationOnCreate()' is called in 'insert()'

Issue: Admin can't access entities​

Cause: Admin doesn't have an active organisation set

Solution: Admins must set an active organisation to access data. Check that 'OrganisationService.getActiveOrganisation()' returns a valid organisation.

Issue: RBAC always denies access​

Cause: Roles not configured in organisation or incorrect structure

Solution: Check organisation's 'roles' field has proper structure