Skip to content

Module Availability Developer Guide

Ed Mozley edited this page Oct 27, 2026 · 2 revisions

Module availability β€” developer guide

How switching a whole module off for the installation works (#217). Users: Switching modules off. Design and next steps: Module availability β€” design.


The one-paragraph version

The list of switched-off modules lives in one setting. analystCanAccessModule() says "no" for a switched-off module, and almost everything β€” every module page, ~700 endpoint gates, the launchers, most cross-module buttons β€” already asks that function, so they follow for free. The rest of this page covers the places that don't ask it: the REST API, workflows, cron jobs, the self-service portal, device agents and public links, plus a handful of screens that used raw grants. Each of those has its own small check, and a test fails if a new REST resource or workflow trigger is added without a module.


The setting

system_settings.modules_disabled β€” a JSON list of the module keys that are off. Everything else is on.

// includes/module_availability.php
const MODULES_ALWAYS_ON = ['tickets', 'system'];

function disabledModules(PDO $conn): array          // cached per request; [] if unreadable
function moduleIsEnabled(PDO $conn, string $key): bool
function moduleDisabledMessage(string $key): string // "Tasks is switched off for this installation."
function requireModuleOnJson(PDO $conn, string $key): void   // 403 + module_disabled, for non-analyst endpoints
function portalModuleOn(string $key): bool          // no connection in hand
function cronModuleSwitchedOff(PDO $conn, string $key, string $job, ?string $trigger): bool

Three decisions, all in the file's header:

  • Only the off list is stored. An upgrade, Database Verification or a fresh install means "everything on" with nothing seeded β€” and a module added in a later release is on everywhere without anybody adding it to a row.
  • It fails open. An unreadable setting means everything on. This is a tidiness switch, not a security control: access grants and is_admin are the security, and they still apply.
  • Tickets and System are never off, even if the stored list names them.

There is no schema change: system_settings already exists, so database/freeitsm.sql and DB Verify are untouched.

Saved by api/system/save_module_availability.php (admins, CSRF, refuses Tickets/System with 422). Shown in System β†’ Modules β†’ Availability (system/modules/index.php), which also tags switched-off rows in the access table; api/system/get_effective_module_access.php gives Switched off for the whole installation as the reason.


Layer 1 β€” the gate everything already asks

function analystCanAccessModule(PDO $conn, int $analystId, string $moduleKey): bool {
    if ($analystId <= 0) return false;
    if ($moduleKey === 'system') return analystIsAdmin($conn, $analystId);
    // A module switched off for the install is off for everyone, administrators
    // and all-modules analysts included (GH #217). ...
    if (!moduleIsEnabled($conn, $moduleKey)) return false;

That one line covers requireModuleAccess() (pages redirect to ?denied=), requireModuleAccessJson(), requireModuleWriteJson(), analystModuleLevel(), the MCP tools, record previews, the Recent trail, report packs, and every page flag built from it (TICKETS_SHOW_DOMAINS, TICKETS_SHOW_PROJECTS, the new TICKETS_MODULE_ON, and so on).

The JSON gates say switched off, not "no access" or "read-only", and add module_disabled: '<key>' to the body.

getAnalystAllowedModules() is still the grants

It deliberately does not subtract switched-off modules: System β†’ Modules shows grants, and an all-modules analyst is still "all modules" there. Anything that decides what an analyst may open from that list goes through:

modulesWithoutSwitchedOff(PDO $conn, ?array $allowed): ?array   // null only when nothing is off

analystAllowedModulesNow() (so analystVisibleModules(), the waffle, home page, sidebar and ⌘K) uses it, as do the four callers that used raw grants: includes/search/search.php, includes/case_report.php, includes/services/analysts.php (the effective modules) and tasks/index.php (now analystCanAccessModule()).

requireAnyModuleAccessJson() and its $owner

"Any of these modules" outlived a switched-off module: Knowledge's articles answered anyone with Tickets after Knowledge was off. When the endpoint's data belongs to one module, pass it:

requireAnyModuleAccessJson(['knowledge', 'tickets', 'lms'], null, 'knowledge');

Set on Knowledge's article/tag/AI-chat endpoints, Calendar's events and categories, Tasks' list and contractors, and the asset↔CI link. A shared lookup β€” cost centres, analysts, departments, settings β€” has no owner and passes none.


Layer 2 β€” places that never asked

Area Where What it does
REST API v1 api/v1/lib/modules.php, called from api/v1/index.php after apiRequirePermission() API_V1_RESOURCE_MODULES maps every permission resource to its module(s) β€” all must be on. reference lookups map by path (API_V1_REFERENCE_PATH_MODULES). Two routes add a second module by path (/projects/{id}/tasks, /problems/{id}/changes). Answers 403 module_disabled.
Workflow triggers WorkflowEngine::TRIGGER_MODULES (by the event's first segment), checked in dispatch() and run() Nothing fires while Workflows, or the event's module, is off. run() covers Test fire and the REST fire: an aborted run with the reason. The check sits after notifications and search indexing, which are not workflows.
Workflow actions WorkflowEngine::ACTION_MODULES, checked in executeActionList() create_task, create_project and attach_checklist are skipped (status skipped, reason in the result), and the run continues β€” like a confidential ticket's email. Covers stored workflows and forms' own action lists.
Workflow editors WorkflowEngine::withoutSwitchedOff() in workflow/editor.php and forms/edit/index.php Only switched-on triggers and actions are offered, plus whatever the workflow or form already uses, so it still opens and saves. availableTriggers() / availableActions() stay complete on purpose β€” api/workflow/save.php validates against them.
Cron cronModuleSwitchedOff() lms_reminders (lms), domains, task_recurrence (tasks), webhook_deliveries (workflow), proxmox_sync / vcloud_sync (assets). Recorded as a JobRun skip. workflow_scheduled gates per part instead: contract expiries (contracts), warranty expiries (assets), planned maintenance (service-status), project alerts (projects).
Job health includes/job_registry.php Those jobs' needed closures are false while their module is off, and pushed Hyper-V sources vanish with Assets β€” so nothing reads as overdue.
Calendar sync includes/calendar_sync/pull.php, api/tickets/schedule_feed.php No task rows while Tasks is off.
Calendar writers asset warranty, software licence, domains, projects, leases Return synced => 0 and touch nothing while Calendar is off.
Portal self-service/includes/header.php ($portalNavModules), the endpoints, self-service/index.php Help Centre (knowledge), Training (lms), My equipment (assets) and the Catalogue (forms) lose their tab and redirect home; their endpoints call requireModuleOnJson(); the dashboard drops Service Status, requests and popular articles. LMS portal learners are stopped in one place: LmsLearner::user() returns null.
Device agents api/external/{system-info,device-manager,software-inventory}/submit Refused after the key check β€” nothing recorded, nothing queued.
Public links api/lms/test_public.php, lms/test.php A test link reads as not valid.
Inbound integrations api/webchat/send.php, includes/integrations/zabbix.php No AI answer without Knowledge; no CI lookup/link without the CMDB.
Notifications NotificationsService::switchedOffClause() Not listed or counted, not deleted β€” they return with the module.
Watchtower includes/watchtower_queries.php A card whose wtCardRegistry() module is off is hidden.
Checklists ChecklistsService::assertClosureAllowed(), checklists/ticket_view.js A "block" closure rule cannot trap tickets while nobody can attach a checklist.

The ticket reading pane

tickets/index.php builds $ticketsModuleOn once β€” analystCanAccessModule() for problems, changes, assets, cmdb, tasks, checklists, knowledge β€” and uses it for the right-click menu and as window.TICKETS_MODULE_ON. In assets/js/inbox.js, ticketModuleOn(key) drives Link to…, the problem/change pills, Ask AI, and the asset/CMDB/task loaders. The endpoints behind them (api/tickets/get_ticket_assets.php and friends) checked only Tickets, so they now also call requireModuleOnJson() β€” availability only, so who may link an asset to a ticket is unchanged.

Other modules' screens β€” two kinds of check

Where the target module's own API already refused an analyst without access, the screen uses analystCanAccessModule(). Where today's screen deliberately works without the grant (Problems linking a change, Assets' Software and Contracts tabs, Leases' contract picker, an LMS author's KB article picker, Intune reporting), it uses moduleIsEnabled(), so not granted behaves exactly as before and only switched off hides it.


Tickets features (slice 2)

TICKET_FEATURES in includes/module_availability.php β€” csat, sla, time-tracking, rota β€” share the modules' switched-off list, so moduleIsEnabled($conn, 'sla'), requireModuleOnJson() and cronModuleSwitchedOff() work for them unchanged. A feature switch is availability only: who may use the feature is untouched. System β†’ Modules lists them under Ticket features; api/system/get_module_access.php returns them as features.

Feature The checks
SLA sla_get_state() returns enabled=false first β€” that one line silences the pane, workspace summary, list pill, batch endpoint and breach check. cron/sla_breach_check.php skips (recorded in both histories); job health maps sla_breach. Settings writes, rules and cron-run endpoints refuse; get_sla_settings.php still answers, because the calendars stay for web chat. REST ticket_sla needs ['tickets','sla']; workflow segment sla maps to the feature; bell rows sla.warning / sla.breached are hidden; Watchtower's paused count is not run.
Time tracking timeTrackingUiOn() / timeTrackingApiOn() return false first, so every existing per-company gate follows. TicketsService::create/update/deleteTimeEntry() refuse with module_disabled β€” deleting was never gated before. REST ticket_time_entries needs ['tickets','time-tracking']. AI write-up and gap analysis stop reading minutes.
CSAT sendCsatSurvey() returns CSAT_RESULT_OFF first (covers every close path and in-chat sends). request_csat.php refuses. tickets/csat/survey.php shows no longer collecting feedback and writes nothing. In includes/messaging/ingest.php a digit falls through as a message; a button press is swallowed (Telegram still gets answerCallbackQuery).
Duty rota tickets/rota.php redirects; the 13 rota endpoints refuse; the War Room / MCP on_call tool carries 'feature' => 'rota' and is dropped from the tool list; includes/projects/capacity.php treats desk hours as off.

The ticket page adds the features to $ticketsModuleOn (via portalModuleOn() β€” availability, not access), so ticketModuleOn('csat') and friends work in inbox.js; the workspace hides its Time tab when both SLA and Time tracking are off.

Switching SLA back on quietly

sla_run_breach_check($conn, true) walks the same tickets and triggers but sends nothing: it writes each warning and breach into sla_notifications_sent (recipients (not sent: SLA switched back on)) and, only where a workflow is listening, into workflow_scheduled_emissions (sla_mark_workflow_event()). api/system/save_module_availability.php runs it after an off→on flip of sla unless send_missed is true; it re-reads the list first with disabledModules($conn, true). Proven on a throwaway install with a genuinely breached ticket: four marks, nothing in email_send_log, and the next normal run silent for it.

Traps

git grep -n "GH #217"    # every change this made
  • TRAP: a new REST resource for a switchable module goes in API_V1_RESOURCE_MODULES, or it keeps answering while its module is off.
  • TRAP: a new trigger family goes in TRIGGER_MODULES (or TICKET_TRIGGER_SEGMENTS), or its workflows keep firing.
  • TRAP: pass $owner to requireAnyModuleAccessJson() when the data belongs to one module.
  • TRAP: never decide what someone may open from getAnalystAllowedModules() β€” wrap it in modulesWithoutSwitchedOff(), or call analystCanAccessModule().
  • TRAP: it fails open. Don't "fix" that into failing closed: a database hiccup would look like half the product vanishing.

Tests

Test Needs Checks
tests/module-availability.php Nothing 16 β€” every REST resource and workflow trigger has a module (control: remove one and it fails); the maps and Watchtower cards name real modules or Tickets features; a ticket's SLA and time entries need their feature; no feature key collides with a module; the setting reader (no row = all on, a list, Tickets/System never off, a broken value fails open), one process per case on SQLite

The end-to-end proof was run against a throwaway install, not committed (it switches a module off on the install it runs against). 20 checks, all passing: Tasks off through the real endpoint β†’ its page redirects, its endpoint and the REST API answer module_disabled, REST Tickets still answers, the home page and the ticket pane stop offering it, the access checker gives the reason, Test fire is aborted saying why, Tickets refuses to be switched off; then back on and everything answers again.


Adding a new module

  1. Add its key to getModuleRegistry() and the waffle β€” it is on everywhere automatically.
  2. Its pages and endpoints use requireModuleAccess() / requireModuleAccessJson() as always β€” that's availability done for them.
  3. Its REST resources go in API_V1_RESOURCE_MODULES; its trigger prefixes in TRIGGER_MODULES; any action that writes into it in ACTION_MODULES; its cron job uses cronModuleSwitchedOff().
  4. Run php tests/module-availability.php.

See also: Module Access β€” developer guide Β· REST API Β· Workflow engine β€” developer guide Β· Code traps

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally