-
Notifications
You must be signed in to change notification settings - Fork 15
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.
- 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.
- 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.
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 totrueforis_adminbefore it ever compares the string. You are an admin. Every test you run passes. Theis_adminbypass β 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_MANGESame 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 manifest is the single declaration. A module states its settings tabs β and therefore its capabilities β in exactly one file, and everything else is derived from it. There is no second list to keep in step.
| Piece | Where | Job |
|---|---|---|
| Manifest | <module>/settings/manifest.php |
THE declaration. Tabs, their capabilities, descriptions, sensitivity, and the settings they write. Found by a glob β nothing to register. |
| Constants |
Cap class in includes/capabilities.php
|
The type. The one thing that can't be derived. Every guard names one of these. |
| Registry |
capRegistry(), capModules()
|
Derived from the manifests. Generates the Roles picker. |
| Setting keys |
settingKeyOwners() in includes/settings_keys.php
|
Derived from the manifests, plus a shrinking residual list for modules not yet converted. |
| Renderer | includes/settings_manifest.php |
settingsVisibleTabs(), settingsTabVisible(), renderSettingsTabBar(). |
| 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. is_admin β all. Cached per request. |
| Guards |
requireCapability() / requireCapabilityJson()
|
Enforce on a page / API. Authoritative, fail closed. |
| Self-check | capSelfCheck() |
Guards the one seam derivation doesn't cross (below). |
| Audit | D005 (System β Debug tools) | Finds the guard nobody wrote. See the audit. |
This used to be four hand-written lists β the permission, its description, the tab layout, and the setting-key map β with a self-check to catch them drifting apart. Needing that check was the bug. Don't reintroduce it: if you find yourself writing a capability's details in two places, derive one from the other.
Two halves, in the same commit: the constant, and the manifest entry that claims it.
// 1. includes/capabilities.php β the TYPE (hand-written; it cannot be derived)
final class Cap
{
const ASSETS_VCENTER = 'assets.vcenter';
}// 2. asset-management/settings/manifest.php β the DECLARATION (everything else derives from this)
[
'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', // the PERMISSION's description, in System β Roles
'sensitive' => true, // reaches credentials β badged
'setting_keys' => ['vcenter_server', 'vcenter_user', 'vcenter_password'],
]That's it. The tick-box appears on System β Roles, the tab appears for anyone who holds it, and those three setting keys become writable only by holders. No migration, no schema change, and no other file to edit.
Mark 'sensitive' => true for anything reaching credentials, email, money, or the audit trail.
A Cap:: constant that no manifest claims. Derivation can't catch it, because the constant is the hand-written half.
It is not cosmetic. 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 is 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, a missing description, two umbrellas in one module, and a dangling alias). It runs inside D005.
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).
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.
The worked example is Asset Management (#830) β 8 tabs, 7 capabilities, 16 endpoints. Follow this order.
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.
// 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.
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".
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 writeWarning
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.
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.
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.
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.
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.
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.
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.)
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.
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-]*"'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.
- One
Cap::constant per settings tab, plus a<module>.manageumbrella. -
<module>/settings/manifest.phpwritten: every tab has acap, alabel_keyand agrant;sensitiveset 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();activeclass 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.
- Roles & Permissions β the model and how it's enforced
- Module Access β Developer Guide β the Layer-1 twin
-
Admin Access Control β the
is_admingate - Raising the PHP floor to 8.1 β why constants, and what an enum would buy
- Security β the whole picture
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)