---
name: admin-acl-section
description: Add or change an Admin panel access-control section — register it in aclRegistry, enforce it server-side, gate the UI, and pick the right default level. Use when a capability needs its own clearance, or when splitting view/edit or basic/full tiers.
---

# Adding an Admin panel ACL section

A **section** is one configurable capability. `aclRegistry()` in
`api/components/Base.cfc` is the single source of truth; `gcc_admin.admin_acl`
holds per-install overrides. Full model and the current registry:
`docs/ACCESS-CONTROL.md`.

## The four steps

### 1. Register it

```cfc
"chat.remove": { "label": "Remove (scrub) a chat post", "def": 5, "group": "Chat" },
```

- **Name** — dotted, hierarchical, grouped by prefix so related sections sort
  together: `players.fleet.view` / `players.fleet.edit`.
- **Label** — reads as a capability in the Access Control list, not a code name.
- **`def`** — the built-in default (see *Choosing a default* below).
- **`group`** — an existing group where possible: `Chat`, `Game`, `Moderation`,
  `Overview`, `Players`, `Player actions`, `Reference`, `Panel`.

Nothing else is needed for the UI: the Access Control screen builds itself from
the registry, and `sectionLevelsFor()` sends every key to the client at login.

### 2. Enforce it server-side — the real gate

```cfc
requireSection("chat.remove");                 // 403 below the level
var canRemove = canSection("chat.remove");     // boolean, for shaping output
```

`requireSection` goes at the top of every endpoint the section protects. Use
`canSection` to shape a response — and prefer **omitting** protected data over
sending it for the client to hide:

```cfc
"original": (wasRemoved && canViewRemoved) ? removedOrig[key] : ""
```

### 3. Gate the UI — cosmetic only

```js
if (canSec("chat.remove")) actions.append(removeBtn);
```

Or read a `perms` key the endpoint returned. Add `sec:` to the **route** as well
when the view has no backing endpoint (a static page) — otherwise direct
navigation bypasses the gate. Add it to the **nav item** so the sidebar hides it.

### 4. Verify both directions

Prove the gate fires, not just that the feature works:

```bash
curl -s -X POST "$B?lvl=5&method=remove&…"   # expect 200
curl -s -X POST "$B?lvl=4&method=remove&…"   # expect 403 "Requires clearance level 5"
```

And confirm the protected data is actually absent from the low-level response —
not merely hidden.

## Choosing a default

| Default | Fits |
|---|---|
| 0 | Everything staff should see (`chat.view`, `changelog`) |
| 1–2 | Routine read access (`players.view`, most `*.view` tabs) |
| 3 | Sensitive reads and light edits (`players.ip.view`, `players.email.view`) |
| 4–5 | Real mutation (`players.fleet.edit`, `chat.remove`) |
| 6–7 | Whole-system reporting (`admins.logins`, `economy`) |
| 9 | Irreversible / systemic (`gamedata.edit`, `server`, `players.password.edit`) |

Two useful patterns:

- **Reserved-future capability** — wire it fully but set it to **9** so it exists
  and is enforceable without any staff seeing it today.
  `federations.faction.view` / `.edit` do exactly this.
- **Escalation of an admin action** — reporting a *removed* post sits at 3 while
  reporting a normal post is 0, because it escalates something an admin already
  handled. When a capability differs only by *context*, add a separate section
  and branch on it rather than raising the base one.

An unregistered section resolves to **9**, so a typo fails closed. `acl` itself
is pinned to 9 in code and can never be lowered.

## Splitting an existing section

**View / edit** — the common case:

```cfc
"players.fleet.view": { …, "def": 2, … },
"players.fleet.edit": { …, "def": 5, … },
```

Guard the read endpoint with `.view`, the save endpoint with `.edit`, and send
`perms.fleetEdit` so the view can hide the editor.

**Basic / full tiers** — everyone sees a redacted version:

```cfc
var canFull  = canSection("events");
var canBasic = canSection("events.basic");
if (!canFull && !canBasic) requireSection("events.basic");   // throws the right 403
// filter content by canFull
auditView(canFull ? "events.watch" : "events.basicWatch", …);
```

Record a different audit action per tier so the log shows what was actually
disclosed.

## Renaming or removing

- **Renaming** a section orphans any `admin_acl` override with the old name —
  the section silently reverts to its default. Either keep the name or migrate
  the rows.
- **Removing** a capability means deleting its registry entry *and* every
  `requireSection` / `canSec` reference. An orphaned registry entry puts a dead
  toggle in the Access Control screen; an orphaned `canSec` call gates the UI on
  a section that now always resolves to 9.

Check with:

```bash
grep -rn "the.section.name" app/Admin/
```

## Don't forget

Overrides are cached in application scope for **60 seconds**. `Acl.set` busts
the cache, but if you change a *default in code* you may still be reading a
cached override — wait a minute (or restart) before concluding it didn't work.
