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:
- "Giảng viên A có sửa được điểm lớp của giảng viên B không?"
- "Gỡ quyền của một người thì bao lâu sau mới hết tác dụng?"
- "Hai admin cùng lúc gán hai role xung đột cho một người thì sao?"
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 (containermy-test-pgdb, bảngtmp_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-permission8.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ò |
| permission | một hành động trên một loại tài nguyên, vd submission.grade = chấm bài nộp |
| role | mộ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 escalation | leo 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 quaGate::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.
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ức | Thêm gì | Ví dụ trong LMS | Trong bài |
|---|---|---|---|
| 1. Flat (Core) | user–role–permission, nhiều role mỗi user | Cuong có role teacher | 1.3, 1.4 |
| 2. Hierarchical | role thừa kế role | dept_head ⊃ teacher ⊃ ta | 1.3, Bẫy 9 |
| 3. Constrained | rà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_office | Bẫy 5 |
| 4. Symmetric | rà 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 đo | Thấp nhất | Trung vị | Cao nhất |
|---|---|---|---|
| Planning | 0,38 ms | 2,75 ms | 8,65 ms |
| Execution | 0,42 ms | 0,64 ms | 7,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õi | Bảng của spatie | Ghi chú |
|---|---|---|
| roles, permissions | roles, permissions | thêm cột guard_name (Bẫy 7), team_id nếu bật teams |
| role_permissions | role_has_permissions | |
| user_roles | model_has_roles | đa hình (model_type) — model nào cũng gán role được |
| — | model_has_permissions | cấp quyền thẳng cho user, không qua role (dùng tiết kiệm) |
| role_inherits | khô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
- Dễ hiểu, khớp sơ đồ tổ chức. Người nghiệp vụ tự đọc được "giảng viên được chấm bài, công bố điểm". Không cần biết code.
- Audit bằng 1 câu query. "Ai đang có quyền sửa điểm đã khoá?" trả lời được ngay (1.3). ABAC không làm được việc này dễ như vậy.
- Onboarding / offboarding nhanh. Người mới vào = gán 1 role. Người nghỉ = gỡ role (và khoá tài khoản).
- Check rẻ và cache được trọn. Quyền không phụ thuộc bản ghi, nên nạp một lần là dùng cho cả request: 400 lần check chỉ tốn 4 query (1.4).
- Có sẵn thư viện, ai cũng biết. spatie, Keycloak, Auth0, IAM của cloud đều có RBAC.
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
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:
- 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. - 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". - 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
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
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
| ACL | RBAC | RBAC + scope (teams) | ABAC | ReBAC | |
|---|---|---|---|---|---|
| Câu hỏi trả lời | user 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ụ LMS | chia sẻ 1 tài liệu cho 3 người | giảng viên được chấm bài | giảng viên của trường 1 | lớp mình dạy, bài chưa khoá, trước hạn | trợ giảng được mời vào lớp → xem bài của lớp |
| Chi phí check | 1 lần tra | tra trong RAM | tra + context tenant | chạy luật, cần lấy thuộc tính | duyệt đồ thị quan hệ |
| "Ai có quyền P?" | dễ, theo từng object | 1 câu query | dễ | khó — phải chạy luật cho mọi user | có API liệt kê |
| Lọc danh sách | join bảng ACL | không cần (không theo bản ghi) | where school_id | phải dịch luật ra SQL | cần API "list objects" |
| Chọn khi | chia sẻ lẻ, ít object | quyền theo chức năng | SaaS nhiều tenant | điều kiện theo dữ liệu, thời gian | chia 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ảng | spatie/laravel-permission | Role trong IdP (Keycloak, Auth0, Entra ID) | Dịch vụ phân quyền (OpenFGA, SpiceDB, Cerbos…) | |
|---|---|---|---|---|
| Hợp khi | cần cây thừa kế, SoD, ràng buộc riêng | 1 app Laravel, team nhỏ–vừa | nhiều app dùng chung SSO | nhiều service, quyền theo quan hệ, đội riêng lo phân quyền |
| Cái được | kiểm soát hết | có sẵn cache, middleware, Blade, teams | 1 chỗ quản lý cho mọi app | check + liệt kê nhanh ở quy mô lớn, API chuẩn |
| Cái mất | tự lo cache, bẫy 2, 5, 6 | không có thừa kế, SoD; bẫy 2, 3, 7 | role nằm trong token (Bẫy 2c), khó làm quyền theo dữ liệu | thê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ần | Cache (cách của spatie) | Role trong JWT | |
|---|---|---|---|
| Độ trễ mỗi check | và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ực | ngay | ngay nếu đi qua API; sai nếu sửa DB tay hoặc process sống lâu | khi token hết hạn |
| Chọn khi | ít check, cần đúng tuyệt đối | mặc định cho monolith Laravel | nhiề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ế
- Permission đi cùng deploy. Permission mới nằm trong seeder idempotent
(
Permission::findOrCreate()), gán luôn cho role cần có. Pipeline chạy seeder →php artisan permission:cache-reset→queue:restart. Quên bước cuối là Bẫy 2b. - Rà soát quyền định kỳ. Mỗi quý chạy query "ai có quyền X" (1.3) cho các quyền nhạy
cảm (
grade.override,user.manage), gửi trưởng bộ phận xác nhận. Chống role creep (người chuyển bộ phận vẫn giữ role cũ). Quyền tạm thời (thỉnh giảng, trực thi) nên có ngày hết hạn — spatie không có sẵn, phải tự thêm cột + job gỡ. - Audit log thay đổi quyền. spatie 8.3.0 có event
RoleAttachedEvent,RoleDetachedEvent,PermissionAttachedEvent,PermissionDetachedEventnhưngevents_enabledmặc định làfalse. Bật lên, ghi "ai gán gì cho ai, lúc nào". - Debug lúc 3 giờ sáng: "sao tôi không vào được?". Cần 1 lệnh in ra quyền hiệu lực và
nguồn của nó (chạy thật):
Kèm theo: team hiện tại (getRoleNames() teacher getPermissionsViaRoles() submission.grade getDirectPermissions() report.export getAllPermissions() submission.grade, report.exportgetPermissionsTeamId()), guard của user, và so kết quả process mới với kết quả của worker (Bẫy 2b). - Theo dõi 403 theo route. 403 tăng vọt ngay sau deploy = thiếu permission hoặc thiếu gán role (Case 1). Đặt alert trên tỉ lệ 403 của từng route, không chỉ 5xx.
- Tài khoản khẩn cấp (break-glass). Một tài khoản super-admin cất riêng, bật MFA, mọi lần dùng đều báo động và ghi log. Không ai dùng hằng ngày.
- Con người. Ai được tạo role mới? Ai duyệt? Không có quy trình thì sau 1 năm có 40 role tên na ná nhau và không ai dám xoá.
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.
- Dashboard: tỉ lệ 403 của route
/classes/{class}/grades/exportnhảy từ 0 lên 100% ngay sau deploy. - Chạy lệnh debug ở mục 5 cho 1 giảng viên: không có
report.export. - Xem seeder: có
Permission::findOrCreate('report.export')nhưng quêngivePermissionTochoteacher. Super-admin đi quaGate::beforenên không ai thấy (Bẫy 4). - Hotfix bằng API (
Role::findByName('teacher')->givePermissionTo('report.export')), không bằng SQL tay (Bẫy 2a). Cache tự reset. Chạyqueue:restartnếu export chạy trong job. - 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.
- Xác minh DB:
model_has_rolesđã không còn dòng của người này. - Hỏi gỡ bằng cách nào: DBA chạy
DELETEtay → 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). - 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 đủ. - 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.
- Sửa gốc: quy trình offboarding đi qua API (hoặc lệnh artisan có reset cache), thêm
Queue::beforeclear 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.
- Liệt kê mọi route có tham số id (
php artisan route:list --json, lọc theo{). - Với từng route: có policy theo bản ghi không, hay chỉ có middleware permission (Bẫy 1)?
- Thêm policy, trả 404 cho bản ghi ngoài phạm vi.
- 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".
- 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).
- Tách 2 câu hỏi: làm được gì (xem điểm → permission
grade.view, rolesecretary) và trên phạm vi nào (khoa của mình → thuộc tínhdepartment_id). - Phạm vi trường → teams của spatie. Phạm vi khoa → policy + query scope theo
department_id(ABAC). - 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
- RBAC = user → role → permission. Check rẻ, cache được, "ai có quyền X" là 1 câu query.
- RBAC không biết bản ghi nào: mọi route có id phải có policy, trả 404 cho bản ghi ngoài phạm vi.
- Thu hồi quyền có 3 độ trễ: sửa DB tay (tới 24 giờ), process sống lâu (tới khi restart), JWT (tới khi hết
hạn). Đã đo: worker vẫn
truesau khi revoke; một dòngQueue::beforesửa được. - Teams rò giữa các job;
Gate::befoređè luật nghiệp vụ; SoD bằng trigger thủng khi song song — cả 3 đều đã tái hiện bằng lab. - Khi thấy role explosion, tách "phạm vi" ra khỏi role: teams cho tenant, ABAC cho điều kiện theo dữ liệu.
Đọc thêm trong repo này
- ABAC — phân quyền theo thuộc tính — policy theo bản ghi, lọc danh sách, Row-Level Security, OPA.
- Livewire public property — vì sao ẩn nút không phải là bảo vệ.
- Domain là gì và chia thế nào — phân quyền là shared kernel, không phải một domain nghiệp vụ.
- Điểm chuyên cần và đồng bộ với trường — phần authen và phân quyền giữa LMS với hệ thống trường.
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ế.