-
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 β except a read that returns credentials. 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 the everyday module depends on and you break the module for everyone. But ask what the response contains, not just what the endpoint does β
get_mailboxes.phpwas a "read", and it was shipping the plaintext OAuth client secret to every logged-in analyst.- 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.
Every one of these was learned the hard way, converting sixteen modules. They are worth more than the mechanics.
-
A read that returns credentials is not an operational read. As above. The mask-then-forget-to-drop-the-original bug is easy to write and invisible in the UI.
-
A configuration change offered as a shortcut from an operational screen still needs the configuration permission. Dragging a Kanban column reorders the statuses for everybody. Adding a property option from the CMDB object page changes the schema. Same act, different route β same permission.
-
Creating is not always administering. The Kanban board lets you type a new tag straight onto a task, so creating a tag is everyday work while deleting one is administration. Let the capability describe what it actually controls, and say so in its
granttext. -
An endpoint that serves several tabs cannot have one guard. It must authorise per setting key. This turned out to be a recurring shape, not a one-off: the shared settings writer, the AI panel shared by seven modules, Tasks' three-tab settings endpoint, Knowledge's Email/Recycle-bin endpoint. Reuse
analystCanWriteSettingKey(); don't invent a second mechanism. -
A feature reachable from several modules needs
requireAnyModuleAccessJson(). The knowledge-base chat is offered in Knowledge and in the ticket reading pane. Gating it on one breaks the other; gating it on nothing lets anyone with a login spend your AI budget. -
Converting a module is an audit of it. Every single conversion found something β unguarded endpoints, dead endpoints, a settings page with no module check at all, and bugs with nothing to do with permissions (saving the Knowledge recycle-bin retention silently wiped the email settings). Expect to find, not merely to move.
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.)
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.
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.
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.
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.
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.
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:
- D005 checks for it statically and reports it as BROKEN, above every other finding. Run it after adding guards.
- 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"Automating a guard insertion across many endpoints is sensible. Just know that line endings are not uniform in this repo: api/messaging/* is CRLF, most of api/tickets/* is LF.
A script that searched for "}\n" therefore found nothing in the CRLF files, strpos() returned false, false + 2 evaluated to 2, and the guard was spliced into the middle of <?php in ten files.
And here is why that was nearly fatal: they still passed php -l. Short open tags made the mangled <? valid, so the syntax check was green on ten thoroughly broken files. It was the three-actor HTTP test that caught it β an endpoint returned 200 to someone who should have been refused.
If you script an edit: match \r?\n, and afterwards assert every file still begins with <?php.
D005 once reported db_verify.php β the endpoint that creates and alters every table in the database β as "signature-authenticated", because it scanned for the word relay_secret anywhere in the file and db_verify declares a relay_secret column in its schema definitions. It had no permission check at all.
Two lessons, both now fixed in the tool:
-
Match the call, not the word.
hash_equals(, nothash_equals. -
Check your write-detection covers what you think it does. The same tool classed
db_verifyas a read, because its SQL patterns includedALTER TABLEandDROP TABLEbut notCREATE TABLE.
A scanner that mistakes a column name for a guard is worse than no scanner: it hides the very hole it exists to find. When you extend D005, test it by deliberately breaking something and confirming it complains.
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 |
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-]*"'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)