Skip to content

Module Access Developer Guide

Ed Mozley edited this page Jul 11, 2026 · 1 revision

Module Access β€” Developer Guide

How to wire a new module, page, or endpoint into the module-access enforcement built in Module Access Control Phase 3, so it respects who's allowed where for free. If you skip this, your new code is an open door β€” a restricted analyst can reach it by typing the URL.

This is the module-access twin of the "Adding a new System feature" checklist on Admin Access Control; the two guards behave the same way and follow the same discipline.

The one rule to remember: a page or a write/mutation endpoint in a module gets a guard. A shared read that other modules or the login page depend on does not. When unsure, grep the codebase for who calls it before gating.


The two guards

Both live in includes/functions.php, take only the module key, open their own DB connection, and fail closed. 'system' is not a module key here β€” it defers to is_admin (see Admin Access Control); any unknown key is denied.

Guard Use on Effect on a denied analyst
requireModuleAccess('<key>') A module page 302 redirect to the launcher (?denied=<key>)
requireModuleAccessJson('<key>') A module write API 403 { success:false, error:… } and exit

Both need includes/functions.php loaded first, and must run before any output (pages) or any DB write (APIs).

Page β€” requireModuleAccess()

session_start();
require_once '../config.php';
require_once '../includes/functions.php';   // <-- must be loaded
require_once '../includes/i18n.php';
// ... theme / timezone init ...

requireModuleAccess('assets');              // <-- before any HTML

$current_page = 'assets';
?>
<!DOCTYPE html> ...

Write API β€” requireModuleAccessJson()

session_start(['read_and_close' => true]);
require_once '../../config.php';
require_once '../../includes/functions.php'; // <-- must be loaded

if (!isset($_SESSION['analyst_id'])) { /* existing 401 auth check */ }
requireModuleAccessJson('assets');           // <-- right after auth, before writes

Note the include depth: pages are one level deep (../includes/…), API endpoints two (../../includes/…).


Module keys (and the key β‰  directory gotcha)

The canonical list of module keys is getModuleRegistry() in includes/functions.php. The guards, the summary screen, and the effective-access tool all iterate it. The key you pass to a guard must be one of these.

Several keys don't match their directory β€” pass the key, not the folder name:

Key (pass this) Page directory API directory
assets asset-management/ api/assets/
changes change-management/ api/change-management/
problems problem-management/ api/problem-management/
wiki system-wiki/ api/wiki/
(all others) same as key api/<key>/

Adding a brand-new module

A module isn't "known" to access control until its key is in the registry. Wire these, in this order:

  1. Register the key β€” add it to the $keys array in getModuleRegistry() (includes/functions.php). This is what makes it appear on System β†’ Modules, in the effective-access tool, and gate correctly.
  2. Launcher entry β€” add a '<key>' => ['name' => t('common.modules.<key>.name'), 'path' => '<dir>/', 'icon' => '…'] row to $modules in includes/waffle-menu.php.
  3. Landing-page card β€” in the root index.php, wrap your card in the standard visibility check so it auto-hides for denied analysts:
    <?php if ($allowed_modules === null || in_array('<key>', $allowed_modules)): ?> … card … <?php endif; ?>
  4. Display name β€” add '<key>' => ['name' => '…', 'description' => '…'] under modules in every lang/<locale>/common.php (at least en).
  5. Page gate β€” put requireModuleAccess('<key>') on every user-facing page in the module's directory (index + any sub-pages / help pages).
  6. API gate β€” put requireModuleAccessJson('<key>') on every write/mutation endpoint in api/<dir>/; leave shared reads open (see below).

Steps 1–4 make the module visible and configurable; steps 5–6 make it enforced. Do all six β€” visibility without enforcement is the exact hole Phase 3 closed.


Adding a page to an existing module

One line: requireModuleAccess('<moduleKey>'), after config.php + functions.php load and before any HTML. If the page didn't already require_once functions.php, add it. That's it β€” the rest is inherited.

Adding an endpoint to an existing module

Decide read vs write:

  • Writes (create/update/delete/save/assign/import/send/reorder/toggle/… β€” anything that mutates or performs an action): add requireModuleAccessJson('<moduleKey>') right after the endpoint's existing analyst_id auth check. Add the functions.php require if missing.
  • Reads (get_/list_/search_/export_/feeds/attachments): leave open if anything outside the module calls them β€” owner pickers, KB suggestions, CMDB object reads, calendar feeds, etc. are consumed cross-module and gating them breaks normal flows. If the read is genuinely private to this module and never called from elsewhere, you may gate it, but the safe default is open.

The test: grep -rn "your_endpoint.php" --include=*.js --include=*.php . β€” if only your module's own front-end calls it and it mutates, gate it. If another module (or login.php) calls it, or it's a pure read, leave it open.

Endpoints that are analyst-triggered UI actions but run through an engine/webhook/cron (e.g. the workflow "test fire") still get the guard as long as they carry their own analyst_id session check β€” they're reached from the UI, not by the headless worker (the worker calls the engine PHP directly, not the HTTP endpoint).


Gotchas

  • functions.php must be loaded, or the guard is an undefined-function fatal. Most endpoints already require it; add it if not.
  • Order matters β€” the page guard must run before any byte is sent (it uses header()), and the API guard before any write.
  • Fail closed β€” both guards deny on any DB/exception. Don't wrap them in a try that swallows the exit.
  • Restricted analysts don't auto-inherit new modules. A restricted analyst's access is a fixed list; when you ship a new module, all-access analysts get it immediately (their access is "all"), but restricted ones won't until an admin grants it on System β†’ Modules. That's correct β€” new surface area shouldn't silently open to someone who was deliberately restricted.
  • system is separate β€” never pass 'system' to these guards for a normal module; System is gated by is_admin via includes/admin_api_guard.php. See Admin Access Control.
  • Key, not directory β€” pass 'assets', not 'asset-management' (see the table above).

Verifying your wiring

  1. Lint everything you touched: php -l <file> β€” must be clean.
  2. Coverage grep β€” confirm the module's pages and write endpoints carry a guard:
    grep -rl "requireModuleAccess" <dir>/ api/<dir>/
    
  3. Live test with the effective-access tool β€” on System β†’ Modules, use the Access level panel to restrict a test analyst, then the effective-access checker to confirm your module shows ❌, and try to open its page/POST its write endpoint as that analyst β€” you should be bounced (302) / 403. Then grant it and confirm access returns. This tool doubles as the verification harness for the whole feature.

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally