-
Notifications
You must be signed in to change notification settings - Fork 15
Admin Access Control
How FreeITSM decides who may enter the System module β the administrators-only area that manages analysts, teams, company access, SSO, security, API keys, demo data and database verification. This is a developer-facing deep dive into the actual mechanism: the is_admin flag, the two enforcement layers, the exact code, and β most usefully β the scoping decisions and edge cases that make it correct.
Shipped in changelog #781, closing GitHub issue #34. Related: Security, System.
Before this, FreeITSM had no administrator concept. Every analyst was, in effect, an admin:
- The
analyststable had nois_admin/rolecolumn β all accounts were equal. - The System module was hard-coded to always be reachable.
getAnalystAllowedModules()literally force-added'system'to every analyst's allowed-module list, so it could never be switched off. - The System pages and APIs only checked "are you logged in?", never "are you allowed?". A page did
if (!isset($_SESSION['analyst_id'])); a save endpoint did the same, then rewrote whatever record the request body named.
The combined effect: any authenticated analyst could manage every analyst, team and company-access grant, reconfigure SSO/security, read API keys, run DB verify, or load/wipe demo data. Most sharply, on a multi-company install a company-restricted analyst could open System β Analysts (or POST straight to the endpoint) and grant themselves access to any company β a textbook broken access control / privilege-escalation path. That's what issue #34 reported.
A single boolean on the analyst:
`is_admin` TINYINT(1) NOT NULL DEFAULT 0 -- new analysts are non-adminAdmin is the one and only gate into System. It is deliberately not the same thing as the per-analyst module list (analyst_modules), which governs the other modules. Keeping admin as its own flag β rather than "has the system module ticked" β means admin rights can't be granted or revoked as an accidental side effect of a module checkbox.
Enforcement is layered:
- A page gate (UX): non-admins never see System β the launcher and landing card hide it, and any System URL bounces them to the landing.
-
A hard API gate (security): every System-mutating endpoint returns
403to non-admins, so a hand-craftedPOSTcan't get around the hidden UI. This is the layer that actually enforces security β the page gate is convenience on top.
All in includes/functions.php.
function analystIsAdmin(PDO $conn, int $analystId): bool {
if ($analystId <= 0) return false;
$stmt = $conn->prepare("SELECT is_admin FROM analysts WHERE id = ?");
$stmt->execute([$analystId]);
return (int) $stmt->fetchColumn() === 1;
}A fresh DB read every call. Use it wherever a wrong answer would be a security bug β because it never trusts cached state, a just-demoted analyst can't keep acting on a stale session.
function requireAdminJson(PDO $conn): void {
$id = (int) ($_SESSION['analyst_id'] ?? 0);
if (!$id || !analystIsAdmin($conn, $id)) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Administrator access required']);
exit;
}
}function sessionIsAdmin(): bool {
if (!isset($_SESSION['analyst_id'])) return false;
if (!array_key_exists('is_admin', $_SESSION)) {
try {
$conn = connectToDatabase();
$_SESSION['is_admin'] = analystIsAdmin($conn, (int) $_SESSION['analyst_id']) ? 1 : 0;
} catch (Throwable $e) {
return false; // fail closed
}
}
return !empty($_SESSION['is_admin']);
}This is the interesting one. It reads $_SESSION['is_admin'], but if that key is absent β e.g. a session that was created before this feature shipped β it self-heals: it looks the flag up once and caches it in the session. That single design choice removed a whole class of problems:
-
No login-path changes. We didn't have to hunt down and edit every place that establishes a session (password login, the OIDC callback, the MFA/OTP step, "trust this device", β¦) to stamp in
is_admin. Miss one and you'd lock an admin out; the lazy populate makes that impossible. - No forced re-login on upgrade. Existing logged-in sessions just pick the flag up on their next page load.
- It fails closed β any error resolving the flag denies access.
The trade-off: sessionIsAdmin() writes to the session, so it needs a writable session (not session_start(['read_and_close' => true])). That's fine, because it's only used on pages (which have writable sessions). The APIs, many of which open the session read-only, use requireAdminJson() instead β which never touches the session and re-checks the DB every call. Clean split: pages get a cheap cached check, APIs get an authoritative uncached one.
And the force-include is gone β getAnalystAllowedModules() no longer appends 'system':
if (empty($rows)) {
return null; // No restrictions β full access
}
return $rows; // (previously: force-added 'system' here)Almost every System page includes a shared header, system/includes/header.php, which already did the login check. The admin gate slots in right after it:
require_once $path_prefix . 'includes/functions.php'; // guarantee the helper exists
if (!isset($_SESSION['analyst_id'])) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// The System module is administrators-only β EXCEPT per-user Preferences, which
// every analyst manages for themselves. Non-admins are bounced to the landing.
if (($current_page ?? '') !== 'preferences' && !sessionIsAdmin()) {
header('Location: ' . BASE_URL);
exit;
}Two subtleties worth calling out:
-
The Preferences exception. System is not wholly admin-only:
system/preferences/holds each analyst's own interface settings (theme, language, display timezone). If we'd gated the whole module, non-admins would lose their own preferences. Each page sets$current_pagebefore including the header, so the gate simply exemptspreferences. -
require_once functions.phpin the header. The gate runs before the header pulls in the waffle menu (which is what would otherwise loadfunctions.php), so the header loads it explicitly β otherwisesessionIsAdmin()would be undefined and fatal every System page.
The four header-less debug tools (system/debug-tools/dNNN/) render through a shared tool-page.php rather than the header, so the same gate lives there too.
The waffle launcher renders on every page, so it needs sessionIsAdmin() available everywhere β hence includes/waffle-menu.php also require_onces functions.php. The filtering has an ordering subtlety:
foreach ($modules as $key => $module):
// System visibility is governed by admin status ALONE (not the per-analyst
// module list) β so an admin with module restrictions still sees it, and a
// non-admin never does. All other modules honour the allowed-modules list.
if ($key === 'system') {
if (!sessionIsAdmin()) continue;
} elseif ($allowed !== null && !in_array($key, $allowed)) {
continue;
}The system branch must come first. If you left the old if ($allowed !== null && !in_array($key, $allowed)) continue; ahead of it, an admin who also had a restricted module list (which no longer contains system, now the force-include is gone) would have System filtered out before the admin check ran. So: System's visibility is decided purely by admin status; every other module still obeys the allowed-list. The landing page (index.php) gates its System card the same way, on sessionIsAdmin() alone.
A single shared include, includes/admin_api_guard.php:
require_once __DIR__ . '/functions.php';
if (!isset($_SESSION['analyst_id'])) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
requireAdminJson(connectToDatabase());Guarded endpoints add one line right after they load config:
require_once '../../config.php';
require_once '../../includes/admin_api_guard.php'; // System admins only (issue #34)It's applied to 41 endpoints: the analyst/team/company-access mutations that live under api/tickets/ (save_analyst, delete_analyst, reset_analyst_password, save_analyst_teams, save_team, delete_team, save_team_analysts, save_team_companies, save_team_departments), plus the SSO / branding / tenant-domain / API-key / encryption / demo-data / debug actions under api/system/.
Minor cost, deliberately accepted: the guard opens a connection for its check and the endpoint opens its own afterwards β two connections per request. On these cold, admin-only endpoints that's irrelevant, and it keeps the guard a drop-in one-liner rather than something that has to thread a
$connback out.
This is the part that takes judgement, and where a careless sweep would break the app. Several endpoints look like "System" but are reached by normal, non-admin (or even anonymous) flows. Guarding them would break everyday use, so they are intentionally left open:
| Endpoint(s) | Why it must stay open |
|---|---|
get_user_preference / set_user_preference
|
Per-user settings β every analyst uses them |
set_active_tenant |
The company switcher β used by any analyst with multi-company access |
get_tenants |
The switcher's company list β called from the tickets inbox, triage, change management, settingsβ¦ everywhere |
get_branding |
Read by every page to render the branded header |
get_sso_providers |
Read by the login page β i.e. anonymous, pre-authentication |
ai/get_settings |
Read by AI features across modules |
get_analysts, get_teams, get_departments, β¦ |
Ticket owner dropdowns, assignment, filtering |
db_verify |
The setup wizard runs it before any account exists / anyone is logged in. Guarding it would break first-time install. It's idempotent and grants nothing, so it stays open |
The rule of thumb, and the thing to remember when adding new endpoints:
A mutation or destructive action in System β guard it. A read that any normal flow (or the login page) depends on β leave it open. When in doubt, check who calls it (
grepthe JS) before gating.
One more boundary that trips people up: team management moved into System (admin), but ticket departments stayed in Tickets β Settings (not admin). The departmentβteam link is editable from both sides β save_team_departments (the System/Teams side, guarded) and save_department_teams (the Tickets-settings side, open). That's intentional: linking a department to a team is ticket configuration, not an access grant, so it isn't an escalation vector and mustn't be locked behind admin.
Guarding the reads too would be defence-in-depth against information disclosure, but the trade β risking a broken dropdown somewhere β isn't worth it; the escalation and destructive vectors are what matter, and those are all closed.
An admin-only area is only safe if you can't accidentally end up with zero admins. Three safeguards:
New analysts default to non-admin, but on the run that first adds the column, existing analysts are promoted to admin β otherwise an upgrade would lock everyone out. db_verify detects "was the column absent before this run?" with the same one-time-probe pattern it uses for multi-tenancy back-fills:
// Probe BEFORE the schema loop adds the column.
$analystIsAdminColWasMissing = false;
try {
$iaProbe = $conn->prepare("SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = ? AND table_name = 'analysts' AND column_name = 'is_admin'");
$iaProbe->execute([$dbName]);
$analystIsAdminColWasMissing = ((int)$iaProbe->fetchColumn() === 0);
} catch (Exception $e) {}
// ... schema loop runs, adding is_admin as DEFAULT 0 ...
// One-time grandfather β only on the run that first added the column.
if ($analystIsAdminColWasMissing) {
$conn->exec("UPDATE analysts SET is_admin = 1");
}Probing before the schema loop is essential: once the column exists, the flag is managed deliberately, so the back-fill must never run again (or it would re-promote everyone you'd carefully demoted). Admins then demote whoever should be a regular analyst.
Both the SQL bootstrap and db_verify's "no analysts exist" seed insert is_admin = 1 for the default admin account.
save_analyst (demote/deactivate) and delete_analyst both refuse if the change would leave no active admin:
if ($id) {
$wasAdmin = (int)($conn->query("SELECT is_admin FROM analysts WHERE id = ".(int)$id)->fetchColumn()) === 1;
$losingAdmin = $wasAdmin && (!$isAdmin || !$isActive); // demote OR deactivate
if ($losingAdmin) {
$otherAdmins = (int)$conn->query("SELECT COUNT(*) FROM analysts
WHERE is_admin = 1 AND is_active = 1 AND id <> ".(int)$id)->fetchColumn();
if ($otherAdmins === 0) {
echo json_encode(['success' => false,
'error' => 'This is the last active administrator β grant admin to another analyst first.']);
exit;
}
}
}Note it counts active admins and treats deactivating the last admin as dangerous as demoting them β an inactive admin is just as much a lock-out.
The analyst form (System β Analysts) gains an Administrator toggle mirroring the existing Active toggle, with i18n keys tickets.settings.modals.analyst.is_admin / is_admin_help (English + Brazilian Portuguese). save_analyst persists the flag in its INSERT/UPDATE, and get_analysts returns it so the form can pre-tick it on edit. get_analysts is a read used by ticket dropdowns and is not admin-gated, so it exposes the flag broadly β that's harmless (knowing who is an admin isn't sensitive).
- Closes: an authenticated non-admin escalating their own access, tampering with other analysts/teams/company grants, or hitting destructive System actions β via the UI or a direct request.
- Assumes: analysts are onboarded, trusted staff behind the app's own authentication. This is authorisation between authenticated users, not protection against an anonymous attacker (that's what login, MFA and brute-force protection are for).
- Not in scope: finer-grained, per-module role-based access control (e.g. "can manage tickets but not assets"). This is a single admin/non-admin axis. A future RBAC layer would build on the same helpers and the same "guard the mutations, not the shared reads" discipline.
-
New page under
system/? If it includessystem/includes/header.php, it's admin-gated automatically. If not (like the debug tools), add thesessionIsAdmin()bounce yourself. -
New endpoint that mutates config or performs an admin action? Add
require_once '.../includes/admin_api_guard.php';right after the config require. Adjust the../depth for the directory. - New endpoint that's a read used by normal ticket flows, or by the login page? Do not gate it.
- Touching the last-admin invariant? Preserve the "can't remove the last active admin" checks.
- Run System β Database Verify on any existing install to add the column and grandfather.
- Security β authentication, encryption, brute-force protection
- System β the module this governs
- GitHub issue #34 β the original report
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
- β³ π’ Ticket numbering
- β³ π 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)