Skip to content

Admin Access Control

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

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.


The problem it fixed

Before this, FreeITSM had no administrator concept. Every analyst was, in effect, an admin:

  1. The analysts table had no is_admin/role column β€” all accounts were equal.
  2. 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.
  3. 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.


The model

A single boolean on the analyst:

`is_admin` TINYINT(1) NOT NULL DEFAULT 0   -- new analysts are non-admin

Admin 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 403 to non-admins, so a hand-crafted POST can't get around the hidden UI. This is the layer that actually enforces security β€” the page gate is convenience on top.

The three helpers

All in includes/functions.php.

analystIsAdmin() β€” authoritative

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.

requireAdminJson() β€” the API gate

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;
    }
}

sessionIsAdmin() β€” the cheap UI check

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)

Layer 1 β€” the page gate

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_page before including the header, so the gate simply exempts preferences.
  • require_once functions.php in the header. The gate runs before the header pulls in the waffle menu (which is what would otherwise load functions.php), so the header loads it explicitly β€” otherwise sessionIsAdmin() 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.

Hiding it in the launcher and landing

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.


Layer 2 β€” the hard API gate

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 $conn back out.


The scoping β€” what is deliberately not guarded

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 (grep the 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.


Never locking yourself out

An admin-only area is only safe if you can't accidentally end up with zero admins. Three safeguards:

1. Grandfather existing analysts on upgrade

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.

2. The seeded default admin is an admin

Both the SQL bootstrap and db_verify's "no analysts exist" seed insert is_admin = 1 for the default admin account.

3. You cannot remove the last administrator

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 UI

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).


Threat model β€” what this does and doesn't cover

  • 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.

Adding a new System feature (contributor checklist)

  1. New page under system/? If it includes system/includes/header.php, it's admin-gated automatically. If not (like the debug tools), add the sessionIsAdmin() bounce yourself.
  2. 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.
  3. New endpoint that's a read used by normal ticket flows, or by the login page? Do not gate it.
  4. Touching the last-admin invariant? Preserve the "can't remove the last active admin" checks.
  5. Run System β†’ Database Verify on any existing install to add the column and grandfather.

Related pages

  • Security β€” authentication, encryption, brute-force protection
  • System β€” the module this governs
  • GitHub issue #34 β€” the original report

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally