Authorization
Staff permissions are a few roles defined in code, each a set of capabilities: plain-English
permissions such as customers.view_all or catalog.manage, one per thing a person does rather
than one per screen and action. Staff never edit permissions. They only choose a role per user on
the Users screen (see Roles). Changing what a role can do is a code change
and a deploy.
Underneath is spatie/laravel-permission, but its tables are only a projection of the code.
The parts
Section titled “The parts”| Part | Where | Job |
|---|---|---|
| Capability registry | app-modules/utils/src/Authorization/Capabilities.php |
Modules register what their policies check |
| Roles | config/roles.php (root) |
Which capabilities each role grants. The app composes the modules, so it owns this list |
| Sync | app-modules/admin/src/Authorization/RoleDefinitions.php, roles:sync |
Writes both to the database |
| Policy base | app-modules/utils/src/Authorization/CapabilityPolicy.php |
One capability grants every ability on a model |
| Super admin | Gate::before in app-modules/admin/src/Providers/AdminServiceProvider.php |
A role with all => true passes every check |
| Panel access | WebMoves\Admin\Models\User::canAccessPanel() |
Admin panel only, and only with a role |
Capabilities
Section titled “Capabilities”A module registers each capability its policies check, in its service provider’s register():
Capabilities::register('automations.manage', 'Build and switch automation rules', 'Administration');Registering a name twice is fine, and the first label wins: settings.manage is registered by several
modules that share it.
'catalog_editor' => [ 'label' => 'Catalog editor', 'description' => 'Products, categories, brands and suppliers.', 'capabilities' => ['catalog.manage', 'files.manage', 'assistant.use'],],The roles are super_admin, manager, sales_rep, accounts and catalog_editor. super_admin
has all => true and no list. Capabilities that no role lists are for super admins only:
users.manage, admin.activity_log, admin.queues (Horizon’s dashboard), assistant.audit and
catalog.replay_syncs.
roles:sync
Section titled “roles:sync”ddev artisan roles:sync (RoleDefinitions::sync()) is idempotent, and runs on every deploy. It:
- fails if a role lists a capability that no module registers, so a typo can’t silently grant nothing
- creates missing capabilities, and deletes permissions no module registers
- sets each role’s permissions to exactly its list
- deletes roles no longer defined, unless a user still has one. Those are reported and left for you to reassign
AdminUserSeeder and the actingAsRole() test helper also run it.
Policies
Section titled “Policies”Most policies are this and nothing more:
class AutomationRulePolicy extends CapabilityPolicy{ protected string $capability = 'automations.manage';}Override a method where an ability needs more. Sales’ CustomerPolicy and QuoteRequestPolicy do
this: customers.delete for deleting, and quote requests are read-only even for managers.
CapabilityPolicy methods take the staff User base class, and Customer is not one, so a
customer can never satisfy a staff policy.
A policy named src/Policies/<Model>Policy.php for src/Models/<Model>.php is found automatically.
Use Gate::policy() for anything else, such as a vendor model.
Two ways to leave something open
Section titled “Two ways to leave something open”-
A Filament resource with no policy is open to every staff role. Every new resource needs one.
-
A custom page (not a resource) is open to everyone on the panel unless it overrides
canAccess():public static function canAccess(): bool{return auth()->user()?->can('settings.manage') ?? false;}
The super admin
Section titled “The super admin”Gate::before(function ($user): ?bool { if (! $user instanceof User) { return null; } // ... true when one of the user's roles has `all => true`});The hook returns null otherwise, so policies and capabilities decide. Because it runs before everything, a test signed in as a super admin says nothing about access.
Adding a capability: a worked example
Section titled “Adding a capability: a worked example”Say only some staff should be able to export supplier reports.
This capability doesn’t exist; it’s only an example.
1. Register it in the owning module’s provider, in register():
Capabilities::register('catalog.export_reports', 'Export supplier reports', 'Catalog');2. Grant it in config/roles.php, to the roles that need it. Leave it out everywhere and it is
for super admins only.
'manager' => [ // ... 'capabilities' => [ // ... 'catalog.export_reports', ],],3. Check it where it matters. On a model of its own, that’s a policy. For one action within a screen, check on the action:
Action::make('exportReport') ->visible(fn (): bool => auth()->user()->can('catalog.export_reports'))4. Sync: ddev artisan roles:sync. Until you run it, nobody has the capability. Deployment runs
it.
5. Test it. Use actingAsRole() for a role that has it and one that doesn’t. If a whole screen
changed hands, update the matrix in tests/Unit/AuthorizationMatrixTest.php.
$this->actingAsRole('sales_rep');Livewire::test(ListSuppliers::class)->assertActionHidden('exportReport');6. Say so. If staff-visible access changed, update Roles.