Skip to content

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.

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

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.

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.

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.

  • 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;
    }
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.

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():

app-modules/catalog/src/Providers/CatalogServiceProvider.php
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.