Skip to content

Roles Developer Guide

Ed Mozley edited this page Jul 12, 2026 · 7 revisions

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.


The moving parts

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.


Adding a capability

One step to make it grantable, plus wiring it to code.

  1. 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.
  2. 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.

The two guards

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

Page

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)

Write API

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 writes

Warning

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.


Wiring a module's settings in

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.

  1. Declare the capability β€” '<module>.manage' in rbacCapabilities().
  2. Gate the settings page β€” requireCapability('<module>.manage') after requireModuleAccess('<module>') on <module>/settings/ (and any admin-only page like an editor or dashboard).
  3. Gate the management write APIs β€” requireCapabilityJson('<module>.manage') on every endpoint that changes configuration. Swap it in place of the requireModuleAccessJson() those endpoints had.
  4. Leave operational endpoints on module access β€” the reads/writes a normal user needs keep requireModuleAccessJson('<module>'). Do not upgrade those to the capability.
  5. Add a per-resource gate if a learner should see only their own records β€” a helper like lmsCanAccessCourse() and a requireLmsCourseAccessJson(), checked in the operational endpoints (see includes/lms_access.php). This is what stops a learner pulling an unassigned course by direct API call.
  6. Route + nav β€” send non-managers to their operational landing (e.g. lms/ β†’ my-courses.php), and show manager-only nav items behind if (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.

What counts as "settings" vs "operational" (LMS as the map)

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()

Editing a capability

  • 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, in rbac_role_capabilities.capability_key, so a bare rename orphans existing grants (the old key is no longer in the registry, so getAnalystCapabilities() 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.

Removing a capability

  • 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 the requireCapability(...) 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.


When and how to split a capability

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:

  1. Declare the finer keys in rbacCapabilities() (keep the umbrella or retire it β€” your call).
  2. Backfill: give every role that held the umbrella the new sub-capabilities, so nobody loses access on the split.
  3. Change the relevant guards from <module>.manage to the finer key.
  4. (Optional, recommended) Teach analystHasCapability() parent-implies-child: holding lms.manage satisfies a check for lms.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.


Database notes

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.

Checklist β€” wiring a module in

  • Declare '<module>.manage' (and its group) in rbacCapabilities().
  • requireCapability('<module>.manage') on the settings page(s), after requireModuleAccess.
  • 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.php is required before any capability guard.
  • Changelog the tightening (non-admins who used to reach those settings now need a role).

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally