drago-ex / permission
Lightweight ACL and role management.
Requires
- php: >=8.3 <9
- nette/application: ^3.2
- nette/di: ^3.1
- nette/http: ^3.3
- nette/robot-loader: ^4.1
- nette/security: ^3.1
Requires (Dev)
- nette/bootstrap: ^3.1
- nette/tester: ^2.5
- phpstan/phpstan: ^2.0
- phpstan/phpstan-nette: ^2.0
- tracy/tracy: ^2.10
Suggests
- tracy/tracy: For the role switch panel in the Tracy bar.
Provides
None
Conflicts
None
Replaces
None
README
Lightweight ACL and role management. The package provides a central ACL factory, modular permission registration per module, and automatic authorization checks in presenters.
Requirements
- PHP >= 8.3, < 9
- Nette Framework
- Composer
Installation
composer require drago-ex/permission
Features
- Central ACL creation
- Modular permission providers per module
- Default roles: guest, user, admin
- Automatic presenter authorization
- Action and signal based privileges
- Role switch panel for Tracy
Roles
Default roles:
- guest
- user (inherits from guest)
- admin (inherits from user)
Roles are registered automatically.
Permission Factory
PermissionFactory creates a Nette\Security\Permission instance, registers default roles, and runs all registered permission providers.
Providers are collected via DI tags.
Permission Providers
Each module registers its own permissions using a Provider implementation.
Providers:
- register ACL resources
- define allow / deny rules
- live inside the module they belong to
Typical resource naming:
- Backend:Sign
- Frontend:Article
Example provider for a Sign module:
use Drago\Permission\Provider; use Drago\Permission\Role; use Nette\Security\Permission; final class PermissionProvider implements Provider { private const string Resource = 'Backend:Sign'; public function register(Permission $acl): void { $acl->addResource(self::Resource); $acl->allow(Role::RoleGuest, self::Resource); } }
This registers the Backend:Sign resource and grants access to guests (unauthenticated users),
which is the minimum required for the login page to be accessible.
Permission Generator (CLI)
The package provides a generator for module providers:
vendor/bin/create-permission
General usage:
vendor/bin/create-permission <ClassName> <Namespace> [Resource] [OutputDir] [options]
When you use the shared PermissionProvider class name, pass the resource and output directory explicitly.
The class name is intentionally the same in every module and the namespace decides where the provider belongs.
Example for a Sign module:
vendor/bin/create-permission PermissionProvider App\Presentation\Sign Sign app/Presentation/Sign
Example for an Admin module:
vendor/bin/create-permission PermissionProvider App\Presentation\Backend\Admin Backend:Admin app/Presentation/Backend/Admin --allow-role=RoleAdmin --allow-with-resource=0
Options
--allow-role=<RoleConstant>defaultRoleGuest--allow-privilege=<string>optional privilege argument forallow()--add-resource=<0|1>default1(generate$acl->addResource(...))--allow-with-resource=<0|1>default1(generateallow(..., self::Resource))--allow=<rule>repeatable, custom allow rules--forceoverwrite existing file
--allow formats:
--allow=RoleAdmin--allow=RoleUser,self::Resource--allow=RoleUser,self::Resource,default
Multi-rule example:
vendor/bin/create-permission PermissionProvider App\Presentation\Backend\Admin Backend:Admin app/Presentation/Backend/Admin --allow=RoleAdmin --allow=RoleUser,self::Resource,default
Generated register() example:
public function register(Permission $acl): void { $acl->addResource(self::Resource); $acl->allow(Role::RoleAdmin); $acl->allow(Role::RoleUser, self::Resource, 'default'); }
Providers of Packages
A package that needs providers ships a script that calls create-permission with its arguments and lists it
in composer.json; vendor/bin/drago-commands of drago-ex/project-tools
runs the scripts of all installed packages:
"extra": { "drago-tools": { "commands": ["bin/create-front-permission"] } }
DI Configuration
The package contains a ready configuration in vendor/drago-ex/permission/src/Permission/conf.neon.
It is not loaded automatically; include it in your configuration, or copy its content:
includes: - ../vendor/drago-ex/permission/src/Permission/conf.neon
The search section needs nette/robot-loader, which this package requires. The bundled config contains:
permissionFactoryservice registration- automatic
searchregistration forPermissionProviderclasses in%appDir%/Presentation
Permission factory:
services: permissionFactory: class: Drago\Permission\PermissionFactory arguments: [tagged(PermissionTag)] - @permissionFactory::create
Module provider:
services: signPermission: class: App\Presentation\Sign\PermissionProvider tags: [PermissionTag]
For larger applications with many providers, you can use the search section to register all matching classes automatically instead of listing each one individually:
search: permissions: in: %appDir%/Presentation classes: [PermissionProvider] tags: [PermissionTag]
Presenter Authorization
Authorization is handled by the Authorization trait.
- runs automatically on presenter startup
- checks ACL using presenter name and resolved privilege
To activate authorization in a presenter, include the trait:
use Drago\Permission\Authorization; class BasePresenter extends Nette\Application\UI\Presenter { use Authorization; }
All presenters extending BasePresenter will then have automatic authorization checks applied.
Unauthorized access:
- not logged in: redirect to
:Sign:inwith a backlink (overridesignInDestination()to change the target); only a plain GET page is stored, a signal or a form is not replayed after signing in - logged in but forbidden: HTTP 403
- a presenter without a registered ACL resource is denied the same way, so forgetting a provider never opens a page
Privilege resolution
The trait automatically resolves which ACL privilege to check based on the current request:
| Situation | Resolved privilege |
|---|---|
| Page load (no signal) | {action}-read |
| Signal from a read-only receiver | {component}-read |
| Signal listed as read-only | {component}-read |
| Any other signal | {component}-write |
| Direct presenter signal (no component) | {signal} |
For component signals, Nette returns a pair from Presenter::getSignal():
[$receiver, $signal] = $presenter->getSignal();
The receiver is the component path joined by hyphens. For example, if a presenter has a roles
component and that component contains a dataGrid component with a paginator subcomponent,
Nette can produce a signal like:
roles-dataGrid-paginator:page
This is resolved as:
$receiver = 'roles-dataGrid-paginator'; $signal = 'page';
The authorization trait uses the first receiver segment as the privilege group:
$group = explode('-', $receiver)[0]; // "roles"
So the signal above resolves to:
roles-read
when it is configured as read-only, otherwise it resolves to:
roles-write
Read-only signals
Override readOnlySignals() to declare signal names that should be treated as read operations.
This checks only the signal name, not the component path.
protected function readOnlySignals(): array { return ['sort', 'page', 'resetFilters', 'setPageSize']; }
Examples:
| Nette signal | Receiver | Signal | Resolved privilege |
|---|---|---|---|
roles-dataGrid:sort |
roles-dataGrid |
sort |
roles-read |
roles-dataGrid-paginator:page |
roles-dataGrid-paginator |
page |
roles-read |
roles-dataGrid:action |
roles-dataGrid |
action |
roles-write |
Read-only receivers
Override readOnlyReceivers() to declare component path substrings whose signals should always
resolve to read privilege. This checks the receiver path, not the signal name.
protected function readOnlyReceivers(): array { return ['Grid-filters', 'Grid-paginator', 'Grid-pageSize']; }
Any signal whose receiver name contains one of these strings will be resolved as {component}-read regardless of the signal name.
This is useful for nested controls where the signal name is generic, for example a filter form submit:
roles-dataGrid-filters-form:submit
The signal name is submit, but the receiver contains Grid-filters, so it resolves to:
roles-read
Be careful with broad receiver masks such as Grid. They make every signal from that receiver read-only,
including row actions like:
roles-dataGrid:action
Use broad receiver masks only when the whole component is read-only.
Deciding in code
For more than name lists, override isReadOnlySignal(). It receives the receiver path and the signal
name and replaces both lists. Drago DataGrid knows its own read-only signals (sorting, filters, paging,
page size), so a presenter with grids can simply ask it:
use Drago\Datagrid\DataGrid; protected function isReadOnlySignal(string $receiver, string $name): bool { return DataGrid::isReadOnlySignal($this, $receiver, $name); }
The grid looks the receiver up in the presenter: it does not depend on the name of the grid component,
and a sort signal of another component stays a write. Row actions (roles-dataGrid:action)
stay roles-write.
Full example
use Drago\Permission\Authorization; class ArticlePresenter extends BasePresenter { protected function readOnlySignals(): array { // these signals only read data checked as "{component}-read" return ['sort', 'page', 'resetFilters', 'setPageSize']; } protected function readOnlyReceivers(): array { // read-only DataGrid subcomponents with generic signal names return ['Grid-filters', 'Grid-paginator', 'Grid-pageSize']; } }
With this configuration:
| Nette signal | Resolved privilege |
|---|---|
roles-dataGrid:sort |
roles-read |
roles-dataGrid-paginator:page |
roles-read |
roles-dataGrid-filters:resetFilters |
roles-read |
roles-dataGrid-filters-form:submit |
roles-read |
roles-dataGrid-pageSize:setPageSize |
roles-read |
roles-dataGrid:action |
roles-write |
If you are not sure what Nette produces for a specific request, temporarily inspect:
bdump($this->getSignal());
or log the value inside resolveAclResource().
Write Buttons
The WriteAccess trait lets a component show its add, edit and delete buttons only to those who may use them.
isWriteAllowed() asks for the same {component}-write privilege that the Authorization trait checks for the
component's signals, so it works with static providers as well as with roles from the database:
use Drago\Permission\WriteAccess; class ArticleControl extends Nette\Application\UI\Control { use WriteAccess; }
$grid->addActionEdit(fn(int $id) => $this->handleEdit($id)) ->setVisible($this->isWriteAllowed());
Hiding a button is only for comfort; the signal itself is still checked by the Authorization trait.
Role Switch in Tracy
While developing, the Tracy bar can switch the roles of the signed-in user, so you can try the application as
another role without a test account. Register the panel (requires tracy/tracy):
tracy: bar: - Drago\Permission\Tracy\RoleSwitchPanel
The tab shows the current roles (red while they are switched). The panel lists the roles of the ACL, from static providers as well as from the database; My roles brings the real ones back, and signing out forgets the switch. Nette adds bar panels only in debug mode, so the panel never runs in production.
Optional add-ons
The package works without them. Set them up by hand as described below when the project needs them.
Related Package: Dynamic Role Management
For dynamic role and access management, use:
drago-ex/project-permission: https://github.com/drago-ex/project-permission
This package is built on top of drago-ex/permission and provides:
- role creation
- assigning roles to users
- allowing or denying access per role