Skip to content

Roles Developer Guide

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

Roles β€” Developer Guide

How to add a permission, convert a module's settings to per-tab permissions, and not introduce a silent security hole while doing it. This is the Layer-2 twin of the Module Access β€” Developer Guide.

Background and the user-facing model: Roles & Permissions.

The two rules to remember

  1. Guard writes, never reads. A settings page or a settings write endpoint gets a capability guard. Operational work and shared reads stay on plain module access. Gate a read that the everyday module depends on and you break the module for everyone.
  2. Never write a capability as a string. Always pass a Cap:: constant. The reason is below, and it is the single most important thing on this page.

Why capabilities are constants, not strings

Misspell a capability in a guard:

requireCapabilityJson('lms.mange');     // 'mange', not 'manage'

This does not error. It fails closed, exactly as designed β€” a permanent, polite 403: You do not have permission…, which reads exactly like a deliberate policy decision.

Now the part that makes it dangerous:

  • analystHasCapability() short-circuits to true for is_admin before it ever compares the string. You are an admin. Every test you run passes. The is_admin bypass β€” the thing that makes deny-by-default safe β€” is also the thing that hides this bug from the only person who could fix it.
  • The user can't tell it's a bug. They see a well-worded "you do not have permission". They don't file a bug report; they ask to be made an admin. If someone obliges, the typo has just become a privilege escalation.
  • It never self-corrects. Nothing logs it. Nothing alerts. It can sit in a release for a year.

So:

requireCapabilityJson(Cap::LMS_MANGE);
//  PHP Fatal error: Undefined constant Cap::LMS_MANGE

Same typo. Loud, immediate, at the call site, on the first request that touches the line β€” and it fires for the admin too, because the fatal happens before the bypass can hide it. Your editor red-underlines it before you save.

That is the whole reason includes/capabilities.php exists. Always Cap::.

(PHP 8.1 enums would be strictly better still β€” a function signature could refuse a string outright. The project floor is 7.4, so constants are the closest available thing to a type. See Raising the PHP floor to 8.1.)


You write two things. Everything else is generated.

This is the single most important thing on the page, so here it is up front.

To add a permission, you write it down twice β€” and only twice:

// ── 1. THE NAME ──  includes/capabilities.php
const ASSETS_VCENTER = 'assets.vcenter';
// ── 2. THE DETAILS ──  asset-management/settings/manifest.php
[
    'id'           => 'vcenter',
    'cap'          => Cap::ASSETS_VCENTER,
    'label_key'    => 'asset-management.settings.tab_vcenter',   // the TAB's name
    'grant'        => 'Configure the vCenter connection, including its credentials',
    'sensitive'    => true,                                       // reaches credentials β†’ badged
    'setting_keys' => ['vcenter_server', 'vcenter_user', 'vcenter_password'],
]

That's the whole job. From those two, the code generates:

       THE NAME              THE DETAILS
   (capabilities.php)   (the module's manifest)
          β”‚                        β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  the tick-box on System β†’ Roles              β”‚   ← capRegistry()
   β”‚  its description and its "sensitive" badge   β”‚   ← capRegistry()
   β”‚  the module heading it sits under            β”‚   ← capModules()
   β”‚  the tab, rendered only for those who hold itβ”‚   ← the manifest renderer
   β”‚  which stored settings that tab may write    β”‚   ← settingKeyOwners()
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

No migration, no schema change, nothing to register anywhere. Manifests are found by a glob.

Why two and not one?

Because the name has to be a real constant, and a constant has to exist in the code before it runs. You cannot conjure one out of a data file without a build step, and FreeITSM deliberately has none β€” you drop it in a web root and it runs.

And that constant is not ceremony: it is the entire safety property. Cap::ASSETS_VCENTER misspelled is an immediate fatal error. 'assets.vcentre' misspelled is a silent, permanent 403 that you, as an administrator, will never see (below). So the second "list" is the one thing worth having.

Two is therefore the floor. If you ever find yourself writing a capability's details in a third place, stop β€” derive it instead.

It used to be four: the name, its description, the tab layout, and the setting-key map, each written out separately, with an automatic check to catch them drifting apart. Needing that check was the bug. Don't reintroduce it.

The one way they can still drift

A Cap:: constant that no manifest claims. Derivation can't catch that, because the constant is the hand-written half β€” so it's checked instead.

It matters. An unclaimed constant is a capability nobody can be granted, so any guard using it 403s everyone except administrators, permanently β€” and invisibly to the administrator, who bypasses the check. It's the same silent-denial failure as a typo, arriving through a different door.

capSelfCheck() catches exactly this (plus a manifest inventing a capability with no constant behind it, a missing description, two umbrellas in one module, and a dangling alias). It runs inside D005.


The moving parts

Reference; you rarely touch most of these.

Piece Where Job
The name Cap class in includes/capabilities.php ✍️ Hand-written. The type. Every guard names one of these.
The details <module>/settings/manifest.php ✍️ Hand-written. Tabs, their capabilities, descriptions, sensitivity, and the settings they write.
Registry capRegistry(), capModules() βš™οΈ Derived. Generates the Roles picker.
Setting keys settingKeyOwners() in includes/settings_keys.php βš™οΈ Derived β€” plus a residual list of modules not yet converted, which shrinks to nothing as they are. It's a to-do list, not a duplicate.
Renderer includes/settings_manifest.php βš™οΈ Renders the tab bar from the manifest.
Resolver getAnalystCapabilities() / analystHasCapability() What an analyst holds: their roles + their teams' roles, umbrellas expanded. is_admin β†’ all. Cached per request.
Guards requireCapability() / requireCapabilityJson() Enforce on a page / API. Authoritative, fail closed.
Aliases capAliases() Retired name β†’ current name, so a rename doesn't strip existing grants.
Self-check capSelfCheck() Catches the one drift derivation can't (above).
Audit D005 (System β†’ Debug tools) Finds the guard nobody wrote. See the audit.

Mark 'sensitive' => true for anything reaching credentials, email, money, or the audit trail β€” it badges the tick-box.

Renaming a capability

The key exists in exactly one place (its constant), so change the value and add an alias:

const ASSETS_VCENTER = 'assets.vcenter';            // was 'assets.vcentre'

function capAliases(): array {
    return ['assets.vcentre' => Cap::ASSETS_VCENTER];
}

Grants already in rbac_role_capabilities keep working β€” capFromKey() maps the old key on read. Without the alias they are silently dropped (safe, but the role quietly loses access and nobody is told).

Removing a capability

Delete the constant and its registry entry, and delete every guard that referenced it β€” the constant no longer exists, so those call sites now fatal, which is exactly what you want: they can't be missed. Any leftover DB rows are ignored (capFromKey() returns null for an unknown key), so tidying rbac_role_capabilities is cosmetic.


Converting a module to per-tab permissions

The worked example is Asset Management (#830) β€” 8 tabs, 7 capabilities, 16 endpoints. Follow this order.

1. Declare the capabilities

One per settings tab, plus a <module>.manage umbrella. Umbrella = 'umbrella' => true; holding it satisfies every capability in that module, so the ordinary "administrator of this module" role stays one tick.

2. Write the manifest

// asset-management/settings/manifest.php
require_once __DIR__ . '/../../includes/capabilities.php';

return [
    'module' => 'assets',
    'tabs'   => [
        ['id' => 'asset-types', 'cap' => Cap::ASSETS_TYPES,   'label_key' => 'asset-management.settings.tab_asset_types'],
        ['id' => 'vcenter',     'cap' => Cap::ASSETS_VCENTER, 'label_key' => 'asset-management.settings.tab_vcenter',
         'sensitive' => true,
         'setting_keys' => ['vcenter_server', 'vcenter_user', 'vcenter_password']],

        // A per-analyst display preference, not administration. Never gated.
        ['id' => 'left-panel',  'cap' => null,                'label_key' => 'common.left_panel.tab'],
    ],
];

'cap' => null is how a tab says "I am a personal preference, not administration". Only the left-panel tab qualifies today. Making that an explicit declaration β€” rather than a judgement call buried in a page β€” is the point.

setting_keys documents which system_settings keys the tab writes. The enforcement for those lives in settings_keys.php (step 5) β€” declaring them here does not guard them.

3. Render the page from the manifest

require_once '../../includes/settings_manifest.php';
requireModuleAccess('assets');

$settingsManifest = require __DIR__ . '/manifest.php';
$visibleTabs      = settingsVisibleTabs(connectToDatabase(), (int) $_SESSION['analyst_id'], $settingsManifest);
$activeTabId      = settingsFirstTabId($visibleTabs);
<?php renderSettingsTabBar($visibleTabs, $activeTabId); ?>

<?php if (settingsTabVisible($visibleTabs, 'vcenter')): ?>
<div class="tab-content<?php echo $activeTabId === 'vcenter' ? ' active' : ''; ?>"
     id="vcenter-tab" data-capability="<?php echo Cap::ASSETS_VCENTER; ?>">
    …
</div>
<?php endif; ?>

A panel the analyst lacks is not emitted at all β€” not hidden. There is no DOM to un-hide.

Warning

Make the active class conditional on $activeTabId. Settings pages hard-code class="tab-content active" on their first tab. If that tab isn't visible to this analyst, they get a settings page with no panel showing at all. settingsFirstTabId() gives you the first visible tab; use it on both the tab bar and the panels.

The data-capability attribute is a label, not a lock. It exists so the page is machine-readable (for the coverage report). Tabs with no capability get data-capability="none" rather than being left bare, so "personal preference" is distinguishable from "someone forgot to declare one".

4. Guard the write endpoints

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 */ }

requireModuleAccessJson('assets');               // Layer 1
requireCapabilityJson(Cap::ASSETS_VCENTER);      // Layer 2 β€” before any write

Warning

config.php before the guard. The guards connect to the database, which needs the DB_* constants. Require the guard before config.php and you get Undefined constant "DB_SERVER".

Warning

The "list before the guard" trap. A GET that returns early β€” a catalogue list, say β€” placed above the guard line is effectively ungated. When an endpoint is management-only, put the guard before any early-returning branch. This bit the LMS's courses.php: its list sat above the guard, so any logged-in analyst could enumerate every course.

5. Move the setting keys into the manifest

If the module's tabs save through the shared save_system_settings.php, list those keys under 'setting_keys' on the tab that owns them, and delete that module's block from the residual list in includes/settings_keys.php. The ownership map derives the rest.

A key that no module claims is refused outright, which is what keeps the generic writer out of the ~70 other keys in system_settings that have their own dedicated endpoints (branding, cron tokens, local_login_enabled, module_permission_mode). If you add a new setting to a tab and forget to declare it, saving that tab fails loudly β€” which is the intended direction. Loud beats open.

The residual list in settings_keys.php is a to-do list: it shrinks to nothing as the last module converts.

6. Run the audit

D005 (System β†’ Debug tools) runs capSelfCheck() and re-scans every endpoint. It will tell you if you left a capability nobody enforces, a guard nobody wrote, or a constant no manifest claims.


The traps (all of these are real; all of them bit)

A get_* endpoint that isn't a read

api/assets/get_vcenter.php performs the vCenter sync despite its name, and is called by the operational Servers page. Gating it on assets.vcenter would have broken Servers for every non-manager.

Check callers, not names. grep -rl "<endpoint>" --include=*.php --include=*.js . before you guard anything.

Endpoints with no guard at all

Converting a module is an audit of it. Expect to find holes, not merely move them. Asset Management turned up:

  • six Intune endpoints β€” including sync.php β€” that checked only "are you logged in", so any analyst could trigger a full sync of the Intune tenant;
  • debug_vcenter.php, which had no module guard whatsoever and dumps every raw field vCenter returns.

Before you start, list the module's endpoints and classify every one: capability-guarded / module-access-only / nothing. The last column is the interesting one.

Reads the everyday module depends on

get_asset_types.php is called by the settings page and by the asset list, which needs type names for its filters. Guard it and the module breaks for everyone. This is the single easiest thing to get wrong.

A guard whose code was never loaded β€” and PHP calls it a 200

The nastiest one, because your test will tell you it passed.

Add requireCapabilityJson(Cap::X) to an endpoint but forget to require includes/rbac.php, and the endpoint doesn't refuse anybody β€” it dies:

Fatal error: Uncaught Error: Call to undefined function requireCapabilityJson()

That is worse than no guard at all. The endpoint looks protected, and instead it is broken for everyone, administrators included. And here is the sting:

PHP serves a fatal error as HTTP 200.

So a verification that checks status codes β€” which is the obvious thing to write β€” reports a cheerful PASS on a completely broken endpoint. That is exactly how it slipped through on Change Management: save_field_layout.php happens to require its includes with __DIR__ . '/../../…' rather than a relative path, so an automated insertion of the rbac.php require missed it, and the test said 200.

Two defences, use both:

  1. D005 checks for it statically and reports it as BROKEN, above every other finding. Run it after adding guards.
  2. Never verify a guard by status code alone. Read the response body and treat a fatal as a failure:
body=$(curl -s -X POST -b "PHPSESSID=$s" "$url" -d '{}')
echo "$body" | grep -qiE "Fatal error|Uncaught" && echo "πŸ’₯ BROKEN"

A side-effecting endpoint in a happy-path test

When testing "does the admin still get a 200", pick a read. Calling api/intune/sync.php as an admin runs a real sync against the live tenant. (Ask me how I know.)


The audit (D005)

System β†’ Debug tools β†’ Endpoint permission coverage. Run it after adding endpoints, after converting a module, and before a release.

Here is the thing worth internalising: making capabilities type-safe catches a misspelled permission. Nothing catches one somebody simply forgot to write. No language feature saves you from a line that isn't there. And that omission is not hypothetical β€” it is how, in this codebase:

  • any logged-in analyst could rewrite the vCenter credentials and switch off brute-force lockout (save_system_settings.php, #829);
  • any analyst could trigger a full sync of the Intune tenant (#830);
  • all 49 of the RFP Builder's endpoints were reachable by anyone logged in, while its pages were correctly gated (#833);
  • an unauthenticated caller could forge audit-log entries attributed to any analyst (#834);
  • an LMS learner could open the course-authoring settings (#836).

Every one of those was found by hand, by accident, while looking for something else. D005 is the systematic version: it reads ~590 endpoints in about two seconds and reports what actually guards each β€” a capability, administrator-only, module access, an API key, a webhook signature, a URL token, "logged in and nothing more", or nothing at all β€” ranked by how much damage the gap allows.

If you add an auth mechanism, teach D005 about it. Its first run produced five criticals of which four were false alarms β€” endpoints that were perfectly well guarded by a method it didn't recognise. A scanner that cries wolf is ignored within a week, and then it protects nothing. The same goes for d005ByDesign(): an endpoint that legitimately only needs "is a human logged in" (your own password, your own MFA) is declared there with a reason, so the finding list stays short enough to act on. Add to it only when the endpoint genuinely acts on the caller's own account β€” if you're adding something because "it's probably fine", it isn't; guard it instead.


Testing a conversion

Three actors, over real HTTP. A forged session is enough:

# write a session file, then curl with its id
printf 'analyst_id|i:39;analyst_name|s:6:"jsmith";' > c:/wamp64/tmp/sess_js
curl -b "PHPSESSID=js" http://localhost/freeitsm-app/asset-management/settings/
Actor Must see Must be refused
Admin every tab; every endpoint works nothing
Granted one capability (e.g. assets.locations) exactly that tab + any preference tabs; its endpoint works every other endpoint, including the setting keys of tabs they lack
Module access, zero capabilities only the preference tabs β€” and their entire everyday job, untouched every settings write

Warning

Check the response BODY, not the status code. A PHP fatal β€” including the "guard not loaded" bug above β€” is served as HTTP 200, so a status-only check reports a broken endpoint as a pass. The admin row of that table is the one that catches it: if an admin gets anything other than the endpoint's normal response, the guard is broken, not working.

Then check the umbrella: a role with only <module>.manage ticked must expand to every tab and every endpoint.

And confirm the panels are absent from the HTML, not hidden:

curl -s -b "PHPSESSID=js" .../settings/ | grep -o 'data-tab="[a-z-]*"'

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 + primary key only β€” unique indexes and foreign keys are added separately (in $uniqueIndexes 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.

Adding a capability needs no database change at all β€” the registry is code.

Checklist β€” converting a module

  • One Cap:: constant per settings tab, plus a <module>.manage umbrella.
  • <module>/settings/manifest.php written: every tab has a cap, a label_key and a grant; sensitive set on anything touching credentials/email/money; personal-preference tabs declared 'cap' => null.
  • Settings page renders the tab bar from the manifest; each panel wrapped in settingsTabVisible(); active class conditional on $activeTabId.
  • Every settings write endpoint guarded β€” above any early return.
  • Every module endpoint classified in D005; anything unguarded is fixed, not ignored.
  • Operational reads left on module access. Callers checked, not names.
  • Setting keys listed on their tab; the module's block removed from the residual list in settings_keys.php.
  • D005 clean (registry self-check passes; no new findings).
  • Verified over HTTP with three actors + the umbrella.
  • Changelog the tightening, and any hole you closed on the way.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally