Roles and permissions¶
naf/rbac stores roles and grants in a database and provides administration fragments.
Applications and plugins declare available permissions in code. The privilege policy controls
which accounts can manage roles or grant access.
Auth verifies an identity and asks what it may do. RBAC supplies editable roles and stored assignments for that identity; it does not replace the account provider or sign-in flow. Use Auth alone when application code can supply fixed grants. Add RBAC when administrators need to compose roles and assign them globally or within a project, team or tenant.
Install the package, configure a database, run its migrations and synchronize declared roles.
The migration and synchronization commands also require naf/cli.
composer require naf/cli
vendor/bin/naf db:migrate up
vendor/bin/naf rbac:sync
For MySQL 8.x, use naf/rbac 0.1.3+; older releases do not quote the system column.
Required packages
In an existing NAF application:
composer require naf/rbac
Also installs these transitive dependencies: naf/auth, naf/database.
Declaring what can be granted¶
In your plugin's bootstrap.php:
use Naf\Rbac\Definition\PermissionDefinition;
use Naf\Rbac\Definition\RoleDefinition;
use function Naf\Rbac\permissions;
use function Naf\Rbac\roles;
permissions()->add(
new PermissionDefinition('users.view', 'See users', '', 'Users', 10),
new PermissionDefinition('users.invite', 'Invite users', '', 'Users', 20),
);
roles()->add(new RoleDefinition(
'admin',
'Administrator',
'Runs this installation.',
['rbac.manage', 'users.view', 'users.invite'],
));
A declared permission is only offerable. It never grants itself to an existing role, so installing a package cannot widen anybody's access.
Integrate identity grants¶
Your user model already implements Naf\Auth\Identity\UserInterface. Point its two grant
methods here and everything downstream — auth()->can(), requirePermission() — works
unchanged:
Import Naf\Rbac\rbac with use function in the user model's file.
public function getRoles(): iterable
{
return rbac()->rolesOf((int) $this->getId());
}
public function getPermissions(): iterable
{
return rbac()->permissionsOf((int) $this->getId());
}
Both are read once per request and cached. Call rbac()->forget() after a change that a
later part of the same request will read back.
PDO accounts without a user model¶
With Auth's DatabaseProvider, the application's identity factory can supply stored grants
directly. After configuring PDO, running the RBAC migrations and synchronizing roles, add
these entries to app/config.php:
use Naf\Auth\Identity\Identity;
use Naf\Auth\Provider\DatabaseProvider;
use function Naf\Rbac\rbac;
return ['auth' => [
'providers' => ['database' => DatabaseProvider::class],
'database' => ['identity_factory' => static fn(array $row): Identity => new Identity(
(string) $row['id'],
roles: rbac()->rolesOf((int) $row['id']),
permissions: rbac()->permissionsOf((int) $row['id']),
)],
]];
This is a fragment to merge into the existing configuration, not a replacement for its
database and session settings. It uses Auth's existing PDO provider and integer application
account IDs. The provider reloads the identity on authenticated requests, so saved grants
affect the next request. Return null from the factory when an application-owned account
rule forbids sign-in, as described in Authentication.
Registration remains application code. Assign its intended initial role in the same PDO transaction as account creation, and never accept a role or permission from public signup input. Initialize administration through a trusted setup command; being the first registrant does not itself establish authority. The application must also check that a grant target is an existing account it permits administrators to manage: RBAC deliberately does not own the host's user table.
Privilege policy¶
PrivilegePolicy enforces the following administration rules:
- Administration requires
rbac.manage. - You can grant only permissions you hold. A role used to administer all permissions must therefore hold all permissions it may grant.
- You cannot manage an account holding permissions you lack. Removing its roles first does not bypass the grant restriction.
- The last role carrying
rbac.managecannot be emptied or deleted. - Changing your own roles also requires
rbac.manage.own. Grant and lockout restrictions still apply; this permission does not allow self-escalation.
Which permission counts for rules 2 and 4 is rbac:lockout_permission, so a host may call its
top role whatever it likes and may have several that qualify.
Every denial throws PrivilegedActionDenied with a stable reason for logs and tests, and a
message for the person who tried.
Scoped grants¶
A grant is a person, a role, and where it applies:
use Naf\Rbac\Scope;
use function Naf\Rbac\rbac;
$rbac = rbac();
$rbac->assignments->assign(7, [$adminId]); // everywhere
$rbac->assignments->assign(7, [$maintainerId], Scope::allOf('project')); // on every board
$rbac->assignments->assign(7, [$memberId], Scope::of('project', 5)); // on board 5
Asking is the same shape:
$rbac->allows(7, 'board.settings', Scope::of('project', 5));
Three spellings reach a place — everywhere, every instance of its kind, and that instance —
and Scope::covers() is the only thing that decides which.
The variables above are IDs of existing stored roles. Resolve those roles in the application before assigning them; a role definition object is not a stored role ID.
An installation-wide grant covers every board. Use distinct permission names when global administration and board-specific access must be separate capabilities.
A role says where it may be handed out at all, so an installation-wide role cannot be granted on one board and a board role cannot be granted installation-wide:
use Naf\Rbac\Definition\RoleDefinition;
new RoleDefinition('admin', 'Administrator', permissions: ['rbac.manage']);
new RoleDefinition('maintainer', 'Maintainer', permissions: ['board.settings'], scopeType: 'project');
Editing a role is deliberately not scoped. A role is one object held in many places, so changing what it carries reaches all of them — that is authority over the installation, not over a board, and the policy asks for it accordingly.
Register a scope source¶
The package knows a grant can attach to something, and nothing about what. A host registers one source per kind, and every screen offers it from then on:
use Naf\Rbac\Contracts\ScopeSourceInterface;
use function Naf\Rbac\roles;
final class Boards implements ScopeSourceInterface
{
/** @param array<string, string> $names */
public function __construct(private array $names) {}
public function type(): string { return 'project'; }
public function label(): string { return 'Boards'; }
/** @return array<string,string> */
public function instances(): array
{
return $this->names;
}
}
roles()->scope(new Boards(['7' => 'Example board', '12' => 'Documentation']));
Teams, tenants, single tickets: another registration, no change in here. The type is a string
the host chooses, and Scope::of('team', 'design') works the moment something answers for
team.
instances() is called while a screen renders, so it may return only what the person granting
is allowed to see. An empty list hides the kind.
This example supplies a fixed map so the whole contract is visible. In an application,
inject your board repository through an explicit constructor and query it in instances().
Filter its result for the current administrator before returning the ID-to-label map.
Synchronize declared roles¶
Synchronization preserves changes made to stored roles. New declarations do not overwrite an administrator's customized grants automatically.
Run rbac:sync after installing a package that declares roles or changing those declarations.
New role declarations can be added, while existing roles remain under administrator control.
rbac:sync reports differences between stored roles and their current declarations,
including newly declared permissions:
6 declared role(s); nothing to add
24 permission(s) declared across 6 group(s).
owner is missing delete, export
manager is missing export
The roles above no longer carry everything their packages declare. …
Review reported differences and grant permissions through the role editor. To restore all
declared roles to their package definitions, run rbac:sync --reapply; this discards local
changes to those roles.
Only declared roles are compared, and each only against its own declaration. A permission no role carries at all is ordinary, because packages ship permissions meant for roles an installation builds itself.
Administration fragments¶
The administration views are fragments intended for the host's settings layout.
The fragments use View, Form/Session and i18n helpers. Install these optional UI dependencies in the host before rendering them:
composer require naf/view naf/form naf/i18n
Configure the host database and authenticated identity as described above. Include the
fragments from a PHP template with Naf\View\view:
<?php use function Naf\View\view; ?>
<?= view('rbac/roles', ['return' => '/admin/settings']) ?>
<?= view('rbac/grants', ['user' => 12, 'name' => 'Alice', 'return' => '/admin/users/12']) ?>
rbac/roles edits what roles carry. rbac/grants edits what one person holds and where —
every place in one form, submitted at once, because a person's access is one decision and not
a series of them.
The host supplies the account being managed. RBAC does not list, create or deactivate accounts.
RBAC 0.1.4+ renders the role and permission checkboxes as native inputs with switch semantics. Disclosure carets indicate whether role and grant groups are open. Keyboard activation, checked and disabled states, and submitted field names follow native form behavior.
Reduced-motion preferences disable control, thumb and caret transitions. Forced-color mode restores the browser's native checkbox rendering.
src/Resources/public/assets/rbac.css styles both through custom properties with neutral
defaults, so mapping them to a host's design is one block:
.rbac-roles, .rbac-grants { --rbac-line: var(--line); --rbac-accent: var(--accent); }
Set --rbac-switch-thumb to a CSS color to customize the switch thumb; its default is
the system Canvas color.
Set rbac:path to null to register no endpoints and drive the services from screens of your
own.
Grant change events¶
A grant change is dispatched as an event, so a host that keeps a history can record it:
use Naf\Rbac\Events\GrantsChanged;
use function Naf\event;
event()->listen(GrantsChanged::class, function (GrantsChanged $moved): void {
// actorId, targetId, scope, before, after
});
One event is dispatched for each grant change. Record audit history in an application listener when the product requires it.
Application responsibilities¶
Users. Who exists, inviting them, deactivating them: that is the host's, and this package only says what they may do.
Permission definitions in the database. Permissions come from code. One that an administrator typed into a form grants nothing, because nothing checks it — a row that looks like power and is not. Roles are what an installation composes freely, from the vocabulary its packages declare.