RBAC — phân quyền theo vai trò: chạy thế nào, và hỏng ở đâu

Câu hỏi phỏng vấn: "Hệ thống của bạn phân quyền thế nào?" Câu trả lời hay gặp: "dùng spatie, có role admin, teacher, student". Interviewer sẽ hỏi tiếp ngay:

Bài này trả lời từng câu bằng code chạy thật, và chỉ ra chỗ RBAC hết dùng được — đó là lúc cần ABAC.

Bối cảnh dùng xuyên suốt (ví dụ minh hoạ): một LMS (hệ thống quản lý học tập) phục vụ nhiều trường. Người dùng gồm sinh viên, trợ giảng, giảng viên, trưởng khoa, phòng đào tạo và super-admin. Stack: Laravel 12 + PostgreSQL 18.

Cái gì đã chạy thật, cái gì là ví dụ. SQL và output là chạy thật trên PostgreSQL 18.4 (container my-test-pgdb, bảng tmp_rbac_*, 200 000 user giả, đã drop sau khi đo). Code Laravel là chạy thật trên Laravel 12.64 + PHP 8.3.33 + spatie/laravel-permission 8.3.0 — package được nạp từ source trong một container tạm, không cài vào project. Phần "role nằm trong JWT" là lập luận, không có lab. Bốn mức RBAC của NIST lấy từ tài liệu chuẩn. Số role/khoa/cơ sở trong phần role explosion là ví dụ minh hoạ. Tài liệu Laravel 13, spatie v8, PostgreSQL 18 và Livewire 4 được tra qua Context7.

Thuật ngữ dùng trong bài

Thuật ngữNghĩa ngắn
authentication (xác thực)trả lời "bạn là ai" — đăng nhập, token
authorization (phân quyền)trả lời "bạn được làm gì" — bài này chỉ nói phần này
RBAC (Role-Based Access Control)quyền gắn vào vai trò; người dùng nhận quyền thông qua vai trò
permissionmột hành động trên một loại tài nguyên, vd submission.grade = chấm bài nộp
rolemột nhóm permission, đặt tên theo công việc: teacher, academic_office
role hierarchy (cây thừa kế role)role cao tự có mọi quyền của role thấp hơn
SoD — Separation of Duties (tách nhiệm vụ)một người không được giữ cùng lúc 2 vai trò xung đột, vd vừa nhập điểm vừa duyệt sửa điểm
least privilege (quyền tối thiểu)chỉ cấp đủ quyền để làm việc, không hơn
role explosion (bùng nổ role)số role tăng theo cấp số nhân vì cố nhét điều kiện (khoa, cơ sở) vào tên role
vertical / horizontal privilege escalationleo quyền dọc: user thường làm được việc của admin. Leo quyền ngang: làm được việc trên dữ liệu của người cùng cấp
IDOR (Insecure Direct Object Reference)đổi id trên URL/payload là đọc hoặc sửa được bản ghi của người khác
guard (Laravel)cách xác thực: web (session), api (token). spatie gắn mỗi role/permission với 1 guard
Gate / Policy (Laravel)nơi app hỏi "user này được làm việc này không". Gate cho câu hỏi chung, Policy cho câu hỏi theo model
access review (rà soát quyền)định kỳ kiểm tra lại ai đang giữ quyền gì — ISO 27001, SOC 2 đều đòi

Trả lời 30 giây (khi interviewer hỏi thẳng)

RBAC gắn quyền vào role, user nhận quyền qua role. Câu check chỉ là "trong các role của user có permission X không" — rẻ, cache được, và dễ audit: "ai có quyền X" là một câu query. Trong code tôi chỉ check permission, không check tên role; super-admin đi qua Gate::before.
Nhưng RBAC chỉ trả lời "được làm loại việc này không", không trả lời "trên bản ghi nào". Nên mọi endpoint có id luôn kèm policy kiểm tra phạm vi, nếu không sẽ dính IDOR.
Hai chỗ hay hỏng khi vận hành: cache quyền (gỡ quyền rồi mà worker chạy lâu vẫn cho qua) và role explosion khi cố nhét "khoa nào, trường nào" vào tên role — lúc đó cần scope theo tenant, hoặc ABAC.

1. Nó là gì / chạy thế nào

1.1. Ba lớp: user → role → permission

RBAC chèn một lớp ở giữa người dùng và quyền. Không ai được cấp quyền trực tiếp; mọi quyền đi qua role. Câu hỏi lúc chạy luôn có cùng một dạng:

allowed(user, p)  =  tồn tại role r thuộc roles(user) sao cho p thuộc perms(r)
                     (roles(user) tính cả các role được thừa kế)

Lợi ích của lớp giữa: 300 giảng viên dùng chung role teacher. Thêm quyền mới cho giảng viên = sửa 1 dòng trong role_permissions, không phải sửa 300 user.

Check quyền đi từ user qua role tới permission Ba cột: user, role, permission. Cuong có role teacher. teacher thừa kế ta, và ta có quyền submission.grade. Lần hỏi thứ nhất: Cuong có submission.grade không — đường Cuong, teacher, ta, submission.grade sáng lên, kết quả ALLOW. Lần hỏi thứ hai: Cuong có grade.override không — đi hết các role của Cuong cũng không gặp quyền này, kết quả DENY. USER ROLE PERMISSION thừa kế thừa kế An Cuong Dung student ta teacher dept_head course.view submission.create submission.grade grade.publish report.export grade.override Hỏi: Cuong có submission.grade? ALLOW — qua teacher → ta Hỏi: Cuong có grade.override? DENY — không role nào của Cuong có Câu check không nhìn bản ghi nào cả — chỉ nhìn user, role và tên permission.
Hình 1. Lần hỏi thứ nhất đi Cuong → teacher → (thừa kế) ta → submission.grade: cho qua. Lần hỏi thứ hai đi hết các role của Cuong mà không gặp grade.override: từ chối. Chú ý dòng cuối: câu check không hề biết Cuong đang muốn chấm bài nào — đây là gốc của Bẫy 1.

1.2. Bốn mức RBAC theo NIST

Mô hình RBAC của NIST (chuẩn ANSI INCITS 359) chia 4 mức, mức sau chứa mức trước. Interviewer hỏi "RBAC của bạn ở mức nào" là đang hỏi bạn có biết mấy thứ ngoài "user có role" hay không.

MứcThêm gìVí dụ trong LMSTrong bài
1. Flat (Core)user–role–permission, nhiều role mỗi userCuong có role teacher1.3, 1.4
2. Hierarchicalrole thừa kế roledept_head ⊃ teacher ⊃ ta1.3, Bẫy 9
3. Constrainedràng buộc SoD: tĩnh (không được giữ 2 role) và động (không được bật 2 role trong 1 phiên)không vừa là teacher vừa là academic_officeBẫy 5
4. Symmetricrà soát ngược: từ permission tìm ra ai đang giữ"ai đang có grade.override?"1.3, mục 5

1.3. Tự dựng trên PostgreSQL: 6 bảng và 1 câu check

Dựng tay trước khi dùng package, để thấy package làm gì bên dưới. 5 bảng lõi + 1 bảng thừa kế:

CREATE TABLE tmp_rbac_users       (id bigint PRIMARY KEY, name text NOT NULL);
CREATE TABLE tmp_rbac_roles       (id bigint PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE tmp_rbac_permissions (id bigint PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE tmp_rbac_role_permissions (
  role_id       bigint REFERENCES tmp_rbac_roles,
  permission_id bigint REFERENCES tmp_rbac_permissions,
  PRIMARY KEY (role_id, permission_id));
CREATE TABLE tmp_rbac_user_roles (
  user_id bigint REFERENCES tmp_rbac_users,
  role_id bigint REFERENCES tmp_rbac_roles,
  PRIMARY KEY (user_id, role_id));
CREATE TABLE tmp_rbac_role_inherits (          -- role_id thừa kế mọi quyền của inherits_id
  role_id     bigint REFERENCES tmp_rbac_roles,
  inherits_id bigint REFERENCES tmp_rbac_roles,
  PRIMARY KEY (role_id, inherits_id));

INSERT INTO tmp_rbac_roles VALUES
 (1,'student'),(2,'ta'),(3,'teacher'),(4,'dept_head'),(5,'academic_office'),(6,'admin');
INSERT INTO tmp_rbac_permissions VALUES
 (1,'course.view'),(2,'submission.create'),(3,'submission.grade'),(4,'grade.publish'),
 (5,'grade.override'),(6,'report.export'),(7,'user.manage'),(8,'course.create');
INSERT INTO tmp_rbac_role_permissions VALUES
 (1,1),(1,2),        -- student: xem khoá học, nộp bài
 (2,1),(2,3),        -- ta: xem khoá học, chấm bài
 (3,4),              -- teacher: công bố điểm (+ thừa kế ta)
 (4,6),              -- dept_head: xuất báo cáo (+ thừa kế teacher)
 (5,5),(5,6),(5,8),  -- academic_office: sửa điểm đã khoá, báo cáo, tạo khoá học
 (6,7);              -- admin: quản lý user
INSERT INTO tmp_rbac_role_inherits VALUES (3,2),(4,3);
INSERT INTO tmp_rbac_users VALUES (1,'An'),(2,'Binh'),(3,'Cuong'),(4,'Dung'),(5,'Em');
INSERT INTO tmp_rbac_user_roles VALUES (1,1),(2,2),(3,3),(4,4),(5,5);

Quyền hiệu lực của Dung (trưởng khoa) — phải đi theo cây thừa kế bằng WITH RECURSIVE:

WITH RECURSIVE eff_roles(role_id) AS (
  SELECT role_id FROM tmp_rbac_user_roles WHERE user_id = 4
  UNION                                    -- UNION (không phải UNION ALL): tự khử trùng, chống vòng lặp
  SELECT i.inherits_id FROM tmp_rbac_role_inherits i JOIN eff_roles e ON i.role_id = e.role_id
)
SELECT r.name AS via_role, p.name AS permission
FROM eff_roles e
JOIN tmp_rbac_roles r            ON r.id = e.role_id
JOIN tmp_rbac_role_permissions x ON x.role_id = e.role_id
JOIN tmp_rbac_permissions p      ON p.id = x.permission_id
ORDER BY p.name;

 via_role  |    permission
-----------+------------------
 ta        | course.view
 teacher   | grade.publish
 dept_head | report.export
 ta        | submission.grade
(4 rows)

Câu check dùng trong request có 2 bản. Bản "phẳng" quên cây thừa kế — trả sai:

-- user 3 (Cuong, teacher) có submission.grade không?
 flat_check
------------
 f              ← teacher không có trực tiếp, chỉ có qua ta

 hierarchical_check
--------------------
 t

Rà soát ngược (mức 4 — symmetric): "ai đang có quyền chấm bài?". Đây là câu auditor hỏi, và là điểm mạnh nhất của RBAC so với ABAC:

WITH RECURSIVE holders(role_id) AS (
  SELECT x.role_id FROM tmp_rbac_role_permissions x
  JOIN tmp_rbac_permissions p ON p.id = x.permission_id WHERE p.name = 'submission.grade'
  UNION
  SELECT i.role_id FROM tmp_rbac_role_inherits i JOIN holders h ON i.inherits_id = h.role_id)
SELECT u.name, r.name AS role
FROM holders h
JOIN tmp_rbac_user_roles ur ON ur.role_id = h.role_id
JOIN tmp_rbac_users u ON u.id = ur.user_id
JOIN tmp_rbac_roles r ON r.id = ur.role_id
ORDER BY u.name;

 name  |   role
-------+-----------
 Binh  | ta
 Cuong | teacher
 Dung  | dept_head
(3 rows)

Chi phí một lần check. Nạp 200 000 user (5% giảng viên, 5% trợ giảng) rồi đo câu check có thừa kế cho 12 user khác nhau:

SELECT count(*) AS users, (SELECT count(*) FROM tmp_rbac_user_roles) AS assignments FROM tmp_rbac_users;
 users  | assignments
--------+-------------
 200008 |      200009

-- EXPLAIN (ANALYZE) câu check cho user 5000 (trích)
   ->  Index Only Scan using tmp_rbac_user_roles_pkey on tmp_rbac_user_roles (actual time=0.170..0.171 rows=1.00 loops=1)
         Index Cond: (user_id = 5000)
 Buffers: shared hit=10
 Planning Time: 2.095 ms
 Execution Time: 0.893 ms
12 lần đoThấp nhấtTrung vịCao nhất
Planning0,38 ms2,75 ms8,65 ms
Execution0,42 ms0,64 ms7,16 ms

Máy lab đang tải cao (load average ~12) nên số dao động mạnh. Kết luận vẫn rõ: câu check chạy nhanh, nhưng plan + chạy + đi mạng tốn vài ms mỗi lần. Một trang có 30 lần @can mà lần nào cũng hỏi DB là thêm cả trăm ms. Vì vậy mọi thư viện RBAC đều nạp hết quyền một lần rồi check trong RAM — và chính cái cache đó sinh ra Bẫy 2.

1.4. Trong Laravel: spatie/laravel-permission

Package phổ biến nhất cho RBAC trong Laravel. Nó làm đúng mô hình ở 1.3, tên bảng khác:

Mô hình lõiBảng của spatieGhi chú
roles, permissionsroles, permissionsthêm cột guard_name (Bẫy 7), team_id nếu bật teams
role_permissionsrole_has_permissions
user_rolesmodel_has_rolesđa hình (model_type) — model nào cũng gán role được
—model_has_permissionscấp quyền thẳng cho user, không qua role (dùng tiết kiệm)
role_inheritskhông cóspatie không có cây thừa kế: role cao phải được gán đủ permission

Seeder và check — code chạy thật trong lab:

foreach (['course.view', 'submission.create', 'submission.grade', 'grade.publish',
          'grade.override', 'report.export', 'user.manage'] as $name) {
    Permission::create(['name' => $name]);
}
Role::create(['name' => 'student'])->givePermissionTo(['course.view', 'submission.create']);
Role::create(['name' => 'teacher'])->givePermissionTo(['course.view', 'submission.grade', 'grade.publish']);
Role::create(['name' => 'academic_office'])->givePermissionTo(['grade.override', 'report.export']);
Role::create(['name' => 'super-admin']);

$an    = User::create(['name' => 'An'])->assignRole('student');
$cuong = User::create(['name' => 'Cuong'])->assignRole('teacher');
$root  = User::create(['name' => 'Root'])->assignRole('super-admin');

// spatie tự đăng ký vào Gate, nên dùng $user->can() như bình thường
$an->can('submission.grade');     // false
$cuong->can('submission.grade');  // true
$cuong->can('grade.override');    // false
$root->can('submission.grade');   // false — super-admin chưa có luật gì, 0 permission

Chỗ dùng trong app:

// routes/web.php — spatie gắn permission vào Gate nên middleware 'can' của Laravel dùng được luôn
Route::post('/submissions/{submission}/grade', [GradeController::class, 'store'])
    ->middleware('can:submission.grade');

// Blade — chỉ để ẨN nút, không phải để bảo vệ (Bẫy 11)
@can('submission.grade') <button>Chấm bài</button> @endcan

// AppServiceProvider::boot() — super-admin, cách spatie khuyên
Gate::before(fn ($user, string $ability) => $user->hasRole('super-admin') ? true : null);

Số query thật khi check nhiều lần trong một request:

400 lần ->can(), cache nguội                         4 query
200 lần ->can() của user khác, cache đã ấm          3 query (gồm 1 câu find user)

spatie nạp toàn bộ bảng role–permission một lần vào cache (mặc định giữ 24 giờ), và nạp role của user một lần cho mỗi instance model. Nhanh — nhưng đổi lại là câu hỏi "khi nào cache đó cũ?" ở Bẫy 2.

1.5. Check đặt ở đâu trong một request

sequenceDiagram
  participant B as Trình duyệt / API client
  participant M as Middleware can(submission.grade)
  participant C as Controller / Livewire action
  participant P as Policy (bản ghi nào?)
  participant D as PostgreSQL
  B->>M: POST /submissions/42/grade
  M->>M: RBAC - user có permission không? (RAM/cache)
  M-->>B: 403 nếu không có
  M->>C: cho qua
  C->>P: Gate::authorize('grade', $submission)
  P->>D: bài 42 thuộc lớp mình dạy không?
  P-->>C: allow / deny
  C->>D: UPDATE điểm

RBAC đứng ở cổng (middleware) và trả lời nhanh câu "được làm loại việc này không". Câu "trên bản ghi này" nằm ở policy — đó là phần của ABAC. Bỏ lớp policy là Bẫy 1.

1.6. PostgreSQL tự nó là một hệ RBAC

Role của PostgreSQL chính là RBAC: role = nhóm quyền, user = role có LOGIN, gán role = GRANT. Dùng nó để tách user DB theo việc (app, migration, báo cáo BI), không dùng cho người dùng cuối của app.

CREATE ROLE tmp_reporting NOLOGIN;                         -- "role" theo nghĩa RBAC
GRANT SELECT ON tmp_rbac_users, tmp_rbac_roles, tmp_rbac_user_roles TO tmp_reporting;
CREATE ROLE tmp_bi_lan LOGIN;                              -- "user"
GRANT tmp_reporting TO tmp_bi_lan;                         -- gán role cho user

   member   | granted_role  | inherit_option | set_option | admin_option
------------+---------------+----------------+------------+--------------
 tmp_bi_lan | tmp_reporting | t              | t          | f

SET ROLE tmp_bi_lan;
SELECT current_user, count(*) FROM tmp_rbac_user_roles;
 current_user | count
--------------+--------
 tmp_bi_lan   | 200009

DELETE FROM tmp_rbac_user_roles WHERE user_id = 3;
ERROR:  permission denied for table tmp_rbac_user_roles

RESET ROLE;
REVOKE tmp_reporting FROM tmp_bi_lan;     -- thu hồi
SET ROLE tmp_bi_lan;
SELECT count(*) FROM tmp_rbac_user_roles;
ERROR:  permission denied for table tmp_rbac_user_roles    ← có hiệu lực ngay câu sau, không cache

Từ PostgreSQL 16, mỗi lần gán role có 3 cờ riêng: INHERIT (tự dùng quyền của role), SET (được SET ROLE sang), ADMIN (được gán role đó cho người khác). Để ý chỗ khác biệt quan trọng với app: thu hồi ở DB có hiệu lực ngay, còn thu hồi ở app phụ thuộc cache (Bẫy 2).

2. Lợi ích

Khi nào thật sự được những lợi ích này: số loại người dùng ít (dưới vài chục role), và quyền là quyền theo chức năng — vào được menu nào, dùng được tính năng nào, xuất được báo cáo nào. Khi quyền phụ thuộc vào dữ liệu cụ thể (lớp nào, trường nào, bài ở trạng thái nào) thì RBAC một mình không đủ.

3. Tác hại / hạn chế — danh sách bẫy

Xếp theo mức hay dính và mức nguy hiểm. Bẫy nổ trễ (ở lần pentest, ở ngày có người nghỉ việc) để lên trước.

Bẫy 1. RBAC không biết "bản ghi nào" → IDOR, leo quyền ngang

Kiểu controller hay gặp nhất: check permission xong là ghi.

function gradeRbacOnly(User $user, int $submissionId, float $score): string
{
    if (! $user->can('submission.grade')) {
        return '403';
    }
    DB::table('submissions')->where('id', $submissionId)->update(['score' => $score]);

    return 'OK, đã ghi điểm '.$score;
}

Cuong chấm bài #1 (lớp 1 — lớp của Cuong)           OK, đã ghi điểm 8
Cuong chấm bài #2 (lớp 2 — lớp của HOA)             OK, đã ghi điểm 0
RBAC cho qua cả khi chấm bài của lớp người khác Cuong gửi hai request chấm bài. Cả hai đi qua cổng kiểm tra permission submission.grade và đều được cho qua, vì cổng chỉ hỏi Cuong có quyền chấm bài hay không. Request một ghi điểm 8 vào bài của lớp 1 do Cuong dạy. Request hai ghi điểm 0 vào bài của lớp 2 do Hoa dạy, và vẫn thành công. Cuong role: teacher cổng RBAC can( 'submission .grade') ✓ ✓ bài #1 · lớp 1 Cuong dạy bài #2 · lớp 2 Hoa dạy điểm 8 ✓ điểm 0 ✗ Cổng chỉ hỏi "Cuong có được chấm bài không?" — không hỏi "bài NÀY thuộc lớp nào?".
Hình 2. Hai request đi qua cùng một cổng RBAC và đều được cho qua. Cái thứ hai ghi điểm 0 vào bài của lớp Hoa dạy. Không có exception, không có log lạ — lỗi chỉ lộ ra khi pentest hoặc khi sinh viên khiếu nại.

Vì sao nổ trễ: test thường chỉ chạy "giảng viên chấm bài lớp mình" — pass. Không ai viết test "giảng viên chấm bài lớp người khác".

Chữa:

  1. Mọi route có id bản ghi phải qua policy: Gate::authorize('grade', $submission) hoặc ->can('grade', 'submission'). Policy kiểm tra phạm vi (lớp mình dạy) — xem bài ABAC.
  2. Bản ghi ngoài phạm vi thì trả 404 thay vì 403 (Response::denyAsNotFound()), để không xác nhận "bài 42 có tồn tại".
  3. Test cố định cho mọi endpoint có id: "user A không đụng được dữ liệu của B".

Bẫy 2. Gỡ quyền rồi mà quyền vẫn còn — 3 lớp cache

Câu "gỡ quyền bao lâu thì hết tác dụng" có 3 đáp án khác nhau, tuỳ quyền đang nằm ở đâu.

(a) Sửa thẳng DB: cache chung giữ bản cũ tới 24 giờ

Seeder dùng DB::table(), hay DBA chạy SQL tay để "hotfix" — không đi qua API của spatie nên không ai xoá cache. Chạy thật với 5 process riêng, dùng chung DB và cache file:

14:12:12 setup xong, Cuong->can(submission.grade) = true (cache đã ấm)
14:12:13 [dba]    DELETE FROM role_has_permissions — xong, không reset cache
14:12:16 [web]    process MỚI: Cuong->can(submission.grade) = true      ← DB đã xoá, cache vẫn cho
14:12:19 [deploy] permission:cache-reset
14:12:21 [web]    process MỚI: Cuong->can(submission.grade) = false

permission.cache.expiration_time mặc định            24 giờ

(b) Worker chạy lâu: giữ bản trong RAM tới khi restart

Lần này admin làm đúng: gọi revokePermissionTo(), spatie tự xoá cache chung. Nhưng một queue worker (một process PHP sống lâu, xử lý nhiều job) đã nạp bảng quyền vào RAM từ trước:

14:11:04 setup xong, Cuong->can(submission.grade) = true (cache đã ấm)
14:11:07 [worker] job 1: Cuong->can(submission.grade) = true
14:11:09 [worker] job 2: Cuong->can(submission.grade) = true
14:11:12 [worker] job 3: Cuong->can(submission.grade) = true
14:11:13 [admin]  revokePermissionTo() xong — spatie đã tự xoá cache chung
14:11:14 [worker] job 4: Cuong->can(submission.grade) = true     ← vẫn true
14:11:16 [web]    process MỚI: Cuong->can(submission.grade) = false
14:11:16 [worker] job 5: Cuong->can(submission.grade) = true     ← vẫn true
14:11:18 [worker] job 6: Cuong->can(submission.grade) = true     ← vẫn true
Thu hồi quyền: process mới thấy ngay, worker sống lâu thì không Dòng thời gian thật từ 14 giờ 11 phút 04 giây tới 18 giây. Queue worker chạy job lúc 07, 09, 12 giây, đều trả true. Lúc 13 giây admin gọi revokePermissionTo, cache chung bị xoá. Worker chạy job lúc 14, 16, 18 giây vẫn trả true vì bảng quyền nằm trong RAM của nó. Một process web mới lúc 16 giây trả false. admin web (process mới) queue worker (1 process sống lâu) :04 :09 :13 :18 cache ấm true true true revoke + xoá cache true! true! false true!
Hình 3. Dòng thời gian thật của lab. Sau khi admin thu hồi (vạch đỏ), process web mới trả false ngay, nhưng worker vẫn trả true ở cả 3 job sau đó. Worker sẽ còn cho qua tới khi bị restart.

Chữa bằng một dòng ở đầu mỗi job — đã chạy thật:

// AppServiceProvider::boot()
Queue::before(fn () => app(PermissionRegistrar::class)->clearPermissionsCollection());

14:11:43 [worker+fix] job 1: Cuong->can(submission.grade) = true
14:11:45 [worker+fix] job 2: Cuong->can(submission.grade) = true
14:11:45 [admin]      revokePermissionTo() xong — spatie đã tự xoá cache chung
14:11:47 [worker+fix] job 3: Cuong->can(submission.grade) = false
14:11:49 [worker+fix] job 4: Cuong->can(submission.grade) = false

Chi phí: mỗi job đọc lại cache chung 1 lần. Với Octane (cũng là process sống lâu), spatie có sẵn cờ register_octane_reset_listener nhưng mặc định là false (đọc trong source 8.3.0) — phải bật lên.

(c) Role nằm trong JWT hoặc session: hết hạn token mới mất

Nếu API đọc role từ claim của JWT, token vẫn hợp lệ tới exp dù đã gỡ role trong DB. Không có lab cho phần này — đó là tính chất của token tự chứa (stateless token). Chữa: token ngắn (5–15 phút) + refresh token kiểm tra lại DB; hành động nhạy cảm (sửa điểm, xuất dữ liệu) luôn check quyền từ server; khi khoá tài khoản thì thu hồi refresh token.

Bẫy 3. Teams (quyền theo trường) rò giữa các job

spatie có tính năng teams: cùng một role teacher nhưng gán theo từng trường. Trường đang xét được giữ trong một biến toàn cục của process (setPermissionsTeamId()). Trong web request thì mỗi request tự set. Trong queue worker thì biến đó sống qua nhiều job:

// Cuong là giảng viên ở TRƯỜNG 1, không có role gì ở trường 2
job trường 2, QUÊN set team → can(submission.grade) = false (team đang nhớ: NULL)
job trường 1, có set team  → can(submission.grade) = true  (team đang nhớ: 1)
job trường 2, QUÊN set team → can(submission.grade) = true  (team đang nhớ: 1)   ← rò từ job trước
job trường 2, có set team  → can(submission.grade) = false (team đang nhớ: 2)

Dòng 3 là lỗi rò dữ liệu giữa các tenant. Nó chỉ xảy ra khi job trường 2 chạy ngay sau job trường 1 trên cùng worker — nên ở local không bao giờ thấy. Đọc source 8.3.0: spatie chỉ tự reset team khi chạy Octane; queue worker thì không.

Chữa: job mang school_id và gọi setPermissionsTeamId() ở dòng đầu handle(); thêm Queue::after(fn () => setPermissionsTeamId(null)) để lỗi quên set biến thành "không có quyền" (fail closed) thay vì "quyền của trường trước". Sau khi đổi team phải $user->unsetRelation('roles')->unsetRelation('permissions') nếu dùng lại object user cũ (docs spatie).

Bẫy 4. Gate::before cho super-admin đè cả luật nghiệp vụ

Gate::before(fn ($user, string $ability) => $user->hasRole('super-admin') ? true : null);
Gate::define('grade-submission', function (User $user, object $submission) {
    // luật nghiệp vụ: bài đã khoá điểm thì KHÔNG AI được chấm lại
    return $submission->status !== 'locked' && $user->can('submission.grade');
});

Cuong  grade-submission (bài #3 đã khoá)            false
Root   grade-submission (bài #3 đã khoá)            true      ← before trả true, luật không được chạy

before trả true là Gate dừng luôn, không gọi tới luật bên dưới. Luật "bài đã khoá thì không ai được sửa" là luật nghiệp vụ bất biến, không phải luật phân quyền — đặt nó trong Gate là đặt sai chỗ.

Chữa: luật bất biến đặt ở tầng domain (service/model ném exception, hoặc constraint DB), ai gọi cũng bị chặn. before luôn trả null cho người không phải super-admin — trả false là chặn sạch mọi quyền của người đó. Ghi audit log mọi hành động của super-admin.

Hệ quả nổ trễ đi kèm: dev hay test tính năng mới bằng tài khoản super-admin. Mọi check đều true, nên quên tạo/gán permission cho role thật cũng không ai thấy — tới production thì giảng viên bị 403 hàng loạt (Case 1).

Bẫy 5. Ràng buộc SoD bằng trigger vẫn thủng khi 2 request chạy song song

Luật: người nhập/công bố điểm (teacher) không được đồng thời là người sửa điểm đã khoá (academic_office). Ràng buộc "không được giữ cả hai" không viết được bằng UNIQUE/CHECK vì nó nhìn nhiều dòng, nên dùng trigger:

CREATE TABLE tmp_rbac_sod (role_a bigint REFERENCES tmp_rbac_roles, role_b bigint REFERENCES tmp_rbac_roles,
                           PRIMARY KEY (role_a, role_b));
INSERT INTO tmp_rbac_sod VALUES (3, 5);   -- teacher ✗ academic_office

CREATE OR REPLACE FUNCTION tmp_rbac_check_sod() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM tmp_rbac_user_roles ur
    JOIN tmp_rbac_sod s ON (s.role_a = NEW.role_id AND s.role_b = ur.role_id)
                        OR (s.role_b = NEW.role_id AND s.role_a = ur.role_id)
    WHERE ur.user_id = NEW.user_id) THEN
    RAISE EXCEPTION 'SoD violation: user % cannot hold role % together with a conflicting role',
      NEW.user_id, NEW.role_id;
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER tmp_rbac_sod_check BEFORE INSERT OR UPDATE ON tmp_rbac_user_roles
  FOR EACH ROW EXECUTE FUNCTION tmp_rbac_check_sod();

-- Tuần tự: chặn được
INSERT INTO tmp_rbac_user_roles VALUES (6, 3);
INSERT 0 1
INSERT INTO tmp_rbac_user_roles VALUES (6, 5);
ERROR:  SoD violation: user 6 cannot hold role 5 together with a conflicting role

Giờ cho 2 admin gán role cho Hoa (user 7) cùng lúc, 2 session thật chạy song song (helper parallel của pgtest.py):

----- admin 1: gán teacher -----
BEGIN
INSERT 0 1
 pg_sleep                  ← giữ transaction 2 giây (vd còn gửi mail, ghi log...)
COMMIT

----- admin 2: gán academic_office -----   (bắt đầu sau 0,5 giây)
BEGIN
INSERT 0 1                 ← trigger không thấy role teacher vì admin 1 CHƯA commit
COMMIT

Kết quả cuối cùng của Hoa
 name |      role
------+-----------------
 Hoa  | academic_office
 Hoa  | teacher           ← SoD bị phá

Chữa: trong trigger, khoá dòng user trước khi kiểm tra. Mọi lần gán role cho cùng một người phải xếp hàng:

CREATE OR REPLACE FUNCTION tmp_rbac_check_sod() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  PERFORM 1 FROM tmp_rbac_users WHERE id = NEW.user_id FOR UPDATE;   -- xếp hàng theo user
  IF EXISTS (...giống trên...) THEN
    RAISE EXCEPTION 'SoD violation: ...';
  END IF;
  RETURN NEW;
END $$;

----- admin 2: gán academic_office -----
BEGIN
Time: 1515.348 ms (00:01.515)      ← chờ admin 1 commit
ERROR:  SoD violation: user 8 cannot hold role 5 together with a conflicting role
ROLLBACK

Kết quả cuối cùng của Khanh
 name  |  role
-------+---------
 Khanh | teacher
Hai admin gán role xung đột cùng lúc: không khoá thì thủng, có khoá thì chặn được Hai kịch bản trên cùng trục thời gian 0 đến 2,5 giây. Kịch bản trên, trigger không khoá: admin 1 chèn role teacher lúc 0 giây và giữ transaction tới 2 giây. Admin 2 chèn academic_office lúc 0,5 giây, trigger không thấy teacher vì chưa commit, nên commit thành công. Kết quả Hoa giữ cả hai role. Kịch bản dưới, trigger khoá dòng user: admin 2 phải chờ 1,5 giây tới khi admin 1 commit, sau đó trigger thấy teacher và báo lỗi SoD. Trigger chỉ SELECT — không khoá admin 1 admin 2 INSERT teacher · check ✓ INSERT academic_office · check ✓ · COMMIT không thấy teacher: admin 1 chưa commit COMMIT Hoa: teacher + academic_office — SoD thủng Trigger khoá dòng user (FOR UPDATE) admin 1 admin 2 INSERT teacher · khoá dòng Khanh · check ✓ chờ khoá dòng user… (đo được 1,5 s) COMMIT ERROR SoD Khanh: chỉ teacher — chặn được 0 s 0,5 1 1,5 2
Hình 4. Cùng một kịch bản thời gian, hai cách viết trigger. Không khoá: mỗi transaction kiểm tra trên ảnh chụp dữ liệu của chính nó, không thấy dòng chưa commit của bên kia, nên cả hai cùng qua. Có khoá dòng user: bên thứ hai phải chờ, và khi được chạy thì thấy dòng đã commit.

Vì sao nổ trễ: test tuần tự luôn pass. Chỉ nổ khi 2 người thật bấm gần như cùng lúc — hoặc khi có job import role hàng loạt chạy song song. Cách khác: chạy transaction gán role ở mức SERIALIZABLE và retry khi gặp serialization failure.

Bẫy 6. Cây thừa kế role có vòng

Một admin cấu hình nhầm: cho ta thừa kế dept_head. Giờ có vòng dept_head → teacher → ta → dept_head.

INSERT INTO tmp_rbac_role_inherits VALUES (2, 4);

-- Viết UNION: không treo, nhưng trợ giảng "trèo ngược" lên có quyền của trưởng khoa
                     binh_ta_permissions
-------------------------------------------------------------
 course.view, grade.publish, report.export, submission.grade

-- Viết nhầm UNION ALL: đệ quy mãi tới khi bị timeout
SET statement_timeout = '2s';
ERROR:  canceling statement due to statement timeout
Time: 2073.384 ms (00:02.073)

-- PG 14+: mệnh đề CYCLE phát hiện vòng và in đường đi
) CYCLE role_id SET is_cycle USING path
 role_id | is_cycle |       path
---------+----------+-------------------
       2 | f        | {(2)}
       4 | f        | {(2),(4)}
       3 | f        | {(2),(4),(3)}
       2 | t        | {(2),(4),(3),(2)}

Chữa: chặn vòng lúc ghi (trước khi thêm cạnh A → B, kiểm tra B có đi tới A được không), dùng UNION hoặc CYCLE lúc đọc, và giới hạn độ sâu. Tốt nhất: giữ cây nông (2–3 tầng). spatie không có cây thừa kế nên tránh được bẫy này — đổi lại role cao phải được gán đủ permission bằng tay.

Bẫy 7. Guard không khớp

App có 2 guard (web cho giao diện, api cho token). Permission tạo cho guard này thì user của guard kia không dùng được. Hai lỗi thật gặp trong lab:

// permission tạo với guard 'api', user dùng guard 'web'
$cuong->givePermissionTo('grades.import');
PermissionDoesNotExist: There is no permission named `grades.import` for guard `web`.

$cuong->can('grades.import');   // false — không throw, chỉ âm thầm từ chối

// class model KHÔNG nằm trong config auth.providers (lab: class User tự khai báo;
// ngoài đời: model Student hay Admin riêng) và không khai báo $guard_name
$student->assignRole('student');
GuardDoesNotMatch: The given role or permission should use guard `` instead of `web`.

Lỗi thứ hai rất khó hiểu: guard rỗng `` là vì spatie không tìm được guard nào gắn với class model đó. Chữa: khai báo protected $guard_name = 'web'; trên model, seeder tạo permission đúng guard, và có test "user thật của từng guard check được quyền".

Bẫy 8. Check tên role trong code thay vì permission

// Sai: thêm role "trợ giảng được chấm bài" là phải sửa code + deploy
if ($user->hasRole('teacher') || $user->hasRole('dept_head')) { ... }

// Đúng: role chỉ là cách nhóm permission, code chỉ hỏi permission
if ($user->can('submission.grade')) { ... }

spatie cũng khuyên vậy: role để nhóm permission; ngoại lệ duy nhất là check super-admin trong Gate::before. Rẻ để giữ luật này: một bước grep trong CI chặn hasRole( ngoài file AppServiceProvider.

Bẫy 9. Role explosion — nhét điều kiện vào tên role

Yêu cầu mới: "trưởng khoa chỉ xem báo cáo của khoa mình, và chỉ ở cơ sở mình". Cách làm vội: tạo role dept_head_cntt_coso1, dept_head_cntt_coso2... Tính thử (ví dụ minh hoạ): 5 loại vai trò × 12 khoa × 3 cơ sở = 180 role. Mở thêm 1 khoa = thêm 15 role, và code phải biết tên role nào ứng với khoa nào.

Dấu hiệu nhận ra: tên role chứa tên đơn vị, số role lớn hơn nhiều số loại người dùng thật, hoặc có người phải giữ 6–7 role cùng lúc. Chữa: tách chiều "phạm vi" ra khỏi role — teams của spatie cho chiều tenant/trường; điều kiện theo dữ liệu (khoa, lớp, trạng thái) đi sang ABAC; quan hệ lồng nhau (lớp → nhóm → bài) đi sang ReBAC.

Bẫy 10. Người có quyền quản lý user tự nâng quyền cho mình

Màn hình "quản lý user" cần permission user.manage. Nếu màn hình đó cho gán bất kỳ role nào, thì người có user.manage tự gán super-admin cho chính mình. spatie không có khái niệm "role nào được gán role nào". Phần này là thiết kế, chưa có lab riêng.

Chữa: luật "chỉ được gán role thấp hơn role của mình", không được tự sửa role của chính mình, super-admin chỉ gán bằng lệnh CLI có log. NIST gọi phần này là quản trị RBAC (administrative RBAC).

Bẫy 11. Chỉ ẩn nút ở giao diện

@can trong Blade chỉ quyết định vẽ hay không vẽ cái nút. Action của Livewire là một endpoint (POST /livewire/update) mà ai cũng gọi được bằng DevTools — tài liệu Livewire 4 nói rõ điều này, và bài Livewire public property đã phân tích payload. Chữa: mọi action/controller tự gọi $this->authorize() hoặc Gate::authorize(); UI chỉ là lớp tiện lợi.

4. Trade-off

4.1. Chọn mô hình

ACLRBACRBAC + scope (teams)ABACReBAC
Câu hỏi trả lờiuser có trong danh sách của object này?role của user có permission P?… trong phạm vi trường S?thuộc tính user/bản ghi/môi trường có thoả luật?user có quan hệ R với object (trực tiếp hay qua chuỗi)?
Ví dụ LMSchia sẻ 1 tài liệu cho 3 ngườigiảng viên được chấm bàigiảng viên của trường 1lớp mình dạy, bài chưa khoá, trước hạntrợ giảng được mời vào lớp → xem bài của lớp
Chi phí check1 lần tratra trong RAMtra + context tenantchạy luật, cần lấy thuộc tínhduyệt đồ thị quan hệ
"Ai có quyền P?"dễ, theo từng object1 câu querydễkhó — phải chạy luật cho mọi usercó API liệt kê
Lọc danh sáchjoin bảng ACLkhông cần (không theo bản ghi)where school_idphải dịch luật ra SQLcần API "list objects"
Chọn khichia sẻ lẻ, ít objectquyền theo chức năngSaaS nhiều tenantđiều kiện theo dữ liệu, thời gianchia sẻ lồng nhau kiểu Google Drive

Hệ thật gần như luôn là lai: RBAC cho cổng chức năng, scope cho tenant, ABAC (policy) cho bản ghi. RBAC là trường hợp riêng của ABAC — role chỉ là một thuộc tính của user.

4.2. Làm RBAC bằng gì

Tự viết 6 bảngspatie/laravel-permissionRole trong IdP (Keycloak, Auth0, Entra ID)Dịch vụ phân quyền (OpenFGA, SpiceDB, Cerbos…)
Hợp khicần cây thừa kế, SoD, ràng buộc riêng1 app Laravel, team nhỏ–vừanhiều app dùng chung SSOnhiều service, quyền theo quan hệ, đội riêng lo phân quyền
Cái đượckiểm soát hếtcó sẵn cache, middleware, Blade, teams1 chỗ quản lý cho mọi appcheck + liệt kê nhanh ở quy mô lớn, API chuẩn
Cái mấttự lo cache, bẫy 2, 5, 6không có thừa kế, SoD; bẫy 2, 3, 7role nằm trong token (Bẫy 2c), khó làm quyền theo dữ liệuthêm 1 hệ thống phải trực, dữ liệu quan hệ phải đồng bộ 2 nơi

4.3. Lấy quyền ở đâu lúc check

Tra DB mỗi lầnCache (cách của spatie)Role trong JWT
Độ trễ mỗi checkvài ms (1.3)~0 sau lần nạp đầu (400 check = 4 query)0 — đọc token
Thu hồi có hiệu lựcngayngay nếu đi qua API; sai nếu sửa DB tay hoặc process sống lâukhi token hết hạn
Chọn khiít check, cần đúng tuyệt đốimặc định cho monolith Laravelnhiều service; token ngắn + check lại server cho việc nhạy cảm

5. Khó khăn khi vận hành thực tế

6. Case thực tế và cách xử lý

Case 1 — Deploy tính năng mới, giảng viên bị 403 hàng loạt

Tính năng "xuất bảng điểm lớp". Dev và QA test bằng tài khoản super-admin — mọi thứ chạy. Lên production, giảng viên bấm là 403.

  1. Dashboard: tỉ lệ 403 của route /classes/{class}/grades/export nhảy từ 0 lên 100% ngay sau deploy.
  2. Chạy lệnh debug ở mục 5 cho 1 giảng viên: không có report.export.
  3. Xem seeder: có Permission::findOrCreate('report.export') nhưng quên givePermissionTo cho teacher. Super-admin đi qua Gate::before nên không ai thấy (Bẫy 4).
  4. Hotfix bằng API (Role::findByName('teacher')->givePermissionTo('report.export')), không bằng SQL tay (Bẫy 2a). Cache tự reset. Chạy queue:restart nếu export chạy trong job.
  5. Hậu kiểm: test tính năng theo từng role thật; cấm test phân quyền bằng super-admin; thêm test "role teacher gọi được route export".

Case 2 — Giảng viên đã nghỉ việc vẫn sửa được điểm

Phòng đào tạo phát hiện điểm bị sửa 2 ngày sau khi giảng viên đó bị gỡ role.

  1. Xác minh DB: model_has_roles đã không còn dòng của người này.
  2. Hỏi gỡ bằng cách nào: DBA chạy DELETE tay → cache chung chưa reset (Bẫy 2a). Hoặc: điểm bị sửa bởi job import chạy trên worker sống lâu (Bẫy 2b).
  3. Ngay lập tức: permission:cache-reset + queue:restart; khoá tài khoản (authentication), thu hồi session và token — gỡ role thôi là chưa đủ.
  4. Tìm thiệt hại: lọc audit log / bảng lịch sử điểm từ lúc gỡ role tới giờ, khôi phục điểm bị sửa.
  5. Sửa gốc: quy trình offboarding đi qua API (hoặc lệnh artisan có reset cache), thêm Queue::before clear bảng quyền, bật audit event của spatie.

Case 3 — Pentest báo IDOR ở API chấm bài

Báo cáo: đổi /api/submissions/1234/grade thành 1235 là chấm được bài lớp khác.

  1. Liệt kê mọi route có tham số id (php artisan route:list --json, lọc theo {).
  2. Với từng route: có policy theo bản ghi không, hay chỉ có middleware permission (Bẫy 1)?
  3. Thêm policy, trả 404 cho bản ghi ngoài phạm vi.
  4. Thêm test ma trận: mỗi route × "chủ sở hữu" / "người cùng role khác phạm vi" / "khách".
  5. Soát log truy cập: có ai đã khai thác trước pentest chưa (request tới id không thuộc lớp của người gọi).

Case 4 — Trường mới muốn "thư ký khoa chỉ xem điểm của khoa mình"

Áp lực tạo role secretary_cntt, secretary_kinhte... (Bẫy 9).

  1. Tách 2 câu hỏi: làm được gì (xem điểm → permission grade.view, role secretary) và trên phạm vi nào (khoa của mình → thuộc tính department_id).
  2. Phạm vi trường → teams của spatie. Phạm vi khoa → policy + query scope theo department_id (ABAC).
  3. Kết quả: vẫn 1 role, thêm khoa không phải thêm role.

7. Câu hỏi phụ interviewer hay hỏi tiếp

Q: RBAC khác ACL thế nào?
A: ACL gắn danh sách "ai được làm gì" vào từng object. RBAC gắn quyền vào vai trò. ACL chi tiết nhưng khó quản khi có triệu object; RBAC dễ quản nhưng không biết object nào.

Q: Check role hay check permission?
A: Permission. Role chỉ để nhóm permission. Check role trong code thì thêm role mới là phải deploy. Ngoại lệ: super-admin trong Gate::before.

Q: Gỡ quyền thì bao lâu có hiệu lực?
A: Phải nói được con số theo từng lớp: qua API spatie — process mới thấy ngay; sửa DB tay — tới 24 giờ (TTL cache mặc định); queue worker/Octane — tới khi restart nếu không clear mỗi job; JWT — tới exp. Đã đo thật ở Bẫy 2.

Q: Làm sao chặn một người vừa nhập vừa duyệt điểm?
A: SoD tĩnh — không cho giữ 2 role: constraint trong DB, trigger phải khoá dòng user (không khoá thì 2 request song song vẫn lọt, đã đo). SoD động — "người tạo phiếu không được tự duyệt phiếu đó": so created_by != approved_by trên bản ghi → đó là ABAC.

Q: Multi-tenant thì RBAC làm sao?
A: Role gắn theo tenant (teams). Tenant hiện tại set theo request, reset trong worker (Bẫy 3). Thêm Row-Level Security ở DB làm lớp phòng thủ thứ hai — xem bài ABAC.

Q: Có nên để role trong JWT?
A: Được, nếu token ngắn và việc nhạy cảm vẫn check lại server. Nhược điểm: không thu hồi ngay được, và token phình to khi nhiều permission — nên để role, không để danh sách permission.

Q: Super-admin nên làm thế nào?
A: Gate::before trả true/null. Nhưng luật bất biến (bài đã khoá) đặt ở domain để super-admin cũng không phá được; MFA; audit log; rất ít người giữ.

Q: Bao nhiêu role là nhiều?
A: Không có con số cố định. Dấu hiệu: tên role chứa tên đơn vị, số role lớn hơn nhiều số loại người dùng thật, một người giữ nhiều role để "cộng" quyền. Lúc đó chuyển chiều phạm vi sang scope/ABAC.

Q: Test phân quyền thế nào cho đủ?
A: Test ma trận role × route bằng data provider; test "user A không đụng được dữ liệu B"; test chạy bằng user thật của từng role, không bằng super-admin; test 2 request song song cho các ràng buộc SoD.

Tóm tắt

Đọc thêm trong repo này

Phụ lục — chạy lại lab

# PostgreSQL 18.4 — bảng tmp_rbac_* (đã drop sau khi đo)
P=/var/www/chiennn/postgresql-learning
$P/scripts/psql.sh < rbac1.sql          # tạo 6 bảng + dữ liệu mẫu ở 1.3
python3 sod_race.py                     # 2 session song song, dùng pgtest.parallel

# Laravel 12.64 + spatie/laravel-permission 8.3.0, KHÔNG cài vào project:
# tải source về thư mục lab, chạy trong container tạm, project mount read-only, không mạng
mkdir -p lab/pkgs lab/tmp/cache && cd lab/pkgs
curl -sL -o p.zip https://codeload.github.com/spatie/laravel-permission/zip/refs/tags/8.3.0 && unzip -q p.zip
curl -sL -o t.zip https://codeload.github.com/spatie/laravel-package-tools/zip/refs/tags/1.93.3 && unzip -q t.zip
cd ../..
docker run --rm --network none \
  -v /var/www/chiennn/laravel-livewire:/var/www/html:ro -v "$PWD/lab":/lab \
  --entrypoint php laravel-app /lab/php/rbac.php

# Demo nhiều process (Bẫy 2): DB sqlite file + cache file dùng chung trong lab/tmp
R="docker run --rm --network none -v /var/www/chiennn/laravel-livewire:/var/www/html:ro -v $PWD/lab:/lab --entrypoint php laravel-app"
$R /lab/php/w_setup.php
$R /lab/php/w_worker.php &   sleep 5;   $R /lab/php/w_admin.php;   $R /lab/php/w_fresh.php;   wait

File lab/php/boot.php — phần không hiển nhiên của lab (nạp package từ source, đổi DB sang sqlite, cache sang array/file để không đụng Redis/PostgreSQL của project):

<?php
$loader = require '/var/www/html/vendor/autoload.php';
$loader->addPsr4('Spatie\\Permission\\', '/lab/pkgs/laravel-permission-8.3.0/src');
$loader->addPsr4('Spatie\\LaravelPackageTools\\', '/lab/pkgs/laravel-package-tools-1.93.3/src');
require_once '/lab/pkgs/laravel-permission-8.3.0/src/helpers.php';

$app = require '/var/www/html/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
config([
    'logging.default' => 'stderr',
    'cache.default' => getenv('LAB_CACHE') ?: 'array',
    'cache.stores.file.path' => '/lab/tmp/cache',
    'database.default' => 'lab',
    'database.connections.lab' => ['driver' => 'sqlite', 'database' => getenv('LAB_DB') ?: ':memory:',
                                  'prefix' => '', 'foreign_key_constraints' => true],
    'permission.teams' => (bool) getenv('LAB_TEAMS'),
]);
$app->register(Spatie\Permission\PermissionServiceProvider::class);

class User extends Illuminate\Foundation\Auth\User
{
    use Spatie\Permission\Traits\HasRoles;
    protected $table = 'users';
    protected $guard_name = 'web';   // class này không nằm trong config auth.providers (Bẫy 7)
    protected $guarded = [];
    public $timestamps = false;
}
// bảng: Schema::create('users', ...) + (require '.../create_permission_tables.php.stub')->up();

Script lab (rbac.php, teams.php, w_*.php, rbac*.sql) nằm trong scratchpad của phiên viết bài, không commit. Phần chưa verify: hành vi JWT (Bẫy 2c) và luật gán role (Bẫy 10) là lập luận thiết kế.