ABAC — phân quyền theo thuộc tính: Policy, Row-Level Security và policy engine
Câu hỏi phỏng vấn: "Giảng viên chỉ được chấm bài của lớp mình đang dạy, bài chưa khoá điểm, trước hạn chấm. Giảng viên thỉnh giảng hết hợp đồng thì mất quyền ngay. Bạn làm thế nào?" RBAC không diễn tả được câu này: "lớp mình dạy", "chưa khoá", "trước hạn" không phải là role. Đây là việc của ABAC. Và câu hỏi tiếp theo mới là câu khó: "Màn hình 'bài chờ chấm' có 1 triệu bài trong DB. Lấy danh sách thế nào?"
Bối cảnh (ví dụ minh hoạ, giống bài RBAC): LMS nhiều trường, Laravel 12 + PostgreSQL 18. Có 4 luật cho hành động "chấm bài".
Cái gì đã chạy thật, cái gì là ví dụ. Policy Laravel, query scope, bài test lệch policy/scope, đo N+1: chạy thật trên Laravel 12.64 + PHP 8.3.33 (container tạm, project mount read-only). Row-Level Security: chạy thật trên PostgreSQL 18.4 (my-test-pgdb, bảngtmp_abac_*1 000 000 dòng, đã drop sau khi đo). OPA: chạy thật bản 1.21.1 trong Docker. Middleware đặt context cho RLS và code gọi OPA từ Laravel là minh hoạ — phần SQL/HTTP bên dưới đã đo riêng. Số đo độ trễ lấy trên máy lab đang tải cao (load average ~12), đuôi p99 dao động mạnh. Tài liệu Laravel 13, PostgreSQL 18, PgBouncer và OPA được tra qua Context7.
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| ABAC (Attribute-Based Access Control) | quyết định dựa trên thuộc tính của người, của bản ghi, của hành động và của môi trường |
| subject / resource / action / environment | người làm / bản ghi bị tác động / hành động / bối cảnh (giờ, IP, thiết bị) |
| policy | tập luật trả về allow/deny từ các thuộc tính |
| PEP (Policy Enforcement Point) | chỗ chặn request và thi hành quyết định — middleware, controller |
| PDP (Policy Decision Point) | chỗ ra quyết định — Policy class, OPA |
| PIP (Policy Information Point) | nơi lấy thuộc tính — DB, cache, HR system, đồng hồ |
| PAP (Policy Administration Point) | nơi viết và quản lý policy |
| XACML | chuẩn OASIS dạng XML đặt ra 4 chữ viết tắt trên; ít ai dùng XML nữa nhưng từ vựng vẫn dùng |
| default deny | không có luật nào cho phép thì từ chối |
| deny-list / allow-list (luật) | "cấm những gì xấu, còn lại cho" / "chỉ cho những gì chứng minh được là tốt" |
| list filtering (lọc danh sách) | từ policy, lấy ra tập bản ghi user được thấy — thay vì hỏi từng bản ghi |
| RLS (Row-Level Security) | PostgreSQL tự thêm điều kiện của policy vào mọi câu query trên bảng |
| leakproof | hàm được PostgreSQL tin là không làm lộ dữ liệu qua lỗi/side effect |
| OPA / Rego | Open Policy Agent — policy engine chạy riêng; Rego là ngôn ngữ viết policy của nó |
| ReBAC (Relationship-Based Access Control) | quyền theo quan hệ trong đồ thị (mô hình Google Zanzibar; OpenFGA, SpiceDB) |
| fail-open / fail-closed | khi thiếu dữ liệu hoặc lỗi thì cho qua / thì chặn |
Trả lời 30 giây (khi interviewer hỏi thẳng)
ABAC ra quyết định từ thuộc tính: của user (trường, lớp được phân công, hạn phân công), của bản ghi (lớp, trạng thái), và của môi trường (giờ hiện tại so với hạn chấm). Trong Laravel tôi viết nó thành Policy, trảResponse::denykèm mã lý do; bản ghi khác trường thì trả 404.
Phần khó là màn hình danh sách: không thể chạy policy cho 1 triệu dòng, nên phải có query scope là bản SQL của cùng luật đó — và có test so hai bản với nhau, vì chúng sẽ lệch.
Tenant isolation tôi đẩy thêm xuống PostgreSQL Row-Level Security làm lớp phòng thủ thứ hai, với 4 điều phải nhớ: app không kết nối bằng owner, dùngSET LOCALchứ khôngSET, policy tenant làRESTRICTIVE, và viết policy sao cho còn dùng được index.
Khi nhiều service cần chung luật, hoặc đội security muốn quản policy riêng, thì mới tách ra policy engine như OPA.
1. Nó là gì / chạy thế nào
1.1. Công thức và 4 nhóm thuộc tính
allow = f(subject, resource, action, environment)
Bốn luật cho hành động grade (chấm bài) trong LMS:
| # | Luật | Thuộc tính dùng | Nhóm | Lấy từ đâu (PIP) |
|---|---|---|---|---|
| 1 | cùng trường | user.school_id, submission.school_id | subject + resource | bảng users, bản ghi |
| 2 | đang được phân công dạy lớp này | phân công (user, class, valid_until) | subject | bảng class_teacher |
| 3 | bài chưa khoá điểm | submission.status | resource | bản ghi |
| 4 | trước hạn chấm của lớp | now(), class.grade_deadline | environment + resource | đồng hồ, bảng classes |
Không có role nào ở đây. Nếu cố làm bằng RBAC thì phải có role kiểu
teacher_lop_3_truoc_15_10 — đó là role explosion.
1.2. Kiến trúc: PEP, PDP, PIP, PAP
Bốn vai trò do chuẩn XACML đặt tên (NIST SP 800-162 dùng lại). Chúng là vai trò, không nhất thiết là 4 hệ thống riêng — trong một app Laravel cả 4 có thể nằm trong cùng một process.
| Vai trò | Laravel | PostgreSQL RLS | OPA |
|---|---|---|---|
| PEP | middleware can:, Gate::authorize() | chính PostgreSQL (tự lọc dòng) | code app gọi HTTP tới OPA |
| PDP | Policy class | CREATE POLICY | OPA + file .rego |
| PIP | model, relation, query | bảng khác trong subquery, current_setting() | input app gửi sang, hoặc data nạp vào OPA |
| PAP | code + PR review | migration | bundle policy có version |
1.3. ABAC bằng Laravel Policy
Policy dưới đây là code chạy thật trong lab. Mỗi nhánh từ chối trả kèm một mã lý do:
class SubmissionPolicy
{
public function grade(Teacher $user, Submission $s): Response
{
if ($user->school_id !== $s->school_id) {
return Response::denyAsNotFound('Không tìm thấy bài nộp.', 'tenant'); // khác trường: giả như không tồn tại
}
if (! $user->teaches($s->class_id)) {
return Response::deny('Bạn không (còn) được phân công dạy lớp này.', 'not_assigned');
}
if ($s->status === 'locked') {
return Response::deny('Bài đã khoá điểm.', 'locked');
}
if (now()->greaterThan($s->schoolClass->grade_deadline)) {
return Response::deny('Đã quá hạn chấm của lớp.', 'deadline');
}
return Response::allow();
}
}
class Teacher extends Illuminate\Foundation\Auth\User
{
public function teaches(int $classId): bool // PIP: "đang dạy lớp nào"
{
return DB::table('class_teacher')->where('user_id', $this->id)->where('class_id', $classId)
->whereDate('valid_until', '>=', today())->exists();
}
}
Gate::policy(Submission::class, SubmissionPolicy::class);
Carbon::setTestNow('2026-09-30 10:00:00'); // tiêm đồng hồ để test được luật thời gian
Gate::inspect() trả cả quyết định lẫn lý do — output thật:
Cuong, bài lớp 1 ALLOW
Cuong, bài lớp 1 đã khoá DENY locked HTTP 403 "Bài đã khoá điểm."
Hoa, lớp 3 (hết hạn thỉnh giảng) DENY not_assigned HTTP 403 "Bạn không (còn) được phân công dạy lớp này."
Cuong, bài lớp 4 (trường 2) DENY tenant HTTP 404 "Không tìm thấy bài nộp."
Lan, lớp 6 (quá hạn chấm) DENY deadline HTTP 403 "Đã quá hạn chấm của lớp."
Minh, bài lớp 4 ALLOW
Hai chi tiết đáng nói khi phỏng vấn:
- 403 hay 404. Khác trường thì trả 404 (
denyAsNotFound): người ngoài không được biết bài đó tồn tại. Cùng trường nhưng bài đã khoá thì trả 403 kèm lý do: người dùng biết bài, cần biết vì sao không chấm được. - Luật thời gian phải test được. Policy gọi
now(), test gọiCarbon::setTestNow(). Đừng viếttime()haynew DateTime()trong policy.
1.4. Phần khó nhất: màn hình danh sách
Policy trả lời cho một bản ghi. Màn hình "bài chờ chấm" cần tập bản ghi. Chạy policy cho 1 triệu dòng rồi lọc trong PHP là không được. Nên phải có bản SQL của cùng 4 luật — một query scope:
// Bản SQL của đúng policy ở trên — dùng cho màn hình danh sách
public function scopeGradableBy(Builder $q, Teacher $t): Builder
{
return $q->where('school_id', $t->school_id)
->where('status', '!=', 'locked')
->whereIn('class_id', DB::table('class_teacher')->select('class_id')
->where('user_id', $t->id)->whereDate('valid_until', '>=', today()))
->whereIn('class_id', DB::table('classes')->select('id')->where('grade_deadline', '>=', now()));
}
Giờ cùng một luật nằm ở hai nơi, viết bằng hai ngôn ngữ (PHP và SQL). Chúng sẽ lệch nhau — chỉ là sớm hay muộn. Cách rẻ nhất để bắt: một bài test so hai bản trên dữ liệu mẫu (chạy thật):
function drift(string $scope): int
{
$bad = 0;
foreach (Teacher::all() as $t) {
$byScope = Submission::query()->{$scope}($t)->pluck('id')->sort()->values()->all();
$byPolicy = Submission::all()->filter(fn ($s) => Gate::forUser($t)->allows('grade', $s))
->pluck('id')->sort()->values()->all();
$bad += count(array_diff($byScope, $byPolicy)) + count(array_diff($byPolicy, $byScope));
}
return $bad;
}
scope gradableBy — số bài lệch 0
scope gradableByDrifted — số bài lệch 9
Hoa : scope có mà policy chặn [21,22,23,24,25,26,27,28,29] | policy cho mà scope thiếu []
gradableByDrifted là bản mà ai đó viết cho màn hình mới và quên điều kiện
valid_until. Kết quả: Hoa (hết hạn thỉnh giảng) vẫn thấy 9 bài trong danh sách, bấm vào thì 403.
Nếu màn hình đó là xuất Excel thì không có bước "bấm vào" — dữ liệu đã ra ngoài (Case 1).
N+1 trong policy. Trang danh sách thường gọi @can('grade', $s) cho từng dòng để
bật/tắt nút. Policy ở trên hỏi DB trong teaches() mỗi lần:
500 dòng, policy gọi teaches() mỗi dòng 500 query
500 dòng, teaches() nhớ kết quả trong request 1 query
// chữa: PIP nạp thuộc tính 1 lần cho cả request
public function teaches(int $classId): bool
{
$this->classIds ??= DB::table('class_teacher')->where('user_id', $this->id)
->whereDate('valid_until', '>=', today())->pluck('class_id')->all();
return in_array($classId, $this->classIds, true);
}
Bẫy nhỏ gặp ngay trong lab: lần đầu đếm được "10 query" thay vì 500. Lý do:$page->each(fn ($s) => Gate::allows(...))—Collection::each()dừng vòng lặp khi callback trảfalse, và dòng thứ 10 là bài đã khoá. Check quyền hàng loạt thì dùngfilter(), đừng dùngeach().
1.5. ABAC ở tầng DB: PostgreSQL Row-Level Security
RLS đưa luật xuống DB: PostgreSQL tự chèn điều kiện của policy vào mọi câu query trên bảng — kể cả
query ai đó quên where, kể cả script báo cáo chạy tay. Lab: 1 000 000 bài nộp, 2 000 lớp, 500 bài
mỗi lớp; 400 giảng viên, mỗi người dạy 5 lớp; giảng viên 400 đã hết hạn phân công.
CREATE ROLE tmp_lms_owner NOLOGIN; -- user chạy migration, sở hữu bảng
CREATE ROLE tmp_lms_app NOLOGIN; -- user app dùng lúc chạy, KHÔNG sở hữu bảng
ALTER TABLE tmp_abac_submissions OWNER TO tmp_lms_owner;
GRANT SELECT, INSERT, UPDATE ON tmp_abac_submissions TO tmp_lms_app;
GRANT SELECT ON tmp_abac_teaching TO tmp_lms_app;
ALTER TABLE tmp_abac_submissions ENABLE ROW LEVEL SECURITY;
-- Đọc: chỉ bài của lớp mình đang được phân công (còn hạn)
CREATE POLICY sub_read ON tmp_abac_submissions FOR SELECT
USING (class_id IN (
SELECT t.class_id FROM tmp_abac_teaching t
WHERE t.teacher_id = NULLIF(current_setting('app.user_id', true), '')::bigint
AND t.valid_until >= current_date));
-- Chấm: bài chưa khoá của lớp mình; dòng sau khi sửa phải ghi đúng người chấm
CREATE POLICY sub_grade ON tmp_abac_submissions FOR UPDATE
USING (status <> 'locked' AND class_id IN (...giống trên...))
WITH CHECK (graded_by = NULLIF(current_setting('app.user_id', true), '')::bigint);
App cho DB biết "ai đang hỏi" qua một biến tự đặt tên (app.user_id), đọc lại bằng
current_setting(). Kết quả khi chạy bằng role app:
-- connection mới, chưa set gì
SELECT current_setting('app.user_id');
ERROR: unrecognized configuration parameter "app.user_id" ← vì vậy phải có tham số thứ 2 = true
SELECT count(*) AS rows_seen_without_user FROM tmp_abac_submissions;
rows_seen_without_user
------------------------
0 ← không biết ai → không thấy gì
SET app.user_id = '1'; SELECT ... count(*), min(class_id), max(class_id) ...
user_id | count | min | max
---------+-------+-----+-----
1 | 2500 | 1 | 5
SET app.user_id = '2';
2 | 2500 | 6 | 10
SET app.user_id = '400'; -- phân công đã hết hạn
user_id | expired_assignment
---------+--------------------
400 | 0
-- UPDATE đi qua policy
UPDATE ... WHERE class_id = 7 AND student_id = 7001; -- bài lớp của người khác
UPDATE 0
UPDATE ... WHERE class_id = 1 AND student_id = 1010; -- bài đã khoá của lớp mình
UPDATE 0
UPDATE ... WHERE class_id = 1 AND student_id = 1001; -- hợp lệ
UPDATE 1
UPDATE ... SET graded_by = 2 WHERE class_id = 1 ...; -- ghi tên người chấm là người khác
ERROR: new row violates row-level security policy for table "tmp_abac_submissions"
Nhìn plan sẽ thấy điều kiện của policy nằm trong Filter — dù câu SQL không có
WHERE:
EXPLAIN (ANALYZE) SELECT count(*) FROM tmp_abac_submissions; -- chạy bằng role app, user 1
...
-> Parallel Seq Scan on tmp_abac_submissions (actual rows=833.33 loops=3)
Filter: ((school_id = (NULLIF(current_setting('app.school_id'::text, true), ''::text))::integer)
AND (ANY (class_id = (hashed SubPlan 1).col1)))
Rows Removed by Filter: 332500
(Plan này là của bản policy viết IN (subquery), mất 275 ms — cách viết lại cho còn 1,4 ms ở
Bẫy 7.)
Laravel đặt "ai đang hỏi" thế nào. Dạng an toàn là đặt trong transaction, bằng
set_config(..., true) (= SET LOCAL), để giá trị tự mất khi transaction kết thúc. Minh
hoạ — hành vi SQL bên dưới đã đo ở Bẫy 4:
// Middleware SetDbUserContext (minh hoạ)
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
return DB::transaction(function () use ($user, $request, $next) {
DB::select("select set_config('app.user_id', ?, true), set_config('app.school_id', ?, true)",
[(string) $user->id, (string) $user->school_id]);
return $next($request);
});
}
Đổi lại: cả request nằm trong một transaction — gọi API ngoài bên trong là giữ transaction lâu. Cách khác là
đặt context ở đầu từng DB::transaction() trong code nghiệp vụ; khó quên hơn nếu gói vào một helper
duy nhất.
1.6. Policy engine chạy riêng: OPA
Khi nhiều service (Laravel, Node, Go) cần chung một bộ luật, hoặc đội security muốn tự quản và test policy, PDP được tách ra thành một tiến trình riêng. OPA là lựa chọn phổ biến (Cedar của AWS là lựa chọn khác). Cùng 4 luật, viết bằng Rego (OPA 1.21.1):
package lms.grading
# Không khớp luật nào → false, không phải "undefined"
default allow := false
allow if count(deny) == 0
deny contains "tenant" if input.user.school_id != input.submission.school_id
deny contains "not_assigned" if not assigned
deny contains "locked" if input.submission.status == "locked"
deny contains "deadline" if {
time.parse_rfc3339_ns(input.now) > time.parse_rfc3339_ns(input.class.grade_deadline)
}
assigned if {
some a in input.user.assignments
a.class_id == input.submission.class_id
a.valid_until >= substring(input.now, 0, 10)
}
Policy là code, nên có unit test riêng — opa test chạy trong CI:
$ opa test -v lms.rego lms_test.rego
data.lms.grading_test.test_empty_input_is_denied: PASS (51.898267ms)
data.lms.grading_test.test_after_deadline_is_denied: PASS (1.033419ms)
data.lms.grading_test.test_teacher_can_grade_own_class: PASS (29.806355ms)
data.lms.grading_test.test_other_school_is_denied: PASS (94.130377ms)
data.lms.grading_test.test_expired_assignment_is_denied: PASS (94.447884ms)
data.lms.grading_test.test_locked_is_denied: PASS (8.399117ms)
--------------------------------------------------------------------------------
PASS: 6/6
Hỏi quyết định cho "Hoa chấm một bài đã khoá của lớp 3, phân công đã hết hạn" — kiểu viết deny contains
trả về mọi lý do một lúc:
$ opa eval -d lms.rego -i input_hoa.json 'data.lms.grading' --format pretty
{
"allow": false,
"deny": [
"locked",
"not_assigned"
]
}
# qua REST, OPA chạy server
$ curl -X POST localhost:18181/v1/data/lms/grading/allow -d '{"input": {...}}'
{"decision_id":"b41141bb-95a0-4be6-baff-67d5101a52de","result":false} HTTP 200
Độ trễ đo thật, 2 000 quyết định qua HTTP keep-alive trên localhost (OPA sidecar):
| Cấu hình | p50 | p99 | max |
|---|---|---|---|
| bật decision log ra console | 2,35 ms | 258,6 ms | 2 578,9 ms |
| tắt decision log, lần 1 | 1,45 ms | 55,3 ms | 367,5 ms |
| tắt decision log, lần 2 | 2,00 ms | 27,8 ms | 135,5 ms |
p50 khoảng 2 ms là con số tin được. Đuôi p99 phụ thuộc nhiều vào máy (máy lab đang tải ~12), nhưng thông điệp
vẫn đúng: mỗi quyết định là một bước mạng, và một trang danh sách 500 dòng không thể hỏi OPA 500
lần. Mỗi quyết định có decision_id và dòng decision log — đây là lợi thế lớn khi cần audit:
{"decision_id": "ebecc40a-e71b-4a06-a911-24c5ff719669", "path": "lms/grading/allow", "result": false,
"timestamp": "2026-09-30T14:17:39.234543656Z", "msg": "Decision Log",
"input": {"user": {"id": 2}, "submission": {"class_id": 3, "id": 21, "school_id": 1, "status": "locked"}}}
Code gọi từ Laravel (minh hoạ, chưa chạy trong lab):
$res = Http::timeout(0.2)->post('http://127.0.0.1:8181/v1/data/lms/grading/allow', ['input' => $input]);
$allowed = $res->ok() && $res->json('result') === true; // thiếu 'result', lỗi, timeout → false (Bẫy 12)
2. Lợi ích
- Diễn tả được luật thật của nghiệp vụ — theo lớp, theo trạng thái, theo thời gian, theo tenant — mà không đẻ ra hàng trăm role.
- Một luật phủ mọi trường hợp mới. Thêm khoa, thêm lớp, thêm trường: không phải sửa quyền, dữ liệu tự khớp luật.
- Bối cảnh môi trường: hạn chấm, giờ hành chính, IP mạng trường, thiết bị đã đăng ký — RBAC không có chỗ cho những thứ này.
- Lý do từ chối rõ ràng (
Response::deny+ mã,denyset của Rego): support trả lời được người dùng, không phải đoán. - Test được như code: bảng case ở 1.3, drift test ở 1.4,
opa testở 1.6. - RLS là lớp phòng thủ thứ hai: một query quên
where school_idkhông làm lộ dữ liệu tenant khác.
Khi nào thật sự được: khi quyền phụ thuộc dữ liệu cụ thể và có nhiều chiều (tenant × lớp × trạng thái × thời gian). Nếu quyền chỉ là "vào được menu nào", ABAC là dao mổ trâu — dùng RBAC.
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ễ (ở màn hình thứ 5, ở ngày thêm PgBouncer) để lên trước.
Bẫy 1. Policy và danh sách lệch nhau
Đã thấy ở 1.4: bản scope quên valid_until → 9 bài lệch. Chỗ lệch thường nằm ở những màn hình
"phụ": xuất Excel, tìm kiếm, API cho mobile, báo cáo, job gửi mail tổng hợp. Mỗi chỗ tự viết query riêng.
Vì sao nổ trễ: màn hình chính có test, màn hình phụ viết sau, bởi người khác, không ai so với policy. Lỗi chỉ lộ khi người dùng thấy dữ liệu lạ.
Chữa: (1) một scope duy nhất cho mỗi hành động, mọi màn hình dùng lại nó; (2) drift test ở
1.4 chạy trong CI; (3) luật tenant/phân công đẩy xuống RLS để query lệch cũng không lộ được; (4) khi có thể, cho
policy dùng lại scope: Submission::gradableBy($user)->whereKey($s->id)->exists() —
một nguồn sự thật, đổi lại mỗi lần check là 1 query.
Bẫy 2. Luật viết kiểu "cấm cái xấu" cho qua khi thiếu thuộc tính
Màn hình danh sách hay tối ưu bằng select('id', 'class_id'). Policy đọc $s->status
— cột không được select trả null, và null === 'locked' là false. Chạy thật:
$denyList = fn (Submission $s) => $s->status === 'locked' ? 'DENY (locked)' : 'ALLOW';
$allowList = fn (Submission $s) => in_array($s->status, ['submitted', 'graded'], true) ? 'ALLOW' : 'DENY';
== select đủ cột
deny-list DENY (locked)
== select thiếu cột status
status đọc được NULL
deny-list ALLOW ← bài đã khoá vẫn cho chấm
allow-list DENY
== bật Model::preventAccessingMissingAttributes()
MissingAttributeException The attribute [status] either does not exist or was not retrieved for model [Submission].
Rego bị y hệt. Policy ở 1.6 viết allow if count(deny) == 0. App quên gửi status và
class.grade_deadline:
$ opa eval -d lms.rego -i input_missing.json 'data.lms.grading'
{
"allow": true, ← luật "locked" và "deadline" không chạy được vì thiếu dữ liệu → không có deny nào
"assigned": true,
"deny": []
}
# bản allow-list: mọi điều kiện phải được chứng minh đúng
allow if {
input.user.school_id == input.submission.school_id
assigned
input.submission.status in {"submitted", "graded"}
time.parse_rfc3339_ns(input.now) <= time.parse_rfc3339_ns(input.class.grade_deadline)
}
$ opa eval -d lms_v2.rego -i input_missing.json 'data.lms.grading_v2.allow'
false
Chữa: viết luật cho phép theo kiểu allow-list (thiếu dữ liệu → không chứng minh được → từ
chối); giữ phần deny chỉ để sinh lý do. Ở Laravel bật
Model::preventAccessingMissingAttributes() (thường qua Model::shouldBeStrict()) ở mọi môi
trường trừ production — select thiếu cột sẽ nổ ngay khi dev chạy thử.
Bẫy 3. RLS: app kết nối bằng owner của bảng → RLS tắt âm thầm
Laravel mặc định dùng một user DB cho cả migration lẫn chạy app. User đó tạo bảng, nên nó là owner — và owner bỏ qua RLS:
SET ROLE tmp_lms_owner;
owner_sees
------------
1000000 ← policy vẫn còn đó, nhưng không áp cho owner
ALTER TABLE tmp_abac_submissions FORCE ROW LEVEL SECURITY;
owner_after_force
-------------------
2500
-- superuser thì FORCE cũng không cản
current_user | superuser_sees
--------------+----------------
postgres | 1000000
Và FORCE sinh ra bẫy thứ hai, nổ trễ hơn: migration dữ liệu chạy bằng owner, không có
app.user_id, nên không thấy dòng nào:
UPDATE tmp_abac_submissions SET status = 'locked' WHERE class_id <= 10 AND status = 'submitted';
UPDATE 0 ← không lỗi, pipeline xanh, dữ liệu không đổi
ALTER ROLE tmp_lms_owner BYPASSRLS;
UPDATE tmp_abac_submissions SET status = 'locked' WHERE class_id <= 10 AND status = 'submitted';
UPDATE 4499
Chữa: hai user DB — migrator (owner, BYPASSRLS) và app
(không owner, không bypass). Migration chạy bằng connection riêng: php artisan migrate --database=migrator. Data migration kiểm
tra số dòng bị ảnh hưởng. Job backup/báo cáo đặt SET row_security = off để nếu bị RLS lọc thì
báo lỗi thay vì trả thiếu dữ liệu (chạy thật):
SET row_security = off;
SELECT count(*) FROM tmp_abac_submissions; -- bằng role app
ERROR: query would be affected by row-level security policy for table "tmp_abac_submissions"
Bẫy 4. RLS + connection dùng lại: SET rò sang request khác
SET app.user_id = '1' sống theo session (connection), không theo request. Nếu connection
được dùng lại cho request sau — PgBouncer ở chế độ transaction pooling, Laravel Octane, queue worker, PDO persistent
connection — request sau thừa hưởng user của request trước. PHP-FPM thông thường (không persistent, không pooler)
thì mỗi request mở connection mới nên không rò — vì vậy lỗi thường chỉ xuất hiện sau khi thêm PgBouncer hoặc
chuyển sang Octane. Mô phỏng trên một connection:
SET app.user_id = '1'; -- request A (Cuong)
request_a_user1
-----------------
2500
-- request B (Hoa) quên set
request_b_forgot_to_set
-------------------------
2500 ← thấy bài của Cuong
-- làm đúng: SET LOCAL trong transaction
BEGIN; SET LOCAL app.user_id = '1'; SELECT count(*) ...; COMMIT;
request_a_user1
-----------------
2500
request_b_forgot_to_set
-------------------------
0 ← giá trị đã mất khi COMMIT
SET, biến sống qua ranh giới
request — request B thấy dữ liệu của A. Với SET LOCAL, biến chết ở COMMIT; request B quên
set thì chỉ thấy 0 dòng.Chi tiết nhỏ nhưng làm sập production: sau khi một SET LOCAL kết thúc, biến không trở về
NULL mà thành chuỗi rỗng:
is_null | is_empty_string
---------+-----------------
f | t
SELECT ''::bigint;
ERROR: invalid input syntax for type bigint: "" ← policy viết current_setting(...)::bigint là 500 ngay
SELECT NULLIF('', '')::bigint AS with_nullif;
with_nullif
-------------
← NULL, policy không khớp dòng nào: fail closed
Tài liệu PgBouncer ghi rõ: ở transaction pooling, server_reset_query không được
chạy (server_reset_query_always mặc định 0), vì client "không được dùng tính năng theo session".
Session pooling thì chạy DISCARD ALL — đã thử, nó xoá sạch cả biến lẫn SET ROLE.
Chữa: luôn SET LOCAL / set_config(..., true) trong transaction; policy
luôn bọc NULLIF(current_setting('app.x', true), ''); có test "request không đặt biến thì thấy 0 dòng".
Bẫy 5. Thêm policy mới dạng PERMISSIVE → mở toang thay vì siết lại
Muốn thêm luật tenant "chỉ thấy bài của trường mình". Viết CREATE POLICY như bình thường — mặc
định là PERMISSIVE, và các policy permissive được nối bằng OR:
CREATE POLICY sub_tenant ON tmp_abac_submissions FOR SELECT
USING (school_id = NULLIF(current_setting('app.school_id', true), '')::int);
permissive_or
---------------
500000 ← trước đó là 2 500; giờ thấy cả trường
DROP POLICY sub_tenant ON tmp_abac_submissions;
CREATE POLICY sub_tenant ON tmp_abac_submissions AS RESTRICTIVE
USING (school_id = NULLIF(current_setting('app.school_id', true), '')::int);
restrictive_and
-----------------
2500
-- user 1 nhưng app.school_id = 2
user1_in_school2
------------------
0
CREATE POLICY, khác đúng một từ. Permissive: "thấy nếu khớp policy A
hoặc B" — thêm luật là thêm quyền. Restrictive: "phải khớp cả luật này" — thêm luật là siết lại.
Luật tenant luôn phải là restrictive.Chữa: luật "phải thoả" (tenant, soft delete, đã xuất bản) luôn là AS RESTRICTIVE.
Test đếm số dòng mỗi user thấy trước/sau migration thêm policy. Định kỳ soát pg_policies (cột
permissive).
Bẫy 6. View bỏ qua RLS
Migration tạo view cho báo cáo. View chạy với quyền của owner của view — mà owner đó thường là owner của bảng, tức là được bỏ qua RLS:
CREATE VIEW tmp_abac_v_submissions AS SELECT id, class_id, status FROM tmp_abac_submissions;
ALTER VIEW tmp_abac_v_submissions OWNER TO tmp_lms_owner;
GRANT SELECT ON tmp_abac_v_submissions TO tmp_lms_app;
via_view
----------
1000000 ← role app, user 1, qua view: thấy hết
ALTER VIEW tmp_abac_v_submissions SET (security_invoker = true); -- PostgreSQL 15+
via_view_security_invoker
---------------------------
2500
Chữa: mọi view trên bảng có RLS tạo với WITH (security_invoker = true). Thêm một
test/lint trong CI: view nào đọc bảng có RLS mà thiếu tuỳ chọn này thì fail.
Bẫy 7. RLS chậm — cùng một luật, ba cách viết, ba con số
1 000 000 dòng, user 1 thấy 2 500 dòng. Đo SELECT count(*) bằng role app:
| Cách viết policy | Plan | Thời gian |
|---|---|---|
class_id IN (SELECT ... ) | Parallel Seq Scan, hashed SubPlan, bỏ 997 500 dòng | 273–280 ms |
class_id = ANY (ARRAY(SELECT ...)) | InitPlan chạy 1 lần → Bitmap Index Scan | 1,4 ms (lần đầu 20 ms) |
gọi hàm plpgsql tmp_abac_can_read(class_id) | Seq Scan, gọi hàm cho 500 000 dòng | 27 832 ms |
(so sánh) cùng điều kiện IN viết ở WHERE, không RLS | Nested Loop + index | 2,1 ms |
-- bản ANY(ARRAY(...)) — plan thật
Aggregate (actual rows=1.00 loops=1)
InitPlan 1
-> Bitmap Heap Scan on tmp_abac_teaching t (actual rows=5.00 loops=1)
Recheck Cond: (teacher_id = (NULLIF(current_setting('app.user_id'::text, true), ''::text))::bigint)
Filter: (valid_until >= CURRENT_DATE)
-> Bitmap Heap Scan on tmp_abac_submissions (actual rows=2500.00 loops=1)
Recheck Cond: (class_id = ANY ((InitPlan 1).col1))
Filter: (school_id = (NULLIF(current_setting('app.school_id'::text, true), ''::text))::integer)
-> Bitmap Index Scan on tmp_abac_sub_class_idx (actual rows=2500.00 loops=1)
Index Cond: (class_id = ANY ((InitPlan 1).col1))
Planning Time: 0.242 ms
Execution Time: 1.360 ms
Dòng cuối của bảng là điểm mấu chốt: cùng một điều kiện, viết ở WHERE thì planner biến nó thành
join dùng index (2,1 ms); viết trong policy thì nó bị giữ nguyên dạng subquery (hashed SubPlan) và thành
bộ lọc cho từng dòng của một lần quét cả bảng. Gói subquery
trong ARRAY(...) ép nó chạy một lần thành hằng số, và index dùng lại được.
Phần thứ hai: leakproof. Với bảng có RLS, PostgreSQL chỉ cho điều kiện của user chạy
trước điều kiện của policy nếu hàm trong đó được đánh dấu LEAKPROOF (không làm lộ dữ liệu qua lỗi).
Hàm không leakproof thì không được dùng làm điều kiện index. Bảng 200 000 sinh viên, 2 trường:
proname | proleakproof
------------+--------------
int8eq | t
lower | f
texteq | t
textlike | f
texticlike | f
-- superuser (không RLS): WHERE lower(email) = '...'
Index Scan using tmp_abac_students_lower_email on tmp_abac_students (actual rows=1.00 loops=1)
Execution Time: 0.111 ms
-- role app (có RLS): cùng câu đó
Parallel Seq Scan on tmp_abac_students (actual rows=0.50 loops=2)
Filter: ((school_id = ...) AND (lower(email) = 'sv150000@mail.test'::text))
Rows Removed by Filter: 100000
Execution Time: 88.628 ms ← chậm ~800 lần
-- role app (có RLS): WHERE email = '...' (texteq leakproof)
Index Scan using tmp_abac_students_email_uq on tmp_abac_students (actual rows=1.00 loops=1)
Execution Time: 0.100 ms
Chữa:
- Luôn
EXPLAINbằng role app (SET ROLE app; SELECT set_config(...)). EXPLAIN bằng superuser là nói dối: superuser bỏ qua RLS nên plan khác hẳn. - Subquery trong policy →
= ANY (ARRAY(...)); không gọi hàm plpgsql theo từng dòng; có index cho cột policy dùng (class_id,school_id). - Tìm kiếm không phân biệt hoa thường: lưu sẵn cột đã chuẩn hoá (email viết thường) rồi so bằng
=, thay vìlower(email)lúc query.LIKE/ILIKEcũng không leakproof.
Bẫy 8. RLS chặn UPDATE mà app không biết
Giảng viên mở form chấm lúc bài còn submitted. Trong lúc đó job "khoá điểm" khoá bài. Giảng viên
bấm lưu — policy UPDATE lọc mất dòng. Laravel nối vào PostgreSQL thật, role app (chạy thật):
mở form: bài #2, status = submitted
job khoá điểm: đã khoá bài #2
Eloquent save() trả về: true ← giao diện báo "Đã lưu điểm"
Điểm thật trong DB: NULL
Query builder update() trả về số dòng: 0
save() của Eloquent không kiểm tra số dòng bị ảnh hưởng. RLS không báo lỗi khi dòng "không thấy"
— nó chỉ không sửa.
Chữa: ghi quan trọng dùng update() của query builder và kiểm tra số dòng, ném
exception khi bằng 0. Kiểm tra policy ở app trước (PEP) để có thông báo rõ ràng; RLS chỉ là lưới an toàn.
Race kiểu này cần thêm khoá lạc quan (xem optimistic locking).
Bẫy 9. Unique toàn bảng làm lộ dữ liệu tenant khác
Bảng sinh viên có RLS theo trường, nhưng UNIQUE (email) trên cả bảng. Trường 1 không thấy sinh viên
trường 2 — nhưng thử chèn là biết:
SET app.school_id = '1';
SELECT count(*) AS visible_in_school1 FROM tmp_abac_students WHERE email = 'sv150000@mail.test';
visible_in_school1
--------------------
0
INSERT INTO tmp_abac_students (school_id, email) VALUES (1, 'sv150000@mail.test');
ERROR: duplicate key value violates unique constraint "tmp_abac_students_email_uq"
INSERT INTO tmp_abac_students (school_id, email) VALUES (1, 'khong-ton-tai@mail.test');
INSERT 0 1
So sánh: cùng câu INSERT chạy bằng superuser (bỏ qua RLS) thì có thêm dòng
DETAIL: Key (email)=(x@mail.test) already exists.; chạy bằng role bị RLS thì PostgreSQL giấu dòng
DETAIL (đã thử cả hai). Nhưng chỉ riêng việc có lỗi hay không đã đủ để dò "email này có ở trường khác
không". Chữa: unique theo tenant
(UNIQUE (school_id, email)); nếu nghiệp vụ thật sự cần unique toàn hệ thống thì trả thông báo chung
chung và chống dò (rate limit). RLS không áp cho TRUNCATE và REFERENCES (foreign key) —
tài liệu PostgreSQL ghi rõ.
Bẫy 10. N+1 trong policy
Đã đo ở 1.4: 500 dòng × 1 query trong teaches() = 500 query; nạp phân công 1 lần = 1 query. Bẫy này
nổ trễ vì dev test với 10 dòng. Chữa: PIP có cache theo request (thuộc tính của subject nạp một
lần), thuộc tính của resource lấy bằng eager loading (with('schoolClass')).
Bẫy 11. Thuộc tính lấy sai nguồn hoặc đã cũ
ABAC chỉ đúng khi thuộc tính đúng. Hai lỗi hay gặp (lập luận thiết kế, không có lab riêng):
- Lấy thuộc tính của subject từ request.
$request->input('school_id')thay vì$user->school_id. Người dùng sửa payload là đổi được trường. Thuộc tính của subject chỉ lấy từ nguồn server tin được. (Bài điểm chuyên cần có đúng bẫy này ở chiều webhook.) - Thuộc tính nằm trong token. Giảng viên chuyển khoa, nhưng JWT còn claim
department=cntttới khi hết hạn. Thuộc tính hay đổi thì đọc từ DB/cache ngắn, không để trong token.
Bẫy 12. Gọi OPA: sai path vẫn trả HTTP 200
Gõ nhầm allow thành alow (chạy thật):
$ curl -X POST localhost:18181/v1/data/lms/grading/alow -d '{"input": {...}}'
{"decision_id":"f7648852-db0a-4d5e-9a98-d5187bdb4c01"} HTTP 200 — không có "result"
Tài liệu OPA ghi rõ: path không tồn tại (undefined) vẫn trả 200, chỉ thiếu key result. Client viết
$res['result'] ?? true là cho qua tất cả. Chữa: client coi mọi thứ khác
result === true là từ chối (như đoạn code ở 1.6); policy luôn có default allow := false;
timeout ngắn và fail-closed khi OPA chết hoặc chậm; có contract test gọi đúng path thật trong CI.
4. Trade-off
4.1. RBAC, ABAC hay ReBAC
| RBAC | ABAC | ReBAC | |
|---|---|---|---|
| Quyết định dựa trên | role của user | thuộc tính user, bản ghi, môi trường | quan hệ trong đồ thị (user → lớp → trường) |
| Diễn tả tốt | quyền theo chức năng | điều kiện so sánh: thời gian, trạng thái, số, tenant | chia sẻ lồng nhau, thừa kế theo cây thư mục |
| "Ai có quyền X?" | 1 câu query | khó — phải chạy luật cho mọi cặp | có API liệt kê |
| Lọc danh sách | không cần | scope song song, RLS, hoặc partial evaluation | API "list objects" |
| Chi phí vận hành | thấp | trung bình — test, drift, hiệu năng | cao nếu dùng hệ riêng (đồng bộ quan hệ) |
| Công cụ | spatie, IdP | Laravel Policy, RLS, OPA, Cedar | OpenFGA, SpiceDB (mô hình Zanzibar) |
RBAC là trường hợp riêng của ABAC (role chỉ là một thuộc tính). Hệ thật thường lai: RBAC cho cổng chức năng, ABAC cho bản ghi, RLS cho tenant.
4.2. Đặt PDP ở đâu
| Policy trong app (Laravel) | RLS trong PostgreSQL | Policy engine (OPA, Cedar) | |
|---|---|---|---|
| Lọc danh sách | tự viết scope song song (Bẫy 1) | tự động cho mọi query | cần partial evaluation / tự dịch ra SQL |
| Thông báo lỗi rõ | có (Response::deny + mã) | không — chỉ "0 dòng" hoặc lỗi chung | có (deny set) |
| Hiệu năng | nhanh nếu nạp thuộc tính 1 lần | tuỳ cách viết: 1,4 ms hay 27,8 s | +1 bước mạng (p50 ~2 ms, đuôi dài) |
| Chặn cả đường vào khác (script, report, SQL tay) | không | có (trừ owner, superuser, BYPASSRLS) | không |
| Dùng chung nhiều service / ngôn ngữ | không | chỉ ai đi qua DB đó | có |
| Audit từng quyết định | tự log | khó | decision log có sẵn |
| Chọn khi | mặc định cho monolith | tenant isolation, phòng thủ lớp 2 | nhiều service; đội security quản policy; cần audit từng quyết định |
Không nên: đưa mọi luật nghiệp vụ vào RLS. Luật có nhiều nhánh và cần thông báo lỗi (hạn chấm, trạng thái) để ở app. RLS giữ các luật "không bao giờ được sai" và đơn giản: tenant, phân công.
5. Khó khăn khi vận hành thực tế
- Test phải đi theo luật. Bảng case cho policy (như output ở 1.3), drift test policy vs scope (1.4),
opa test(1.6), test RLS chạy bằng role app (không bằng superuser), test "không đặt context thì thấy 0 dòng". - "Sao tôi không chấm được?" Không có mã lý do thì support phải đọc code. Log mọi lần deny kèm
mã (
tenant,not_assigned,locked,deadline) và user id; với OPA dùngdecision_id. Đếm deny theo mã —deadlinetăng vọt cuối kỳ là bình thường,tenanttăng là có người dò. - Hiệu năng sau khi bật RLS. Theo dõi
pg_stat_statementstrước/sau; EXPLAIN bằng role app; index cho cột policy dùng. - Độ tươi của thuộc tính. Cache thuộc tính trong phạm vi request là an toàn. Cache lâu hơn thì phải xoá khi phân công đổi — cùng bài toán với cache quyền ở bài RBAC.
- Đổi policy = đổi code. PR có người nắm nghiệp vụ review; với OPA, bundle có version, rollout dần, giữ bản cũ để rollback.
- Migration với RLS. Role
migratorcóBYPASSRLS; data migration kiểm tra số dòng; backup chạy bằng role bypass (theo tài liệu,pg_dumpmặc định đặtrow_security = offvà báo lỗi nếu bị RLS lọc — chưa verify pg_dump trong lab, chỉ verify hành vi củarow_security = off). - Lúc 3 giờ sáng, user nói "không thấy bài nào". Checklist: request có đặt
app.user_idkhông (log câuset_config)? connection đi qua PgBouncer mode gì? bảng có bịFORCEmới đây không? policy mới thêm là permissive hay restrictive? thử lại bằngSET ROLE app+ đúng context trên replica.
6. Case thực tế và cách xử lý
Case 1 — File Excel xuất ra có điểm của lớp khác
Giảng viên thỉnh giảng đã hết hợp đồng vẫn xuất được bảng điểm lớp cũ.
- Tái hiện bằng đúng user đó trên staging. Màn hình web: không thấy lớp (policy chặn). File export: có.
- So query của export với scope chuẩn: export tự viết query, thiếu
valid_until(Bẫy 1). - Sửa: export dùng lại
gradableBy/visibleTo; thêm export vào drift test. - Phòng thủ lớp 2: đẩy luật phân công xuống RLS cho bảng điểm.
- Xử lý sự cố dữ liệu: liệt kê các lần export của user hết hạn (log), báo bộ phận bảo vệ dữ liệu theo quy trình.
Case 2 — Sau khi thêm PgBouncer, vài giảng viên thấy bài của lớp khác
- Thời điểm khớp với lần bật PgBouncer
pool_mode = transaction. - Grep code: middleware dùng
DB::statement("SET app.user_id = ...")— mức session (Bẫy 4). - Chữa tạm (vài phút): chuyển pool về
session, hoặc bậtserver_reset_query_always = 1— tài liệu PgBouncer gọi đây là "đổi lỗi ngẫu nhiên thành lỗi chắc chắn". - Chữa gốc:
set_config(..., true)trong transaction; policy bọcNULLIF; test không đặt context thì 0 dòng. - Soát log: request nào đọc dữ liệu không thuộc user trong khoảng thời gian bị ảnh hưởng.
Case 3 — Bật RLS xong, trang "bài chờ chấm" từ 20 ms lên 300 ms
pg_stat_statements: câu đếm bài chờ chấm tăng mean time gấp ~15 lần.- EXPLAIN bằng role app: Parallel Seq Scan +
hashed SubPlan(Bẫy 7). EXPLAIN bằng superuser thì vẫn thấy dùng index — đó là lý do team không phát hiện lúc review. - Viết lại policy thành
= ANY (ARRAY(...))→ Bitmap Index Scan, 1,4 ms. - Soát các
WHEREdùnglower(),LIKEtrên bảng có RLS.
Case 4 — Data migration báo "UPDATE 0", pipeline vẫn xanh
Hai ngày sau mới phát hiện cột mới toàn giá trị mặc định.
- Migration chạy bằng user owner; tuần trước bảng được bật
FORCE ROW LEVEL SECURITY(Bẫy 3). - Chữa: connection migration dùng role
BYPASSRLS; chạy lại backfill. - Phòng: mọi data migration kiểm tra số dòng bị ảnh hưởng so với số đếm trước đó, lệch là ném exception.
7. Câu hỏi phụ interviewer hay hỏi tiếp
Q: ABAC khác RBAC thế nào?
A: RBAC chỉ nhìn một thuộc tính là role. ABAC nhìn thêm thuộc tính của bản ghi và môi trường. Đổi lại, ABAC khó
trả lời "ai đang có quyền X" — auditor sẽ hỏi câu này.
Q: ABAC khác ReBAC thế nào?
A: ReBAC lưu quyền thành quan hệ (Hoa là trợ giảng của lớp 3, lớp 3 thuộc khoa CNTT) và duyệt đồ thị. Hợp với chia
sẻ lồng nhau và cần liệt kê ngược. ABAC hợp với điều kiện so sánh (thời gian, trạng thái). Luật "đang dạy lớp này"
là quan hệ — hệ lớn có thể đưa phần đó sang ReBAC, giữ phần thời gian/trạng thái ở ABAC.
Q: Dùng OPA thì lọc danh sách thế nào?
A: Ba cách: partial evaluation (OPA trả về phần điều kiện còn lại để dịch sang SQL — chưa thử trong lab), giữ scope
song song + drift test, hoặc để RLS lo phần lọc. Không hỏi OPA từng dòng.
Q: Có nên dùng RLS cho mọi luật?
A: Không. RLS cho tenant và phân công — luật đơn giản, không bao giờ được sai. Luật nhiều nhánh cần thông báo lỗi
để ở app. RLS khó debug, dễ chậm nếu viết sai (275 ms vs 1,4 ms đã đo).
Q: Luật theo thời gian test thế nào?
A: Tiêm đồng hồ: Carbon::setTestNow() ở Laravel, input.now ở Rego. Không gọi
time() hay time.now_ns() trong policy.
Q: PDP chết thì fail-open hay fail-closed?
A: Ghi và dữ liệu nhạy cảm: fail-closed, timeout ngắn (vd 200 ms), circuit breaker. Đọc dữ liệu công khai có thể
cho qua có điều kiện — nhưng phải là quyết định có ghi lại, không phải mặc định vô tình (Bẫy 12).
Q: ABAC có chậm không?
A: Một bản ghi: rẻ, nếu thuộc tính nạp một lần (1 query thay vì 500). Danh sách: phải là SQL. RLS: viết đúng 1,4 ms,
viết sai 275 ms hoặc 27,8 giây — đo trên 1 triệu dòng.
Q: 403 hay 404?
A: 404 khi người đó không được biết bản ghi tồn tại (khác tenant). 403 khi họ biết bản ghi nhưng không được làm hành
động đó (bài đã khoá) — để giao diện giải thích được.
Q: Ai được sửa policy?
A: Dev, qua PR có test và người nắm nghiệp vụ review. Nếu nghiệp vụ muốn tự sửa luật trên giao diện thì cần PAP có
xem trước, test và audit — thường không đáng công, trừ khi luật đổi hằng tuần.
Tóm tắt
- ABAC = quyết định từ thuộc tính của user, bản ghi, hành động, môi trường. 4 vai trò: PEP chặn, PDP quyết, PIP cấp thuộc tính, PAP quản policy.
- Laravel Policy là PDP tự nhiên: trả
Response::denykèm mã; 404 cho khác tenant. - Phần khó là danh sách: scope SQL song song với policy sẽ lệch — đã đo 9 bài lệch; giữ một drift test trong CI.
- Luật viết kiểu "cấm cái xấu" cho qua khi thiếu thuộc tính — đã tái hiện ở cả PHP và Rego. Viết kiểu allow-list.
- RLS: không kết nối bằng owner,
SET LOCAL+NULLIF, tenant làRESTRICTIVE, view cósecurity_invoker, policy viếtANY(ARRAY(...)), EXPLAIN bằng role app. - Tách policy engine (OPA) khi nhiều service cần chung luật hoặc cần decision log; nhớ mỗi quyết định là một bước mạng và path sai vẫn trả 200.
Đọc thêm trong repo này
- RBAC — phân quyền theo vai trò — cổng chức năng, cache quyền, teams, SoD.
- Optimistic locking — chống ghi đè khi form mở lâu (Bẫy 8).
- Điểm chuyên cần và đồng bộ với trường — khoá điểm,
hạn chấm, và bẫy tin
school_idtrong payload. - Livewire public property — trust boundary ở Livewire.
Phụ lục — chạy lại lab
# PostgreSQL 18.4 — bảng tmp_abac_*, role tmp_lms_owner / tmp_lms_app (đã drop sau khi đo)
P=/var/www/chiennn/postgresql-learning
$P/scripts/psql.sh < abac_setup.sql # 1 000 000 bài, 2 000 lớp, 400 giảng viên, 2 policy
$P/scripts/psql.sh < abac_tests.sql # T0–T8: context, UPDATE, SET/SET LOCAL, OR/AND, view, owner, FORCE
$P/scripts/psql.sh < abac_perf.sql # IN vs ANY(ARRAY) vs hàm plpgsql
$P/scripts/psql.sh < abac_students.sql # leakproof + unique toàn bảng
# Laravel 12.64: policy, scope, drift test, N+1 — sqlite trong RAM, không mạng, project read-only
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/abac.php
# Eloquent save() với RLS: chung network namespace với container PostgreSQL (pg_hba trust 127.0.0.1)
docker run --rm --network container:my-test-pgdb -v /var/www/chiennn/laravel-livewire:/var/www/html:ro \
-v "$PWD/lab":/lab --entrypoint php laravel-app /lab/php/rls_eloquent.php
# OPA 1.21.1
docker run --rm -v "$PWD/lab/opa":/p:ro openpolicyagent/opa:latest test -v /p
docker run --rm -v "$PWD/lab/opa":/p:ro openpolicyagent/opa:latest eval -d /p/lms.rego -i /p/input_hoa.json 'data.lms.grading'
docker run -d --name tmp-opa -p 127.0.0.1:18181:8181 -v "$PWD/lab/opa":/p:ro openpolicyagent/opa:latest \
run --server --addr :8181 /p/lms.rego /p/lms_v2.rego
docker rm -f tmp-opa
File boot.php của lab Laravel nằm ở phụ lục bài RBAC. Script lab nằm trong
scratchpad của phiên viết bài, không commit. Phần chưa verify: middleware đặt context và code gọi
OPA từ Laravel (minh hoạ), partial evaluation của OPA, hành vi của pg_dump với RLS (theo tài liệu), Bẫy 11
(lập luận thiết kế).