Row-Level Security PostgreSQL: Cô Lập Dữ Liệu Tenant Trong SaaS Multi-Tenant
Tóm tắt: Row-Level Security (RLS) là cơ chế PostgreSQL cho phép database quyết định mỗi truy vấn được đọc hoặc ghi những dòng nào, dựa trên danh tính người dùng. Trong SaaS multi-tenant, RLS chuyển việc cô lập dữ liệu từ code ứng dụng xuống tầng database — nơi một mệnh đề WHERE bị quên không còn gây rò rỉ.
Mục lục
- Vì sao một mệnh đề WHERE bị quên là rủi ro lớn nhất của SaaS multi-tenant?
- Row-Level Security là gì?
- Khung kiểm soát cô lập tenant 5 lớp
- Triển khai RLS theo 6 bước
- Năm sai lầm khiến RLS âm thầm mất tác dụng
- Khi nào KHÔNG nên dùng RLS
- Câu hỏi thường gặp
Vì sao một mệnh đề WHERE bị quên là rủi ro lớn nhất của SaaS multi-tenant?
Mọi ứng dụng SaaS multi-tenant đều mang cùng một khiếm khuyết tiềm ẩn. Ở đâu đó trong codebase có một câu truy vấn quên mất AND tenant_id = $1.
Có thể là endpoint mới viết chiều thứ Sáu. Có thể là background job chạy ngoài mọi request context. Có thể là tác vụ export CSV, re-index tìm kiếm, hoặc một lời gọi ORM dùng nhầm repository method.
Ứng dụng có hàng trăm đường truy vấn. Việc cô lập tenant hiện đang được đảm bảo bởi điều kiện: tất cả chúng phải đúng, mãi mãi, kể cả những đoạn code chưa được viết. Đó không phải mô hình bảo mật. Đó là một niềm hy vọng.
Rủi ro này không phân bố đều. Nó dồn vào đúng lúc hệ thống chịu tải và đúng những đường code ít được review nhất. Với doanh nghiệp Nhật, rò rỉ chéo giữa các khách hàng là loại sự cố phải báo cáo, và nó xoá sạch nền tảng thương mại của một sản phẩm SaaS B2B.
Phần còn lại của bài trình bày cách chuyển bảo đảm cô lập từ kỷ luật lập trình viên xuống tầng database, kèm những cái bẫy khiến cơ chế này mất tác dụng trong im lặng. Đây là quyết định kiến trúc chúng tôi áp dụng trong dịch vụ phát triển hệ thống cho doanh nghiệp Nhật.
Row-Level Security là gì?
Row-Level Security (RLS) là tính năng của PostgreSQL cho phép giới hạn quyền đọc và ghi ở mức từng dòng dữ liệu, dựa trên danh tính hoặc vai trò của người truy vấn.
Mặc định, bất kỳ role nào có quyền SELECT trên một bảng đều đọc được toàn bộ dòng trong bảng đó. Khi bật RLS, PostgreSQL gắn một biểu thức policy vào mọi truy vấn chạm tới bảng và đánh giá biểu thức đó trên từng dòng.
Tài liệu chính thức của PostgreSQL nêu rõ thứ tự thực thi: biểu thức policy được đánh giá cho từng dòng trước mọi điều kiện hay hàm đến từ truy vấn của người dùng (PostgreSQL Documentation §5.9, 2026).
Thứ tự đó là điểm mấu chốt. Policy không phải gợi ý mà ứng dụng có thể bỏ qua. Nó được áp dụng trước mệnh đề WHERE của chính ứng dụng. Lập trình viên quên lọc tenant vẫn chỉ nhận về dữ liệu của tenant mình.
Một policy có hai nửa, và cả hai đều quan trọng.
| Thành phần | Kiểm soát điều gì | Áp dụng cho |
|---|---|---|
USING |
Những dòng hiện có nào nhìn thấy được | SELECT, UPDATE, DELETE |
WITH CHECK |
Những dòng mới hoặc sửa nào được cho phép | INSERT, UPDATE |
Thiếu WITH CHECK, một tenant chỉ đọc được dữ liệu của mình nhưng vẫn ghi được dòng mang tenant_id của người khác. Đó là lỗ hổng cô lập theo chiều ngược lại, và nó khó phát hiện hơn nhiều.
Cần phân biệt RLS với ba khái niệm hay bị nhầm:
| Khái niệm | Thực thi ở đâu | Chống được gì | Không chống được gì |
|---|---|---|---|
| RLS | Database engine | Truy vấn thiếu điều kiện tenant | Lỗi ở cache, job nền, object storage |
| Lọc ở tầng ứng dụng | Code ứng dụng | Đúng khi mọi truy vấn đều đúng | Bất kỳ đường code nào bị bỏ sót |
| GRANT / quyền bảng | Database engine | Truy cập cả bảng | Không phân biệt được dòng nào của ai |
| Mã hoá dữ liệu | Tầng lưu trữ | Đọc trực tiếp file/backup | Truy vấn hợp lệ nhưng sai tenant |
Khung kiểm soát cô lập tenant 5 lớp
RLS đóng một loại lỗi cụ thể, không phải tất cả. Khi VAON thực hiện Audit kiến trúc cho hệ thống multi-tenant, chúng tôi rà theo 5 lớp dưới đây. Thứ tự phản ánh tần suất gặp lỗi thực tế, không phải mức độ nghiêm trọng.
| Lớp | Câu hỏi kiểm tra | Bằng chứng cần có |
|---|---|---|
| 1. Schema | Mọi bảng chứa dữ liệu tenant đã bật ENABLE và FORCE ROW LEVEL SECURITY chưa? |
Truy vấn pg_class trả về 0 bảng thiếu |
| 2. Kết nối | Ứng dụng có kết nối bằng role không phải owner, không phải superuser không? | Kết quả \du + connection string thực tế |
| 3. Phiên | Context tenant được set theo phạm vi transaction hay phạm vi session? | Code set context + cấu hình pooler |
| 4. Truy vấn | Index có bắt đầu bằng tenant_id không? Policy filter áp dụng sớm hay muộn? |
EXPLAIN (ANALYZE, BUFFERS) |
| 5. Ngoài SQL | Cache, job nền, search index, object storage có mang tenant trong khoá không? | Rà từng thành phần, không suy đoán |
Ba lớp đầu quyết định RLS có hoạt động hay không. Lớp 4 quyết định hệ thống có chịu nổi tải hay không. Lớp 5 nằm ngoài tầm với của RLS và cần biện pháp riêng.
Điểm đáng chú ý: trong các hệ thống chúng tôi rà soát, lỗi thường nằm ở lớp 2 và lớp 3 chứ không phải lớp 1. Các policy được viết đúng, nhưng chúng không hề được thi hành.
Triển khai RLS theo 6 bước
Có hai cách xác định tenant hiện tại: mỗi tenant một PostgreSQL role, hoặc truyền định danh tenant qua biến session lúc chạy. Hướng dẫn của AWS nêu rõ phương án thứ hai được ưu tiên, vì phương án thứ nhất đòi hỏi tạo một PostgreSQL user mới cho mỗi tenant (AWS Prescriptive Guidance, 2026).
Bước 1 — Thêm cột phân biệt. Mọi bảng chứa dữ liệu tenant mang một cột tenant_id NOT NULL. Output: schema có cột nhất quán trên toàn bộ bảng.
Bước 2 — Tách role kết nối. Tạo role riêng cho ứng dụng, chỉ có SELECT/INSERT/UPDATE/DELETE. Migration chạy bằng role đặc quyền tách biệt. Đây là bước hay bị bỏ nhất và cũng là bước quyết định.
Bước 3 — Bật RLS kèm FORCE.
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
Bước 4 — Viết policy có cả hai nửa.
CREATE POLICY tenant_isolation ON invoice
FOR ALL
TO app_user
USING (tenant_id = current_setting('app.current_tenant', true)::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant', true)::UUID);
Tham số true trong current_setting nghĩa là trả về NULL thay vì báo lỗi khi context chưa được set. Kết quả: truy vấn không khớp dòng nào. Nguyên tắc là fail closed, không fail open.
Bước 5 — Set context theo phạm vi transaction.
BEGIN;
SELECT set_config('app.current_tenant', $1, true); -- true = phạm vi transaction
SELECT * FROM invoice ORDER BY issued_at DESC LIMIT 50;
COMMIT;
Định danh tenant lấy từ session hoặc JWT đã xác thực phía server, không lấy từ tham số request do client kiểm soát.
Bước 6 — Đánh index theo policy. Cột dẫn đầu của index trên bảng tenant phải là tenant_id, vì đó là điều kiện RLS thêm vào mọi truy vấn.
CREATE INDEX invoice_tenant_issued_idx ON invoice (tenant_id, issued_at DESC);
Kinh nghiệm triển khai thực tế
VAON đã xây dựng hệ quản lý thi công công trường phục vụ hơn 200.000 người dùng theo mô hình pool — dùng chung bảng, phân biệt bằng tenant_id — với RLS đưa vào ngay từ giai đoạn thiết kế schema. Ba vấn đề chúng tôi thực sự gặp, xếp theo mức độ khó phát hiện:
Ứng dụng kết nối bằng role sở hữu bảng. Các policy đã viết đúng và test thủ công bằng user hạn chế đều pass. Nhưng ở môi trường chạy thật, ứng dụng dùng chính role sở hữu bảng, nên toàn bộ policy không được thi hành. Không có lỗi nào được ném ra — chỉ là truy vấn trả về nhiều dòng hơn mức đáng lẽ. Cách chữa là FORCE ROW LEVEL SECURITY cộng với tách role, và cách phát hiện là một bài test tự động chứ không phải code review.
Context tenant rò qua connection pooler. Đặt context bằng SET thuần khiến giá trị sống sót qua transaction và ở lại trên connection. Với pooler chạy chế độ transaction, connection đó lập tức được giao cho request khác. Triệu chứng chỉ xuất hiện dưới tải đồng thời và gần như không tái hiện được trên môi trường staging. Cách chữa là SET LOCAL hoặc set_config(..., true).
Truy vấn chậm do index sai cột dẫn đầu. Sau khi bật RLS, một số truy vấn xuống cấp vì index không bắt đầu bằng tenant_id. Planner phải lấy dòng ra rồi mới lọc tenant. Chẩn đoán nằm ở chỗ EXPLAIN (ANALYZE, BUFFERS) cho thấy policy filter được áp dụng muộn.
Cả ba đều không phải lỗi của bản thân RLS. Chúng là lỗi của môi trường xung quanh RLS — và đó chính là lý do khung 5 lớp ở trên tồn tại. Cách chúng tôi đưa các quyết định kiến trúc này vào giai đoạn thượng nguồn được mô tả trong mô hình làm việc của VAON.
Năm sai lầm khiến RLS âm thầm mất tác dụng
RLS thất bại trong im lặng. Khi cấu hình sai, bạn không nhận được lỗi — bạn nhận được kết quả. Truy vấn trả về quá nhiều dữ liệu trông y hệt truy vấn chạy đúng.
1. Quên FORCE ROW LEVEL SECURITY. Tài liệu PostgreSQL nêu rõ: superuser và role có thuộc tính BYPASSRLS luôn bỏ qua hệ thống row security; chủ sở hữu bảng cũng thường bỏ qua, trừ khi dùng ALTER TABLE ... FORCE ROW LEVEL SECURITY. Hậu quả: policy tồn tại nhưng không chạy. Cách tránh: thêm FORCE cho mọi bảng tenant và tách role ứng dụng khỏi role owner.
2. Dùng SET thay vì SET LOCAL sau connection pooler. Hậu quả: một tenant đọc dữ liệu của tenant khác, không xác định, chỉ dưới tải. Cách tránh: luôn dùng phạm vi transaction.
3. View chạy dưới quyền chủ sở hữu. Theo thiết kế truyền thống, view thực thi bằng quyền của chủ sở hữu view, nên view thuộc sở hữu role đặc quyền trở thành lỗ thủng xuyên qua policy. Cách tránh: PostgreSQL 15 trở lên, khai báo WITH (security_invoker = true) cho mọi view trên bảng tenant.
4. Ràng buộc unique đặt ở phạm vi toàn cục. Tài liệu PostgreSQL nêu rõ các kiểm tra toàn vẹn tham chiếu luôn bỏ qua row security, và cảnh báo về rò rỉ qua "kênh ngầm". Cụ thể: nếu email có UNIQUE toàn cục, tenant nhận lỗi trùng lặp vừa biết được một tenant khác đang dùng địa chỉ đó. Cách tránh: UNIQUE (tenant_id, email).
5. Bỏ qua tương tác với query planner. Chỉ hàm được đánh dấu LEAKPROOF mới được sắp xếp lên trước bước kiểm tra bảo mật. Hàm không leakproof không thể đẩy xuống, kế hoạch thực thi xuống cấp thành sequential scan kèm bộ lọc muộn. Tài liệu của Supabase ghi nhận việc đánh index cho cột được policy tham chiếu tạo cải thiện hơn 100 lần trên bảng lớn, và bọc lời gọi hàm vào scalar subquery đưa một test case từ 178.000 ms xuống 12 ms (Supabase Docs, 2026).
Kỷ luật vá lỗi cũng là một phần của biện pháp kiểm soát
RLS là code của database engine, và code engine có CVE. CVE-2024-10976 (CVSS 4.2) cho phép một query plan được tái sử dụng áp dụng sai policy khi user ID thay đổi; đã sửa ở các bản 17.1, 16.5, 15.9, 14.14, 13.17, 12.21 (PostgreSQL Security, 2024). CVE-2025-8713 (CVSS 3.1) để lộ dữ liệu mẫu qua thống kê optimizer từ những dòng mà policy lẽ ra phải che giấu; đã sửa ở 17.6, 16.10, 15.14, 14.19, 13.22, phát hành 14/08/2025 (PostgreSQL Security, 2025).
Cả hai đều không dễ khai thác. Bài học không nằm ở mức độ nghiêm trọng, mà ở chỗ: "chúng tôi đã bật RLS" là tuyên bố có kèm số phiên bản.
Khi nào KHÔNG nên dùng RLS
RLS không phải lựa chọn đúng trong mọi trường hợp. Bốn tình huống nên cân nhắc phương án khác:
Khách hàng yêu cầu cô lập vật lý. Một số khách hàng doanh nghiệp Nhật, đặc biệt trong lĩnh vực tài chính và y tế, yêu cầu dữ liệu nằm ở database riêng hoặc vùng riêng như điều kiện hợp đồng. RLS là cô lập logic. Trong trường hợp này mô hình silo là câu trả lời đúng, dù chi phí vận hành cao hơn.
Rất ít tenant nhưng mỗi tenant rất lớn. Với năm đến mười tenant dữ liệu lớn, database-per-tenant đơn giản hơn: dễ backup, khôi phục riêng và dễ chứng minh khi audit.
Hệ thống không dùng PostgreSQL. RLS là tính năng của PostgreSQL. Nếu bạn đang dùng MySQL hoặc một engine không có cơ chế tương đương, đừng chọn database theo một tính năng đơn lẻ — hãy giải quyết bài toán cô lập ở tầng khác và kiểm soát chặt hơn.
Đội chưa có quy trình vá lỗi. RLS chuyển bảo đảm bảo mật vào engine. Nếu cần đánh giá năng lực vận hành trước khi cam kết, hãy bắt đầu bằng dịch vụ Audit kiến trúc của VAON. Nếu bạn không có quy trình cập nhật minor version định kỳ, bạn đang chuyển rủi ro chứ không phải giảm rủi ro.
Cần nói rõ: hiện VAON chưa có chứng chỉ ISO 27001. Quy chuẩn an toàn thông tin của chúng tôi được xây dựng theo khung ISO 27001 và chứng chỉ nằm trong lộ trình. Chúng tôi nói thẳng điều này thay vì để khách hàng phát hiện ở vòng thẩm định.
Câu hỏi thường gặp
RLS có làm chậm truy vấn không?
Chỉ khi bạn vẫn đánh index theo mẫu truy cập cũ. Policy thêm tenant_id làm điều kiện vào mọi truy vấn, nên tenant_id phải là cột dẫn đầu trong composite index. Làm đúng thì chi phí không đáng kể. Làm sai thì bạn nhận sequential scan kèm bộ lọc muộn, và EXPLAIN (ANALYZE, BUFFERS) sẽ chỉ ra ngay.
Ứng dụng đã lọc theo tenant rồi, RLS có thừa không? Không. RLS là biện pháp kiểm soát có hiệu lực với cả câu truy vấn bạn chưa viết. Lọc ở tầng ứng dụng đúng cho tới khi có một đường code sai, và bạn không thể chứng minh điều đó không xảy ra trên một codebase đang lớn dần. RLS cho phép chứng minh từ system catalog.
Có thể bổ sung RLS vào database production đang chạy không? Có, theo từng bảng. Bật ở môi trường shadow trước, chạy bộ test cô lập, rồi triển khai dần. Phần rủi ro nhất không phải các policy, mà là phát hiện ra ứng dụng đang kết nối bằng role sở hữu bảng và do đó chưa từng thi hành bất cứ điều gì.
RLS có thay thế được audit hay penetration test không? Không. RLS đóng lại loại lỗi thiếu điều kiện tenant, vốn là loại lớn nhất. Cache, background job, search index và đường dẫn object storage nằm ngoài phạm vi đó và cần biện pháp riêng.
Cần PostgreSQL phiên bản nào?
Thực tế là 15 trở lên, vì tuỳ chọn security_invoker cho view chỉ có từ bản 15. Dù chạy major version nào, hãy luôn ở minor release đã vá.
Làm sao kiểm tra hệ thống hiện tại có bảng nào lọt lưới?
Truy vấn pg_class kết hợp pg_policy, lọc theo relrowsecurity và relforcerowsecurity. Bất kỳ bảng nào chứa dữ liệu tenant mà rls_enabled = false đều là sự cố đang chờ lưu lượng truy cập.
Kết luận
- RLS chuyển bảo đảm cô lập tenant từ kỷ luật lập trình viên xuống tầng database, nơi nó áp dụng trước mọi truy vấn của ứng dụng.
- Ba lỗi phổ biến nhất không nằm ở policy mà ở môi trường quanh nó: role kết nối, phạm vi session, và cột dẫn đầu của index.
- Cô lập là thuộc tính phải đúng ở mọi tầng. RLS là sàn, không phải trần.
Nếu bạn đang thiết kế schema multi-tenant, hoặc chưa chắc các policy hiện tại có thực sự được thi hành trên production hay không, hãy đăng ký Audit kiến trúc miễn phí của VAON — chúng tôi rà theo đúng khung 5 lớp trong bài và trả lại kết quả kèm truy vấn kiểm chứng.