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


The moving parts

Piece Where Job
Constants Cap class in includes/capabilities.php The type. Every guard call site names one of these.
Registry capRegistry() Each capability's module, label, umbrella flag and sensitivity. Generates the Roles picker.
Aliases capAliases() Retired key β†’ current key, so a rename doesn't strip existing grants.
Resolver getAnalystCapabilities() / analystHasCapability() What an analyst holds: union of their + their teams' roles, expanded for umbrellas, filtered through the registry. is_admin β†’ all.
Guards requireCapability() / requireCapabilityJson() Enforce on a page / API. Authoritative, fail closed.
Manifest <module>/settings/manifest.php The only place a settings tab may be declared. The tab bar is rendered from it.
Renderer includes/settings_manifest.php settingsVisibleTabs(), settingsTabVisible(), renderSettingsTabBar().
Setting keys includes/settings_keys.php Who owns each system_settings key, for tabs that share the generic save endpoint.
Self-checks capSelfCheck(), settingsManifestSelfCheck() Prove the registry, the manifest and the key map agree.

Adding a capability

Two halves, in the same commit β€” a constant without a registry entry is grantable by nobody, which means a permanent silent 403.

// includes/capabilities.php

final class Cap
{
    const ASSETS_VCENTER = 'assets.vcenter';        // 1. the constant
}

function capRegistry(): array
{
    return [
        Cap::ASSETS_VCENTER => [                    // 2. the registry entry
            'module'    => 'assets',
            'umbrella'  => false,
            'sensitive' => true,                    // reaches credentials
            'label'     => 'Configure the vCenter connection, including its credentials',
        ],
    ];
}

That's all it takes to appear on System β†’ Roles β€” the picker is generated from capRegistry(). No migration, no schema change.

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

Run capSelfCheck() β€” it catches a half-added capability, an unknown module, a missing label, two umbrellas in one module, and a dangling alias.

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. Promote the setting keys

For tabs that save through the shared save_system_settings.php, move that module's rows in includes/settings_keys.php from the module-access fallback to the real capability:

'vcenter_password' => ['module' => 'assets', 'cap' => Cap::ASSETS_VCENTER, 'tab' => 'vcenter'],
//                                            ^^^ was null (= plain module access)

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, add it here or saving that tab will fail loudly β€” which is the intended direction. Loud beats open.

6. Run the self-checks

capSelfCheck();                                   // registry ↔ constants ↔ modules
settingsManifestSelfCheck($manifest);             // manifest ↔ registry ↔ settings_keys

Three lists that must agree is exactly the shape of bug this design exists to prevent. Don't keep them in step by hand β€” check them.


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


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

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 and one capRegistry() entry per settings tab, in the same commit; sensitive set on anything touching credentials/email/money.
  • A <module>.manage umbrella ('umbrella' => true).
  • <module>/settings/manifest.php written; 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; anything found unguarded is fixed, not ignored.
  • Operational reads left on module access. Callers checked, not names.
  • settings_keys.php rows promoted from null to the real capability.
  • capSelfCheck() and settingsManifestSelfCheck() clean.
  • 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