Lập trình · 22/09/2026

Học Laravel 13 – Bài 21: Authorization Laravel 13 với Gate và Policy: bảo vệ project và task

Bài 21 — Gate và Policy: sau khi xác định người dùng ở bài đăng nhập, TaskFlow cần biết họ được thao tác trên project nào. Bài này mở luồng quản lý thật: tạo project, xem danh sách task, thêm task và đánh dấu hoàn thành, tất cả có kiểm tra quyền phía server.

Authorization Laravel 13 với Gate và Policy: bảo vệ project và task

Bài 21 — Gate và Policy: sau khi xác định người dùng ở bài đăng nhập, TaskFlow cần biết họ được thao tác trên project nào. Bài này mở luồng quản lý thật: tạo project, xem danh sách task, thêm task và đánh dấu hoàn thành, tất cả có kiểm tra quyền phía server.

1. Chốt mô hình quyền trước

Ở mốc này, project có một owner. Chỉ owner được xem project, thêm task và hoàn thành task bên trong. Mọi user đăng nhập được tạo project riêng. assignee không tự có quyền truy cập; chưa có membership, vai trò nhóm hay admin bypass. Quy tắc đơn giản này cần được nói rõ để người học không suy diễn rằng được giao task đồng nghĩa được sửa project.

Authentication trả lời “ai đang gọi”; authorization trả lời “người đó được làm gì với tài nguyên này”. Ẩn nút trên Blade chỉ cải thiện giao diện; người dùng vẫn có thể tự gửi HTTP request. Vì vậy controller phải kiểm tra quyền trước validation/ghi dữ liệu và trước trả nội dung riêng tư.

2. Gom quyền theo model vào Policy

app/Policies/ProjectPolicy.php

<?php

namespace App\Policies;

use App\Models\Project;
use App\Models\User;

class ProjectPolicy
{
    public function create(User $user): bool
    {
        return true;
    }

    public function view(User $user, Project $project): bool
    {
        return (int) $project->owner_id === (int) $user->id;
    }

    public function update(User $user, Project $project): bool
    {
        return $this->view($user, $project);
    }
}

app/Policies/TaskPolicy.php

<?php

namespace App\Policies;

use App\Models\Task;
use App\Models\User;

class TaskPolicy
{
    public function update(User $user, Task $task): bool
    {
        return (int) $task->project->owner_id === (int) $user->id;
    }
}

Laravel tìm Policy theo quy ước App/Models và App/Policies trong cấu trúc này. Các HTTP test bên dưới đi qua Gate thật, giúp xác nhận discovery đang hoạt động. Không cần vừa discovery vừa đăng ký một bộ rule khác trùng tên.

Gate::authorize gọi policy và ném lỗi nếu bị từ chối. Gate cũng hỗ trợ rule độc lập bằng Gate::define cho quyền không gắn với model, chẳng hạn một trang vận hành. TaskFlow chưa có role quản trị nên không thêm rule admin giả hoặc cho mọi user vượt policy bằng before.

3. Controller thực thi ranh giới

app/Http/Controllers/ProjectController.php

<?php

namespace App\Http\Controllers;

use App\Models\Project;
use App\Queries\TaskListQuery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;

class ProjectController extends Controller
{
    public function index(Request $request)
    {
        return view('projects.index', [
            'projects' => $request->user()->ownedProjects()->latest('id')->paginate(20),
        ]);
    }

    public function store(Request $request)
    {
        Gate::authorize('create', Project::class);
        $data = $request->validate(['name' => ['required', 'string', 'max:120']]);
        $project = $request->user()->ownedProjects()->create([
            'name' => $data['name'], 'slug' => (string) Str::uuid(),
        ]);

        return to_route('projects.show', $project);
    }

    public function show(Request $request, Project $project, TaskListQuery $query)
    {
        Gate::authorize('view', $project);
        $tasks = $query->paginate($project, $request->query());
        foreach ($tasks as $task) {
            $task->setRelation('project', $project);
        }

        return view('projects.show', [
            'project' => $project, 'tasks' => $tasks,
        ]);
    }
}

app/Http/Controllers/ProjectTaskController.php

<?php

namespace App\Http\Controllers;

use App\Actions\CompleteTask;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;

class ProjectTaskController extends Controller
{
    public function store(Request $request, Project $project, CreateTask $createTask)
    {
        Gate::authorize('update', $project);
        $data = $request->validate([
            'title' => ['required', 'string', 'max:120'],
            'description' => ['nullable', 'string', 'max:2000'],
            'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
        ]);
        $createTask->handle($project, new CreateTaskData(
            $data['title'], $data['description'] ?? null, $data['priority'],
        ));

        return to_route('projects.show', $project);
    }

    public function complete(Project $project, Task $task, CompleteTask $completeTask)
    {
        Gate::authorize('update', $task);
        $completeTask->handle($task);

        return to_route('projects.show', $project);
    }
}

Danh sách project truy vấn qua ownedProjects của user hiện tại, không tải tất cả rồi ẩn bằng CSS. Khi tạo, owner_id lấy từ relationship, còn slug là UUID do server tạo. Payload cố gửi owner_id không thay được chủ sở hữu. UUID ở đây giúp tạo slug ổn định, không phải cơ chế bảo mật.

Controller tạo task authorize project trước, validate danh sách field rồi xây DTO. status và assignee_id do client gửi không được đưa vào Action; task mới vẫn todo, chưa có assignee. CompleteTask chỉ được gọi sau policy Task. Các Action vẫn dùng được từ command nhưng caller ngoài HTTP cũng phải chịu trách nhiệm authorization theo ngữ cảnh.

Trang project gắn relationship project đã biết vào mỗi task trong paginator để @can không query lại project từng dòng. Việc này hợp lệ vì query đã scoped theo chính project đó. Không gắn tùy tiện một project vào task đến từ nguồn chưa kiểm chứng.

4. Nested binding không thay Policy

routes/web.php

<?php

use App\Http\Controllers\TaskFlowOverviewController;
use App\Http\Controllers\TaskPreviewController;
use App\Http\Controllers\SessionController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTaskController;
use App\Http\Middleware\AssignRequestId;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/taskflow', TaskFlowOverviewController::class)
    ->middleware(AssignRequestId::class)
    ->name('taskflow.overview');

Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
    ->middleware('throttle:task-preview')->name('tasks.preview');

Route::middleware('guest')->group(function () {
    Route::get('/login', [SessionController::class, 'create'])->name('login');
    Route::post('/login', [SessionController::class, 'store'])
        ->middleware('throttle:login')->name('login.store');
});
Route::view('/dashboard', 'dashboard')->middleware('auth')->name('dashboard');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');

Route::middleware('auth')->scopeBindings()->group(function () {
    Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
    Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
    Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
    Route::post('/projects/{project}/tasks', [ProjectTaskController::class, 'store'])->name('project-tasks.store');
    Route::patch('/projects/{project}/tasks/{task}/complete', [ProjectTaskController::class, 'complete'])
        ->name('project-tasks.complete');
});

scopeBindings buộc task trong URL thuộc project đi cùng. Task thuộc project khác trả 404 kể cả caller sở hữu cả hai project. Sau khi binding đúng, policy vẫn cần xác minh user sở hữu tài nguyên. Hai lớp kiểm tra giải quyết hai câu hỏi khác nhau.

Project tồn tại nhưng không thuộc user trả 403 trong contract hiện tại; ID không tồn tại trả 404. Điều này có thể tiết lộ sự tồn tại của ID, dù không trả nội dung. Nếu sản phẩm yêu cầu che cả sự tồn tại, dùng chiến lược 404 nhất quán và sửa test. Đừng vô tình thay đổi contract chỉ ở một endpoint.

5. Giao diện sử dụng cùng rule

Dashboard liên kết đến /projects. Trang danh sách có form tạo project; trang chi tiết có form thêm task, tìm kiếm và nút Complete cho task chưa done. Các form POST dùng @csrf, form hoàn thành thêm @method('PATCH'). Blade escape tên và mô tả thay vì render HTML người dùng nhập.

@can('update', $task)
    <form method="POST" action="{{ route('project-tasks.complete', [$project, $task]) }}">
        @csrf
        @method('PATCH')
        <button>Complete</button>
    </form>
@endcan

Đây là phần trích giao diện; controller mới là nơi cưỡng chế quyền. Truy cập /login bằng tài khoản local đã tạo ở bài 20, mở dashboard rồi tạo project riêng để thực hành. Không cần đổi owner của project demo. Bài này chưa có sửa tên/xóa project, xóa task hay chia sẻ project.

6. Test đối kháng với hai tài khoản

tests/Feature/ProjectAuthorizationTest.php

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class ProjectAuthorizationTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
        $this->withoutVite();
    }

    public function test_owner_can_create_project_task_and_complete_it(): void
    {
        $user = User::factory()->create();
        $other = User::factory()->create();
        $this->actingAs($user)->post('/projects', ['name' => 'My project', 'owner_id' => $other->id])
            ->assertRedirect();
        $project = $user->ownedProjects()->sole();
        $this->post('/projects/'.$project->id.'/tasks', [
            'title' => 'My task', 'priority' => 'high', 'status' => 'done', 'assignee_id' => $other->id,
        ])->assertRedirect(route('projects.show', $project));
        $task = $project->tasks()->sole();
        $this->assertSame('todo', $task->status);
        $this->assertNull($task->assignee_id);
        $this->get(route('projects.show', $project))->assertOk()->assertSeeText('My task');
        $this->patch(route('project-tasks.complete', [$project, $task]))->assertRedirect();
        $this->assertSame('done', $task->fresh()->status);
        $this->patchJson(route('project-tasks.complete', [$project, $task]))->assertStatus(409);
    }

    public function test_other_user_cannot_read_create_or_complete_tasks(): void
    {
        $project = Project::factory()->create();
        $task = Task::factory()->for($project)->create();
        $this->actingAs(User::factory()->create());
        $this->get(route('projects.index'))->assertOk()->assertDontSeeText($project->name);
        $this->get(route('projects.show', $project))->assertForbidden();
        $this->postJson(route('project-tasks.store', $project), ['title' => 'Intrusion', 'priority' => 'normal'])
            ->assertForbidden();
        $this->patchJson(route('project-tasks.complete', [$project, $task]))->assertForbidden();
        $this->assertDatabaseCount('tasks', 1);
        $this->assertSame('todo', $task->fresh()->status);
    }

    public function test_nested_binding_rejects_a_task_from_another_project(): void
    {
        $first = Project::factory()->create();
        $second = Project::factory()->for($first->owner, 'owner')->create();
        $task = Task::factory()->for($second)->create();
        $this->actingAs($first->owner)->patchJson(route('project-tasks.complete', [$first, $task]))
            ->assertNotFound();
        $this->assertSame('todo', $task->fresh()->status);
    }

    public function test_guest_cannot_write_and_owner_validation_does_not_insert(): void
    {
        $project = Project::factory()->create();
        $this->postJson(route('project-tasks.store', $project), [])->assertUnauthorized();
        $this->actingAs($project->owner)->postJson(route('project-tasks.store', $project), [
            'title' => ' ', 'priority' => 'urgent',
        ])->assertUnprocessable()->assertJsonValidationErrors(['title', 'priority']);
        $this->assertDatabaseCount('tasks', 0);
    }
}
php artisan test --filter=ProjectAuthorizationTest
php artisan test

Suite pass 61 test, 258 assertions. Test owner thực sự ghi dữ liệu, payload giả owner/status/assignee bị bỏ qua, user khác bị chặn cả đọc và ghi, nested URL sai trả 404 và guest nhận 401 JSON. Test validation xác nhận không insert khi dữ liệu sai; gọi complete lần hai giữ contract 409 của bài 18.

Chưa kiểm chứng thay đổi quyền đồng thời với thao tác ghi. Nếu sau này hỗ trợ chuyển owner hoặc thu hồi membership, cần xem xét race giữa authorize và write, transaction và thiết kế dữ liệu. Policy đúng tại một thời điểm không tự khóa quyền vĩnh viễn.

Bài tập: thêm test assignee không phải owner bị từ chối, test lọc tìm kiếm không lộ task project khác, và xây ma trận quyền trước khi thêm membership. Tham khảo Laravel Authorization.

Điều hướng: Bài 20 · Lộ trình. Tiếp theo: session, cookie và CSRF.

Điều hướng khóa học Laravel 13

Bài trước (20) · Bài sau (22) · Mục lục trọn bộ 37 bài

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.