Trung bình Laravel

Phân quyền trong Laravel với Gate và Policy: before hook, Form Request, @can và cách test

Check quyền sở hữu copy-paste khắp nơi rồi cũng dẫn tới IDOR. Gom luật phân quyền vô Gate và Policy, áp ở mọi điểm vô và chứng minh bằng test Pest.

15 Th11, 2026 12 phút Cập nhật 23 Th10, 2026 6 Khối code
Sơ đồ
graph TD A[Kiểm tra: user có được sửa post] --> B{Before hook} B -->|true| C[Cho phép] B -->|false| D[Từ chối] B -->|null| E{Là guest và tham số user không nullable} E -->|có| D E -->|không| F[Method của policy hoặc closure của gate] F -->|allow| C F -->|deny| D

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, delete và 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

graph TD A[Kiểm tra: user có được sửa post] --> B{Before hook} B -->|true| C[Cho phép] B -->|false| D[Từ chối] B -->|null| E{Là guest và tham số user không nullable} E -->|có| D E -->|không| F[Method của policy hoặc closure của gate] F -->|allow| C F -->|deny| D

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 ở viewAny và view cho 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à trong Gate::inspect(), hữu ích hơn nhiều so với một chữ false trơn khi team support hỏi vì sao editor không lưu được.
  • forceDelete trả false cho 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ả false trong 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. @can giấu cái nút; endpoint thì curl vô vẫn được.
  • Tưởng policy lọc luôn query. PostPolicy::view không ngăn được Post::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 $user khô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ó trait AuthorizesRequests thì đó là method không tồn tại. Xài Gate::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-permission có 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 tương tác

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

php
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
    }
}
python
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 ý
Gợi ý 1

Check trường hợp guest trước tiên để không hook hay callback nào nhận phải null.

Gợi ý 2

Trong vòng lặp before, bỏ qua kết quả null và return ngay ở kết quả khác null đầu tiên.

Gợi ý 3

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.

Gợi ý 4

Viết denies và authorize dựa trên allows để luật chỉ nằm ở một chỗ.

Lời giải · php
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.');
        }
    }
}
Lời giải · python
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.")

Cùng chủ đề Laravel