-
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.)
| 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. |
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.
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.
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.
capSelfCheck(); // registry β constants β modules
settingsManifestSelfCheck($manifest); // manifest β registry β settings_keysThree 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.
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.)
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 and onecapRegistry()entry per settings tab, in the same commit;sensitiveset on anything touching credentials/email/money. - A
<module>.manageumbrella ('umbrella' => true). -
<module>/settings/manifest.phpwritten; 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; anything found unguarded is fixed, not ignored.
- Operational reads left on module access. Callers checked, not names.
-
settings_keys.phprows promoted fromnullto the real capability. -
capSelfCheck()andsettingsManifestSelfCheck()clean. - 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)