Roles & permissions (RBAC)

View on GitHub

Add 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 without saved_filters.delete_any cannot delete someone else's filter", and "an admin can delete any member's saved filter", mirroring tests/Feature/CommentControllerTest.php's own/any test shapes exactly.
  • tests/Feature/Models/SavedFilterTest.php — add a test asserting can_delete reflects the Policy decision for both the owner and a non-owner, mirroring tests/Feature/Models/CommentTest.php's can_edit/can_delete coverage.
  • tests/Feature/RoleServiceTest.php — update the exact permission-id-set assertions for Member/Viewer defaults to include the two new cases, the same update 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 own tests section calls out.