Báo cáo bảo mật chỉ có hai dòng: "Đổi ID bài viết trong PUT /api/posts/{id} là user nào đã login cũng sửa được bài của người khác." Controller bên web có check, if ($post->user_id !== auth()->id()) abort(403);, copy tay. Controller API, viết sau đó sáu tháng bởi một người khác, thì không có. Action publish hàng loạt trong trang admin cũng không. Mỗi lần thêm luật mới ("editor được sửa mọi bài trong team của mình", "bài đã publish quá 24 giờ thì không ai sửa nữa") là phải vá vô mười bốn chỗ, và chỗ thứ mười lăm luôn là chỗ làm rò dữ liệu.
Laravel có sẵn lời giải: Gate và Policy. Tụi nó gom mọi quyết định kiểu "user này có được làm việc này không?" về một chỗ, đặt cho nó một cái tên, rồi để controller, Form Request, route, Blade và test cùng hỏi một câu theo cùng một cách. Cái khó không nằm ở API; mà nằm ở chỗ xài cho nhất quán và biết mấy góc cạnh dễ đứt tay của nó.
Gate và Policy khác nhau ra sao
Cả hai trả lời cùng một câu hỏi, chỉ là tổ chức khác nhau:
- Gate là một closure có tên, thường khai báo trong
AppServiceProvider::boot(). Hợp với mấy quyền không gắn với một model cụ thể: "xem dashboard báo cáo", "vô Horizon", "đăng nhập dưới danh nghĩa user khác". - Policy là một class gom các quyền của một model:
PostPolicy::view,update,deletevà mấy cái khác. Hợp với mọi thứ liên quan tới một record cụ thể.
Bên dưới, policy được đăng ký vô cùng một service Gate. Gate::allows('update', $post) nhìn kiểu của $post, tìm ra PostPolicy, rồi gọi PostPolicy::update($user, $post). Vì vậy $user->can(...), @can, Gate::authorize(...) và ->can(...) trên route đều chạy y chang nhau với cả gate lẫn policy.
Một lần kiểm tra quyền được xử lý ra sao
Thứ tự ở đây quan trọng. Callback before chạy đầu tiên và có thể chặn ngang mọi thứ: trả true là cho phép, false là từ chối, null nghĩa là "không ý kiến, đi tiếp". Sau đó tới method của policy (hoặc closure của gate) quyết định. Nếu user là guest mà tham số $user của method không nullable, Laravel từ chối luôn mà không thèm gọi method. Thiếu method trong policy cũng là từ chối. Laravel mặc định đóng cửa khi không chắc, đúng hành vi mình muốn.
Viết một policy
Tạo bằng php artisan make:policy PostPolicy --model=Post. Từ Laravel 11, policy nằm trong App\Policies và đặt tên theo model trong App\Models sẽ được tự động phát hiện, khỏi cần đăng ký gì hết.
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class PostPolicy
{
public function viewAny(?User $user): bool
{
return true;
}
public function view(?User $user, Post $post): Response
{
if ($post->published_at !== null) {
return Response::allow();
}
return $user?->id === $post->user_id
? Response::allow()
: Response::denyAsNotFound();
}
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
public function update(User $user, Post $post): Response
{
if ($user->id !== $post->user_id && ! $user->isEditorOf($post->team_id)) {
return Response::deny('You can only edit your own posts.');
}
if ($post->published_at?->lt(now()->subDay())) {
return Response::deny('Published posts are locked after 24 hours.');
}
return Response::allow();
}
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function restore(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function forceDelete(User $user, Post $post): bool
{
return false;
}
}
Mấy lựa chọn trong đó là có chủ ý:
?User $userởviewAnyvàviewcho guest đi vô tới method. Thiếu dấu?là guest bị từ chối tự động.Response::denyAsNotFound()trả 404 thay vì 403 cho bài nháp. Trả 403 là ngầm xác nhận có một bài nháp mang ID đó, bản thân chuyện này đã là rò thông tin.Response::deny('...')mang theo message, message đó nằm luôn trong response 403 và trongGate::inspect(), hữu ích hơn nhiều so với một chữfalsetrơn khi team support hỏi vì sao editor không lưu được.forceDeletetrảfalsecho tất cả; before hook bên dưới sẽ mở cửa cho admin.
Before hook: super admin mà không làm rối từng method
Admin thường được làm mọi thứ. Viết || $user->isAdmin() trong từng method vừa rối vừa dễ quên. Xài before hook, cho từng policy hoặc cho toàn app.
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Gate::before(function (User $user, string $ability): ?bool {
return $user->is_super_admin ? true : null;
});
Gate::define('view-reports', function (User $user): bool {
return $user->hasRole('finance') || $user->hasRole('manager');
});
}
}
Chi tiết quan trọng nhất: trả null, không phải false, khi hook không có ý kiến. Trả false là từ chối mọi quyền của mọi user không phải admin, và bug này hay lọt lên production vì người test lại đang login bằng tài khoản admin.
Policy cũng có thể khai báo public function before(User $user, string $ability): ?bool. Nó chỉ chạy cho policy đó, và chỉ khi policy thật sự có method cho quyền đang được kiểm tra. Xài cho mấy luật kiểu "user bị ban thì không được làm gì với bài viết".
Kiểm tra quyền ở đâu
Kiểm tra càng gần điểm vô càng tốt, và mỗi loại điểm vô chọn một kiểu thôi để người review biết chỗ mà coi.
Trong controller, gọi Gate::authorize(). Nó throw AuthorizationException, Laravel render ra 403 (hoặc 404 với denyAsNotFound). Controller từ Laravel 11 trở đi không còn kế thừa base class có AuthorizesRequests, nên $this->authorize() chỉ chạy khi mình tự thêm trait đó; còn facade thì lúc nào cũng xài được.
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class PostPublishController
{
public function __invoke(Post $post): RedirectResponse
{
Gate::authorize('update', $post);
$post->update(['published_at' => now()]);
return to_route('posts.show', $post);
}
}
Khi đã có Form Request validate input, method authorize() của nó là chỗ tự nhiên nhất. Nó chạy trước validation, nên user không có quyền cũng không biết được field nào sai.
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'body' => ['required', 'string'],
];
}
}
Trường hợp đơn giản thì kiểm tra ngay trên route: Route::put('/posts/{post}', UpdatePostController::class)->can('update', 'post');. Với quyền không có instance model, truyền tên class: ->can('create', Post::class).
Trong Blade, @can giấu mấy nút user không xài được. Đó là chuyện UX, không phải bảo mật: check phía server vẫn phải có.
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan
@cannot('delete', $post)
<p class="text-sm text-gray-500">Only the author can delete this post.</p>
@endcannot
Kiểm thử policy
Test policy ở hai tầng. Test kiểu unit gọi policy thông qua gate để before hook cũng được tính; test feature chứng minh endpoint thật sự áp dụng nó. Loại thứ hai mới là loại bắt được vụ controller API bị bỏ quên.
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('lets authors update their own posts', function () {
$author = User::factory()->create();
$post = Post::factory()->for($author)->create(['published_at' => null]);
expect($author->can('update', $post))->toBeTrue();
});
it('forbids other users from updating a post', function () {
$post = Post::factory()->create();
$stranger = User::factory()->create();
expect($stranger->can('update', $post))->toBeFalse();
$this->actingAs($stranger)
->putJson("/api/posts/{$post->id}", ['title' => 'Hacked', 'body' => 'x'])
->assertForbidden();
});
it('hides drafts from other users with a 404', function () {
$draft = Post::factory()->create(['published_at' => null]);
$this->actingAs(User::factory()->create())
->getJson("/api/posts/{$draft->id}")
->assertNotFound();
});
it('lets super admins force delete through the before hook', function () {
$admin = User::factory()->create(['is_super_admin' => true]);
$post = Post::factory()->create();
expect($admin->can('forceDelete', $post))->toBeTrue()
->and(User::factory()->create()->can('forceDelete', $post))->toBeFalse();
});
Một thói quen đáng giữ: route nào nhận model thì viết một test "người lạ nhận 403/404". Rẻ bèo, và đó chính xác là cái test đã có thể bắt được lỗ IDOR ban đầu.
Những lỗi hay gặp
- Trả
falsetrong before hook. Nó từ chối mọi thứ với mọi người mà hook không cho phép rõ ràng. Trảnullđể đi tiếp. - Chỉ kiểm tra quyền trong view.
@cangiấu cái nút; endpoint thì curl vô vẫn được. - Tưởng policy lọc luôn query.
PostPolicy::viewkhông ngăn đượcPost::all()trả về bài nháp của người khác. Endpoint dạng danh sách cần query scope (->whereBelongsTo($user)) phản ánh đúng luật trong policy. - Quên guest.
User $userkhông nullable nghĩa là guest luôn bị từ chối, đúng với thao tác ghi nhưng sai với trang public. - Gọi
$this->authorize()trong controller Laravel 11+. Không có traitAuthorizesRequeststhì đó là method không tồn tại. XàiGate::authorize()hoặc thêm trait vô. - 403 làm lộ sự tồn tại. Với tài nguyên riêng tư, ưu tiên
denyAsNotFound(). - Luật nghiệp vụ lại rỉ ra controller. Check nào cần cả user lẫn model thì nó thuộc về policy, kể cả khi "chỉ có một dòng".
Khi nào xài Gate, Policy, hay không xài cái nào
- Xài policy cho mọi thứ về một model hoặc một loại model: CRUD, publish, archive, export một record.
- Xài gate cho quyền toàn app không gắn model: dashboard, tool admin, mở tính năng theo role.
- Xài before hook cho mấy ngoại lệ cắt ngang như super admin hay user bị ban, và giữ nó trong vài dòng.
- Đừng xài cái nào cho validation ("title phải unique") hay cho giới hạn dữ liệu trên danh sách; mấy cái đó thuộc về rule của Form Request và query scope.
- Với ma trận role và permission phức tạp lưu trong database, package như
spatie/laravel-permissioncó thể nối vô cùng cái gate đó, nên$user->can()vẫn chạy ở mọi nơi.
Cái lợi thật sự không nằm ở cú pháp; mà ở chỗ câu hỏi "ai được sửa bài viết?" chỉ có đúng một câu trả lời trong codebase, và mọi điểm vô, mọi view, mọi test đều hỏi đúng câu trả lời đó.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết một gate phân quyền nhỏ, mô phỏng cách Laravel xử lý ability. User là array thường (PHP) hoặc dict (Python), ví dụ ['id' => 1, 'role' => 'admin']. Class Gate gồm: - define(string $ability, Closure $callback): void đăng ký một ability. Callback nhận ($user, ...$args). - before(Closure $callback): void thêm một before hook. Hook nhận ($user, $ability, ...$args) và chạy theo đúng thứ tự được thêm vô. - allows(?array $user, string $ability, mixed ...$args): bool. Luật theo thứ tự: user null (guest) bị từ chối và không callback nào được gọi; before hook đầu tiên trả về giá trị khác null sẽ quyết định, chỉ === true mới cho phép; ability chưa define thì từ chối; còn lại thì callback của ability quyết định, chỉ === true mới cho phép (giá trị truthy như 1 vẫn bị từ chối). - denies(...) là ngược lại của allows(...). - authorize(...) không trả gì khi được phép, và throw AuthorizationException với message 'This action is unauthorized.' khi bị từ chối. Python: API y vậy với tên snake_case, user: dict | None, *args, và AuthorizationError thay cho AuthorizationException. 'Chỉ True mới cho phép' nghĩa là kết quả phải đúng là object True.
Code khởi tạo
final class AuthorizationException extends RuntimeException {}
final class Gate
{
/** @var array<string, Closure> */
private array $abilities = [];
/** @var list<Closure> */
private array $beforeCallbacks = [];
public function define(string $ability, Closure $callback): void
{
// TODO: remember $callback for $ability
}
public function before(Closure $callback): void
{
// TODO: append $callback to the before callbacks
}
public function allows(?array $user, string $ability, mixed ...$args): bool
{
// TODO: guests (null user) are always denied, no callback is called
// TODO: run before callbacks in order with ($user, $ability, ...$args);
// the first non-null result decides (allowed only if it is === true)
// TODO: undefined ability => false
// TODO: otherwise call the ability callback with ($user, ...$args); allowed only if === true
return false;
}
public function denies(?array $user, string $ability, mixed ...$args): bool
{
// TODO: the opposite of allows()
return true;
}
public function authorize(?array $user, string $ability, mixed ...$args): void
{
// TODO: throw AuthorizationException('This action is unauthorized.') when denied
}
}
from collections.abc import Callable
from typing import Any
class AuthorizationError(Exception):
pass
class Gate:
def __init__(self) -> None:
self._abilities: dict[str, Callable[..., Any]] = {}
self._before: list[Callable[..., Any]] = []
def define(self, ability: str, callback: Callable[..., Any]) -> None:
# TODO: remember callback for ability
raise NotImplementedError
def before(self, callback: Callable[..., Any]) -> None:
# TODO: append callback to the before callbacks
raise NotImplementedError
def allows(self, user: dict | None, ability: str, *args: Any) -> bool:
# TODO: guests (None user) are always denied, no callback is called
# TODO: run before callbacks in order with (user, ability, *args);
# the first non-None result decides (allowed only if it is True)
# TODO: undefined ability -> False
# TODO: otherwise call the ability callback with (user, *args); allowed only if it is True
raise NotImplementedError
def denies(self, user: dict | None, ability: str, *args: Any) -> bool:
# TODO: the opposite of allows()
raise NotImplementedError
def authorize(self, user: dict | None, ability: str, *args: Any) -> None:
# TODO: raise AuthorizationError("This action is unauthorized.") when denied
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýCheck trường hợp guest trước tiên để không hook hay callback nào nhận phải null.
Trong vòng lặp before, bỏ qua kết quả null và return ngay ở kết quả khác null đầu tiên.
So sánh chặt (=== true bên PHP, is True bên Python) để giá trị truthy như 1 không mở được quyền.
Viết denies và authorize dựa trên allows để luật chỉ nằm ở một chỗ.
final class AuthorizationException extends RuntimeException {}
final class Gate
{
/** @var array<string, Closure> */
private array $abilities = [];
/** @var list<Closure> */
private array $beforeCallbacks = [];
public function define(string $ability, Closure $callback): void
{
$this->abilities[$ability] = $callback;
}
public function before(Closure $callback): void
{
$this->beforeCallbacks[] = $callback;
}
public function allows(?array $user, string $ability, mixed ...$args): bool
{
if ($user === null) {
return false;
}
foreach ($this->beforeCallbacks as $before) {
$result = $before($user, $ability, ...$args);
if ($result !== null) {
return $result === true;
}
}
if (! isset($this->abilities[$ability])) {
return false;
}
return ($this->abilities[$ability])($user, ...$args) === true;
}
public function denies(?array $user, string $ability, mixed ...$args): bool
{
return ! $this->allows($user, $ability, ...$args);
}
public function authorize(?array $user, string $ability, mixed ...$args): void
{
if ($this->denies($user, $ability, ...$args)) {
throw new AuthorizationException('This action is unauthorized.');
}
}
}
from collections.abc import Callable
from typing import Any
class AuthorizationError(Exception):
pass
class Gate:
def __init__(self) -> None:
self._abilities: dict[str, Callable[..., Any]] = {}
self._before: list[Callable[..., Any]] = []
def define(self, ability: str, callback: Callable[..., Any]) -> None:
self._abilities[ability] = callback
def before(self, callback: Callable[..., Any]) -> None:
self._before.append(callback)
def allows(self, user: dict | None, ability: str, *args: Any) -> bool:
if user is None:
return False
for callback in self._before:
result = callback(user, ability, *args)
if result is not None:
return result is True
callback = self._abilities.get(ability)
if callback is None:
return False
return callback(user, *args) is True
def denies(self, user: dict | None, ability: str, *args: Any) -> bool:
return not self.allows(user, ability, *args)
def authorize(self, user: dict | None, ability: str, *args: Any) -> None:
if self.denies(user, ability, *args):
raise AuthorizationError("This action is unauthorized.")

