Features
Roles & permissions
Roles and permissions are managed in admin UI backed by Spatie Permission. Canonical permission names live in PermissionEnum; permissions:sync (and the UI sync button) upsert them into the database. Seeded roles are super-admin, admin, reader, and the system role public (anonymous Public CMS API actor).
Scale with groups
For large memberships, attach roles to a group instead of assigning roles user-by-user. Members inherit admin Spatie permissions and the role’s collection/file access matrices. There is no separate group→collection ACL.
Never invent permission strings
Only use values from App\Enums\PermissionEnum. Creating ad-hoc names in the DB that are not in the enum will drift from the TypeScript mirror (resources/js/enums/permission-enum.ts) and from permissions:sync --prune. Full resolution rules: Effective permissions.
Permissions (admin CRUD area)
These gate the roles/permissions screens themselves:
| Area | Exact strings |
|---|---|
| Roles | can-show-roles, can-create-roles, can-edit-roles, can-delete-roles |
| Permissions | can-show-permissions, can-create-permissions, can-edit-permissions, can-delete-permissions |
Roles
Controller & pages
| Item | Detail |
|---|---|
| Controller | App\Http\Controllers\Admin\RoleController |
| Helper | App\Support\Authorization\PermissionGrouper (groups permissions for the form UI) |
| Pages | resources/js/pages/admin/roles/index.tsx, form.tsx |
Role create/edit shows Unsaved + Discard in the form header while dirty (Discard resets the form in place). Delete on the roles index opens a confirm dialog. Bulk deletes elsewhere (users, groups, collections) also confirm with a selected count.
Routes
| Action | Route | Permission |
|---|---|---|
| Index | GET /settings/roles | can-show-roles |
| Create form | GET /settings/roles/create | can-create-roles |
| Store | POST /settings/roles | can-create-roles |
| Edit form | GET /settings/roles/{role}/edit | can-edit-roles |
| Update | PUT /settings/roles/{role} | can-edit-roles |
| Destroy | DELETE /settings/roles/{role} | can-delete-roles |
| Bulk | POST /settings/roles/bulk-actions | Authorized in FormRequest |
Store/update validate name, optional permission_ids[], optional collection_permissions (per-collection create/read/update/delete), and optional file_permissions (global create/read/read_private/update/delete for the Public CMS API), then sync Spatie permissions / collection / file grants and flush caches.
Bulk: action delete only. BulkRoleActionRequest::deletableIds() skips super-admin.
Super-admin & public protection
super-admin cannot be deleted or renamed (403 on destroy; bulk skips it). public is system / not assignable: cannot rename, delete, or attach to users; Spatie permission checkboxes are hidden — only Collection access and Files access are editable.
Collection access (Public CMS API)
On role create/edit, the Collection access matrix grants headless /api/v1 actions independently of Spatie admin permissions:
| UI | Meaning |
|---|---|
| ✓ (green) | Action allowed for that collection |
| ✕ (red) | Action denied (default if never granted) |
Actions: Create, Read, Update, Delete. Missing grant = deny.
Use this on:
public— anonymous callers (no Bearer token)- Custom roles — then bind an API key to that role for partners/backends
Full flow, endpoints, and auth resolution: Public CMS API.
Files access (Public CMS API)
Same role form → Files access matrix. Grants are global (not per folder), stored in file_permissions:
| UI action | Stored action | Meaning |
|---|---|---|
| Create | create | Upload via POST /api/v1/files |
| Read | read | List / show / content / transforms for public (effective) files; expand public file fields on items |
| Read private | read_private | Same endpoints for files whose effective visibility is private |
| Update | update | Patch metadata |
| Delete | delete | Soft delete |
File visibility (files.access): public, private, or null (inherit from nearest ancestor). Effective private without read_private → Files API 403 (or omitted from list); item expanders return null / omit the entry (no id leak). Details: Public CMS API — Public vs private files.
Typical public website pattern
On the public role: grant Read (✓) if the site needs images/files; leave Read private as denied (✕). Use a custom API-key role with Read private only when a backend must fetch private assets.
Permissions (Spatie rows)
Controller & page
| Item | Detail |
|---|---|
| Controller | App\Http\Controllers\Admin\PermissionController |
| Model | Spatie\Permission\Models\Permission |
| Page | resources/js/pages/admin/permissions/index.tsx |
Routes
| Action | Route | Permission |
|---|---|---|
| Index | GET /settings/permissions | can-show-permissions |
| Store | POST /settings/permissions | can-create-permissions |
| Update | PUT /settings/permissions/{permission} | can-edit-permissions |
| Destroy | DELETE /settings/permissions/{permission} | can-delete-permissions |
| Sync | POST /settings/permissions/sync | can-edit-permissions |
The index UI is primarily a read-only list plus Sync from enum (gated by can-edit-permissions). Prefer sync over hand-editing names.
Sync from PermissionEnum
Artisan
php artisan permissions:sync
php artisan permissions:sync --prune
| Item | Detail |
|---|---|
| Command | App\Console\Commands\SyncPermissionsCommand |
| Signature | permissions:sync {--prune} |
| Behavior | Upserts every PermissionEnum::values() for the default guard |
--prune | Deletes DB permissions whose names are not in the enum |
| Side effect | Regenerates resources/js/enums/permission-enum.ts |
PermissionSeeder only runs Artisan::call('permissions:sync') (no prune).
UI sync
PermissionController::sync calls the same command with '--prune' => $request->boolean('prune'). The permissions page currently POSTs {}, so prune defaults to false.
Seeded roles
RoleEnum + Database\Seeders\RoleSeeder (after PermissionSeeder):
| Role value | Seeded permissions |
|---|---|
super-admin | All permissions in the DB for the guard |
admin | All permissions in the DB for the guard |
reader | Only enum values whose name starts with can-show- |
public | None — system role for anonymous /api/v1 (Collection access + Files access only) |
Reader show set (exact):
can-show-dashboard
can-show-users
can-show-groups
can-show-roles
can-show-permissions
can-show-files
can-show-collections
can-show-activity-logs
can-use-ai is not in the reader set. Runtime difference between super-admin and admin: super-admin also bypasses Gates via Gate::before / EffectivePermissionResolver.
DatabaseSeeder order: PermissionSeeder → RoleSeeder → CreateSuperAdminSeeder.
Full PermissionEnum catalog
Do not invent strings outside this list:
can-show-dashboard
can-show-users
can-create-users
can-edit-users
can-delete-users
can-restore-users
can-force-delete-users
can-show-groups
can-create-groups
can-edit-groups
can-delete-groups
can-restore-groups
can-force-delete-groups
can-show-roles
can-create-roles
can-edit-roles
can-delete-roles
can-show-permissions
can-create-permissions
can-edit-permissions
can-delete-permissions
can-show-files
can-create-files
can-edit-files
can-delete-files
can-restore-files
can-force-delete-files
can-download-files
can-favorite-files
can-copy-files
can-replace-files
can-tag-files
can-update-file-metadata
can-show-collections
can-create-collections
can-edit-collections
can-delete-collections
can-restore-collections
can-force-delete-collections
can-show-activity-logs
can-use-ai
can-show-api-keys
can-manage-api-keys
can-show-jobs
can-manage-jobs
can-manage-project-settings
Source: app/Enums/PermissionEnum.php. Helper: PermissionEnum::values().
API Keys (admin)
| Action | Route | Permission |
|---|---|---|
| Index / create UI | GET /settings/api-keys | can-show-api-keys |
| Store | POST /settings/api-keys | can-manage-api-keys |
| Revoke / destroy | DELETE /settings/api-keys/{apiKey} | can-manage-api-keys |
Keys authenticate the Public CMS API (Authorization: Bearer ek_…). Permissions for API calls come from the key’s role Collection access and Files access matrices — not from Spatie permissions on the creating user. Details: Public CMS API.
Source map
| Concern | Location |
|---|---|
| Role controller | app/Http/Controllers/Admin/RoleController.php |
| Permission controller | app/Http/Controllers/Admin/PermissionController.php |
| API keys | app/Http/Controllers/Admin/ApiKeyController.php |
| Collection permission sync | app/Services/Api/CollectionPermissionSync.php |
| File permission sync | app/Services/Api/FilePermissionSync.php |
| Sync command | app/Console/Commands/SyncPermissionsCommand.php |
| Enums | app/Enums/PermissionEnum.php, RoleEnum.php, FilePermissionAction.php, FileAccess.php |
| Seeders | database/seeders/PermissionSeeder.php, RoleSeeder.php |
Collection access rules
Beyond the CRUD matrix, each collection grant may include rules.fields and rules.item_filter. Configure under Settings → Roles → Collection access → Fields & item filter.
Full dialect and API effects: Public CMS API — Field-level rules.
These field flags also drive the admin item form UI: no read hides the field; missing create/update (for new vs edit) makes it read-only. Super-admins and Spatie-only admins without a collection grant stay unrestricted in the form (fieldGrants is null). Details: Collection items — Field ACL.
Preview as role
Editors can simulate how a role (including public) sees an item without switching accounts. The item form Preview as… action calls collections.items.preview-as-role and shows the redacted JSON payload (same enforcer + file expander as the public API). See Collection items — Preview as role.
Jobs monitor
| Permission | Purpose |
|---|---|
can-show-jobs | View /settings/jobs (pending sample + failed jobs) |
can-manage-jobs | Retry / delete failed jobs |
Details: Operations — Jobs monitor.
Project settings
| Permission | Purpose |
|---|---|
can-manage-project-settings | Edit /settings/project, branding /settings/appearance, webhook test |
Details: Project settings.