Roles & permissions (RBAC)
View on GitHubAdd a new permission
The general, step-by-step pattern for adding any new
projects.*/issues.*/comments.* permission and getting it to show
up correctly in Settings → Roles & management — deciding who can do
what in a project, independent of which feature area the permission
belongs to. Worked example: the two permissions the integrations
system actually uses today, projects.integrations.view and
projects.integrations.update — but every step below applies
verbatim to a permission for any other feature.
Step 1 — Add the enum case
File: app/Enums/Permissions/Permission.php
enum Permission: string
{
// ... existing cases ...
case SETTINGS_VIEW = 'projects.settings.view';
case SETTINGS_UPDATE = 'projects.settings.update';
case INTEGRATIONS_VIEW = 'projects.integrations.view';
case INTEGRATIONS_UPDATE = 'projects.integrations.update';
}
The string value's dot structure matters on the frontend (see step
5) — group.subgroup.action renders as a "Group" card with a
"Subgroup" section inside it. Stick to the existing projects.,
issues., comments. prefixes unless you're introducing a genuinely
new top-level area.
Step 2 — Decide who gets it by default
File: app/Services/RoleService.php
Every project gets four system roles (Owner, Admin, Member, Viewer).
Owner and Admin get every permission that exists —
defaultPermissionIdsFor() grants them Permission::cases() in full,
so you never need to touch anything in RoleService for those two
tiers in new projects. Projects whose system roles already exist are
not topped up automatically, though — see step 6 for the one-liner
that fixes that. You decide what Member and Viewer get by
default, by adding the new case(s) to the relevant
*_DEFAULT_PERMISSIONS constant(s):
private const array MEMBER_DEFAULT_PERMISSIONS = [
Permission::PROJECT_VIEW,
Permission::MEMBERS_VIEW,
Permission::ROLES_VIEW,
Permission::SETTINGS_VIEW,
Permission::INTEGRATIONS_VIEW, // <- added
Permission::ISSUES_VIEW,
// ...
];
private const array VIEWER_DEFAULT_PERMISSIONS = [
Permission::PROJECT_VIEW,
Permission::MEMBERS_VIEW,
Permission::ROLES_VIEW,
Permission::INTEGRATIONS_VIEW, // <- added
Permission::ISSUES_VIEW,
];
Rule of thumb used throughout this codebase: give Member/Viewer the
*_VIEW half of a pair by default, never the *_UPDATE/*_CREATE/
*_DELETE half — mutating abilities are Owner/Admin-only (or granted
explicitly via a custom role) unless you have a specific reason to
open them up further. These defaults are seeded only when a project's
system role is first created (ensureSystemRoles()), so a new entry
reaches new projects only; existing projects keep whatever their admins
configured.
Step 3 — Add the authorization check
Two options, pick based on whether the ability is about an existing model or a whole feature area:
Option A — add a method to an existing Policy (most common). File:
app/Policies/ProjectPolicy.php:
public function viewIntegrations(User $user, Project $project): bool
{
return $project->hasPermission($user, Permission::INTEGRATIONS_VIEW);
}
public function updateIntegrations(User $user, Project $project): bool
{
return $project->hasPermission($user, Permission::INTEGRATIONS_UPDATE);
}
hasPermission() (on App\Models\Project) is the only check —
there is no tier allow-list to maintain. Owner always passes; every
other member passes when any of their roles (their system role or a
custom role) has been granted the specific Permission through the
permission_role pivot. What Admin/Member/Viewer can do is therefore
decided purely by the permissions on those roles (the defaults from
step 2, as edited in Settings → Roles).
Call it from a controller with $this->authorize('updateIntegrations', $project);
(Laravel resolves ProjectPolicy::updateIntegrations automatically —
no explicit policy registration needed, see app/Policies/*.php for
the existing convention).
Option B — create a dedicated Policy when the ability is about
creating a not-yet-existing child resource (mirrors
app/Policies/RolePolicy.php::create):
$this->authorize('create', [Role::class, $project]);
with
public function create(User $user, Project $project): bool
{
return $this->hasRolesOrSettingsAccess($user, $project, Permission::ROLES_CREATE);
}
Use Option B only when there's no instance yet to check against (e.g. "can this user create a new X for this project" before the X exists).
Step 4 — Expose it to the frontend
File: app/Http/Controllers/SettingsController.php
Compute the boolean directly off the Project model
(hasPermission) in the action for the tab that
needs it — each settings tab has its own action (see
../settings-tabs/README.mdSettings tabs/settings is not one page — every tab under it (Preferences, Profile, Notifications, Security & access, Export in the Account section; Labels, Issue Types,…) — and
don't route through the Policy for these; Policies here are reserved
for authorizing actual mutating requests ($this->authorize(...) in a
controller action), while SettingsController computes plain booleans
for the UI to conditionally render:
$hasIntegrationsAccess = $selectedProject?->hasPermission($user, PermissionEnum::INTEGRATIONS_VIEW) ?? false;
$canUpdateIntegrations = $hasIntegrationsAccess
&& $selectedProject->hasPermission($user, PermissionEnum::INTEGRATIONS_UPDATE);
The controller's private can() helper wraps the same
hasPermission() call (null-safe for "no project selected") for the
common case. Add both booleans to that action's
Inertia::render(...) props array — and only to that action's, so no
other tab pays for them — then thread them through the tab's page
(e.g. Settings/Integrations.tsx) into your tab component exactly the
way canUpdateIntegrations already is
(see ../integrations/04-frontend-backend-wiring-overview.mdIntegrationsFrontend ↔ backend wiring overviewA map of the whole feature, useful to read before diving into any of the other guides. Three separate flows: reading the settings page, writing a toggle/we…
for the full prop-threading list).
Step 5 — Make it render well in Settings → Roles & management
The permission will show up automatically, grouped correctly, the
moment it exists in the permissions DB table (see step 6) — no
frontend grouping code needs to change. But its label and
description need a manual entry or it renders as a bare,
context-free word.
File: resources/js/utils/permissions.ts — add to PERMISSION_META:
'projects.integrations.view': {
label: 'View integrations',
description: 'See which integrations are connected to the project.',
},
'projects.integrations.update': {
label: 'Manage integrations',
description:
'Connect, configure, or disconnect third-party integrations.',
},
Why this is needed: getPermissionLabel() looks up this map by the
full dotted key; if missing, it falls back to just the last
dot-segment, humanized — so projects.integrations.view without an
entry renders as the bare word "View" (colliding visually with
every other *.view permission) and an empty description. Always add
the entry in the same PR/commit that adds the enum case.
You only need to touch resources/js/utils/permissionGroups.ts
(GROUP_META) if you're introducing a new top-level group (the
part before the first dot) — projects, issues, and comments
already have entries. A new projects.integrations.* permission
reuses the existing projects group entry and gets its own
"Integrations" subsection for free, because
getPermissionSection() in resources/js/utils/permissions.ts
auto-derives the subsection label from the second dot-segment
(humanize(parts[1])) — no map needed for that part.
Step 6 — Seed it into the database (don't skip this!)
database/seeders/PermissionSeeder.php loops Permission::cases() and
updateOrCreates a row per case — it's what actually makes a new enum
case queryable via PermissionModel/PermissionService::getAll(). It
is not re-run automatically when you add an enum case:
- Tests are fine —
tests/Pest.phpcalls$this->seed(PermissionSeeder::class)in a globalbeforeEach, so every test run always has every current enum case as a row. - Your local dev database (and every real environment) is not fine
until you run it yourself:
This is idempotent (php artisan db:seed --class=PermissionSeeder # or, inside Docker: make shell php artisan db:seed --class=PermissionSeederupdateOrCreatekeyed onkey) — safe to run as often as you like, including in production after a deploy that adds new permission cases.
Seeding only creates the permissions row. Existing projects' Owner
and Admin system roles do not get it automatically (they are only
populated when first created), so also grant it to them:
php artisan permissions:grant-to-existing-roles projects.integrations.view projects.integrations.update
# default is --roles=owner,admin; the command is additive and safe to re-run
(Implemented in app/Console/Commands/GrantPermissionsToExistingRoles.php.)
Skip this and Admins of existing projects will be denied the new
ability until someone ticks it in Settings → Roles. Owner is never
affected — it passes every hasPermission() check unconditionally.
If you skip the seeding, getPermissionLabel/the whole Roles UI won't be
"broken" — the permission just won't exist as a row at all, so it
silently never appears in the list, and no role can ever be granted
it. If you ever see "I added a permission but it's nowhere in the UI",
this is almost always the reason — check with:
php artisan tinker --execute="App\Models\Permission::where('key', 'like', 'projects.integrations%')->get(['key','group'])"
Step 7 — Tests
tests/Feature/PermissionSeederTest.php— no change needed, it already asserts the seeder is idempotent and covers all current cases generically.tests/Feature/RoleServiceTest.php— the'it creates the owner, admin, member and viewer system roles with their default permissions'test asserts exact permission-id sets per tier; if you added the new case toMEMBER_DEFAULT_PERMISSIONS/VIEWER_DEFAULT_PERMISSIONS, update the expected list there.- Whatever Policy method you added — cover it directly if it has
interesting branching, or (more commonly) it's covered implicitly by
the Controller test for the action it guards (see
tests/Feature/ProjectIntegrationControllerTest.php's "member without the integrations.update permission cannot toggle an integration" test for the shape to copy). tests/Feature/SettingsControllerTest.php— add'settings page grants a[n] X access to Y'tests hitting the tab's own route (/settings/<tab>) and asserting the new boolean prop for Owner/Admin (true), a plain Member (view-only: view=true, update=false), and a Viewer with no synced system role (false) — see the three*integrations*tests already there for the exact pattern.
