Bật RLS rồi mà trợ lý AI vẫn đọc được tài liệu phòng nhân sự
Thủ phạm thường là role kết nối, và cách sửa chỉ cần vài câu SQL đặt đúng chỗ.
Tóm tắt nhanh
- Bật RLS mà chưa có policy thì Postgres chặn hết mọi dòng, nhưng owner, superuser và role BYPASSRLS vẫn đi vòng qua được.
- Gán phòng ban làm biến cục bộ của transaction trước khi chạy truy vấn retrieval, rồi dùng USING cho đọc và WITH CHECK cho ghi.
- Policy permissive cộng dồn bằng OR, nên cần thêm một policy restrictive làm hàng rào cứng, kèm index có department_id đứng đầu.
Bạn đã chạy ENABLE ROW LEVEL SECURITY, viết policy cẩn thận, test bằng psql thấy ổn. Vậy mà trợ lý AI vẫn trả lời câu hỏi của một nhân viên kinh doanh bằng một đoạn trích từ tài liệu nội bộ của phòng nhân sự.
Policy không hỏng. Ứng dụng đang kết nối bằng role owner của bảng, và tài liệu Postgres nói rõ owner bình thường được bỏ qua row security.
Đây là tình huống FDE hay gặp khi đưa một trợ lý RAG vào doanh nghiệp: một kho tài liệu chung, nhiều phòng ban, và yêu cầu “chỉ được thấy dữ liệu của mình”. Lọc bằng prompt hay bằng WHERE trong code ứng dụng đều phụ thuộc vào việc không ai quên. RLS đẩy quy tắc xuống database, nơi mọi truy vấn đều phải đi qua.
Bài này đi từng bước trên Postgres local: một bảng documents, một role assistant_app, và một phòng ban được gán theo từng transaction. Bạn cần Postgres, psql và quyền tạo role. Phần cuối có pgvector cho ai đang làm retrieval bằng vector.
Bước 1–2: dựng bảng rồi xem nó “đóng cửa”
Ví dụ dưới đây đã rút gọn: không có embedding, khoá chính nhập tay để khỏi phải cấp quyền cho sequence. Ràng buộc CHECK chặn dòng có phòng ban rỗng, để không tài liệu nào nằm ngoài mọi ranh giới.
CREATE TABLE documents (
id bigint PRIMARY KEY,
department_id text NOT NULL CHECK (department_id <> ''),
title text,
content text
);
INSERT INTO documents VALUES
(1, 'hr', 'Quy chế lương 2026', '...'),
(2, 'sales', 'Bảng giá đại lý', '...'),
(3, 'finance', 'Dự toán quý 4', '...');
CREATE ROLE assistant_app LOGIN PASSWORD 'doi-mat-khau-nay';
GRANT SELECT, INSERT, UPDATE ON documents TO assistant_app;
Giờ bật RLS và ép cả owner phải tuân thủ:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;
Kiểm tra: đăng nhập bằng assistant_app và chạy SELECT count(*) FROM documents;. Kết quả phải là 0. Theo tài liệu Postgres, bảng đã bật RLS mà chưa có policy nào sẽ dùng policy mặc định là từ chối, không dòng nào hiện ra hay sửa được. Mặc định là chặn, và đó là điều bạn muốn.
Câu FORCE không phải trang trí. Superuser và role có thuộc tính BYPASSRLS luôn bỏ qua RLS, còn owner thì bỏ qua trừ khi bảng được đặt FORCE ROW LEVEL SECURITY.
Bài hướng dẫn của Render về RAG multi-tenant trên pgvector coi FORCE là bắt buộc. Supabase cũng cảnh báo cùng kiểu rủi ro: secret key của họ đi qua role service_role, vốn có bypassrls.
Trợ lý AI không bao giờ được cầm loại khoá đó.
Bước 3: một policy, hai cánh cửa
Supabase gợi ý một cách hình dung dễ nhớ: mỗi policy giống một mệnh đề WHERE được gắn vào mọi truy vấn. Cái “WHERE” của bạn so department_id với một biến của phiên:
CREATE POLICY dept_isolation ON documents
FOR ALL
TO assistant_app
USING (department_id = (SELECT current_setting('app.department_id', true)))
WITH CHECK (department_id = (SELECT current_setting('app.department_id', true)));
Câu lệnh này có ba phần đáng đọc kỹ.
USING và WITH CHECK canh hai việc khác nhau. USING quyết định những dòng đang có nào được thấy hoặc được sửa, còn WITH CHECK kiểm tra dòng mới sinh ra từ INSERT hay UPDATE. Thiếu WITH CHECK, một agent có quyền ghi có thể đọc tài liệu phòng mình rồi đổi department_id để đẩy nó sang phòng khác.
TO assistant_app khoanh vùng policy. Nếu bỏ trống, policy mặc định áp cho PUBLIC, tức mọi role, và bạn sẽ khó biết rule nào đang tác động lên ai.
(SELECT ...) bọc quanh current_setting. Theo Supabase, cách này khiến planner tính giá trị một lần (initPlan) thay vì mỗi dòng một lần. Mẹo chỉ đúng khi kết quả không phụ thuộc dữ liệu của dòng, và biến phòng ban thoả điều kiện đó.
Kiểm tra: đăng nhập bằng assistant_app, mở một transaction mà không gán phòng ban, rồi chạy SELECT count(*) FROM documents;. Kết quả bạn cần thấy là 0. Nếu khác 0, dừng lại và xem lại policy trước khi đi tiếp, vì “quên gán phòng ban” là lỗi chắc chắn sẽ xảy ra trong code thật.
Bước 4: gán phòng ban cho từng transaction
Render khuyên đặt mã tenant thành biến cục bộ của transaction trước khi chạy truy vấn retrieval. Với ví dụ này, tenant chính là phòng ban:
BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT id, department_id, title FROM documents;
COMMIT;
Kiểm tra: chỉ dòng id = 1 hiện ra. Tiếp theo, thử ghi lấn sang phòng khác:
BEGIN;
SET LOCAL app.department_id = 'hr';
UPDATE documents SET department_id = 'sales' WHERE id = 1;
ROLLBACK;
Lệnh UPDATE phải bị Postgres từ chối vì dòng mới vi phạm WITH CHECK. Còn UPDATE ... WHERE id = 2 thì chạy mà không sửa dòng nào, vì USING giấu dòng của phòng sales.
Chữ LOCAL mới là phần quan trọng. Ứng dụng thật dùng connection pool, và một biến gán ở cấp session có thể còn sót lại cho request kế tiếp của người dùng khác. Biến cục bộ hết hiệu lực khi transaction kết thúc.
Trong code, đừng nối chuỗi giá trị phòng ban vào câu SQL. Giá trị đó phải lấy từ phiên đăng nhập đã xác thực, không bao giờ lấy từ thứ mà LLM sinh ra.
Vì sao thêm một policy lại có thể mở rộng quyền?
Giả sử ba tháng sau, một đồng nghiệp thêm policy cho phép mọi phòng đọc tài liệu có title bắt đầu bằng “Thông báo”. Mọi policy permissive áp cho cùng một truy vấn được nối với nhau bằng OR. Chỉ một policy viết lỏng tay, như USING (true) dùng để debug rồi quên xoá, là đủ mở toang bảng.
Policy restrictive vận hành ngược lại: tất cả phải cùng qua thì một dòng mới được truy cập. Bạn dùng nó làm hàng rào cứng cho ranh giới phòng ban:
CREATE POLICY dept_guardrail ON documents
AS RESTRICTIVE
FOR ALL
TO assistant_app
USING (department_id = (SELECT current_setting('app.department_id', true)));
Kiểm tra: tạo tạm một policy permissive USING (true) cho assistant_app, rồi chạy lại truy vấn ở Bước 4. Bạn vẫn chỉ thấy dòng của phòng nhân sự. Xoá policy tạm đi sau khi thử.
Bước 5: để policy không làm chậm retrieval
Tài liệu Postgres nhận xét rằng policy chỉ dựa vào giá trị của chính dòng đang xét là trường hợp đơn giản nhất và chạy nhanh nhất, nên khuyên thiết kế theo hướng đó. Policy ở trên đúng là như vậy: nó so một cột với một hằng số của transaction, không join hay subquery sang bảng khác.
Phần còn lại là index. Supabase lưu ý rằng một cột chỉ được tính là có index khi nó đứng đầu trong một btree index:
CREATE INDEX documents_dept_idx ON documents (department_id);
Nếu đã có index (created_at, department_id) thì nó không giúp gì cho policy này. Nếu cần index ghép, hãy để department_id đứng trước.
Khi có pgvector: kết quả thiếu mà không báo lỗi
Thử hình dung kho có 1.000 đoạn văn, trong đó phòng nhân sự chỉ có 50. Truy vấn vector lấy LIMIT 10 qua index HNSW sẽ tìm các láng giềng gần nhất trong toàn kho trước, rồi RLS mới lọc. Kết quả có thể chỉ còn hai dòng, LLM trả lời kém, và không có lỗi nào được ghi lại.
Render nêu đúng hiện tượng này: lọc kết hợp với HNSW có thể trả về ít dòng hơn LIMIT. Tham số cần tìm là hnsw.iterative_scan của pgvector, quyết định index có tiếp tục quét cho đến khi đủ LIMIT hay không.
Hãy tra tài liệu của đúng phiên bản pgvector bạn đang cài để biết các giá trị nó nhận, rồi bật nó trong cùng transaction với truy vấn retrieval.
Dù chọn giá trị nào, hãy biến nó thành một phép đo. Bọc chính truy vấn retrieval của bạn trong count(*), chạy dưới role assistant_app với phòng ban nhỏ nhất. Đoạn dưới đã rút gọn: phần ORDER BY là chỗ bạn dán biểu thức khoảng cách vector đang dùng trong ứng dụng.
BEGIN;
SET LOCAL app.department_id = 'hr';
SELECT count(*) FROM (
SELECT id FROM documents
ORDER BY /* biểu thức khoảng cách vector của bạn */
LIMIT 10
) AS hits;
COMMIT;
Kiểm tra: với 50 đoạn của phòng nhân sự, kết quả phải là 10. Nếu nhận 2 hay 3, retrieval đang thiếu ngữ cảnh một cách im lặng. Đưa phép đếm này vào test tự động, so với giá trị nhỏ hơn giữa LIMIT và số đoạn thực có của phòng ban, và chạy lại mỗi khi đổi cấu hình index.
Những lỗi hay gặp, và chúng trông thế nào ở công ty khách hàng
Lỗi phổ biến nhất là lỗi mở đầu bài: chuỗi kết nối của trợ lý dùng lại tài khoản migration, tức owner của bảng, hoặc dùng một khoá admin “cho nhanh”. Lỗi thứ hai khó thấy hơn.
Tài liệu Postgres cảnh báo rằng kiểm tra toàn vẹn tham chiếu như khoá ngoại bỏ qua RLS, nên có thể tạo ra “kênh ngầm” làm rò thông tin, chẳng hạn lỗi vi phạm ràng buộc cho người dùng biết một id có tồn tại ở phòng khác.
Ở công ty khách hàng, kỹ năng này hiện ra ngay buổi đầu bàn về bảo mật. Đội an ninh sẽ hỏi: “Làm sao chứng minh trợ lý không đọc được dữ liệu phòng khác?”.
Câu trả lời thuyết phục là một role tên rõ ràng, policy nằm trong migration, và một bộ test chạy trong CI cố đọc, ghi lấn ranh giới và phải thất bại. Nên hỏi quyền dùng tài khoản Postgres ít đặc quyền ngay từ tuần đầu, trước khi ai đó gửi bạn mật khẩu của owner.
Nếu bạn đang muốn chuyển sang làm FDE, hãy đưa bài thực hành này vào portfolio dưới dạng một repo nhỏ: schema, policy, test cô lập và một đoạn README giải thích vì sao có FORCE và AS RESTRICTIVE.
Khi tin tuyển dụng nhắc đến “enterprise data”, “permissions” hay “multi-tenant RAG”, hãy viết vào CV một dòng cụ thể kiểu “thiết kế cô lập dữ liệu theo phòng ban bằng Postgres RLS cho trợ lý RAG, có test trong CI”.
Một dòng như vậy nặng ký hơn một danh sách framework.
Trợ lý AI sẽ ngày càng được cấp thêm quyền ghi, gọi tool và tự chạy truy vấn. Thứ giữ ranh giới khi đó không phải câu “đừng tiết lộ dữ liệu” trong system prompt, mà là câu SQL từ chối ngay ở tầng database.
Bài này có hữu ích không?
Cảm ơn bạn đã góp ý!