-
Notifications
You must be signed in to change notification settings - Fork 15
Roles Developer Guide
How to add, edit or remove a granular permission (a capability), and how to wire a module's settings into the Roles system so it respects who's allowed to administer it for free. This is the Layer-2 twin of the Module Access β Developer Guide; the guards behave the same way and follow the same discipline.
Background and the user-facing view: Roles & Permissions.
The one rule to remember: a settings page or a settings write endpoint gets a capability guard. Everyday operational work and shared reads do not β those stay on plain module access. When unsure whether something is "settings", ask: would you be comfortable a non-manager could do it? If yes, it's operational.
| Piece | Where | Job |
|---|---|---|
| Registry |
rbacCapabilities() in includes/rbac.php
|
The source of truth β every capability the app defines, grouped by module. |
| Resolver |
getAnalystCapabilities() / analystHasCapability()
|
Compute what an analyst holds (union of their + their teams' roles, β© the registry; is_admin β all). |
| Guards |
requireCapability() / requireCapabilityJson()
|
Enforce a capability on a page / API. Authoritative, fail closed. |
| Tables |
rbac_roles, rbac_role_capabilities, rbac_analyst_roles, rbac_team_roles
|
Roles, their capabilities, and their assignments. |
| UI |
system/roles/ + api/system/roles.php, role.php
|
Admin-only screen to create roles and assign them. |
The registry entry format:
function rbacCapabilities(): array {
return [
'lms' => [
'label' => 'LMS',
'capabilities' => [
'lms.manage' => 'Manage courses, learning groups and assignments, and view everyone\'s progress',
],
],
// more modules hereβ¦
];
}A capability key is <module>.<action>. The value is the human label shown in the Roles picker.
One step to make it grantable, plus wiring it to code.
-
Declare it in
rbacCapabilities()β add a'<module>.<action>' => 'Label'under the module's group (create the group if the module is new). That's all it takes for it to appear on System β Roles, be tickable, and resolve correctly. The DB needs nothing β validity is checked against this registry (rbacCapabilityExists()), so an undeclared key can never be saved. - Enforce it wherever the capability should be required β see Wiring a module's settings below.
No migration, no schema change. The registry is code; the tables only ever reference it.
Both live in includes/rbac.php, take a capability key, open their own DB connection if needed, and fail closed. is_admin short-circuits both to allowed.
| Guard | Use on | Effect on a lacking non-admin |
|---|---|---|
requireCapability('<cap>') |
a settings page |
302 redirect to the launcher (?denied=<cap>) |
requireCapabilityJson('<cap>') |
a settings write API |
403 { success:false, error:β¦ } and exit
|
require_once '../includes/functions.php';
require_once '../includes/rbac.php'; // <-- load it
// β¦ i18n / theme / timezone init β¦
requireModuleAccess('lms'); // Layer 1: can they enter?
requireCapability('lms.manage'); // Layer 2: can they administer? (before any HTML)session_start(['read_and_close' => true]);
require_once '../../config.php'; // <-- MUST precede any guard that connects
require_once '../../includes/functions.php';
require_once '../../includes/rbac.php';
header('Content-Type: application/json');
if (!isset($_SESSION['analyst_id'])) { /* 401 */ }
requireCapabilityJson('lms.manage'); // right after auth, before writesWarning
config.php before the guard. requireCapabilityJson() connects to the database, which needs the DB_* constants from config.php. If you require the guard (or admin_api_guard.php) before config.php, you get Undefined constant "DB_SERVER". Order: config.php β functions.php/rbac.php β guard.
The worked example is the LMS. The pattern is: classify each surface, gate the management ones, leave the operational ones on module access β and add a per-resource check where a learner should only reach their records.
-
Declare the capability β
'<module>.manage'inrbacCapabilities(). -
Gate the settings page β
requireCapability('<module>.manage')afterrequireModuleAccess('<module>')on<module>/settings/(and any admin-only page like an editor or dashboard). -
Gate the management write APIs β
requireCapabilityJson('<module>.manage')on every endpoint that changes configuration. Swap it in place of therequireModuleAccessJson()those endpoints had. -
Leave operational endpoints on module access β the reads/writes a normal user needs keep
requireModuleAccessJson('<module>'). Do not upgrade those to the capability. -
Add a per-resource gate if a learner should see only their own records β a helper like
lmsCanAccessCourse()and arequireLmsCourseAccessJson(), checked in the operational endpoints (seeincludes/lms_access.php). This is what stops a learner pulling an unassigned course by direct API call. -
Route + nav β send non-managers to their operational landing (e.g.
lms/βmy-courses.php), and show manager-only nav items behindif (analystHasCapability(...))in the module header.
Warning
The "list before the guard" trap. A GET that returns early (e.g. a course list) placed above the guard line is effectively ungated. When a whole endpoint is management-only, put the guard before any early-returning branch. This bit the LMS courses.php β its catalogue list sat above the guard, so any logged-in analyst could enumerate every course until the gate was moved up.
| Endpoint kind | Gate |
|---|---|
| Author/upload courses, run groups, assign, everyone's progress, other people's answers | requireCapabilityJson('lms.manage') |
| A learner's own content feed, their own progress, the SCORM runtime |
requireModuleAccessJson('lms') + per-course requireLmsCourseAccessJson()
|
-
Change the label β edit the string in
rbacCapabilities(). Safe; nothing stored references the label. -
Rename a key (
lms.manageβlms.administer) β the key is stored, inrbac_role_capabilities.capability_key, so a bare rename orphans existing grants (the old key is no longer in the registry, sogetAnalystCapabilities()filters it out and roles silently lose it). If you must rename, ship a one-line data migration:UPDATE rbac_role_capabilities SET capability_key = 'new' WHERE capability_key = 'old', and update the guards that reference the old string. Prefer not to rename.
-
Remove it from
rbacCapabilities(). It immediately stops applying:getAnalystCapabilities()intersects granted keys with the registry, so a de-declared key is ignored even while its rows remain. Also remove therequireCapability(...)calls that referenced it (a call for an undeclared capability would deny everyone but admins forever). -
Tidy the rows (optional, cosmetic):
DELETE FROM rbac_role_capabilities WHERE capability_key = '<cap>'.
Because the registry is authoritative, you can never end up in a state where a stale DB row grants something the code no longer understands.
Start each module with a single <module>.manage umbrella. Split it into finer capabilities (lms.lesson.add, lms.assign, β¦) only when a real role appears that should hold one side but not the other β not speculatively, or you burden every admin with a checkbox nobody sets.
When you do split:
- Declare the finer keys in
rbacCapabilities()(keep the umbrella or retire it β your call). - Backfill: give every role that held the umbrella the new sub-capabilities, so nobody loses access on the split.
- Change the relevant guards from
<module>.manageto the finer key. -
(Optional, recommended) Teach
analystHasCapability()parent-implies-child: holdinglms.managesatisfies a check forlms.lesson.add. With the<module>.<action>(and<module>.<resource>.<action>) convention this is a prefix rule, so the umbrella keeps working without re-granting.
The naming convention is already hierarchical, which is what makes all of this additive rather than a rewrite.
The four rbac_* tables are created by Database Verify and mirrored in database/freeitsm.sql. Per the house rule, the $schema array in api/system/db_verify.php builds columns + PK only β the unique indexes and foreign keys are added separately (in the $uniqueIndexes list and the FK loop) and in freeitsm.sql. The join tables cascade on delete, so removing a role, analyst or team cleans up its assignments. If you add an rbac_* table, follow that split or the keys won't exist on an upgraded install.
- Declare
'<module>.manage'(and its group) inrbacCapabilities(). -
requireCapability('<module>.manage')on the settings page(s), afterrequireModuleAccess. -
requireCapabilityJson('<module>.manage')on every management write API (guard above any early return). - Operational endpoints keep
requireModuleAccessJson('<module>'); add a per-resource gate if learners should see only their own. - Route non-managers to their operational landing; hide manager-only nav behind
analystHasCapability(). -
config.phpis required before any capability guard. - Changelog the tightening (non-admins who used to reach those settings now need a role).
- Roles & Permissions β the user-facing model and the two core rules
- Module Access β Developer Guide β the Layer-1 twin of this guide
-
Admin Access Control β the
is_admingate and the guard pattern reused here -
LMS Authoring β the worked example (
lms.manage,includes/lms_access.php)
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)