Roles & permissions (RBAC)
View on GitHubAdd own-vs-any authorization to a new resource
Worked example: giving SavedFilter the same "you can always manage
your own, but need a broader permission to manage anyone else's"
shape Comment already has — closing the gap
../saved-filters/README.mdSaved filtersA SavedFilter is a named, stored combination of search/label/status/ priority/assignee query params for a project (the priority param holds priority ids, e… flags
(today, any project member can delete any saved filter, with no
ownership distinction at all).
The pattern, as CommentPolicy already establishes it
File: app/Policies/CommentPolicy.php
public function update(User $user, Comment $comment): bool
{
$project = $comment->issue->project;
if ($comment->user_id === $user->id) {
return $project->hasPermission($user, Permission::COMMENTS_UPDATE_OWN);
}
return $project->hasPermission($user, Permission::COMMENTS_UPDATE_ANY);
}
The shape is always the same: check who owns the resource first, then
branch to one of two separate permissions — a *_own one that Member gets by
default and a *_any one that only Owner/Admin get (the roles trusted
to touch other people's things) — never a single permission with an ownership check bolted on
inline. This is what lets a custom role grant "edit your own comments"
without also granting "edit anyone's."
Step 1 — Add an owner column
New migration:
Schema::table('saved_filters', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('project_id')->constrained()->nullOnDelete();
});
Nullable, so existing rows created before this change don't need a
backfill to remain valid — an "unowned" saved filter simply never
matches the "own" branch below and always falls through to the _any
check.
Step 2 — Add the two permissions
Following
01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag…'s
pattern exactly:
// app/Enums/Permissions/Permission.php
case SAVED_FILTERS_DELETE_OWN = 'saved_filters.delete_own';
case SAVED_FILTERS_DELETE_ANY = 'saved_filters.delete_any';
// app/Services/RoleService.php
private const array MEMBER_DEFAULT_PERMISSIONS = [
// ...existing entries...
Permission::SAVED_FILTERS_DELETE_OWN,
];
Only _OWN goes into MEMBER_DEFAULT_PERMISSIONS (Viewer gets
neither, matching its read-only default posture) — _ANY is
deliberately left out of every non-Owner/Admin default, exactly the
comment permissions' precedent.
Because defaults are seeded only when a project's system roles are
first created, also run
php artisan permissions:grant-to-existing-roles saved_filters.delete_own saved_filters.delete_any
so the Owner/Admin roles of existing projects get both (see
01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag… step 6).
Step 3 — Write the Policy
New file: app/Policies/SavedFilterPolicy.php
<?php
namespace App\Policies;
use App\Enums\Permissions\Permission;
use App\Models\SavedFilter;
use App\Models\User;
class SavedFilterPolicy
{
public function delete(User $user, SavedFilter $savedFilter): bool
{
$project = $savedFilter->project;
if ($savedFilter->user_id === $user->id) {
return $project->hasPermission($user, Permission::SAVED_FILTERS_DELETE_OWN);
}
return $project->hasPermission($user, Permission::SAVED_FILTERS_DELETE_ANY);
}
}
File: app/Http/Controllers/SavedFilterController.php
public function destroy(SavedFilter $savedFilter): RedirectResponse
{
$this->authorize('delete', $savedFilter);
$this->savedFilterService->delete($savedFilter);
return redirect()->back()->with('success', 'Saved filters has been deleted successfully.');
}
replacing the old $this->authorize('view', $savedFilter->project) —
this is why the pattern belongs in a dedicated Policy method rather
than inline in the Controller: authorize('delete', $savedFilter)
resolves to SavedFilterPolicy::delete automatically (Laravel's
Policy auto-discovery, keyed on the model class), no explicit
registration needed, same as every other Policy in this codebase.
Step 4 — Store the owner on create
File: app/Services/SavedFilterService.php (see
../saved-filters/01-extract-the-service-layer.mdSaved filtersExtract the Service layerSavedFilterController is the one Controller in the app that constructs and deletes an Eloquent model directly, bypassing the Controller → Service → Reposit…
if this doesn't exist yet in your checkout)
public function create(Project $project, array $data): SavedFilter
{
$filter = $this->savedFilterRepository->create([
...$data,
'user_id' => auth()->id(),
]);
$this->activityLogService->log($project->id, "Saved a new filter: \"{$filter->name}\"");
return $filter;
}
Step 5 — Expose it to the frontend, if the UI needs to know
Following the same convention
01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag…'s step 4
uses — compute the boolean directly off the model/Gate for a
UI-facing prop, the same way Comment does it as a model-appended
attribute rather than a Controller-computed one:
// app/Models/SavedFilter.php
protected $appends = ['can_delete'];
public function getCanDeleteAttribute(): bool
{
return auth()->check() && Gate::forUser(auth()->user())->allows('delete', $this);
}
Add use Illuminate\Support\Facades\Gate; to the model's imports.
Reach for this model-appended-attribute shape (rather than
computing the boolean in the Controller and threading it through as a
separate prop, the way SettingsController does for
canUpdateIntegrations) specifically when the resource is already
serialized as a list of model instances sent straight to the frontend
— can_delete then travels for free with every filter in the array,
with no separate parallel array of ids-that-are-deletable to keep in
sync.
Tests
tests/Feature/SavedFilterControllerTest.php— add "the owner can delete their own saved filter", "a non-owner member withoutsaved_filters.delete_anycannot delete someone else's filter", and "an admin can delete any member's saved filter", mirroringtests/Feature/CommentControllerTest.php's own/any test shapes exactly.tests/Feature/Models/SavedFilterTest.php— add a test assertingcan_deletereflects the Policy decision for both the owner and a non-owner, mirroringtests/Feature/Models/CommentTest.php'scan_edit/can_deletecoverage.tests/Feature/RoleServiceTest.php— update the exact permission-id-set assertions for Member/Viewer defaults to include the two new cases, the same update01-add-a-new-permission.mdRoles & permissions (RBAC)Add a new permissionThe general, step-by-step pattern for adding any new projects./issues./comments. permission and getting it to show up correctly in Settings → Roles & manag…'s own tests section calls out.
