A permission check on this platform answers two questions, in order. First,
does the ordinary rule allow it — the node's owner, group and mode, and any
grant scoped to the path. Second, if the ordinary rule refused and the only
reason to let it through is that the person is a member of the admin group,
is their session elevated right now.
Membership is who you are. Elevation is what state your session is in. The check asks both, and neither answer stands in for the other: a member who is not elevated is a member who is being asked to become one, and an elevated session belongs to a member or it does not exist. Everything below is the detail of those two questions; this paragraph is the part to remember.
Content is a filesystem, and the rules are the ones you know#
Content lives in a virtual filesystem. Pages, media, documents, workflow
definitions and templates are all Nodes in one tree, and permissions on them
are the familiar ones: an owner, a group, and mode bits, evaluated by voters.
Borrowing Unix semantics is not nostalgia. It means a directory of pages, a media collection and a document template are governed by the same rules, so a permission question has one answer whichever feature is asking it.
- Read a node: see it, and read its content.
- Write a node: change it.
- Execute a directory: traverse into it.
Execute is the one people forget. A user who cannot traverse a directory cannot reach anything beneath it, whatever the permissions on those children say. Denied execute is one of the few things that genuinely answers 403: a permission was evaluated, and refused.
A directory with no index file is not that. It answers 404, and the platform behaves like a web server with directory listing disabled. It answered 403 until someone measured it, which was wrong twice over — no permission had been consulted on that branch, so the status asserted an authorization decision that never happened, and it confirmed to an anonymous visitor that the path exists. A status code is part of the response; it discloses whatever it distinguishes.
A directory can opt into listing its children by carrying a truthy autoindex
flag in its extras. That is per-directory and explicit, never the default.
One policy, two readers#
The interesting part is not the model, it is where the decision lives.
A permission rule is expressed once, as a method on the owning service. The server's guard calls it. The API that tells the admin UI what to enable calls the same method. The client never re-implements the rule to grey out a button:
public function canUploadTo(?string $preset, UserInterface $user): bool
The server stays authoritative — the client's copy is a UX hint and nothing more. But because both read one method, the hint cannot drift into a lie, and a policy change is one edit rather than two that must be kept in sync by memory.
The same holds for the second question. The admin client derives no right from
mode bits and none from membership: the listing's write flags and the
session's elevation state are two readings of one server decision, and the
prompt that asks for elevation hangs off the server's 403, not off anything
the client worked out for itself.
Scoping#
Some rights are path-scoped: a role that is privileged inside one subtree is
an ordinary role outside it. This is what lets an editor own a section without
owning the platform — /content belongs to admin and to the content group,
/assets to admin and to assets_editors, and a member of either group
writes there without being a member of admin.
A path-scoped grant is not a global one wearing a prefix. When you read a grant, read the scope with it; a role name alone does not tell you what it can reach.
Membership is not a right#
There is no root user and no super-administrator role. The most a person can be
is a member of the admin group, and membership is a fact about them, not a
permission on anything. The ordinary rule is evaluated for a member exactly as
for anyone else. Where it refuses, membership could override it — that is what
"admin" has meant on most systems — and that override is the thing the second
question governs.
Seven places in the platform ask it. Each is a gate, named for what it guards, and each asks the ordinary rule first and the elevation state only when membership is the last reason left:
| gate | what a member is asking to do |
|---|---|
vfs.permission |
read, write or traverse a node the ordinary rule refuses — the whole-tree bypass |
vfs.chmod |
change the mode of a node they do not own |
vfs.chown |
change the owner or group of a node they do not own |
vfs.delete_sticky |
delete, from a sticky directory, a node they neither own nor parent |
terminal.role |
run a terminal command reserved to administrators |
media.listing |
list another user's home in the media library |
font.install |
install a font for the document engine without the installer role |
Whether the elevation answer decides or is only recorded is an
installation switch, COOLMS_ELEVATION_MODE. In enforce, the production
posture, a member who is not elevated is refused at the gate like anyone else
and is offered elevation. In log_only, membership still decides and every
place where elevation would have refused is counted, so an installation can
watch the narrowing before living under it. A non-member is refused either
way; there is nothing to rehearse.
Where that switch lives, how a value like this is set, and how it relates to the settings an administrator changes at runtime is the subject of a page of its own, which is forthcoming. What elevation is — how a session enters that state, for how long, and how it leaves it — is described on Identity & groups and, in full, on a forthcoming Elevation page.
Invariants worth preserving#
These are load-bearing. When you touch this area, keep them true:
- Every mutating operation on an authenticated path declares an explicit gate — and where that gate would be answered by membership, it asks the elevation state as well.
- Object-level authorization lives in the service or processor, not only in a query.
- Public endpoints that take an identifier answer identically for known and unknown values. Rate limiting slows an enumeration oracle; it does not close one.
- One-time codes are consumed by an atomic conditional update, never read-then-write.
- Every anonymous write sits behind its own per-IP rate limiter, applied after the auth gate.
- Membership never answers a gate on its own. If a new gate is added and it
can be passed by
isAdminalone, the second question has been skipped.