Limited rights
Define ACL roles, controller permissions and per-role data filtering so that module-managed users see only what their role allows.
This recipe shows how a module gives the MikoPBX web interface role-based access control (RBAC): how to register custom ACL roles and rules, how to contribute per-controller permission flags consumed by the front-end, and how to inject per-role WHERE conditions into CDR queries so each role sees only its own call records.
Three Web-UI hooks carry the whole flow. They are declared on WebUIConfigInterface and CDRConfigInterface, and you override them in your module's config class:
onAfterACLPrepared(AclList $aclList)
WebUIConfigInterface
Add roles, components (controllers) and allow rules to the Phalcon ACL.
onGetControllerPermissions(string $controller, array &$permissions)
WebUIConfigInterface
Add custom permission flags for a controller, returned to JavaScript.
applyACLFiltersToCDRQuery(array &$parameters, array $sessionContext = [])
CDRConfigInterface
Add per-role WHERE conditions to CDR list queries.
How MikoPBX resolves rights
MikoPBX uses Phalcon's in-memory ACL (Phalcon\Acl\Adapter\Memory, aliased as AclList). The ACL has three kinds of entries:
Roles —
Phalcon\Acl\Role. A logged-in session carries exactly one role.Components —
Phalcon\Acl\Component. In MikoPBX a component is a controller class name, e.g.MikoPBX\AdminCabinet\Controllers\CallDetailRecordsController.Rules —
allow($role, $component, $actions)grants a role the listed controller actions (index,modify,save, …).
The ACL is built in Core/src/Common/Providers/AclProvider.php and starts from setDefaultAction(AclEnum::DENY) — anything not explicitly allowed is denied. Two roles are then registered before any module is consulted:
AclProvider::ROLE_ADMINS('admins') getsallow('admins', '*', '*')— the administrator bypasses every check.AclProvider::ROLE_GUESTS('guests') getsdeny('guests', '*', '*')— the anonymous default.
A module that introduces limited users defines additional roles and grants each one only the controllers and actions it should reach. When the user's session role is set to one of those custom roles, the SecurityPlugin denies every request that the ACL does not explicitly allow.
Two bypasses you must know about before you rely on the ACL. SecurityPlugin::isAllowedAction() (Core/src/AdminCabinet/Plugins/SecurityPlugin.php) reads the role from the JWT (Bearer header, or the refreshToken cookie mapped through Redis). When no role can be extracted it falls back to ROLE_ADMINS for localhost requests and ROLE_GUESTS for everything else. On top of that, beforeDispatch() skips the ACL check entirely for isLocalHostRequest(). So a request originating from 127.0.0.1 — internal workers, health checks, anything proxied without the original client address — is treated as an administrator. Never model your access control on the assumption that the ACL is the only gate for local traffic.
The ACL also grants a few things to '*' (every role) after the module hook runs: the Errors controller (show401/show404/show500), the Session controller (index/start/changeLanguage/end) and the stateless password helper endpoints. You cannot revoke those from a module hook — they are added after your onAfterACLPrepared() returns.
The assembled ACL is cached. After you change anything that affects role membership or rules, clear the cache with MikoPBX\Common\Providers\AclProvider::clearCache();. ModuleUsersUI does this in onAfterModuleEnable(), onAfterModuleDisable() and inside modelsEventChangeData() when its access-group models change — see Extensions/ModuleUsersUI/Lib/UsersUIConf.php.
Step 1 — register roles and rules with onAfterACLPrepared
onAfterACLPrepared(AclList $aclList): void runs while MikoPBX builds the ACL (the result is then cached). Use it to add roles, components and allow rules. The signature is fixed by WebUIConfigInterface:
For ModuleBlackList we define one role per managed access group. Each role gets the controllers it is allowed to use; a group flagged fullAccess is granted everything with allow($role, '*', '*').
Key points, all verified against Extensions/ModuleUsersUI/Lib/UsersUIACL.php:
A component must be added (
addComponent) before you canallowa role on it. ModuleUsersUI builds an$actionsArray[$controller] = [...actions]map and then loopsaddComponent(new Component($controller), $actions)followed byallow($role, $controller, $actions).allow($role, '*', '*')is the real escape hatch ModuleUsersUI uses for itsfullAccessgroups.Roles must be globally unique. ModuleUsersUI prefixes every role with
UsersUIRoleID(constantConstants::MODULE_ROLE_PREFIXinExtensions/ModuleUsersUI/Lib/Constants.php); ModuleBlackList usesBlackListRoleID. This prefix is also how you recover the access-group id later (Step 3).
Step 2 — expose permission flags with onGetControllerPermissions
The web front-end asks the Core AclController "what may the current user do on this controller?" so it can show or hide buttons. The Core computes the standard three-level flags itself (index, getNewRecords, modify, edit, save, delete, copy) from the SecurityPlugin, then lets modules add custom flags through this hook.
The contract, confirmed in Core/src/AdminCabinet/Controllers/AclController.php:
$controlleris the fully-qualified controller class name (e.g.MikoPBX\AdminCabinet\Controllers\CallDetailRecordsController).$permissionsarrives as an empty array scoped to your module. Whatever you put in it is returned to JavaScript underdata.custom. You do not touch the built-in flags — those are computed separately.
The relevant Core slice that calls this hook:
This hook only affects what the UI renders. It is a convenience for show/hide logic — it is not a security boundary. Real enforcement happens in the ACL (Step 1) and, for REST, in the security attributes (see the note at the end). Always back a hidden button with a real ACL rule.
Step 3 — filter CDR data per role with applyACLFiltersToCDRQuery
A limited role that can open the Call Detail Records page should still see only its own calls. CDR list queries are funnelled through one hook before execution:
$parameters is the Phalcon query-builder array (conditions, bind) that is about to run. You mutate it in place to add your WHERE clause.
$sessionContext is filled from the caller's JWT by Core/src/PBXCoreREST/Controllers/BaseController.php (lines 368-387) and carries:
$sessionContext['role']— the user's role,$sessionContext['user_name']— the login,$sessionContext['session_id']— the token/session id.
The interface docblock still describes an "AdminCabinet context" in which $sessionContext arrives empty and you are told to read the role from SessionProvider. That branch is dead in 2025.1.1. The hook has exactly one call site — Core/src/PBXCoreREST/Lib/Cdr/GetListAction.php:225 — and the AdminCabinet Call Detail Records page reaches it over the REST API too: its controller is a static shell and the grid fetches /pbxcore/api/v3/cdr (Core/sites/admin-cabinet/assets/js/src/CallDetailRecords/call-detail-records-index.js:264), so the JWT is always present. ModuleUsersUI reflects this — it simply returns early when $sessionContext['role'] is null. Write the same shape; do not build a SessionProvider fallback.
The hook is invoked from Core/src/PBXCoreREST/Lib/Cdr/GetListAction.php:
ModuleBlackList recovers the access-group id from the role prefix (the inverse of Step 1) and rewrites the query conditions:
The mutation pattern above is exactly what ModuleUsersUI does in Extensions/ModuleUsersUI/Lib/UsersUICDRFilter.php:
src_num/dst_numare the CDR table columns for source and destination extension numbers.{allowedNumbers:array}is Phalcon's bound-array placeholder, paired with$parameters['bind']['allowedNumbers'].ModuleUsersUI supports several modes (
selected,outgoing-selected,not-selected,all) and falls back toconditions = '1=0'when a restricted group has no extensions — read itsapplyCDRFilterRules()for the full logic.ModuleUsersUI splits the dispatch (
UsersUIConf::applyACLFiltersToCDRQuery()reads$sessionContext['role'], then delegates toUsersUICDRFilter). Keep your config-class hook thin and put the query logic in a helper.
Note: REST RBAC with the ResourceSecurity attribute
The hooks above secure the web interface and CDR lists. For your module's REST controllers, MikoPBX 2025.1.1+ uses a resource-based attribute, MikoPBX\PBXCoreREST\Attributes\ResourceSecurity, applied to a controller class or an action method:
The constructor is ResourceSecurity(string $resource, ?ActionType $action = null, ?array $requirements = null, bool $optional = false, string $description = '', array $extensions = []). ActionType is one of READ, WRITE, ADMIN, SENSITIVE; SecurityType is one of LOCALHOST, BEARER_TOKEN, PUBLIC (Core/src/PBXCoreREST/Attributes/). For a real usage, see the Core controller that declares #[ResourceSecurity('employees', requirements: [SecurityType::LOCALHOST, SecurityType::BEARER_TOKEN])] in Core/src/PBXCoreREST/Controllers/Employees/RestController.php. Full REST RBAC details live in the REST API reference.
Where call-level isolation fits
ACL governs who can open which page and see which records. If you instead need to restrict which numbers a group may dial, that is enforced in the dialplan, not the ACL. The ModuleUsersGroups module (Extensions/ModuleUsersGroups/) is the reference for that: it builds group-isolation dialplan contexts and outbound-rule restrictions (Extensions/ModuleUsersGroups/Lib/UsersGroupsConf.php, model AllowedOutboundRules). It does not implement the ACL hooks described here — use it as the companion pattern when limited rights must extend to call routing.
Checklist
Override
onAfterACLPrepared()to add a uniquely-prefixed role per group, register each allowed controller as a component, andallowthe actions (allow($role, '*', '*')for full access).Clear the ACL cache with
AclProvider::clearCache()on enable/disable and whenever your role/rights models change.Override
onGetControllerPermissions()to add custom UI flags underdata.custom(UI hint only — never your sole guard).Override
applyACLFiltersToCDRQuery()to add per-role CDRWHEREclauses; read the role from$sessionContext['role']and return early when it is null.Guard your REST controllers with
#[ResourceSecurity(...)].
See also
Last updated
Was this helpful?