Alpha — the platform is in development. Docs cover what ships today; anything unbuilt is marked. Concepts
-rw-rw-r-- content:content /docs/concepts/permissions.dtmpl ● verified against v2.0

Permissions & the VFS

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.

Warning

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 isAdmin alone, the second question has been skipped.