Concepts
Effective permissions
Externa authorizes with session auth (Fortify), Spatie roles/permissions, and a custom effective permission layer that unions direct roles with roles inherited from user groups. Super-admins bypass ability checks via Gate::before.
Two permission layers
| Layer | What it gates | Storage |
|---|---|---|
Spatie / PermissionEnum | Admin UI capability (who can open collections, files, roles, AI, …) | Spatie roles / permissions + EffectivePermissionResolver |
collection_permissions / file_permissions | Headless /api/v1 (and field ACL / item_filter / file visibility on that surface) | Role × collection × action (+ optional rules), and role × file action (create / read / read_private / update / delete) |
Spatie does not replace Collection access. A user with can-show-collections still needs the role’s Collection access matrix (and optional field/item_filter rules) for Public CMS API and for enforcer-aware surfaces. read_private unlocks effective-private files on /api/v1/files* — see Public CMS API and Roles & permissions.
Session authentication (Fortify)
| Item | Detail |
|---|---|
| Package | laravel/fortify |
| Guard | web (session) → App\Models\User |
| Provider | App\Providers\FortifyServiceProvider |
| Username | email (lowercase_usernames) |
| Home | /dashboard |
| Features | Registration, password reset, email verification, two-factor auth (confirm + confirm password), passkeys (confirmPassword) |
| Actions | CreateNewUser, ResetUserPassword |
| Views | Inertia pages under resources/js/pages/auth/* |
| Rate limits | login, two-factor, passkeys |
User model traits include Spatie HasRoles, Fortify TwoFactorAuthenticatable + PasskeyAuthenticatable (PasskeyUser), and soft deletes. Passkeys guide: Passkeys.
Spatie Permission
- Package:
spatie/laravel-permission - Config:
config/permission.php - Canonical permission names live in
App\Enums\PermissionEnum - Sync command:
php artisan permissions:sync(SyncPermissionsCommand)- Upserts DB permissions from the enum
- Regenerates
resources/js/enums/permission-enum.ts
- Seeders:
PermissionSeederruns sync;RoleSeedercreates built-in roles andsyncPermissions
Admin FormRequests typically call AuthorizesWithPermission::authorizePermission(), which delegates to EffectivePermissionResolver::hasPermission().
PermissionEnum
All values (kebab-case strings):
| Area | Permissions |
|---|---|
| Dashboard | can-show-dashboard |
| Users | can-show-users, can-create-users, can-edit-users, can-delete-users, can-restore-users, can-force-delete-users |
| Groups | can-show-groups, can-create-groups, can-edit-groups, can-delete-groups, can-restore-groups, can-force-delete-groups |
| 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 |
| Files | 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 |
| Collections | can-show-collections, can-create-collections, can-edit-collections, can-delete-collections, can-restore-collections, can-force-delete-collections |
| Activity | can-show-activity-logs |
| Jobs | can-show-jobs, can-manage-jobs |
| AI | can-use-ai |
| Project | can-manage-project-settings |
| API keys (admin UI) | can-show-api-keys, can-manage-api-keys |
Source: app/Enums/PermissionEnum.php. Helper: PermissionEnum::values().
Public CMS API is separate
Spatie permissions above gate the admin UI. Headless /api/v1 access uses collection_permissions (role × collection × create/read/update/delete, optional field ACL / item_filter) and file_permissions (including read_private). See Public CMS API and the callout at the top of this page.
RoleEnum
Built-in roles (app/Enums/RoleEnum.php), seeded in RoleSeeder:
| Role | Value | Seeded permissions |
|---|---|---|
| Super admin | super-admin | All permissions in the DB for the guard |
| Admin | admin | All permissions (same set as super-admin at seed time) |
| Reader | reader | Only permissions whose name starts with can-show- |
| Public | public | None (system, not assignable) — anonymous actor for /api/v1 only |
Reader show set: every permission whose name starts with can-show- (dashboard, users, groups, roles, permissions, files, collections, activity logs, api keys, jobs, …). Reader does not get can-use-ai, can-manage-project-settings, or mutating file/collection permissions.
The public role cannot be assigned to users/groups, renamed, or deleted. Configure its Collection access matrix for anonymous API traffic.
Super-admin vs admin
Both roles may hold the same permission rows after seeding. Runtime difference: super-admin also bypasses Laravel Gates via Gate::before and is treated as all-powerful in EffectivePermissionResolver / useCan. Admin must actually possess each permission through roles/groups.
EffectivePermissionResolver
Class: App\Services\Authorization\EffectivePermissionResolver (singleton in AppServiceProvider).
API
| Method | Returns |
|---|---|
permissionsFor(User $user) | Effective permission name list |
hasPermission(User $user, string $permission) | Boolean |
roleNamesFor(User $user) | Effective role names |
effectiveRoleIds(User $user) | Effective role ids (direct ∪ via groups) |
isSuperAdmin(User $user) | Whether super-admin is among effective roles |
forget(?User $user = null) | Clear per-request cache |
Resolution algorithm
- Role names / ids = unique union of:
- Direct:
$user->roles - Via groups: roles attached to each of the user’s groups
- Direct:
- If
super-admin∈ role names → permissions = fullPermissionEnum::values() - Else permissions = unique union of:
- Spatie
$user->getAllPermissions()(direct + via assigned roles) - Permissions from roles attached through the user’s groups (group → role → permission)
- Spatie
- Results are cached for the request keyed by user id
hasPermission returns true immediately for super-admins; otherwise checks membership in permissionsFor.
Groups scale roles, not a separate ACL matrix
Attach Spatie roles to a group → members inherit admin Spatie permissions and any collection_permissions / file_permissions configured on those roles. There is no group-level collection ACL table. Admin field/item_filter rules (CollectionPermissionGuard::rulesForUser) use effectiveRoleIds(), the same set as Spatie effective roles. Headless /api/v1 still authenticates as a single role (public or API key).
HomePath (post-login redirect) also uses EffectivePermissionResolver::hasPermission, so group-inherited can-show-* permissions affect where users land after login.
Gate::before
Configured in AppServiceProvider::configureAuthorization():
Gate::before(function (?User $user): ?bool {
if ($user === null) {
return null;
}
if (app(EffectivePermissionResolver::class)->isSuperAdmin($user)) {
return true;
}
return null;
});
Super-admins therefore pass any Gate / authorize() ability check, including abilities that are not registered. Covered by tests/Feature/Authorization/SuperAdminGateTest.php.
Middleware
Aliases in bootstrap/app.php:
| Alias | Class | Behavior |
|---|---|---|
permission | EnsureUserHasPermission | permission:{name} — 403 unless EffectivePermissionResolver::hasPermission |
can.manage.files | EnsureCanManageFiles | Maps route name / HTTP method / bulk action to a file PermissionEnum, then same resolver |
These are custom aliases (not Spatie’s default role / permission middleware package aliases).
Where they are applied
Admin (routes/admin.php): individual resources use ->middleware('permission:…') with the matching PermissionEnum value. All /files/* routes sit inside Route::middleware('can.manage.files').
AI (routes/ai.php): authenticated AI group uses auth + verified + permission:can-use-ai (some import status routes add further checks). Per-tool Spatie checks via ChecksAiPermissions; item tools also apply CollectionPermissionEnforcer (field ACL / item_filter).
Collections (routes/collections.php): auth + verified plus Spatie permission: per action (same pattern as admin). See Collections API.
Frontend: useCan and auth.permissions
Shared by HandleInertiaRequests:
'auth' => [
'user' => $user,
'permissions' => $user ? $permissionResolver->permissionsFor($user) : [],
'roleNames' => $user ? $permissionResolver->roleNamesFor($user) : [],
'isSuperAdmin' => $user ? $permissionResolver->isSuperAdmin($user) : false,
],
Hook: resources/js/hooks/use-can.ts
const { can, auth } = useCan()
if (can(PermissionEnum.CanEditCollections)) {
// show edit UI
}
Helper userHasPermission (resources/js/lib/permissions.ts):
- No user →
false auth.isSuperAdmin→true- Else
auth.permissions.includes(permission)
Used by sidebar nav filtering, collections destructive actions, AI FAB visibility, admin screens, and similar UI gates.
Enforcement matrix
| Surface | Server middleware | Additional enforcement |
|---|---|---|
| Admin CRUD | permission: per route | FormRequests + Gates |
| Files | can.manage.files | Action → permission mapping |
| AI chat / tools | permission:can-use-ai | Per-tool requirePermission + CollectionPermissionEnforcer on item tools |
| Collections HTTP (session) | auth + verified + permission: per action | FormRequests; items also use CollectionPermissionEnforcer |
Public CMS API /api/v1 | ResolveApiAccess | collection_permissions / file_permissions via guards + enforcer |
| Settings / dashboard | auth (+ verified where applied) | Feature-specific |
| Project / appearance | permission:can-manage-project-settings | Project settings |
| Jobs monitor | permission:can-show-jobs / can-manage-jobs | Operations |
Related pages
- Public CMS API — headless
/api/v1, public role, API keys - Roles & permissions — product feature guide
- Architecture — shared props and middleware stack
- AI assistant model — tool-level permission gates
- Admin & files API — route-level permission strings