Role i uprawnienia (RBAC)

View on GitHub

Dodaj autoryzację własne-vs-cudze do nowego zasobu

Przećwiczony przykład: nadanie SavedFilter tego samego kształtu "zawsze możesz zarządzać własnymi, ale potrzebujesz szerszego uprawnienia, żeby zarządzać cudzymi", jaki już ma Comment — domykając lukę, którą flaguje ../saved-filters/README.mdZapisane filtrySavedFilter to nazwana, zapisana kombinacja query paramów search/label/status/priority/assignee dla projektu (param priority zawiera id priorytetów, np. 1,… (dziś każdy członek projektu może usunąć dowolny zapisany filtr, bez żadnego rozróżnienia własności).

Wzorzec, tak jak już ustala go CommentPolicy

Plik: 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);
}

Kształt jest zawsze ten sam: sprawdź najpierw, kto jest właścicielem zasobu, a potem rozgałęź się do jednego z dwóch osobnych uprawnień — *_own, które Member dostaje domyślnie, i *_any, które domyślnie dostają tylko Owner/Admin (role zaufane, żeby dotykać cudzych rzeczy) — nigdy pojedyncze uprawnienie z doklejonym inline sprawdzeniem własności. To właśnie pozwala roli niestandardowej przyznać "edytuj własne komentarze" bez jednoczesnego przyznawania "edytuj czyjekolwiek."

Krok 1 — Dodaj kolumnę właściciela

Nowa migracja:

Schema::table('saved_filters', function (Blueprint $table) {
    $table->foreignId('user_id')->nullable()->after('project_id')->constrained()->nullOnDelete();
});

Nullable, więc istniejące wiersze utworzone przed tą zmianą nie potrzebują uzupełnienia, żeby pozostać poprawne — "nieposiadany" zapisany filtr po prostu nigdy nie pasuje do poniższej gałęzi "własny" i zawsze przechodzi do sprawdzenia _any.

Krok 2 — Dodaj dwa uprawnienia

Na wzór dokładnego wzorca 01-add-a-new-permission.mdRole i uprawnienia (RBAC)Dodaj nowe uprawnienieOgólny, krok-po-kroku wzorzec dodawania dowolnego nowego uprawnienia projects./issues./comments. i sprawienia, żeby poprawnie pojawiło się w Settings → Rol…:

// 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,
];

Tylko _OWN trafia do MEMBER_DEFAULT_PERMISSIONS (Viewer nie dostaje żadnego, zgodnie ze swoją domyślną postawą tylko-do-odczytu) — _ANY jest celowo pominięte w każdej domyślnej wartości poza Owner/Admin, dokładnie zgodnie z precedensem uprawnień komentarzy.

Ponieważ wartości domyślne są zasiewane tylko przy pierwszym utworzeniu ról systemowych projektu, uruchom też php artisan permissions:grant-to-existing-roles saved_filters.delete_own saved_filters.delete_any, żeby role Owner/Admin istniejących projektów dostały oba (zobacz 01-add-a-new-permission.mdRole i uprawnienia (RBAC)Dodaj nowe uprawnienieOgólny, krok-po-kroku wzorzec dodawania dowolnego nowego uprawnienia projects./issues./comments. i sprawienia, żeby poprawnie pojawiło się w Settings → Rol… krok 6).

Krok 3 — Napisz Policy

Nowy plik: 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);
    }
}

Plik: 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.');
}

zamieniając stare $this->authorize('view', $savedFilter->project) — to dlatego ten wzorzec należy do dedykowanej metody Policy zamiast inline w Controllerze: authorize('delete', $savedFilter) rozstrzyga się automatycznie do SavedFilterPolicy::delete (auto-discovery Policy w Laravelu, kluczowane po klasie modelu), żadna jawna rejestracja nie jest potrzebna, tak samo jak przy każdej innej Policy w tym kodzie.

Krok 4 — Zapisz właściciela przy tworzeniu

Plik: app/Services/SavedFilterService.php (zobacz ../saved-filters/01-extract-the-service-layer.mdZapisane filtryWyodrębnij warstwę ServiceSavedFilterController to jedyny Controller w aplikacji, który tworzy i usuwa model Eloquent bezpośrednio, omijając konwencję Controller → Service → Reposit…, jeśli tego jeszcze nie ma w Twoim checkoucie)

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

Krok 5 — Wyeksponuj to na frontend, jeśli UI musi to wiedzieć

Na wzór tej samej konwencji, jakiej używa krok 4 01-add-a-new-permission.mdRole i uprawnienia (RBAC)Dodaj nowe uprawnienieOgólny, krok-po-kroku wzorzec dodawania dowolnego nowego uprawnienia projects./issues./comments. i sprawienia, żeby poprawnie pojawiło się w Settings → Rol… — oblicz boolean bezpośrednio z modelu/Gate na potrzeby propa UI, w ten sam sposób, w jaki robi to Comment jako atrybut dołączony do modelu (appended) zamiast obliczonego przez Controller:

// app/Models/SavedFilter.php
protected $appends = ['can_delete'];

public function getCanDeleteAttribute(): bool
{
    return auth()->check() && Gate::forUser(auth()->user())->allows('delete', $this);
}

Dodaj use Illuminate\Support\Facades\Gate; do importów modelu. Sięgnij po ten kształt atrybutu dołączonego do modelu (zamiast obliczać boolean w Controllerze i przeprowadzać go jako osobny prop, tak jak robi to SettingsController dla canUpdateIntegrations) konkretnie wtedy, gdy zasób jest już serializowany jako lista instancji modelu wysyłana wprost na frontend — can_delete podróżuje wtedy za darmo z każdym filtrem w tablicy, bez żadnej osobnej równoległej tablicy id-które-można-usunąć do utrzymywania w zgodzie.

Testy

  • tests/Feature/SavedFilterControllerTest.php — dodaj "the owner can delete their own saved filter", "a non-owner member without saved_filters.delete_any cannot delete someone else's filter" oraz "an admin can delete any member's saved filter", na wzór dokładnych kształtów testów własne/cudze z tests/Feature/CommentControllerTest.php.
  • tests/Feature/Models/SavedFilterTest.php — dodaj test asercujący, że can_delete odzwierciedla decyzję Policy zarówno dla właściciela, jak i osoby niebędącej właścicielem, na wzór pokrycia can_edit/can_delete w tests/Feature/Models/CommentTest.php.
  • tests/Feature/RoleServiceTest.php — zaktualizuj dokładne asercje zbiorów id uprawnień dla domyślnych wartości Member/Viewer, żeby uwzględniały dwa nowe przypadki, ta sama aktualizacja, o jakiej mówi sekcja testów w 01-add-a-new-permission.mdRole i uprawnienia (RBAC)Dodaj nowe uprawnienieOgólny, krok-po-kroku wzorzec dodawania dowolnego nowego uprawnienia projects./issues./comments. i sprawienia, żeby poprawnie pojawiło się w Settings → Rol….