Permission System
Whity Core uses role-based access control (RBAC) with a two-part design: an in-memory permission catalogue (the source of truth for which permissions exist) and database tables that map roles → permissions and users → roles (the source of truth for who has what). This page is grounded in the current source; cited files are authoritative.
Related: Architecture · TENANT_ISOLATION · HOOK_SYSTEM · Plugin-Development.
The pieces
Section titled “The pieces”| Component | Responsibility | File |
|---|---|---|
PermissionRegistry |
In-memory catalogue of all known permissions, keyed by source (core or plugin name). |
src/Core/RBAC/PermissionRegistry.php |
CorePermissions |
Canonical list of built-in permissions. | src/Core/RBAC/CorePermissions.php |
RoleChecker |
Resolves whether a user has a role/permission, including hierarchy inheritance + caching. | src/Auth/RoleChecker.php |
RbacMiddleware |
Enforces a route’s required role/permission against the authoritative store. | src/Http/RbacMiddleware.php |
RolesApiHandler |
CRUD for roles + permission assignment, tenant-scoped. | src/Api/RolesApiHandler.php |
permissions, role_permissions, roles, user_roles |
Database catalogue + grants. | database/migrations/ |
Permission naming: resource:action
Section titled “Permission naming: resource:action”Permissions are strings in colon notation: resource:action. The registry validates the strict form with ^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$ (see PermissionRegistry::isValidPermission()). Examples: users:read, roles:manage, tenants:delete, plugins:read.
History: the original seeds (migrations 002 and 007) used dot notation (
users.read). Migration016_normalize_permission_notation.phprewrote them to colon notation so the stored data matches what the RBAC layer validates (issue #55). Fresh databases already seed colon notation.
The built-in set is defined as constants on CorePermissions and registered under the core source:
users:read users:write users:deleteroles:read roles:write roles:delete roles:managetenants:read tenants:write tenants:deleteous:read ous:write ous:delete ous:assignpermissions:readaudit:readplugins:read plugins:enable plugins:disable plugins:upload plugins:uninstall plugins:reloaddelegation:managerelations:read relations:manage
plugins:*(WC-218) replaced the single coarseplugins:managewith six per-action permissions so each plugin operation can be delegated independently:plugins:read(GET /api/plugins),plugins:enable(enable / re-enable),plugins:disable,plugins:upload(route lands in a later slice; permission seeded now),plugins:uninstall,plugins:reload. Migration013_grant_plugins_manage_to_adminseeds all six into the catalogue and grants them to the seededadminrole.
audit:read(WC-34) gates the read-only security audit trail (GET /api/audit-logs). Migration016_create_audit_logseeds it into thepermissionscatalogue and grants it to the seededadminrole, so administrators can read the trail out of the box. See AUDIT_TRAIL.
relations:read/relations:manage(WC-65) gate the family relations feature:relations:readcovers the read surface (relationship-type vocabulary, persons, a node’s relations) andrelations:managecovers every write (create/edit/delete a person, add/remove a relation edge). Migration020_create_relationsseeds both into thepermissionscatalogue and grants them to the seededadminrole. See RELATIONS.
PermissionRegistry — the in-memory catalogue
Section titled “PermissionRegistry — the in-memory catalogue”PermissionRegistry (src/Core/RBAC/PermissionRegistry.php) holds every permission the platform currently knows about, organized by source: the literal core for built-ins, or the plugin name for plugin permissions.
// The single registration entry point for core AND plugin sources. Every// permission is validated against the resource:action pattern; an invalid// permission throws InvalidPermissionException.$registry->register('core', CorePermissions::all());$registry->register('my-plugin', ['my_plugin:use', 'my_plugin:admin']);
// Queries$registry->exists('users:read'); // true$registry->getAll(); // ['permission' => 'source', ...]$registry->getBySource('core'); // ['users:read', ...] (per-source list)Key behaviours:
- Lazy core registration — core permissions register themselves on first read (
ensureCoreRegistered()), so validation works even without explicit bootstrap wiring (issue #55). - Plugins are the single source of truth for their own permissions — when a plugin is unloaded, its source entry disappears from the registry, so its permissions instantly stop existing. There are no orphaned permission rows to clean up.
- Worker-level state — the registry holds nothing request-specific, so it is safe to share across the requests a FrankenPHP worker serves.
- Registration dispatches a
permission.registeredhook via the optionalHookManager(plugin_id,source,permissions).
How a plugin declares permissions
Section titled “How a plugin declares permissions”Plugins declare permissions through the declarative PluginInterface::getPermissions() (sdk/src/PluginInterface.php) — there is no onEnable() method. The PluginLoader reads the array and calls PermissionRegistry::register($plugin->getName(), $plugin->getPermissions()) (PluginLoader::registerCapabilities()). A plugin that declares a permission outside the resource:action pattern is rejected with a logged warning rather than crashing the host.
final class MyPlugin implements \Whity\Sdk\PluginInterface{ public function getName(): string { return 'my-plugin'; } public function getVersion(): string { return '1.0.0'; } public function getRoutes(): array { return []; }
public function getPermissions(): array { return ['my_plugin:use', 'my_plugin:admin']; }
public function getHooks(): array { return []; } public function getMigrations(): array { return []; }}See Plugin-Development for the full plugin contract.
Database tables
Section titled “Database tables”Permission grants live in the database; the registry only governs existence.
-- permissions (migration 002): the catalogue rows the registry validates againstCREATE TABLE permissions ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, -- resource:action description TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW());
-- role_permissions (migration 002): role -> permission grants, by permission_idCREATE TABLE role_permissions ( id SERIAL PRIMARY KEY, role_id INTEGER NOT NULL REFERENCES roles(id), permission_id INTEGER NOT NULL REFERENCES permissions(id), created_at TIMESTAMP NOT NULL DEFAULT NOW(), UNIQUE(role_id, permission_id));Note:
role_permissionsreferences permissions bypermission_id(a foreign key topermissions.id), not by a permission string. Roles relate to permissions many-to-many through this junction table.
Users get roles two ways: the primary users.role_id column (migration 001), and the many-to-many user_roles junction (migration 015). Roles can inherit from a parent via roles.parent_id (migration 017), and roles are tenant-scoped via the nullable roles.tenant_id (migration 018).
RoleChecker — resolving access
Section titled “RoleChecker — resolving access”RoleChecker (src/Auth/RoleChecker.php) is the authoritative resolver. It is constructed with the Database and the PermissionRegistry.
hasPermission
Section titled “hasPermission”public function hasPermission(int $userId, string $permission, int $tenantId): boolEvery check is tenant scoped (WC-54): the user’s effective grants include roles reached through their organizational unit, which are tenant-bound, so the resolved tenant id (from TenantContext) is required.
Resolution order:
- Registry check —
if (!$this->registry->exists($permission)) return false;. An unregistered permission (e.g. one whose plugin was unloaded) can never be granted. - Effective permission set — otherwise resolve the user’s full effective permission set and test membership. The set is the union, over every effective role (see below), of that role’s hierarchy-resolved permissions. Every grant — direct, role-hierarchy-inherited, or OU-inherited — is read through the SAME real-schema join (
role_permissions.permission_id → permissions.name); there is norole_permissions.permission_stringcolumn.
Effective roles (direct + OU inheritance)
Section titled “Effective roles (direct + OU inheritance)”A user’s effective role set is the UNION of:
- Their direct role (
users.role_id). - Every role assigned to their organizational unit AND each ancestor OU — the
organizational_units.parent_idchain walked up to the root — viaou_role_assignments, filtered to the current tenant.
So a user in a child OU inherits the roles granted to every OU above it, and OU role assignments are additive (they never restrict). The OU parent-chain walk has the same visited-set cycle detection and MAX_HIERARCHY_DEPTH bound as the role hierarchy. Because the lookups are tenant scoped, an OU role assigned in tenant A can never grant anything in tenant B.
Role hierarchy + worker cache
Section titled “Role hierarchy + worker cache”getEffectivePermissionsForRole($roleId) walks up the roles.parent_id chain from the given role, unioning each role’s directly-granted permissions (resolved by joining role_permissions → permissions and reading permissions.name). A higher role inherits everything its ancestors grant (super_admin → admin → editor → viewer).
Safety:
- Cycle detection via a visited-set — a malformed loop (
A → B → A) is logged and traversal stops with the permissions collected so far. - Depth bound
MAX_HIERARCHY_DEPTH = 64— even a non-repeating-but-pathological chain cannot loop forever.
Resolved sets are memoized in two worker-level static caches: $effectivePermissionCache (per role id; a role’s resolved permissions are tenant-independent) and $effectiveUserPermissionCache (per userId:tenantId, because OU membership and OU role assignments are tenant scoped). Both hold only derived, non-request data, so they are safe across requests on a persistent worker. They must be invalidated when any authorization input changes — RoleChecker::clearCache() is called by RolesApiHandler (role/permission writes), UsersApiHandler (role re-assignment or OU-membership change) and OusApiHandler (OU role assign/remove).
Other helpers
Section titled “Other helpers”hasRoleForProfile($profileId, $role, $tenantId, ?$resourceType = null, ?$resourceId = null)— true when$roleis in the profile’s effective role set (direct role + OU/ancestor-OU roles, tenant scoped). Passing a resource type and id also consults role grants addressed at that one record (resource_role_assignments); the answer is then a superset of the tenant-wide one. It is exactlyin_array($role, getEffectiveRolesForProfile($profileId, $tenantId, $resourceType, $resourceId), true).getRoleForUser($userId)— the user’s primary (direct) role name only.getPermissionsForUser($userId)— directly-granted permissions for the user’s primary role (no inherited set).getEffectiveRolesForUser($userId, $tenantId)— the effective role-name set (direct + OU/ancestor-OU inherited).getEffectivePermissionsForUser($userId, $tenantId)— the full effective permission sethasPermission()tests against.
Enforcement at the route boundary
Section titled “Enforcement at the route boundary”RbacMiddleware (src/Http/RbacMiddleware.php) enforces a route’s requiredRole and/or requiredPermission. It runs inside the kernel’s core pipeline only for routes that declare a requirement (see Architecture for the full middleware order).
Flow:
- If the route requires neither a role nor a permission, pass through (fail-open).
- Extract the bearer token from the
Authorizationheader (oraccess_tokencookie); missing →401. - Validate the JWT via
JwtParser; invalid/expired →401. Theuser_idclaim must be an integer → else401. - Read the resolved tenant id from
TenantContext(set earlier byEnforceTenantIsolation); an unresolved tenant →401(fail closed, since OU-inherited grants cannot be evaluated without it). - If
requiredRoleis set, enforce it viaRoleChecker::hasRole($userId, $role, $tenantId). - If
requiredPermissionis set, enforce it viaRoleChecker::hasPermission($userId, $permission, $tenantId); the403body echoes the missing permission underrequired. - Attach the decoded payload as
Request::$userand call the next handler.
Security invariant: authorization is always decided against the server-side store via
RoleChecker. Role/permission claims that may appear in the JWT are never trusted for access decisions (issue #54). The core routes inpublic/index.phpare protected with the legacy role string'admin'.
Inside a handler you can still check a permission explicitly (pass the resolved tenant id):
$tenantId = TenantContext::getTenantId();if (!$roleChecker->hasPermission($request->user->user_id, 'reports:read', $tenantId)) { return Response::error('Permission denied', 403);}In-handler resolution for plugins (PermissionResolver, SDK 1.16)
Section titled “In-handler resolution for plugins (PermissionResolver, SDK 1.16)”The route-level gate is flat: it answers one question, once, before the handler runs. A handler that needs a second decision — “may this caller see the archived rows?”, “may they act on this record?” — needs resolution, not a gate.
Core handlers get RoleChecker injected directly. Plugins do not: they receive only a raw \PDO, so before SDK 1.16 the only option was to re-derive the answer in hand-written SQL. That is a security defect, not merely duplication. Real resolution is not one join — it gates on ACTIVE membership, walks the OU ancestor chain, walks the role hierarchy with cycle/depth guards, unions live (non-revoked, OU-scoped) delegations, and validates the slug against the registry. Any partial re-implementation drifts from what the middleware enforces, and the system then holds two different answers to the same authorization question.
The host therefore registers a narrow, read-only resolver in the service container under the SDK interface name:
use Whity\Sdk\Rbac\PermissionResolver;
$rbac = \Whity\app(PermissionResolver::class);
if (!$rbac->hasPermission($profileId, $tenantId, 'demo_catalog:manage')) { return Response::error('Insufficient permissions', 403);}
// One pass instead of a check per row:$caps = $rbac->effectivePermissions($profileId, $tenantId);| Method | Answers |
|---|---|
hasPermission(int $profileId, int $tenantId, string $permission, ?string $resourceType = null, ?int $resourceId = null): bool |
Does this profile effectively hold this permission in this tenant — optionally, at this record? |
hasRole(int $profileId, int $tenantId, string $role, ?string $resourceType = null, ?int $resourceId = null): bool |
Is this role in the profile’s effective role set (membership role + OU/ancestor-OU roles) — optionally, at this record? |
effectivePermissions(int $profileId, int $tenantId, ?string $resourceType = null, ?int $resourceId = null): list<string> |
The full effective permission set, tenant-wide or at one record. |
Properties that make it safe to hand to plugin code:
- Same instance the middleware uses. The implementation (
Whity\Core\RBAC\RoleCheckerPermissionResolver) wraps the delegation-awareRoleCheckerthatRbacMiddlewareenforces with, in bothpublic/index.phpand the CLI kernel. A live delegation therefore unlocks an in-handler check exactly as it unlocks a route gate. - Read only. Three question methods and nothing else — no
clearCache(), no\PDO. RegisteringRoleCheckeritself would have exposed both. It grants no authority a plugin lacks; it only lets the plugin ask the question correctly. effectivePermissions()is exactly the sethasPermission()answerstruefor.RoleChecker::getEffectivePermissionsForProfile()deliberately returns the raw set (the document designer stores arbitrary tenant-defined tags in the same column); the resolver filters it through the registry so the two methods can never disagree. Passing the raw set straight through would have recreated the very divergence the contract exists to close.- Fails closed.
\Whity\app()throws aRuntimeExceptionwhen a service is not registered, and only ever auto-instantiates a concrete class that takes no constructor parameters at all and has not declared itself host-wired (see below). It will never improvise a security service — an auto-builtRoleCheckerwith a different database handle, an empty registry or no delegation resolver would answer differently from the middleware.
Asking at one record ($resourceType / $resourceId)
Section titled “Asking at one record ($resourceType / $resourceId)”Earlier minors deliberately omitted these parameters rather than accept and discard them: authority was addressable only to a tenant or an OU, so a resource argument would have been silently ignored and the caller would have believed it held a record-scoped answer while holding the tenant-wide one — which fails open. With the polymorphic resource_role_assignments table (migration 088, issue #712 §2) they are honoured:
// "May this caller act on THIS document?" — not "anywhere in the tenant".if (!$rbac->hasPermission($profileId, $tenantId, 'hello:manage', 'hello:document', $docId)) { return Response::error('Insufficient permissions', 403);}
// Since SDK 1.22 the ROLE question narrows the same way.if ($rbac->hasRole($profileId, $tenantId, 'approver', 'hello:document', $docId)) { // …}Rules that hold for all three methods:
- Additive. Omitting the pair preserves the previous tenant-wide behaviour exactly, so every existing caller is unaffected.
- Both or neither. A type without an id (or an id without a type) collapses to no scope. Matching one column and ignoring the other would return grants from the wrong resource.
- The scoped answer is a SUPERSET of the unscoped one. A resource grant widens authority at that record; it never narrows it.
- Never a substitute for membership. Membership is gated before resource grants are consulted, so a profile with no ACTIVE membership in the tenant resolves to nothing whatever is granted at the resource — a grant cannot become a back door into a tenant.
- Unregistered types resolve to nothing.
$resourceTypemust be one the host registered (core shipsou; plugins declare their own viaPluginResourceTypesInterface, SDK 1.18). An unknown type is never a reason to widen authority.
Because a role is now askable at a record, “this profile holds role X at record A and role Y at record B” needs no change to memberships — that table’s UNIQUE(profile_id, tenant_id) still means one membership row per tenant, and per-record staffing is expressed as resource grants alongside it. Until SDK 1.22 only the permission side took the pair, so such a grant was representable in storage and resolvable by RoleChecker::getEffectiveRolesForProfile(), yet unreachable through hasRole() — which reads as a missing schema capability rather than a missing parameter.
The route-level requiredRole / requiredPermission gate stays flat and tenant-wide: it runs before the handler, with no record in hand. A record-scoped grant therefore does not open a whole route — that is the point of resolving inside the handler.
Writing a grant (/api/resource-role-grants)
Section titled “Writing a grant (/api/resource-role-grants)”Resolution shipped before any way to create what it resolves, so a consumer could ask the platform “does this profile hold this role at this record?” while still storing that authority in its own table — two sources of truth for one question, which is strictly worse than keeping one private table. Three routes close that (ResourceRoleGrantsApiHandler):
| Route | Permission | Notes |
|---|---|---|
POST /api/resource-role-grants |
roles:manage |
{resource_type, resource_id, role_id, profile_id?} |
GET /api/resource-role-grants?resource_type=T&resource_id=N |
roles:read |
both shapes in one list |
DELETE /api/resource-role-grants/{id} |
roles:manage |
id comes from the list route |
DELETE /api/resource-role-grants/all?resource_type=T&resource_id=N |
roles:manage |
the record-delete cleanup |
profile_idnullability is the meaning. Omitted ornullgrants to everyone at this resource; a value grants to that one profile here. Both are creatable, both list, and the two partial unique indexes keep them independent — repeating the everyone-grant is not satisfied by an existing profile-grant for the same role.- Idempotent, never
409. A repeat grant answers200withcreated: falseand the id of the row that already says it, mirroringPOST /api/users/{id}/memberships. A conflict would force every caller to treat “already true” as an error and hand-roll a read-before-write that races anyway. - Revoke is by grant id, not by
(resource, role, profile). Over HTTP an omittedprofile_idand an explicitnullare indistinguishable, so a tuple-addressed revoke would let a dropped parameter silently revoke the everyone-grant instead of one profile’s. resource_idis validated against the caller’s tenant. The column carries no foreign key, so this is the only thing stopping a grant addressed at another tenant’s record. Core checksoudirectly; for a plugin type the owning plugin answers therbac.resource_grant.verify_resourcefilter hook by settingexiststotrue. It fails closed — with nobody vouching, the grant is refused rather than written against an unvalidated integer, because a grant left at a stale id is silently inherited by whatever record reuses that id.- Only the tenant’s own roles and globals are grantable, so a resource grant cannot attach another tenant’s private role. A role outside that set is
404, never403, so cross-tenant role existence is never disclosed. - Gated on the existing
roles:read/roles:manage. A new permission would need a grant migration, and such a migration reaches theadminrole only — so operators running a custom administrative role would silently lose a capability their plugins depend on. - No UI ships with this. It is a platform capability consumed by plugins over HTTP, like
/api/entity-tags: a core screen would have to render a record picker per resource type, which only the owning plugin can do.
Cleaning up when the record is deleted
Section titled “Cleaning up when the record is deleted”resource_id carries no foreign key, so core is never told a record disappeared and its grants outlive it — to be inherited by whatever record reuses that id. DELETE /api/resource-role-grants/all?resource_type=T&resource_id=N is how an owner closes that hole in one call, mirroring DELETE /api/entity-tags/all. Without it the first consumer to delete such a record hand-rolls list-then-revoke-each: the loop this surface exists to eliminate, and one that leaves the resource half-cleaned if it dies midway.
- Returns the number of grants revoked. Since 0 is a success, a bare
204would leave the caller unable to tell “wiped 7 grants” from “wiped nothing because I addressed the wrong record” — the one distinction a cleanup log needs, and what makes the call verifiable. - Idempotent: no grants is
200/revoked: 0, never404. The caller is deleting a record and neither knows nor cares whether it carried grants, so the call must be safe to make unconditionally and safe to retry. That is the property the whole route exists for. - It does NOT ask the owning plugin to vouch for the resource, and this is the one place that check is deliberately absent.
POSTfails closed when nobody vouches, because writing a grant at an unvalidated id is the hazard it exists to prevent. Cleanup is the mirror image: by then the record is usually already deleted, so nobody can vouch — a fails-closed check would refuse precisely the calls that matter, and would also make cleanup impossible while the owning plugin is disabled. Nothing is granted by skipping it: theDELETEis tenant-scoped, so the blast radius is rows the caller could already have listed and revoked one id at a time, and a delete can only ever remove authority. resource_typeIS still validated, for the opposite reason. An unregistered type would answerrevoked: 0, which is indistinguishable from “that record had no grants”: the caller logs a successful cleanup that removed nothing while the real rows survive. The registry is the only thing that turns that silent no-op into a loud error. It also keeps this route’s parameters identical to the list route’s, soGETandDELETE .../allalways address the same rows.
Host-wired services (Whity\Core\Container\HostWiredService)
Section titled “Host-wired services (Whity\Core\Container\HostWiredService)”Failing closed only helps if failing is visible. PermissionRegistry is concrete and its single constructor argument is optional, so the container’s auto-instantiation fallback happily built a fresh, empty one: \Whity\app(PermissionRegistry::class)->exists('some_plugin:manage') answered false for a permission the plugin had declared and the loader had accepted, with nothing thrown, warned or logged. The caller denied access and there was nothing to diagnose from.
Constructor shape was the wrong test. The property that matters is whether an empty instance is distinguishable from a legitimate one — and for a registry it never is: “no permissions registered”, “no probes contributed”, “no handler for this job”, “no transport for this channel” are all ordinary answers. So the classes say so themselves:
final class MyRegistry implements \Whity\Core\Container\HostWiredService {}A class carrying that marker is never auto-instantiated, whatever its constructor looks like; an unregistered lookup raises the documented, catchable RuntimeException naming the class. It carries no methods and no behaviour.
Marked today: PermissionRegistry, ResourceTypeRegistry, HealthProbeRegistry, TableOwnershipRegistry, DataTypeRegistry, JobRegistry, TransportRegistry, PromptRegistry, LanguageRegistry. A convention test (Tests\Core\Container\HostWiredRegistryConventionTest) fails if a new stateful *Registry in src/ is added without it.
The marker is the safety net, not the fix: a host that fills a registry must register the populated instance in both entry points — public/index.php and BaseCommand::setupKernel(). A registry wired in only one of them means the same plugin, reached over HTTP and through a CLI command, disagrees about what exists (the divergence behind #717 and #724).
Roles API + tenant scoping
Section titled “Roles API + tenant scoping”RolesApiHandler (src/Api/RolesApiHandler.php) provides full role CRUD, scoped by the nullable roles.tenant_id column (migration 018):
tenant_id IS NULL→ global/system role, visible to all tenants (the seededadminid 1 anduserid 2 are global).- non-NULL
tenant_id→ tenant-owned custom role, isolated to its owner.
Visibility rules:
- Read (
GET /api/roles,/api/roles/{id},/api/roles/{id}/permissions): a tenant seesWHERE (r.tenant_id = ? OR r.tenant_id IS NULL); the system tenant (id 0) sees every role. - Write (
PATCH/DELETE /api/roles/{id}): a tenant may modify only its own roles; a global (NULL) base role returns404for a tenant and is manageable only by the system tenant. - Create (
POST /api/roles): stamps the new role with the current tenant id, unless a system-tenant (0) caller names another owner — see below. A role with active user assignments cannot be deleted (409). - Every list/detail row carries two independent server-computed booleans:
manageable(may THIS caller write the row) andglobal(is this a NULL-tenant role shared by every tenant). They are not interchangeable — for the system tenantmanageableis true for every role — and the admin UI gates its Edit/Delete actions on the first while marking rows and warning about blast radius with the second. The raw owningtenant_idis never returned.
Creating a role for another tenant, or for everyone (#888)
Section titled “Creating a role for another tenant, or for everyone (#888)”The platform is administered from the system tenant, so deriving the owner from the caller made every operator-created role a tenant-0 role — owned by the system tenant, and therefore invisible to every other tenant. Two optional fields fix that, honoured only for a tenant-0 caller:
| Body | Owner written | Meaning |
|---|---|---|
| neither field | TenantContext::getTenantId() |
Unchanged behaviour; every pre-existing client. |
"tenant_id": 7 |
7 |
A role owned by tenant 7. |
"global": true |
NULL |
A global base role every tenant sees. |
POST /api/v1/roles{ "name": "Ward Supervisor", "description": "Runs a ward", "permissions": [3, "users:read"], "tenant_id": 7}Rules, and the reasons for them:
- Either field sent by a non-system caller is a
403, not a silent ignore — a field accepted and discarded teaches the caller it worked. tenant_idis integer-only (a digit string is accepted).nulland""are a400, deliberately not a synonym for absent: ownership has three states, and over HTTP an omitted field and an explicit null are not reliably distinguishable by clients, so overloadingnullfor “global” would make the target tenant depend on a JSON serialiser’s habits.global: trueis the separate, unmistakable form. (This is a deliberate divergence from the more toleranttenant_idonPOST /api/users/{id}/memberships, which has no third state to confuse.)- Sending both is a
400rather than a precedence rule. - A named tenant that does not exist is a
404, not a403— consistent with how an invisible role is answered elsewhere in the handler. - Name uniqueness is checked in the target namespace: tenant 7’s own names plus the global base names it inherits, or — for a
globalcreate — the global namespace alone, so a tenant’s private role name cannot block naming a base role. - The response echoes the resolved owner as
tenantId(null⇒ global) andglobal. - Ownership is settled at create and never moves:
PATCHaccepts no tenant field.
Assigning permissions (ids OR names)
Section titled “Assigning permissions (ids OR names)”Create and update accept the assigned permissions under the canonical permissions key. Each entry may be either a numeric permissions.id (the form the web UI sends — its checkboxes come from GET /api/permissions, which returns {id, name, ...}) or a resource:action name string; mixed arrays are accepted (RolesApiHandler::resolvePermissionIds()):
POST /api/roles{ "name": "editor", "description": "Content editors", "permissions": [3, "posts:read", "posts:write"]}Ids are validated against the catalogue; names are resolved to ids via permissions.name. Unknown ids/names are dropped, never fabricated, before being linked through role_permissions (which references permissions by id). Every mutating write calls RoleChecker::clearCache() so RBAC checks never go stale.
Permission delegation (WC-34)
Section titled “Permission delegation (WC-34)”Delegation lets a role-holder grant a SUBSET of their OWN effective permissions to a role or a user, tenant- and optionally OU-scoped, with a revocable lifecycle. It layers on top of the RBAC resolution above without replacing it.
| Component | Responsibility | File |
|---|---|---|
permission_delegations |
Stores each delegated permission (one row per permission). | database/migrations/014_create_permission_delegations.php |
DelegationRepository |
All tenant-scoped SQL for delegations (insert/list/find/revoke + resolution lookup). | src/Core/Delegation/DelegationRepository.php |
DelegationService |
Enforces the subset invariant on create; resolves live delegated permissions for RoleChecker. |
src/Core/Delegation/DelegationService.php |
DelegationsApiHandler |
RBAC-protected API (create/list/revoke), gated on delegation:manage. |
src/Api/DelegationsApiHandler.php |
PermissionNotDelegableException |
Typed domain error for a subset-invariant violation → 422. |
src/Api/Exception/PermissionNotDelegableException.php |
Storage model
Section titled “Storage model”CREATE TABLE permission_delegations ( id SERIAL PRIMARY KEY, tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, grantor_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, grantee_type VARCHAR(16) NOT NULL, -- 'role' | 'user' (CHECK-constrained) grantee_id INTEGER NOT NULL, permission VARCHAR(255) NOT NULL, -- a resource:action string ou_id INTEGER NULL REFERENCES organizational_units(id) ON DELETE CASCADE, granted_at TIMESTAMP NOT NULL DEFAULT NOW(), revoked_at TIMESTAMP NULL, -- NULL = LIVE; non-destructive revoke CONSTRAINT chk_permission_delegations_grantee_type CHECK (grantee_type IN ('role','user')));- Polymorphic grantee — modelled as a discriminator + id pair (
grantee_type+grantee_id) with a CHECK pinning the type torole|user. This keeps resolution a single equality match (no “exactly one of two FK columns” gymnastics). - One row per permission — each delegated permission is an independent, individually-revocable grant.
- Lifecycle — a delegation is LIVE while
revoked_at IS NULL; revocation is non-destructive (it stampsrevoked_at) so the historical grant survives for the audit trail. - Indexing —
idx_pd_resolution(tenant_id, grantee_type, grantee_id, revoked_at)covers the hot resolution lookup; secondary indexes cover listing by grantor and by OU.
The HARD subset invariant
Section titled “The HARD subset invariant”A grantor can NEVER delegate a permission they do not themselves currently hold.
Enforced server-side, always, in DelegationService::delegate(): it computes the grantor’s effective permission set via RoleChecker::getEffectivePermissionsForUser() (direct role + role hierarchy + OU inheritance) and rejects any requested permission outside that set — or any permission not registered in the PermissionRegistry — by throwing PermissionNotDelegableException. The handler translates that into a safe 422 and writes no row; the internal reason (which permissions were denied) is logged, never leaked.
The delegation:manage permission gates who may manage delegations (the API route); it never widens what a grantor may delegate. To avoid transitive re-delegation escalation, the grantor’s delegable set is their BASE RBAC effective set — the RoleChecker the service uses to bound a grantor is deliberately not delegation-aware, so “you can only delegate what RBAC grants you, never what was delegated TO you”.
How delegated permissions enter resolution
Section titled “How delegated permissions enter resolution”RoleChecker takes an optional DelegatedPermissionResolver (implemented by DelegationService). When wired (as it is in public/index.php), getEffectivePermissionsForUser() unions the user’s base effective permissions with the live delegated permissions resolved for them, so a non-revoked delegation actually makes hasPermission() return true:
effective = (direct role + hierarchy + OU roles) ∪ (live delegations to the user) ∪ (live delegations to any of the user's effective roles)Delegated grants are resolved tenant-scoped and OU-scoped:
- A user-targeted delegation reaches that user; a role-targeted delegation reaches every user whose effective role set contains that role.
- OU scope: a delegation with
ou_id IS NULLapplies tenant-wide; a delegation scoped to OU X applies only to grantees whose OU is X or a descendant of X (resolved from the user’s OU + ancestor chain, mirroring OU role inheritance). - Cache: delegated grants flow through the same per-user worker cache as RBAC, so create/revoke call
RoleChecker::clearCache()to avoid serving a stale resolved set.
All routes are gated on delegation:manage and are tenant-scoped:
POST /api/delegations—{granteeType: 'role'|'user', granteeId, permissions: string[], ouId?: int|null}. Returns201(one row per permission) or422when the subset invariant is violated;404when the grantee/OU is not visible to the tenant.GET /api/delegations— list with optionalgranteeType/granteeId/grantorUserId/includeRevokedfilters.DELETE /api/delegations/{id}— non-destructive revoke;404when not found / not visible / already revoked.
Deleted/unloaded plugins: automatic denial
Section titled “Deleted/unloaded plugins: automatic denial”Because step 1 of hasPermission() consults the registry, removing a plugin instantly denies its permissions with no DB cleanup:
- Before: the plugin’s
getPermissions()are in the registry; granted users pass. - The plugin is unloaded / hot-reloaded away →
PluginLoader::unregisterAll()drops it, and its source entry leaves the registry. - After:
registry->exists('my_plugin:use')isfalse, sohasPermission()returnsfalseimmediately even though arole_permissionsrow may still exist.
Summary
Section titled “Summary”- Permissions are
resource:actionstrings;PermissionRegistry(in-memory) decides which exist,role_permissions(DB) decides which are granted. CorePermissionsis the canonical built-in set, registered under thecoresource.- Plugins declare permissions via
PluginInterface::getPermissions(); thePluginLoaderregisters them. Unloading a plugin removes them instantly. RoleCheckerresolves access: registry existence → direct grant → hierarchy inheritance, with cycle/depth-safe traversal and a worker-level cache invalidated on writes.RbacMiddlewareenforces route requirements against the authoritative store and never trusts JWT role/permission claims.RolesApiHandleris tenant-scoped viaroles.tenant_id(NULL = global), and accepts permission ids or names.- Delegation (WC-34) lets a role-holder grant a SUBSET of their own effective permissions to a role or user, tenant/OU-scoped and revocable. The HARD invariant — you can never delegate a permission you do not hold — is enforced server-side in
DelegationService, and live delegations enterhasPermission()resolution through theDelegatedPermissionResolverwired intoRoleChecker. - Plugins resolve through the host, not through their own SQL:
\Whity\app(\Whity\Sdk\Rbac\PermissionResolver::class)returns a read-only facade over the same delegation-awareRoleCheckerthe middleware enforces with (SDK 1.16, WC-712).