preflow / rbac
Preflow RBAC — roles managed in Folio answering can()
Requires
- php: >=8.4
- preflow/auth: ^0.27 || @dev
- preflow/core: ^0.27 || @dev
- preflow/data: ^0.20 || @dev
- preflow/devtools: ^0.26 || @dev
- preflow/folio: ^0.27 || @dev
- preflow/i18n: ^0.26 || @dev
Requires (Dev)
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
- preflow/auth: <0.27
- preflow/core: <0.27
- preflow/folio: <0.27
- preflow/i18n: <0.26
Replaces
None
README
Roles managed in Folio, answering can().
Install
composer require preflow/rbac
php preflow folio:extensions:enable rbac
php preflow folio:extensions:apply
- Add
Preflow\Rbac\RbacServiceProvidertoconfig/providers.php(afterFolioServiceProvider). It does nothing while the Folio extensionrbacis not enabled (config/folio/extensions.json). - Enabled, it answers
can()from the roles and registers the field typesrbac_permissionsandrbac_roles. folio:extensions:apply(run it on every deploy) installs the extension once per version and per environment:- It creates the staff role
admin("Administrator",*) when no staff role exists. - If
config/roles.phpexists, it imports every role it defines that doesn't exist yet, inpreflow/roles' format:['staff' => ['editor' => ['page.*']], 'members' => [...]]. - It never changes a role that exists, so running it again is safe.
- It creates the staff role
- Sites seed their own roles the same way:
RoleStore::ensure(string $population, string $name, string $label, array $permissions)creates a role when it is missing and never overwrites one.
Roles
Roles are rbac_role records, edited under Settings → Roles: a name (unique per
population), a label, a population (staff or members) and permissions.
- The permissions editor has "Everything" (
*) as a switch on top, then the content types as a matrix: one row per type, grouped by sidebar section, a column per verb (View, Create, Edit, Publish, Delete) and "All" for the type's{type}.*. A verb that cannot apply shows as "–": create and delete on a singleton, publish on a type without publishing. Such a name, if a role has it stored, is kept unchanged on save. Every other declared permission (Dashboard, assets, extensions' own) is a chip under its group, with the group's pattern (e.g.assets.*) as the group's "All". With JavaScript, what "Everything" or an "All" already grants is shown checked and locked. A stored name that is no longer declared is shown as unknown and dropped on the next save. - A user may do X when any of their roles (
getRoles()) defined for their population has a pattern matching X. Unknown roles grant nothing (logged in debug). - Roles stored as a JSON string (as SQL returns a list) or a comma-separated string work.
User types
A user type gives its users roles with an rbac_roles field:
{ "key": "member", "users": "members", "fields": { "roles": { "type": "rbac_roles" } } }
The field lists the roles of the type's users population; a "population" on the field
names another one. The value is a list of role names.
Delegation
Nobody grants permissions they don't hold. Coverage follows how permissions match:
*covers everything.x.*covers the patternx.*and every name or pattern belowx.(x.view,x.y.z,x.y.*). It does not cover a barex, just asx.*doesn't grant a permission namedx.- A name covers only itself.
When a user is signed in:
- Roles. A role save or delete is refused unless the user's own roles cover every
pattern the role has now, so nobody touches a role above them. Every pattern the save adds
must be covered as well ("You can't grant permissions you don't have: {patterns}"). A
stored pattern that is no longer declared counts as part of the role, so only
*can edit such a role. The same rule holds for member roles. - Accounts. An account holding a role the editor doesn't fully cover can't be changed,
disabled, reset or deleted by them ("You can't change an account with roles you can't
grant."). Viewing it is still a matter of
staff.view. Every role added to or removed from an account must be covered. - The permissions editor and the roles checkboxes show what you can't grant as disabled ("You can't grant this"). The server check is the authority; a crafted POST gets the same refusal.
The messages are in English, like the rest of Folio's admin. A site's lang/en/rbac.php
changes single keys; de and fr are in lang/. Without a signed-in user (the CLI:
folio:extensions:apply, the staff import) delegation does not apply.
Preflow\Rbac\PermissionCoverage has the check: covers(), coversAll(), uncovered(),
and held() for the signed-in user (null when nobody is signed in).
Staff accounts
Staff accounts are records, managed in Folio by staff who may. The part model:staff is
opt-in: enabling rbac doesn't add it. A site lists it under the extension's
enabled_parts in config/folio/extensions.json and runs folio:extensions:apply:
{"enabled": {"rbac": {"disabled_parts": [], "enabled_parts": ["model:staff"]}}}
- Type. Type and table
staff, shown under Settings. It uses the storage aliasstaffwhen the site has one, elsedefault. It is a user type (populationstaff), so it is never exported. - Fields.
email: stored trimmed and lowercased, unique. In the admin, a new or changed address can't be one of a config user of the default guard ("This e-mail address belongs to a configured admin."): the record would take over that sign-in. The import moves those.namepassword_hash: hidden.roles:rbac_roles, population staff.active: default true.auth_epoch: hidden,"0"for a new account.last_login_atandcreated_at: read-only.
- Sign-in. Staff sign in through a
RecordUserProvider('type' => 'staff', 'active' => 'active'). While a config admin remains, put it first in aChainUserProvider, before the config users (see preflow/auth and the migration below). A disabled account never signs in, and its open sessions end on the next request. - Role names. A role is added to an account only by the name of a defined staff role ("Unknown role: {names}."), so nobody squats a name that a role gets later. A stored name that is no longer defined may stay. This applies on every path, the CLI included.
Two roles: super admin and content admin
There is no negation, so a narrower role lists what it gets. New types, including the settings of an extension enabled later, stay hidden from it until granted.
super_admin:*.content_admin:folio.dashboard,page.*,{type}.*for each content type,assets.*, and the permissions of the moderation screens (e.g.members.decide). It does not getstaff.*,rbac_role.*, any*_settings.*orfolio.extensions.
A content admin can't edit a super admin's account or role, and can't grant what they don't
hold. tests/TwoRolesTest.php checks both roles on every admin route and screen.
No lock-out
Every save and delete of an account or a role, the CLI included, must leave at least one
active manager ("At least one account must keep the right to manage staff and roles.").
A manager is an active staff account whose roles grant staff.edit and rbac_role.edit.
- Only the last active manager is protected. Disabling, demoting or deleting them is refused, and so is a role edit that takes either permission away from them. Removing your own manager role is refused only if you are that last one; while another active manager remains, a manager may step down.
- The protection starts once a manager exists. Before that (the config admin era, bootstrapping) creating accounts isn't blocked. Config users never count as managers; the import makes the first manager.
Account lifecycle
Types/StaffAdmin adds an Account panel to the staff form, with record actions. They
need the permission staff.edit. Each one is checked by StaffRules on the record as it
would be saved. A crafted POST to an account above you, or disabling the last manager, gets
the same refusal as the form, shown as a flash.
-
Invite.
- A new account is stored without a password, so it can't sign in.
- Once it is stored, it gets an invitation mail (Folio's
StaffPasswords::invite()): a set-password link valid for 7 days, sent after the response. - If the mail can't be queued, the account is kept and the flash says so.
- "Send invitation" stays on the panel while no password is set.
-
Send reset link. A set-password link valid for one hour.
-
Disable / Enable. Disabling, by the action or by unticking Active in the form:
- bumps
auth_epoch, so live sessions end; - forgets the account's remember-me logins;
- voids its invite and reset links.
Enabling changes nothing else, so old cookies and links stay dead.
- bumps
-
Own account. Every signed-in staff account has its own page at
{prefix}/_account(the user menu links there). It needs no permission: an account without roles may open it.- Changing the password asks for the current one and is throttled (
folio.password). - It signs out the account's other sessions.
- It changes nothing but the password.
- Changing the password asks for the current one and is throttled (
Mails need config/notify.php; the throttles need APP_KEY. The action messages are rbac.staff.* in
lang/{en,de,fr}/rbac.php.
Moving a config admin to staff
php preflow folio:staff:import-config creates a staff record for each config user of the
default guard's provider (directly, or inside a chain).
- The e-mail is lowercased. The password hash moves as is, so the old password keeps working.
nameis the e-mail,activeis true, the epoch is"0".- Roles are mapped by name to the staff roles that exist (
admin,*, comes from the install). An unknown name is skipped with a warning. - The rules above apply. An e-mail that already has a record is skipped, so the command is safe to repeat. It never prints a hash.
For an existing site with an .env admin:
-
In
config/auth.php, point the guard at a chain: staff records first, then the config users:'providers' => [ 'staff' => ['class' => Preflow\Folio\Users\RecordUserProvider::class, 'type' => 'staff', 'active' => 'active'], 'config' => ['class' => Preflow\Auth\ConfigUserProvider::class, 'users' => [/* the .env admin */]], 'chain' => ['class' => Preflow\Auth\ChainUserProvider::class, 'providers' => ['staff', 'config']], ], 'guards' => ['session' => ['class' => Preflow\Auth\SessionGuard::class, 'provider' => 'chain', 'population' => 'staff']],
While the staff type isn't enabled yet, the chain resolves the config user as before.
-
Enable
model:staff(enabled_parts) and runphp preflow folio:extensions:apply. -
Run
php preflow folio:staff:import-config. -
Sign in again with the old password. Once the record exists, it decides for that e-mail: the config entry no longer answers it, and the config-era sessions and remember-me cookies end on their next request (from now on, disabling or demoting the record takes effect).
-
Later, remove the config user (and the chain, pointing the guard at
staff) and theFOLIO_ADMIN_*values in.env.
Revisions store the author's id from then on. Folio shows the account's name for an id and the stored e-mail for older revisions.
Limits
folio:importandfolio:pullwrite roles without the delegation check. They need server access, like the CLI. (Staff accounts are a user type and never travel.)- There is no lock around the last-manager check. Two managers demoting each other at the same moment can leave none. Recover with a config user in the chain or the CLI.
- Editing a role you cover also changes every account holding it, including accounts above you that also hold that role. Removing a pattern from it can demote them.
- Disable and Enable save the record directly. They skip other extensions' save hooks, and they write no revision.
- An opt-in part listed under both
disabled_partsandenabled_partsis off.
Keeping the right to edit roles
A save or delete of a role is refused when, on the roles as they would be afterwards:
- the signed-in user's own staff roles together no longer grant both
rbac_role.view(the roles list and edit form) andrbac_role.edit("You would lose the right to edit roles."), or - the staff roles together no longer grant both.
This covers removing permissions, renaming the role, moving it to members and deleting
it. A refused save shows the form again with the message; a refused delete keeps the role.
Before the extension is installed, the same check applies: the first role you save must
grant yourself rbac_role.view and rbac_role.edit (or run folio:extensions:apply,
which creates admin).
Bootstrap
Until folio:extensions:apply has installed the extension in this environment, staff may
do everything (logged once per request), so enabling RBAC never locks out the admin. Once
installed, staff get only what their roles grant; with no staff role at all they get
nothing (logged: "no staff roles defined; run folio:extensions:apply or create one under
Settings → Roles").
Switching from preflow/roles
preflow/rbac replaces preflow/roles while the extension is enabled (its resolver wins).
- Require
preflow/rbac, add its provider, enable the extension. - Run
php preflow folio:extensions:apply: the roles ofconfig/roles.phpbecomerbac_rolerecords (andadmin, if you had no staff role). - Check the roles under Settings → Roles, then remove
preflow/rolesandconfig/roles.phpwhen you like; later edits happen in Folio only.