Row-level security cho multi-tenancy: chung một schema, không đọc chéo tenant Nâng cao
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một SaaS hoá đơn B2B giữ 200 công ty trong một schema PostgreSQL 17. Mọi bảng có cột tenant_id: Acme là tenant 1, Globex là tenant 2. API lọc bằng WHERE tenant_id = $1.
Tính năng export CSV mới quên điều kiện đó. Cùng một role, trang danh sách trả 1.000 hoá đơn của Acme, còn export trả 200.000 dòng của 200 tenant. Đây là BOLA ở cấp tenant.
Database có thể từ chối trả dòng của tenant khác ngay cả khi code quên không, và cái gì vẫn phá được nó: role mà app dùng để kết nối, và connection pool giữ trạng thái giữa các request?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Ít tenant lớn, hợp đồng đòi tách vật lý hoặc restore riêng từng tenant | Database cho mỗi tenant | Tự động hoá migration và connection × N |
| Vài chục đến vài trăm tenant, cần extension hoặc schema khác nhau | Schema cho mỗi tenant | Catalog và thời gian migration tăng theo N |
| Nhiều tenant nhỏ, một schema sản phẩm | Schema chung + tenant_id + RLS |
Role của app không phải owner, không có BYPASSRLS; set tenant bằng SET LOCAL |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Ba mô hình tenancy
Phần tiêu đề “Ba mô hình tenancy”| Database mỗi tenant | Schema mỗi tenant | Schema chung | |
|---|---|---|---|
| Ranh giới cô lập | Database, có thể là server riêng | Schema + search_path |
Một điều kiện trên từng dòng |
| Hậu quả khi quên filter | Không có; rủi ro chuyển sang việc route nhầm connection | Sai search_path = sai tenant |
Đọc chéo tenant, trừ khi có RLS |
| Migration | Chạy N lần | Chạy N lần trong một database | Chạy một lần |
| Báo cáo liên tenant | Khó | Làm được (UNION) |
Đơn giản |
| Noisy neighbour | Cô lập nếu ở server riêng | Dùng chung | Dùng chung; cần giới hạn theo tenant |
Schema chung rẻ nhất khi vận hành, nên việc cô lập không thể chỉ dựa vào kỷ luật viết code. Đó là việc của row-level security (RLS).
Thuật ngữ RLS
Phần tiêu đề “Thuật ngữ RLS”| Thuật ngữ | Ý nghĩa |
|---|---|
ENABLE ROW LEVEL SECURITY |
Policy áp dụng cho role thường. Không có policy nào = default deny |
Policy USING |
Dòng hiện có nào được SELECT, UPDATE, DELETE nhìn thấy |
Policy WITH CHECK |
Dòng mới nào INSERT/UPDATE được ghi. Bỏ trống ở ALL = dùng lại USING |
FORCE ROW LEVEL SECURITY |
Áp policy cả với owner của bảng |
BYPASSRLS, superuser |
Luôn bỏ qua policy, dù có FORCE hay không |
app.tenant_id |
Setting tự đặt, policy đọc qua current_setting() |
Đang tải diagram…
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Policy đọc tenant từ một setting theo transaction. NULLIF(..., '') là cần thiết: sau khi SET LOCAL hết hiệu lực, setting có giá trị '' chứ không phải NULL, và ''::bigint báo lỗi.
ALTER TABLE demo_rls.invoices ENABLE ROW LEVEL SECURITY;ALTER TABLE demo_rls.invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON demo_rls.invoices USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint);tenant = 1BEGIN rồi set_config(app.tenant_id, 1, true): giá trị chỉ sống đến hết transaction này.
set_config('app.tenant_id', '1', true): tham số thứ batruenghĩa là chỉ trong transaction, tương đươngSET LOCAL.- Query quên điều kiện tenant; đây đúng là trường hợp RLS sinh ra để chặn.
- PostgreSQL kiểm tra role:
rls_appkhông sở hữu bảng và không cóBYPASSRLS, nên policy được gắn vào. - Điều kiện của policy là điều kiện bình thường; có index bắt đầu bằng
tenant_idthì nó thànhIndex Cond. COMMITxoá setting. Connection không mang tenant nào sang request sau.
Vì sao SET thường bị lộ sau pool
Phần tiêu đề “Vì sao SET thường bị lộ sau pool”SET không có LOCAL sống đến hết session khi transaction của nó commit. Connection trong pool, ở pg.Pool hay PgBouncer transaction pooling, sống lâu hơn request. PgBouncer ghi SET/RESET là “Never” ở transaction mode.
Request A chạy SET app.tenant_id = 1 trên C1. SET là mức session: sống lâu hơn transaction.
Lỗi chỉ xảy ra khi một nhánh code không set tenant và nhận đúng connection mà tenant khác vừa dùng, nên nó lúc có lúc không. Xem PgBouncer transaction mode để thấy hiện tượng tương tự với setting khác.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Chưa set tenant = 0 dòng, nhờ
current_setting(..., true)trả NULL vàNULLIF. Không cómissing_ok, session mới sẽ lỗiunrecognized configuration parameter "app.tenant_id". - Foreign key và unique constraint bỏ qua RLS.
UNIQUE (number)toàn bảng làm lộ việc tenant khác đã dùng số đó. Unique theo tenant:(tenant_id, number). - View chạy bằng quyền owner của view trừ khi tạo với
security_invoker(PostgreSQL 15+). View do owner của bảng sở hữu sẽ bỏ qua policy. TRUNCATEvàREFERENCESkhông chịu RLS.- Với backup và job quản trị,
SET row_security = offlàm query bị lọc báo lỗi thay vì lặng lẽ trả thiếu dòng. - Index bắt đầu bằng
tenant_id:(tenant_id, created_at DESC),(tenant_id, number). Điều kiện của policy khi đó khớp cột đầu của index. - Leakproof: điều kiện của người dùng chạy sau policy, trừ khi hàm của nó là
LEAKPROOF.int8eq,texteqlà leakproof;jsonb @>vàLIKEthì không, nên dưới RLS chúng không thành điều kiện index được. - Hàm được policy gọi phải khai báo
STABLE, không để mặc địnhVOLATILE, nếu không nó bị tính lại từng dòng và index bị bỏ qua.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Script trong examples/src/row-level-security/ (200 tenant × 1.000 hoá đơn):
docker compose -f examples/docker-compose.yml up -d postgresfor f in 01-setup 02-without-rls 03-policies 04-bypass 05-performance 06-isolation-test; do docker compose -f examples/docker-compose.yml exec -T postgres \ psql -U hub -d hub < examples/src/row-level-security/$f.sqldonenpx --prefix examples ts-node examples/src/row-level-security/pool-leak.tsOutput của lần chạy, đã rút gọn (PostgreSQL 17):
-- 02: rls_app, export không có WHERE, trước khi có RLS rows | tenants 200000 | 200-- 03: rls_app, tenant 1, cùng query sau khi có policy rows | tenants 1000 | 1INSERT ... tenant_id = 2 -> ERROR: new row violates row-level security policy for table "invoices"no tenant set -> count 0-- 04: ai bỏ qua policysuperuser hub: 200000 | owner, no FORCE: 200000 | rls_admin (BYPASSRLS): 200000owner after FORCE: 0, with tenant 2 set: 1000rls_app with row_security = off -> ERROR: query would be affected by row-level security policyDemo pool chạy 400 request đồng thời (pool max: 5), xen kẽ tenant 1 và 2. Số request bị lộ thay đổi mỗi lần chạy (173–196 qua ba lần):
session SET on pool: 400 requests, 187 saw another tenant, 0 saw nothingforgot to set, same pool: saw tenant_id 2SET LOCAL per transaction: 400 requests, 0 saw another tenant, 0 saw nothingforgot to set, transaction-local pool: saw 0 tenantsHiệu năng dưới policy, đã set tenant 1:
-- trang danh sách: điều kiện policy thành Index CondIndex Scan using invoices_tenant_created_idx Index Cond: (tenant_id = (NULLIF(current_setting(...))::bigint)-- meta @> '{"status":"disputed"}': không dùng GIN dưới RLS (superuser thì BitmapAnd với GIN)Index Scan using invoices_tenant_created_idx Filter: (meta @> ...) Rows Removed by Filter: 999-- policy gọi hàm plpgsql VOLATILE so với STABLESeq Scan on invoices Rows Removed by Filter: 199000 Execution Time: 40.406 msIndex Scan using invoices_tenant_created_idx Execution Time: 0.018 msTrade-offs
Phần tiêu đề “Trade-offs”Schema chung + RLS
- Quên
WHERE tenant_idthì ra 0 dòng của tenant khác thay vì lộ dữ liệu. WITH CHECKchặn ghi vào tenant khác, kể cả chuyển một dòng sang tenant khác.- Một schema, một lần migration, báo cáo liên tenant đơn giản qua role
BYPASSRLSriêng.
Chi phí phải quản lý
- Đúng hay sai phụ thuộc vào role và việc set
app.tenant_idtheo từng transaction. - Operator không leakproof mất khả năng dùng index; hàm volatile trong policy gây full scan.
- Lỗi im lặng: setting sai trả về dữ liệu trông hợp lý, không báo lỗi.
- Noisy neighbour và restore theo tenant vẫn chưa được giải quyết.
Checklist production
Phần tiêu đề “Checklist production”- Tách role: migration sở hữu bảng; API đăng nhập bằng role không phải owner, không có
BYPASSRLS. ENABLEvàFORCE ROW LEVEL SECURITYtrên mọi bảng cótenant_id; một query trong CI fail khi thiếu.- Chỉ set tenant bằng
set_config('app.tenant_id', $1, true)bên trongBEGIN; không dùngSETmức session. - Policy dùng
current_setting(..., true)+NULLIF, để chưa set tenant thì ra 0 dòng. - Mọi index và unique constraint bắt đầu bằng
tenant_id. - Chạy test cô lập cho từng tenant bằng role của app (
06-isolation-test.sql): dòng của tenant khác = 0,INSERTchéo tenant bị từ chối. - Kiểm kê role có
rolsuperhoặcrolbypassrlsđăng nhập được; chỉ công cụ quản trị được dùng. - Xem
EXPLAINcủa query nóng bằng role của app, không bằng superuser.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Set tenant: SET mức session so với trong transaction
Hay gặp
// mức session, và có thể chạy trên connection khác với query phía sauawait pool.query("SELECT set_config('app.tenant_id', $1, false)", [tenantId]);await pool.query('SELECT * FROM demo_rls.invoices');Nên viết
const client = await pool.connect();try { await client.query('BEGIN'); await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); await client.query('SELECT * FROM demo_rls.invoices'); await client.query('COMMIT');} catch (e) { await client.query('ROLLBACK'); throw e; }finally { client.release(); }Vì sao
Giá trị local bị xoá ở COMMIT hoặc ROLLBACK, trước khi connection về pool.
Bẫy thứ hai:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| “Đã bật RLS nên owner cũng bị chặn” | Owner bỏ qua policy khi không có FORCE |
FORCE + role app không phải owner |
“SET cũng được, request nào cũng set” |
Một nhánh code quên; tenant cũ còn trên connection | SET LOCAL / set_config(..., true) |
“RLS thay được WHERE tenant_id” |
app.tenant_id sai hoặc nhánh BYPASSRLS không còn lớp kiểm tra thứ hai |
Giữ filter tường minh; RLS là lớp chặn cuối |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao owner thấy 200.000 dòng sau
ENABLEnhưng 0 dòng sauFORCEkhi chưa set tenant? - Dùng
SET LOCAL, một nhánh code quên set tenant. Nó trả về gì, và vì sao an toàn hơn so vớiSET? - Vì sao
meta @> '{...}'dùng GIN index khi chạy bằng superuser nhưng không khi chạy bằngrls_app?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Row Security Policies — PostgreSQL 17: default deny, ai bỏ qua policy,
FORCE,row_security, kênh rò rỉ qua constraint. - CREATE POLICY — PostgreSQL 17:
USINGvàWITH CHECK, permissive và restrictive, thứ tự với hàm leakproof. - SET — PostgreSQL 17: vòng đời của
SETvàSET LOCALqua commit và rollback. - System Administration Functions — PostgreSQL 17:
is_localcủaset_config,missing_okcủacurrent_setting. - PgBouncer — Features: tính năng SQL nào chạy được ở từng pooling mode.