Skip to content

Authorization

Reshape's authorization is built on Spatie Permission with its Teams feature turned on, where a "team" is a Company. A permission can be granted to a user within a specific company, or globally — and a small wrapper around Spatie's API makes writing that distinction natural to read.

Managing a company's roles and permissions

Scoped vs. global permission checks

Spatie Permission's teams feature works by setting a "current team ID" before checking permissions. Reshape wraps that in two expressive methods on User (app/Concerns/HasRolesPermissions.php):

php
// Scope every check/write inside the callback to a specific company...
$user->in(Company|int|null $company) : App\Support\Authorization\Scope

// Scope every check/write to be global (no company)...
$user->everywhere() : App\Support\Authorization\Scope
php
$user->in($company)->hasPermissionTo(Permission::CompaniesUpdate);          // scoped to $company
$user->everywhere()->hasPermissionTo(Permission::PrivilegedCompaniesUpdate); // global

A third helper merges both scopes for display purposes:

php
// The user's roles in $in, merged with their global roles...
$user->getRoles(Company|int|null $in = null) : Illuminate\Support\Collection

Both return an App\Support\Authorization\Scope — a readonly decorator that sets Spatie's permission team ID for the duration of one call (hasPermissionTo, assignRole, roles(), anything Spatie's HasRoles trait exposes), then restores the previous team ID. A null team ID means globalconfig/permission.php has 'teams' => true with 'team_foreign_key' => 'company_id', and a null company_id on the pivot is Spatie's convention for a team-independent grant.

Direct Spatie calls

Because HasRolesPermissions pulls in Spatie's HasRoles trait directly, $user->hasPermissionTo(...) will compile — but it uses whatever team ID happens to be set, which is easy to get wrong. Always go through ->in($company) or ->everywhere() so the scope is explicit at the call site.

The permission catalog

Every permission the application knows about is a case of one enum (app/Enums/Permission.php) — 156 of them, each a dotted string. The naming convention is:

  • {scope}.{resource}.{action} — a company-scoped ability (e.g. companies.roles.index, users.themes.view)
  • privileged.{resource}.{action} — a global, admin-level ability, checked with ->everywhere()
php
// Every permission whose name starts with "privileged."...
Permission::privileged() : array

Values are a frontend contract

Permission values are read by the frontend's own authorization logic (web/src/lib/authorization). Never rename an existing case's string value — add a new case instead. The enum's own docblock repeats this warning for exactly this reason.

The catalog is seeded into the database by Database\Preparers\PermissionPreparer, run as part of php artisan db:prepare (see Installation).

Roles

Six roles ship out of the box (app/Enums/Role.php), each mapping to a curated list of Permission cases:

php
// The permissions granted to this role...
$role->permissions() : array

// Whether company owners can hand this role to a member...
$role->isAssignable() : bool
RoleNotes
SuperAdministrator, AdministratorEvery permission
ModeratorEvery privileged (global admin) permission, nothing company-scoped
StandardUserA curated, unprivileged list
CompanyStaffCompany-scoped abilities, no delete/roles — isAssignable()
CompanyOwnerFull company-scoped abilities incl. delete + roles — isAssignable()

CompanyStaff and CompanyOwner are the only two roles marked isAssignable() — meaning they're the only roles a company owner can hand out to members through the UI. The others are platform-level and assigned outside the company member flow.

Actions (app/Actions/Roles/)

php
// Create a role (scoped to a company, or global if $companyId is null) with permissions...
CreateRole::create(array $input, array $permissions, ?int $companyId) : App\Models\Role

// Update a role's attributes and re-sync its permissions...
UpdateRole::update(Role $role, array $input, array $permissions) : App\Models\Role

// Delete a role...
DeleteRole::delete(Role $role) : void

Both CreateRole and UpdateRole pin guard_name to web explicitly — Sanctum's auth:sanctum middleware changes the request's default guard, which Spatie's Role model would otherwise inherit.

Enforcing it

Two layers, applied together:

  • Route middleware gates entire route groups by role — coarse, fast, first line of defense. The management area is gated by role:super-administrator|administrator|moderator.
  • Policies make the fine-grained, per-resource decision — 18 of them under app/Policies/. The pattern is consistent everywhere: check the privileged/global permission first, then fall back to a scoped check against the specific company (or other resource) in question, so a global admin always passes and a company member only passes for their own company.

Extending it

To add a new ability:

  1. Add a case to Permission (never rename an existing one — see the warning above).
  2. Add it to whichever roles in Role::permissions() should have it.
  3. Reference it from the relevant policy method with $user->in(...) or $user->everywhere().
  4. Re-run php artisan db:prepare (or just the PermissionPreparer) so the new permission exists in the database.