Khi một boolean cố đóng vai bằng chứng
Một trải nghiệm thiết kế phân quyền: cùng một lỗi quay lại bốn lần, ở bốn chất liệu khác nhau, và mỗi lần nó im lặng theo một kiểu riêng.
Một câu “đã đóng” quá sớm
Trong một ghi chú bàn giao mình đọc lại tuần đó, có một câu rất chắc: lỗ truy vết ủy quyền “đã đóng”. Tài liệu an ninh đã đổi metadata audit từ một delegation_id sang mảng delegations[], đổi index sang GIN, và ghi phần đóng góp còn hiệu lực của từng bản ủy quyền. Nhìn riêng tài liệu ấy, mọi thứ khớp.
Nhưng DDL thật không sống ở đó. Nó sống trong tài liệu lược đồ vật lý của bảng audit. Và tại đó, cả ba dấu vết cũ vẫn còn: index trên một trường scalar, mô tả metadata theo trường scalar, lời giải thích truy vấn cũng vậy.
Không exception. Không cảnh báo. Không phép kiểm nào đỏ. Chỉ có hai tài liệu đều hợp lý khi đọc riêng, nhưng kể hai hình dạng dữ liệu khác nhau khi đặt cạnh nhau.
Điều làm mình ngồi lại lâu không phải cái lỗ đó. Là chuyện nó là lần thứ ba mình gặp đúng một lỗi trong cùng một hệ thống, và hai lần trước đều đã được sửa rồi. Còn lần thứ tư thì lúc đó mình chưa đếm, vì nó không nằm trong code.
Ba từ, và một lời khai
Atom là một việc cụ thể, thường có dạng (resource, action), như DOCUMENT:VIEW hay USER:VIEW. Nó không phải vai trò; một vai trò có thể chứa nhiều atom.
Scope là vùng dữ liệu mà atom ấy được phép tác động, như toàn tenant hoặc một tập đơn vị. Hai người cùng có DOCUMENT:VIEW có thể nhìn hai vùng khác nhau.
Provenance là nguồn thẩm quyền: quyền đến trực tiếp từ assignment nào, hay đến từ những bản ủy quyền nào, do ai trao, và phần phạm vi nào của từng bản còn hiệu lực để đóng góp vào hành động.
Hãy hình dung một phán quyết quyền như một vận đơn đi qua kho. Atom là loại hàng. Scope là vùng được giao tới. Provenance là người gửi và các chặng trung chuyển. true chỉ giống con dấu “được qua”. Con dấu ấy tiện, nhưng không thể thay vận đơn.
Và mỗi lần vận đơn đổi tay là một biên: chỗ dữ liệu bị chép sang một hình dạng khác. Từ đối tượng trong bộ nhớ sang câu SQL. Từ hàng dữ liệu sang dòng audit. Từ kết quả đo sang một dòng trong tài liệu bàn giao. Cả bài này nói về những chỗ đổi tay đó.
Và đây là điều bài muốn chứng minh, nói trước để bạn khỏi phải đoán: lỗi không nằm ở riêng boolean, ở tập scope, ở index hay ở tài liệu. Nó xuất hiện khi một quan hệ nhiều thành phần bị nén xuống một hình dạng nghèo hơn, rồi một biên phía sau vẫn cần đúng phần vừa bị bỏ đi. Bốn trường hợp dưới đây chỉ là bốn biểu hiện của cùng một phép nén ấy.
Một lời khai trước khi kể tiếp: ca này đến từ một hệ thống thật mình tham gia thiết kế, và tài liệu của nó không công khai. Nghĩa là phần “chuyện đã xảy ra” bạn không kiểm được - nó đứng trên trải nghiệm của người viết. Phần bạn kiểm được là cơ chế PostgreSQL được dẫn tài liệu chính thức ngay tại chỗ dùng, cùng phép truy vết cuối bài. Riêng RLS, mình đang kể một thiết kế đích đã được duyệt trên giấy, không phải một cơ chế đã có policy và ca kiểm runtime. Ranh giới ấy quan trọng, vì chính bài này nói về việc đừng dùng nhãn thay bằng chứng.
Lần thứ nhất: một boolean làm mất provenance
Thiết kế đầu tiên hỏi ủy quyền bằng một hàm trả boolean, đại loại hasDelegated(). Câu trả lời đúng với câu hỏi hẹp của nó, và thiếu gần như mọi thứ các biên phía sau cần. Nó nói được “có ủy quyền”, nhưng không nói bản nào, do ai trao, phủ vùng dữ liệu nào - tức là bỏ lại phía trước hàm mọi thứ mà việc tính scope, chặn dữ liệu và ghi audit đều cần.
Lần này chưa gây sự cố nào nhìn thấy được. Nó chỉ làm lần thứ hai gần như tất yếu: khi hàm hỏi quyền không trả về scope, phải có ai đó đi gom scope ở chỗ khác.
Lần thứ hai: một tập phẳng làm mất atom
Chỗ khác ấy là ngữ cảnh phiên. Khi dựng nó, hệ lấy phạm vi trực tiếp của người thao tác rồi UNION scope của mọi ủy quyền còn hiệu lực vào một unitIds dùng chung.
Sai lệch lộ ra khi đặt một phản ví dụ rất bình thường lên thiết kế. Một người có USER:VIEW trực tiếp, và được ủy quyền DOCUMENT:VIEW tại đơn vị A. Ngữ cảnh phiên thêm A vào unitIds. Khi hệ kiểm USER:VIEW, atom trực tiếp cho phép, còn lớp lọc dữ liệu thì đọc unitIds đã nở ra. Hồ sơ người dùng của đơn vị A sẽ xuất hiện trong kết quả.
Ủy quyền không hết hạn. Người trao vẫn còn quyền. Mọi điều kiện hiệu lực đều đúng. Nó chỉ đúng cho một atom khác.
Nhưng khoan. Nếu kiểm đủ điều kiện hiệu lực của ủy quyền trước khi UNION thì sao?
Vẫn sai. Thứ bị mất không phải trạng thái hiệu lực, mà là cặp (atom, scope): một scope chỉ có nghĩa khi đi kèm DOCUMENT:VIEW đã bị đổ vào ô dùng chung mà USER:VIEW cũng đọc. Đây không phải lỗi thiếu một if. Đây là lỗi chọn sai hình dạng dữ liệu, và không điều kiện nào thêm vào cứu được nó.
Phần sửa ở tầng ứng dụng phải đụng cả hai lần cùng lúc: trả về danh sách ứng viên ủy quyền cho đúng atom đang hỏi thay cho boolean, rồi cất kết quả vào một túi khóa theo atom thay cho tập phẳng. Cách truy cập này buộc người gọi đưa lại đúng (resource, action) mới lấy được scope, nên giảm khả năng trộn atom trong bộ nhớ. Nhưng chưa thể nói nhãn không rời khỏi vận đơn nữa: khi kết quả đi tiếp vào giao dịch và biến phiên database, biên đó vẫn phải được chứng minh riêng.
Chen ngang: chữa quá tay vì đặt chốt sai tầng
Nhận ra scope phải theo atom xuống tận chỗ đọc dữ liệu thì một câu hỏi khó xuất hiện: bắt mọi đường nạp dữ liệu phải tuân theo nó bằng cách nào?
Các bản nháp trước đã dựng lớp chặn riêng, cờ đánh dấu theo luồng, StatementInspector, nhiều lớp gateway bọc executor, một SQL parser, manifest có checksum và các cổng ArchUnit. Đó là câu trả lời logic cho yêu cầu “lập trình viên không thể quên filter”. Nhưng native query trả DTO đi vòng entity listener, connection lấy qua unwrap đi vòng gateway, và parser không hiểu hết các biến thể cú pháp SQL thì manifest phải chọn: hoặc bỏ lọt, hoặc chặn nhầm việc thật.
Độ phức tạp thừa ở đây không phải phép lấy phần chung theo atom. Là số đường phải tự chứng minh cùng một phép tính ấy.
Thiết kế được duyệt bỏ các lớp chặn ấy và chọn PostgreSQL RLS làm điểm cưỡng chế đích, đặt dưới chỗ mọi đường nạp dự kiến hội tụ. RLS không phải phép màu: tài liệu PostgreSQL 18 nói rõ superuser và role có BYPASSRLS luôn bỏ qua policy, table owner thường cũng bỏ qua, FORCE ROW LEVEL SECURITY mới buộc owner chịu policy, và bật RLS mà chưa có policy nào thì mặc định là từ chối tất cả. Độ phức tạp không biến mất, nó chuyển thành nghĩa vụ chứng minh: role runtime không phải superuser và không mang BYPASSRLS, quan hệ giữa owner với FORCE được kiểm đúng, mọi query cần bảo vệ đều chạy trong giao dịch đã đặt scope, và policy gặp biến phiên rỗng hoặc sai thì từ chối chứ không mở.
Hai nghĩa vụ khác vẫn còn mở trong chính thiết kế này. Thứ nhất, PostgreSQL chấp nhận biến cấu hình tùy biến có tên hai phần như app.unit_scope; nếu role ứng dụng có thể tự chạy SET LOCAL qua native SQL thì database chưa có căn cứ phân biệt scope do module quyền tính với scope do caller tự ghi. Thứ hai, biến ấy chỉ chứa scope, không chứa atom, trong khi một request có thể kiểm nhiều atom; đặc tả chưa buộc rõ một atom trên mỗi giao dịch, chưa đặt lại scope theo từng statement, và cũng chưa mang cặp (atom, scope) xuống database. Vì vậy đây mới là quyết định kiến trúc kèm danh sách bằng chứng còn nợ, chưa phải hàng rào đã được chứng minh là đang chạy.
Đây không phải một lần tái diễn của phép nén. Nó là phản ứng quá tay ngay sau khi phát hiện phép nén, nên mình để nó đứng ngoài chuỗi bốn lần - với một câu hỏi riêng: phần phức tạp này đang trả tiền cho một luật nghiệp vụ phải luôn đúng, hay chỉ trả tiền cho việc đặt chốt sai tầng?
Thiết kế đích đang được kiểm chứng: bốn nhịp
Thiết kế đích được nén thành bốn nhịp. Hai nhịp đầu mô tả phép tính ở tầng ứng dụng, nhịp ba mô tả cơ chế cưỡng chế dự kiến, và nhịp bốn mô tả hình dạng audit. Ở thời điểm viết, policy RLS và bằng chứng runtime cho nhịp ba chưa tồn tại.
Nhịp một: lấy đúng ứng viên ủy quyền cho atom đang hỏi. Theo thiết kế, module danh tính không trả một boolean. Nó trả một Collection<DelegationGrant> cho đúng atom đang hỏi. Mỗi ứng viên mang delegationId, delegatorId và requestedScope.
Nhịp hai: tính scope hiệu dụng, theo từng atom. Module quyền sẽ lấy phần chung - phép giao - giữa phạm vi yêu cầu của từng ứng viên và phạm vi gốc hiện tại của người trao cho chính atom đó. Người trao bị thu hẹp thì phần đóng góp tự co lại. Sau đó hệ hợp các phần đóng góp với scope trực tiếp của người thao tác, rồi cất vào một map khóa theo atom trong vòng đời request.
Nhớ kỹ cụm khóa theo atom. Nó giữ nhãn trong túi ở tầng ứng dụng; nó chưa chứng minh nhãn ấy còn nguyên khi scope được chép sang database.
Nhịp ba: chặn tại database - đích thiết kế, chưa có bằng chứng runtime. Một lớp mỏng dự kiến tham gia giao dịch, đọc scope của atom đang được kiểm rồi đặt nó vào biến phiên PostgreSQL bằng SET LOCAL. Policy RLS dự kiến dùng USING cho các hàng đang được đọc, sửa hoặc xóa, và WITH CHECK cho các hàng được thêm hoặc sửa. Theo tài liệu SET, SET LOCAL chỉ sống đến hết giao dịch hiện tại, dù commit hay rollback. Vòng đời này giúp giới hạn nguy cơ scope của request trước lưu lại trên connection được tái sử dụng, nhưng tự nó không chứng minh ai được phép đặt biến hay biến đó thuộc atom nào. Ở thời điểm viết, policy chưa có, ca kiểm runtime chưa có, và cột owner_unit_id mà policy dự kiến bám vào vẫn chưa được chốt trong lược đồ có thẩm quyền.
Nhịp bốn: ghi lại các nguồn thẩm quyền đã tham gia phép tính vừa dùng. Theo đặc tả đã sửa, audit không ghi một delegation_id tùy ý. Nó ghi mảng mọi bản ủy quyền có đóng góp, kèm granted_scope là phần chung của từng bản. Đây là hình dạng cần được giữ khi hiện thực và kiểm thử, không phải bằng chứng rằng runtime đã lưu vận đơn nguyên hình.
Ba giới hạn phải nói rõ, vì bốn nhịp trên gọn hơn hệ thật. Một: “phạm vi gốc” nghĩa là quyền không bắt nguồn từ ủy quyền - nó vẫn có thể đến từ assignment, role binding hay group. Hai: vì phạm vi gốc đã loại ủy quyền ra nên không ai lấy quyền được ủy quyền làm bàn đạp ủy quyền tiếp; cấm tái ủy quyền là chính sách của thiết kế này, không phải luật chung của mọi mô hình. Ba: “có đóng góp” nghĩa hẹp - phần chung khác rỗng - chứ không khẳng định bản ấy là điều kiện tối thiểu cho từng hàng dữ liệu; quyền trực tiếp đã đủ, hoặc hai bản cùng phủ một vùng, thì cả hai vẫn được ghi. Chọn hộ “bản nào mới thật sự cần” là một phép nén nữa.
Đặc tả đầy đủ còn assignment, role binding, cache trong request và hàng rào cho giai đoạn chuyển đổi. Bốn nhịp cố ý bỏ chúng khỏi khung hình. Quan hệ trung tâm mà thiết kế muốn đạt vẫn là: ứng viên phục vụ phép giao; phép giao phục vụ việc chặn; việc chặn và audit dùng cùng một kết quả. Chữ “muốn” ở đây có chủ ý - đó là luồng dữ liệu phải được hiện thực và đo, không phải luồng đã quan sát được.
Lần thứ ba: index vẫn tạo, truy vấn vẫn chạy, kết quả biến mất
Đến đây thiết kế trên giấy đã được cho là đúng. Lần thứ ba xảy ra sau mốc đó.
Khi audit chuyển từ một nguồn ủy quyền sang nhiều nguồn, metadata đổi từ một trường scalar ở mức trên cùng sang delegations[], mỗi phần tử có delegation_id, delegator_id và granted_scope. Nhưng DDL vẫn giữ nguyên:
CREATE INDEX idx_audit_delegation
ON audit_log ((metadata->>'delegation_id'));
PostgreSQL không phản đối câu này. Với hình dạng mới, key delegation_id không còn ở mức trên cùng nữa, và toán tử trích xuất JSONB trả NULL khi key không tồn tại ở nơi được hỏi. Index vẫn được tạo. Câu query cũ vẫn hợp lệ. Nó chỉ không bao giờ tìm thấy bản ghi mới.
Không thành phần nào chạy sai hợp đồng riêng của nó. JSONB trả NULL đúng đặc tả. B-tree index nhận một biểu thức đúng cú pháp. Query nhận về một tập rỗng hoàn toàn hợp lệ. Vận đơn đã đổi hình dạng, nhưng đường tra cứu vẫn hỏi theo nhãn cũ.
Cách sửa phải đổi ba thứ cùng lúc - hình dạng lưu, index, và truy vấn:
CREATE INDEX idx_audit_delegations
ON audit_log USING GIN ((metadata -> 'delegations'));
SELECT * FROM audit_log
WHERE metadata -> 'delegations' @> '[{"delegation_id": "..."}]'::jsonb;
Nó chạy vì cả ba lại cùng mô tả một vật. Và lưu ý: Có GIN index không có nghĩa PostgreSQL chắc chắn dùng nó cho câu query ấy - biểu thức index, operator class và dạng truy vấn vẫn phải được chọn cùng nhau, rồi kiểm bằng EXPLAIN trên đúng câu query thật chứ không phải câu query trong đầu.
Và đây là chỗ đắt: lần thứ nhất đã được sửa ở tầng kiểu dữ liệu. Việc sửa đó không bảo vệ được gì cho tầng DDL. Trình biên dịch bảo vệ được cặp (atom, scope) trong bộ nhớ, nhưng nó không đọc chuỗi trong CREATE INDEX.
Lần thứ tư: một nhãn không phải một phép đo
Đây là lần mình nhận ra sau cùng. Không có dòng code nào tham gia, và mình là người gây ra.
Cảnh như sau. Quyết định chuyển sang RLS đã được viết thành tài liệu thiết kế và đóng dấu duyệt. Nhưng phần sửa cho lỗi scope rò giữa các atom lúc ấy vẫn chỉ tồn tại trên bản vẽ: chưa có code chạy, chưa có policy thật trong database, chưa có test hồi quy. Trong lúc rà lại, câu hỏi đặt ra là tiêu chí “phạm vi ủy quyền không rò sang atom khác” đã đạt chưa.
Mình trả lời “chưa đạt”.
Nếu cụm ấy chỉ có nghĩa “chưa đủ bằng chứng để công nhận” thì nó không sai. Nhưng mình đã dùng nó như một kết quả đo - và ở nghĩa đó, mình không có dữ liệu để phán. Khi chưa có gì chạy thì không tài liệu nào chứng minh được một tiêu chí chỉ đo được lúc chạy, theo chiều nào cả: đạt không chứng minh được, mà không đạt cũng vậy.
Nhãn ngược chiều hỏng y hệt. “Đã duyệt” nghĩa là quyết định có thẩm quyền - chỉ vậy thôi. Nó không nghĩa là policy đã tồn tại, role runtime không phải superuser và không mang BYPASSRLS, hay test đã chạy xanh. Cả hai nhãn đều bị đem đi trả lời câu hỏi mà chúng không chứa dữ liệu để trả lời. “Đã duyệt”, “chưa kiểm chứng” và “đã đo và không đạt” là ba trạng thái khác nhau; nén cả ba xuống một nhãn đạt hay chưa đạt - đó là phép nén thứ tư.
Nhãn ấy là một boolean trá hình - chỉ khác hasDelegated() ở chỗ không nằm trong code, nên không trình biên dịch nào, không EXPLAIN nào soi được nó. Chỉ có một người đọc kỹ. Và nếu bạn thấy hình dạng này quen thì đúng: “lỗ đã đóng” trong ghi chú bàn giao ở đầu bài cũng là một cái nhãn như vậy.
Vì sao nó cứ quay lại
Bốn lần, một phép toán duy nhất: mỗi lần là một phép nén làm rơi mất một quan hệ. Provenance nén thành boolean. Cặp (atom, scope) nén thành một tập phẳng. Một cấu trúc mảng nén thành đường tra cứu của trường scalar cũ. Một trạng thái nhiều chiều nén thành một nhãn quy trình. Bốn chất liệu khác nhau, nhưng phép toán chỉ có một - và đó mới là thứ còn đúng khi mọi cái tên trong bài này đã đổi.
Cái khác nhau giữa bốn lần là kiểu im lặng, vì mỗi biên im lặng theo cách riêng của nó. Boolean im lặng bằng cách trả về một câu đúng mà không mang theo căn cứ. Tập phẳng im lặng bằng cách giữ đủ phần tử nhưng đánh rơi nhãn atom. Biểu thức index lệch hình dạng im lặng bằng một tập kết quả rỗng hoàn toàn hợp lệ. Nhãn quy trình im lặng bằng một câu đúng - đúng trong phạm vi của nó. Bốn kiểu im lặng ấy không cảnh báo lẫn nhau được, nên một lần sửa ở tầng này không hề bảo vệ tầng kia.
Cái giá của việc giữ vận đơn đầy đủ là có thật: kiểu dữ liệu xấu hơn, payload to hơn, cache khó hơn. Cái mua được là quyền vẫn còn đúng nghĩa ở biên kế tiếp. Một trao đổi, không phải một chiến thắng.
Và vì là trao đổi nên có chỗ không đáng làm. Nếu một quyết định không bao giờ phải giải trình lại - không audit, không ủy quyền, không nhiều tenant chung một bảng - thì boolean là câu trả lời đúng, không phải câu trả lời lười. Phép nén chỉ thành lỗi khi có một biên phía sau cần đúng thứ vừa bị bỏ đi.
Một phép thử bạn có thể làm trong tuần này
Chọn đúng một atom nhạy cảm trong hệ thống của bạn. Lần nó qua năm ô: nguồn quyền, nơi scope bị biến đổi, chỗ chặn thật trong hệ, vết audit, và truy vấn dùng để tìm lại vết ấy. Điền tên file, class hoặc method thật vào từng ô. Đừng điền bằng câu “service xử lý” hay “database bảo vệ”.
Nếu một ô trống, kết quả đó chỉ chứng minh biên ấy chưa có bằng chứng truy được. Nó chưa chứng minh hệ thống có lỗ; có thể bằng chứng nằm ở nơi bạn chưa tìm tới, hoặc một tầng thấp hơn đã chặn thay mà bạn không phải là người viết nó. Ô trống là lý do để hỏi tiếp, không phải giấy phép kết tội.
Cheat-sheet đi kèm biến phép thử này thành một trang in được. Nếu không lần nổi một atom qua năm ô trong mười phút, đừng vội thêm lớp bảo vệ mới. Có thể thứ thiếu trước tiên là bản đồ.
Đọc lại ghi chú bàn giao
Sau lượt sửa, câu bàn giao đã đổi. Không còn “tài liệu an ninh đã sửa nên lỗ đã đóng”. Nó nói rõ DDL vật lý cũng phải đổi, truy vấn cũng phải khớp, và những chỗ chưa tự nhất quán thì vẫn đang mở.
Dài hơn, xấu hơn, và là bản ghi chú đầu tiên trong cả chuỗi không cố nén một trạng thái nhiều chiều xuống một câu. Nó không trả lời “an toàn chưa”. Nó nói ai còn nợ ai bằng chứng gì - và đó là thứ duy nhất một tài liệu bàn giao trả lời được thật.
Tài nguyên đi kèm
Cheat-sheet Toàn bộ cơ chế trên một trang - in ra hoặc lưu về máy được.