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ảng tmp_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 / environmentngười làm / bản ghi bị tác động / hành động / bối cảnh (giờ, IP, thiết bị)
policytậ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
XACMLchuẩ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 denykhô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
leakproofhàm được PostgreSQL tin là không làm lộ dữ liệu qua lỗi/side effect
OPA / RegoOpen 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-closedkhi 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::deny kè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ùng SET LOCAL chứ không SET, 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ậtThuộc tính dùngNhómLấy từ đâu (PIP)
1cùng trườnguser.school_id, submission.school_idsubject + resourcebảng users, bản ghi
2đang được phân công dạy lớp nàyphân công (user, class, valid_until)subjectbảng class_teacher
3bài chưa khoá điểmsubmission.statusresourcebản ghi
4trước hạn chấm của lớpnow(), class.grade_deadlineenvironment + 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.

Một quyết định ABAC đi qua PEP, PDP và PIP Hoa gửi request chấm bài 21 của lớp 3. PEP là chỗ chặn request, trong Laravel là Gate::authorize. PEP hỏi PDP là SubmissionPolicy. PDP lấy thuộc tính từ PIP: Hoa thuộc trường 1, phân công lớp 3 hết hạn ngày 29 tháng 9, hôm nay là 30 tháng 9. PDP xét lần lượt: luật cùng trường đúng, luật đang được phân công sai, hai luật sau không cần xét. Quyết định là DENY với mã not_assigned. PEP trả 403 cho Hoa. PAP ở trên cùng là nơi quản lý policy: với Laravel, policy là code, đi qua review và deploy. PAP · nơi viết policy — ở đây: code, review, deploy Hoa chấm bài #21 PEP Gate::authorize PDP SubmissionPolicy::grade() 1. cùng trường? 2. đang được phân công lớp này? 3. bài chưa khoá? 4. trước hạn chấm? ✓ 1 = 1 ✗ hết hạn 29/09 không cần xét không cần xét → DENY, code = not_assigned lấy thuộc tính PIP · class_teacher, classes · đồng hồ now() school=1 · lớp 3 valid_until=2026-09-29 · now=2026-09-30 ← 403 not_assigned
Hình 1. Request đi vào PEP, PEP hỏi PDP. PDP lấy thuộc tính qua PIP (phân công của Hoa hết hạn hôm qua), xét luật theo thứ tự và dừng ở luật đầu tiên sai. Quyết định mang theo mã lý do — thứ mà support cần lúc người dùng hỏi "sao tôi không chấm được?".
Vai tròLaravelPostgreSQL RLSOPA
PEPmiddleware can:, Gate::authorize()chính PostgreSQL (tự lọc dòng)code app gọi HTTP tới OPA
PDPPolicy classCREATE POLICYOPA + file .rego
PIPmodel, relation, querybảng khác trong subquery, current_setting()input app gửi sang, hoặc data nạp vào OPA
PAPcode + PR reviewmigrationbundle 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:

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ùng filter(), đừng dùng each().

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"
Cùng một câu SELECT, mỗi giảng viên thấy phần của mình Bên trái là câu lệnh SELECT count sao từ bảng bài nộp, không có WHERE. Bên phải là 20 ô, mỗi ô là một lớp. Khi app.user_id bằng 1, lớp 1 đến 5 sáng lên và kết quả là 2 500 dòng. Khi app.user_id bằng 2, lớp 6 đến 10 sáng lên, cũng 2 500 dòng. Khi connection mới chưa đặt gì, không ô nào sáng và kết quả là 0 dòng. tmp_abac_submissions — mỗi ô = 1 lớp = 500 bài (vẽ 20 lớp đầu / 2 000) SET app.user_id = '1'; SET app.user_id = '2'; -- connection mới, chưa SET SELECT count(*) FROM tmp_abac_submissions; -- không có WHERE → 2 500 dòng (lớp 1–5) → 2 500 dòng (lớp 6–10) → 0 dòng — fail closed lớp 1 lớp 2 lớp 3 lớp 4 lớp 5 lớp 6 lớp 7 lớp 8 lớp 9 lớp 10 lớp 11 lớp 12 lớp 13 lớp 14 lớp 15 lớp 16 lớp 17 lớp 18 lớp 19 lớp 20 Câu SQL giống hệt nhau. PostgreSQL tự chèn điều kiện của policy theo app.user_id. Không đặt biến → NULL → không khớp dòng nào. Quên set thì mất dữ liệu trên màn hình, không lộ dữ liệu.
Hình 2. RLS biến "lọc danh sách" thành việc của DB. App không cần scope cho luật tenant/phân công: cùng một câu SELECT, mỗi người thấy phần của mình. Và khi quên đặt biến, kết quả là 0 dòng — lỗi dễ thấy, không phải lỗi lộ dữ liệu.

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ìnhp50p99max
bật decision log ra console2,35 ms258,6 ms2 578,9 ms
tắt decision log, lần 11,45 ms55,3 ms367,5 ms
tắt decision log, lần 22,00 ms27,8 ms135,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

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 rò giữa hai request dùng chung connection; SET LOCAL thì không Hai kịch bản trên cùng một connection số 7. Kịch bản trên dùng SET: request A của Cuong đặt app.user_id bằng 1 và đọc 2 500 bài của Cuong. Biến này vẫn còn sau khi A xong. Request B của Hoa quên đặt biến, nhận lại connection và đọc ra 2 500 bài của Cuong. Kịch bản dưới dùng SET LOCAL trong transaction: biến chỉ sống tới COMMIT, sau đó thành chuỗi rỗng, NULLIF biến nó thành NULL, request B đọc ra 0 dòng. SET (mức session) connection #7 request A · Cuong · SET app.user_id = 1 app.user_id = 1 SELECT → 2 500 bài của Cuong ✓ request B · Hoa · quên set …vẫn = 1 SELECT → 2 500 bài của CUONG ✗ SET LOCAL trong transaction connection #7 A · BEGIN · SET LOCAL · … · COMMIT app.user_id = 1 SELECT → 2 500 bài của Cuong ✓ request B · Hoa · quên set '' (rỗng) → NULLIF → NULL SELECT → 0 dòng — fail closed ✓
Hình 3. Cùng một connection phục vụ 2 request liên tiếp. Với 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
Policy permissive nối bằng OR mở rộng quyền, restrictive nối bằng AND thu hẹp quyền Hai khung giống nhau. Khung lớn là mọi bài của trường 1, 500 000 dòng. Khung nhỏ bên trong là bài của các lớp mình dạy, 2 500 dòng. Bên trái, thêm policy tenant dạng permissive: vùng được thấy lan từ khung nhỏ ra cả khung lớn, kết quả 500 000 dòng. Bên phải, thêm dạng restrictive: vùng được thấy co về đúng khung nhỏ, kết quả 2 500 dòng. PERMISSIVE (mặc định) → OR lớp mình dạy 2 500 school_id = 1 · 500 000 dòng → thấy 500 000 dòng ✗ AS RESTRICTIVE → AND lớp mình dạy 2 500 school_id = 1 · 500 000 dòng → thấy 2 500 dòng ✓
Hình 4. Cùng một câu 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 policyPlanThời gian
class_id IN (SELECT ... )Parallel Seq Scan, hashed SubPlan, bỏ 997 500 dòng273–280 ms
class_id = ANY (ARRAY(SELECT ...))InitPlan chạy 1 lần → Bitmap Index Scan1,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òng27 832 ms
(so sánh) cùng điều kiện IN viết ở WHERE, không RLSNested Loop + index2,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:

  1. Luôn EXPLAIN bằ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.
  2. 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).
  3. 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/ILIKE cũ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):

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

RBACABACReBAC
Quyết định dựa trênrole của userthuộc tính user, bản ghi, môi trườngquan hệ trong đồ thị (user → lớp → trường)
Diễn tả tốtquyền theo chức năngđiều kiện so sánh: thời gian, trạng thái, số, tenantchia sẻ lồng nhau, thừa kế theo cây thư mục
"Ai có quyền X?"1 câu querykhó — phải chạy luật cho mọi cặpcó API liệt kê
Lọc danh sáchkhông cầnscope song song, RLS, hoặc partial evaluationAPI "list objects"
Chi phí vận hànhthấptrung bình — test, drift, hiệu năngcao nếu dùng hệ riêng (đồng bộ quan hệ)
Công cụspatie, IdPLaravel Policy, RLS, OPA, CedarOpenFGA, 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 PostgreSQLPolicy engine (OPA, Cedar)
Lọc danh sáchtự viết scope song song (Bẫy 1)tự động cho mọi querycầ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 chungcó (deny set)
Hiệu năngnhanh nếu nạp thuộc tính 1 lầntuỳ 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ôngcó (trừ owner, superuser, BYPASSRLS)không
Dùng chung nhiều service / ngôn ngữkhôngchỉ ai đi qua DB đócó
Audit từng quyết địnhtự logkhódecision log có sẵn
Chọn khimặc định cho monolithtenant isolation, phòng thủ lớp 2nhiề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ế

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ũ.

  1. 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ó.
  2. So query của export với scope chuẩn: export tự viết query, thiếu valid_until (Bẫy 1).
  3. Sửa: export dùng lại gradableBy/visibleTo; thêm export vào drift test.
  4. Phòng thủ lớp 2: đẩy luật phân công xuống RLS cho bảng điểm.
  5. 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

  1. Thời điểm khớp với lần bật PgBouncer pool_mode = transaction.
  2. Grep code: middleware dùng DB::statement("SET app.user_id = ...") — mức session (Bẫy 4).
  3. Chữa tạm (vài phút): chuyển pool về session, hoặc bật server_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".
  4. Chữa gốc: set_config(..., true) trong transaction; policy bọc NULLIF; test không đặt context thì 0 dòng.
  5. 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

  1. pg_stat_statements: câu đếm bài chờ chấm tăng mean time gấp ~15 lần.
  2. 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.
  3. Viết lại policy thành = ANY (ARRAY(...)) → Bitmap Index Scan, 1,4 ms.
  4. Soát các WHERE dùng lower(), LIKE trê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.

  1. Migration chạy bằng user owner; tuần trước bảng được bật FORCE ROW LEVEL SECURITY (Bẫy 3).
  2. Chữa: connection migration dùng role BYPASSRLS; chạy lại backfill.
  3. 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

Đọc thêm trong repo này

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ế).