Policy untuk Related Record
Sebuah relasi mengajukan dua kelompok pertanyaan authorization yang berbeda dan sengaja memisahkannya karena subject-nya berbeda. Membaca atau mengubah related record adalah ability pada policy milik record tersebut. Menghubungkan atau melepaskan record adalah ability pada policy milik owner — apakah sebuah tag boleh dipasang ke post adalah urusan post, bukan urusan tag. Halaman ini memetakan secara lengkap ability mana yang ditanyakan kepada siapa.
Dua policy
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Label;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
/**
* The owner's policy: reading the project, and membership of its relations.
*/
final class ProjectPolicy
{
public function viewAny(User $user): bool
{
return $user->can('projects.read');
}
public function view(User $user, Project $project): bool
{
return $user->can('projects.read');
}
// Membership abilities. Nothing but a relation asks for these.
public function attachAny(User $user, Project $project): bool
{
return $user->can('projects.update');
}
public function detach(User $user, Project $project, Label $label): bool
{
return $user->can('projects.update');
}
public function associateAny(User $user, Project $project): bool
{
return $user->can('projects.update');
}
public function dissociate(User $user, Project $project, Task $task): bool
{
return $user->can('projects.update');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* The related record's own policy: reading and writing labels.
*/
final class LabelPolicy
{
public function viewAny(User $user): bool
{
return $user->can('labels.read');
}
public function view(User $user, Label $label): bool
{
return $user->can('labels.read');
}
public function create(User $user): bool
{
return $user->can('labels.create');
}
public function update(User $user, Label $label): bool
{
return $user->can('labels.update');
}
public function delete(User $user, Label $label): bool
{
return $user->can('labels.delete');
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Daftarkan dengan cara Laravel biasa — melalui convention-based discovery atau secara eksplisit:
use Illuminate\Support\Facades\Gate;
Gate::policy(Project::class, ProjectPolicy::class);
Gate::policy(Label::class, LabelPolicy::class);2
3
4
Peta lengkap ability
| Method manager | Ability | Ditanyakan kepada | Argument tambahan |
|---|---|---|---|
canViewAny(Model $owner) | viewAny | class related model | — |
canView(Model $owner, Model $record) | view | related record | — |
canCreate(Model $owner) | create | class related model | — |
canEdit(Model $owner, Model $record) | update | related record | — |
canDelete(Model $owner, Model $record) | delete | related record | — |
canRestore(Model $owner, Model $record) | restore | related record | — |
canForceDelete(Model $owner, Model $record) | forceDelete | related record | — |
canAttach(Model $owner) | attachAny | owner | — |
canDetach(Model $owner, Model $record) | detach | owner | related record |
canAssociate(Model $owner) | associateAny | owner | — |
canDissociate(Model $owner, Model $record) | dissociate | owner | related record |
Ability yang memiliki dua subject menerima kedua model dalam urutan tersebut:
public function detach(User $user, Project $project, Label $label): bool;
public function dissociate(User $user, Project $project, Task $task): bool;2
Membership ability tanpa specific record diberi nama attachAny dan associateAny karena pemeriksaan dilakukan sebelum record dipilih. Pada saat menentukan apakah button boleh ada, related record spesifik belum diketahui; record baru diketahui setelah dialog diisi.
Mengakses relasi mensyaratkan owner dapat dilihat
Setiap relation endpoint selalu menanyakan dua hal sebelum memeriksa operation tertentu:
abort_unless($resource::canView($owner), 403); // the owner's `view`
abort_unless($manager::canViewAny($owner), 403); // the related model's `viewAny`2
Tanpa check pertama, relation endpoint akan menjadi jalan mem-bypass penolakan view. User yang tidak boleh membuka project 7 masih bisa mencoba membaca label miliknya hanya dengan menyebut project 7 dalam request relation. Kedua check saling melengkapi dan tidak menggantikan satu sama lain.
Owner juga di-load melalui Resource::query(). Jika scope Resource mengeluarkan owner tersebut, response sudah 404 bahkan sebelum policy diperiksa.
canViewAny() berjalan sebelum query
$relations = RelationTable::forRecord(ProjectResource::class, $project, $request);Manager yang canViewAny()-nya false tidak dimasukkan ke payload sama sekali dan query manager tidak pernah dijalankan. Manager yang melakukan query dahulu lalu menyembunyikan row tetap telah membaca data:
// With LabelPolicy::viewAny() returning false:
collect($relations)->pluck('key'); // ['tasks'] — 'labels' is not there at all2
Check yang sama juga mengontrol record sub-navigation. Relation page yang tidak boleh dibaca tidak memiliki link dan tetap mengembalikan 403 jika URL-nya diminta langsung.
Authorization per action
Setiap relation action membawa closure ->authorize() miliknya sendiri dan check tersebut ditanyakan kembali pada endpoint setelah record di-resolve:
| Action | Check |
|---|---|
CreateRelatedAction | canCreate($owner) |
EditRelatedAction | canEdit($owner, $record) |
DeleteRelatedAction | canDelete($owner, $record) |
AttachAction | isManyToMany($owner) dan canAttach($owner) |
DetachAction | canDetach($owner, $record) |
DetachBulkAction | canAttach($owner) untuk set, kemudian canDetach($owner, $record) per record |
AssociateAction | isOneToMany($owner) dan canAssociate($owner) |
DissociateAction | canDissociate($owner, $record) |
RestoreAction | canRestore($owner, $record) |
RestoreBulkAction | canRestore($owner, $record) per record |
ForceDeleteAction | canForceDelete($owner, $record) |
ForceDeleteBulkAction | canForceDelete($owner, $record) per record |
Action yang tidak di-authorize menjadi null pada serialized payload sehingga tidak pernah dirender. Namun endpoint tetap menanyakan authorization yang sama sebelum handler berjalan. Menyembunyikan button bukan control keamanan:
POST /{panel}/relations/action { "action": "detach", ... }
→ 403 even though no detach button was ever sent to that user2
Bulk action meng-authorize seluruh record sebelum menulis satu pun dan menjalankan seluruh set dalam satu transaction. Selection yang berisi satu record terlarang tidak boleh mengubah separuh selection.
Strict authorization
Seluruh relation ability melewati RelationManager::authorize(), yang mendelegasikan ke PandaPanel\Support\PolicyGate — jalur yang sama dengan Resource::authorize(). Inilah alasan strict mode juga mencakup relation abilities walaupun ability seperti attachAny tidak memiliki method can* pada Resource:
use PandaPanel\Core\Panel;
Panel::make('admin')
->path('admin')
->strictAuthorization();2
3
4
5
| Situasi | Tanpa strict mode | Dengan strict mode |
|---|---|---|
| Model tidak memiliki policy terdaftar | Gate::allows() menjawab false | PanelAuthorizationException::missingPolicy() |
| Policy ada tetapi method ability tidak ada | menjawab false | PanelAuthorizationException::missingPolicyMethod() |
Policy mendefinisikan before() | jawaban before() digunakan | jawaban tetap digunakan — before() dapat menjawab seluruh ability |
PanelAuthorizationException: The policy [App\Policies\ProjectPolicy] for [App\Models\Project]
does not define [attachAny], so that ability can only ever be denied. Add the method, or turn
off strictAuthorization() for this panel.2
3
Deny adalah arah default yang aman. Namun attachAny yang terlupa dan attachAny yang sengaja mengembalikan false terlihat sama dari UI — button sama-sama tidak muncul. Strict mode membedakan keduanya dan sangat berguna selama development.
Override ability pada manager
Method can* adalah ordinary static method. Override ketika jawaban akhir bukan hanya urusan policy:
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Resources\RelationManager;
final class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
/**
* A closed project's tasks are read-only, whatever the policy says about
* the task itself.
*/
public static function canEdit(Model $owner, Model $record): bool
{
return $owner->getAttribute('status') !== 'closed'
&& parent::canEdit($owner, $record);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Panggil parent:: daripada mengimplementasikan Gate check sendiri. Parent-lah yang mengarahkan pemeriksaan melalui PolicyGate; override yang langsung memanggil Gate::allows() membuat ability tersebut keluar dari strict mode.
Hal yang perlu diperhatikan
attachAny,detach,associateAny, dandissociateberada pada policy owner. Menaruhnya pada policy related record menghasilkan button yang tidak pernah muncul dan tanpa penjelasan error.detachdandissociatemenerima tiga argument. Method dengan dua argument tidak cocok dengan signature yang dipanggil Laravel dan dianggap denial.->authorize()pada relation action menggantikan check bawaan. Gunakan hanya jika memang ingin mengambil alih keputusan; jika hanya mempersempit, panggil method manager di dalam closure Anda.canViewAny()menanyakanviewAnymilik related model, bukan owner. MenolakviewAnypada related model menyembunyikan relasi di semua tempat: inline, relation page, dan seluruh endpoint.- Policy related record mengatur record; policy owner mengatur pairing. Menghapus label adalah
LabelPolicy::delete; melepas label dari project adalahProjectPolicy::detach. - Policy yang hilang menghasilkan notice satu kali saat development.
PandaPanel\Support\MissingPolicyNoticemenjelaskan resource yang hilang dari navigation karena alasan tersebut; strict mode adalah versi yang menghentikan request.