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

LayerWhat it gatesStorage
Spatie / PermissionEnumAdmin UI capability (who can open collections, files, roles, AI, …)Spatie roles / permissions + EffectivePermissionResolver
collection_permissions / file_permissionsHeadless /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)

ItemDetail
Packagelaravel/fortify
Guardweb (session) → App\Models\User
ProviderApp\Providers\FortifyServiceProvider
Usernameemail (lowercase_usernames)
Home/dashboard
FeaturesRegistration, password reset, email verification, two-factor auth (confirm + confirm password), passkeys (confirmPassword)
ActionsCreateNewUser, ResetUserPassword
ViewsInertia pages under resources/js/pages/auth/*
Rate limitslogin, 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: PermissionSeeder runs sync; RoleSeeder creates built-in roles and syncPermissions

Admin FormRequests typically call AuthorizesWithPermission::authorizePermission(), which delegates to EffectivePermissionResolver::hasPermission().

PermissionEnum

All values (kebab-case strings):

AreaPermissions
Dashboardcan-show-dashboard
Userscan-show-users, can-create-users, can-edit-users, can-delete-users, can-restore-users, can-force-delete-users
Groupscan-show-groups, can-create-groups, can-edit-groups, can-delete-groups, can-restore-groups, can-force-delete-groups
Rolescan-show-roles, can-create-roles, can-edit-roles, can-delete-roles
Permissionscan-show-permissions, can-create-permissions, can-edit-permissions, can-delete-permissions
Filescan-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
Collectionscan-show-collections, can-create-collections, can-edit-collections, can-delete-collections, can-restore-collections, can-force-delete-collections
Activitycan-show-activity-logs
Jobscan-show-jobs, can-manage-jobs
AIcan-use-ai
Projectcan-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:

RoleValueSeeded permissions
Super adminsuper-adminAll permissions in the DB for the guard
AdminadminAll permissions (same set as super-admin at seed time)
ReaderreaderOnly permissions whose name starts with can-show-
PublicpublicNone (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

MethodReturns
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

  1. Role names / ids = unique union of:
    • Direct: $user->roles
    • Via groups: roles attached to each of the user’s groups
  2. If super-admin ∈ role names → permissions = full PermissionEnum::values()
  3. Else permissions = unique union of:
    • Spatie $user->getAllPermissions() (direct + via assigned roles)
    • Permissions from roles attached through the user’s groups (group → role → permission)
  4. 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:

AliasClassBehavior
permissionEnsureUserHasPermissionpermission:{name} — 403 unless EffectivePermissionResolver::hasPermission
can.manage.filesEnsureCanManageFilesMaps 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):

  1. No user → false
  2. auth.isSuperAdmintrue
  3. Else auth.permissions.includes(permission)

Used by sidebar nav filtering, collections destructive actions, AI FAB visibility, admin screens, and similar UI gates.

Enforcement matrix

SurfaceServer middlewareAdditional enforcement
Admin CRUDpermission: per routeFormRequests + Gates
Filescan.manage.filesAction → permission mapping
AI chat / toolspermission:can-use-aiPer-tool requirePermission + CollectionPermissionEnforcer on item tools
Collections HTTP (session)auth + verified + permission: per actionFormRequests; items also use CollectionPermissionEnforcer
Public CMS API /api/v1ResolveApiAccesscollection_permissions / file_permissions via guards + enforcer
Settings / dashboardauth (+ verified where applied)Feature-specific
Project / appearancepermission:can-manage-project-settingsProject settings
Jobs monitorpermission:can-show-jobs / can-manage-jobsOperations
Previous
Architecture