Skip to content

PHP Enums and RBAC

Ed Mozley edited this page Jul 14, 2026 · 2 revisions

PHP enums and the RBAC capability system

Why a capability is a Cap:: constant and never a string β€” and what an enum would still add if the PHP floor ever moved to 8.1.

This page describes the code as it is now. An earlier version argued against a string-keyed design that no longer exists β€” that design was replaced, largely because of the argument below. What's left is a narrower, and more honest, question than this page used to claim.

Companion pages: Roles β€” Developer Guide (how to actually add a capability) and Raising the PHP floor to 8.1 (the decision).


What the code does today

A capability is a class constant, and every guard names one:

// includes/capabilities.php
final class Cap
{
    const TICKETS_MAILBOXES = 'tickets.mailboxes';
    const ASSETS_VCENTER    = 'assets.vcenter';
    // …76 of them
}
// api/tickets/save_mailbox.php
requireCapabilityJson(Cap::TICKETS_MAILBOXES);

Everything else about a capability β€” its module, its description, whether it's sensitive, which stored settings it guards β€” is not written next to the constant. It lives in that module's settings manifest, and the registry is derived:

// includes/capabilities.php β€” the registry is BUILT, not written
function capRegistry(): array
{
    $registry = [];
    foreach (settingsManifests() as $m) {          // globbed from <module>/settings/manifest.php
        foreach ($m['tabs'] as $tab) {
            if ($tab['cap'] === null) continue;    // a personal preference β€” nothing to grant
            $registry[$tab['cap']] = [
                'module'    => $m['module'],
                'sensitive' => !empty($tab['sensitive']),
                'label'     => $tab['grant'],
            ];
        }
    }
    return $registry;
}

So a permission is written down exactly twice β€” the name (a constant) and the details (a manifest entry). The Roles picker, the module list, the tab bar and the setting-key ownership map are all generated from those two.


Why the name is a constant

Suppose a capability were a plain string, and you mistyped one in a guard:

requireCapabilityJson('tickets.mailboxs');   // 'mailboxs'

That does not error. It fails closed, exactly as the guard is designed to: HTTP 403, "You do not have permission to manage these settings." Which is indistinguishable from the system working correctly.

Three properties make that the dangerous kind of bug rather than an ordinary one.

It is invisible to you. analystHasCapability() short-circuits to true for is_admin before it ever compares the string:

function analystHasCapability(PDO $conn, int $analystId, string $capability): bool {
    if ($analystId <= 0) return false;
    if (analystIsAdmin($conn, $analystId)) return true;   // ← never reaches the comparison
    return in_array($capability, getAnalystCapabilities($conn, $analystId), true);
}

You are an administrator. Every test you run passes. The is_admin bypass β€” the thing that makes deny-by-default safe to switch on β€” is also the thing that hides this class of bug from the only person who could fix it.

The user cannot tell it's a bug. They see a polite, well-worded 403 saying "you do not have permission." They don't file a bug report; they ask to be made an administrator. If someone obliges, a typo has quietly become a privilege escalation.

It never self-corrects. A crash gets fixed. A wrong number gets noticed. A permanent, silent, correct-looking denial can sit in a release for a year, logging nothing.

The constant kills all three

requireCapabilityJson(Cap::TICKETS_MAILBOXS);
//  PHP Fatal error: Undefined constant Cap::TICKETS_MAILBOXS

Loud, immediate, at the call site, on the first request that touches the line β€” and, crucially, it fires for the administrator too, because the fatal happens before analystHasCapability() gets its chance to short-circuit. That asymmetry is gone.

One precision, so this isn't oversold: PHP resolves a constant fetch at runtime, not parse time, so php -l won't flag it. What you get is a runtime fatal on that line, the first time it runs β€” plus your editor and any static analyser flagging it immediately, which a string literal is simply invisible to.

The rule the whole design rests on: always pass a Cap:: constant. Never write the string.


So what would an enum actually add?

Here an honest answer has to walk back what this page used to claim.

When the argument was first written, a capability's metadata β€” module, label, sensitivity β€” lived in a hand-maintained registry array, in parallel with the constants, in parallel with the tab layout, in parallel with the setting-key map. Four lists that had to agree. The enum's headline benefit was to collapse them onto one type:

enum Capability: string {
    case TicketsMailboxes = 'tickets.mailboxes';
    public function label(): string     { return match($this) { … }; }
    public function isSensitive(): bool { return match($this) { … }; }
    public function module(): Module    { return match($this) { … }; }
}

That benefit is already gone β€” and we got it without enums. The four lists were collapsed into one by deriving the registry from the settings manifest. The label, the module and the sensitivity now live in exactly one place and cannot drift, because nothing else writes them down. An enum would move that metadata onto the type instead: a different home, not a better one β€” and arguably a worse one, since the manifest keeps a tab's permission next to the tab it belongs to and the setting keys it guards.

What an enum offers What the code already has
Capability::cases() capAll() β€” derived from the manifests
Capability::tryFrom($s) capFromKey($s) β€” same job, same null-on-unknown, plus alias resolution for renamed keys
$cap->label() / ->module() / ->isSensitive() capLabel() / capModule() / capIsSensitive() β€” reading the derived registry
A typo is caught at the call site Already caught β€” see above

The one thing genuinely left: a real parameter type

function requireCapabilityJson(string $capability, ?PDO $conn = null): void      // today
function requireCapabilityJson(Capability $capability, ?PDO $conn = null): void  // with 8.1

The guard's parameter is string. Passing Cap::TICKETS_MAILBOXES is how it is written, but requireCapabilityJson('anything') remains syntactically legal. The constant makes the right thing easy and the typo loud; it does not make the wrong thing impossible. An enum's signature would refuse a string outright, at every call site, permanently.

That is a real gap. It is also, now, the only one β€” and it is not a break-every-PHP-7.4-user gap. Which is precisely why the floor stayed at 7.4.


What none of this fixes

Honesty, since the rest of the page is an argument:

  • It does not stop copy-paste. requireCapabilityJson(Cap::LMS_MANAGE) pasted into a Tickets endpoint is still wrong. The constant makes it auditable β€” "find usages of Cap::LMS_MANAGE" returns a list you can actually read β€” but not impossible.
  • It does not make the database type-safe. rbac_role_capabilities.capability_key is a VARCHAR, and someone with a MySQL client can still put junk in it. capFromKey() makes the code ignore junk deterministically, which is what actually matters.
  • It does not write the guard for you. A missing requireCapabilityJson() is still the single most likely permission bug, and no type system on earth catches a line you didn't write. That needs an audit β€” which is what D005 is for, and it has repeatedly found real holes.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally