Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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
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ữ Ý 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…

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);
Export cho Acme không có WHERE tenant_id: policy vẫn lọc, index vẫn được dùng
Export cho Acme không có WHERE tenant_id: policy vẫn lọc, index vẫn được dùngBước 1/5: BEGIN rồi set_config(app.tenant_id, 1, true): giá trị chỉ sống đến hết transaction này.APIrole rls_appPostgreSQLrewriter + plannerapp.tenant_id = 1invoices200 tenant, 200k dòngtenant = 1
API → PostgreSQLtenant = 1
Bước 1/5

BEGIN rồi set_config(app.tenant_id, 1, true): giá trị chỉ sống đến hết transaction này.

  1. set_config('app.tenant_id', '1', true): tham số thứ ba true nghĩa là chỉ trong transaction, tương đương SET LOCAL.
  2. Query quên điều kiện tenant; đây đúng là trường hợp RLS sinh ra để chặn.
  3. PostgreSQL kiểm tra role: rls_app không sở hữu bảng và không có BYPASSRLS, nên policy được gắn vào.
  4. Điều kiện của policy là điều kiện bình thường; có index bắt đầu bằng tenant_id thì nó thành Index Cond.
  5. COMMIT xoá setting. Connection không mang tenant nào sang request sau.

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.

SET mức session: request của Globex thừa hưởng tenant của Acme từ connection dùng lại
SET mức session: request của Globex thừa hưởng tenant của Acme từ connection dùng lạiBước 1/6: Request A chạy SET app.tenant_id = 1 trên C1. SET là mức session: sống lâu hơn transaction.thời gian →app.tenant_id = 1 sống qua COMMIT …SET app.tenant_id = 1mức sessionRequest Atenant 1 (Acme)Connection C1backend trong poolRequest Btenant 2 (Globex)
Bước 1/6

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.

  • 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ỗi unrecognized 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.
  • TRUNCATE và REFERENCES không chịu RLS.
  • Với backup và job quản trị, SET row_security = off là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, texteq là leakproof; jsonb @> và LIKE thì 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 định VOLATILE, nếu không nó bị tính lại từng dòng và index bị bỏ qua.

Script trong examples/src/row-level-security/ (200 tenant × 1.000 hoá đơn):

Terminal window
docker compose -f examples/docker-compose.yml up -d postgres
for 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.sql
done
npx --prefix examples ts-node examples/src/row-level-security/pool-leak.ts

Output 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 | 1
INSERT ... tenant_id = 2 -> ERROR: new row violates row-level security policy for table "invoices"
no tenant set -> count 0
-- 04: ai bỏ qua policy
superuser hub: 200000 | owner, no FORCE: 200000 | rls_admin (BYPASSRLS): 200000
owner after FORCE: 0, with tenant 2 set: 1000
rls_app with row_security = off -> ERROR: query would be affected by row-level security policy

Demo 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 nothing
forgot to set, same pool: saw tenant_id 2
SET LOCAL per transaction: 400 requests, 0 saw another tenant, 0 saw nothing
forgot to set, transaction-local pool: saw 0 tenants

Hiệu năng dưới policy, đã set tenant 1:

-- trang danh sách: điều kiện policy thành Index Cond
Index 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 STABLE
Seq Scan on invoices Rows Removed by Filter: 199000 Execution Time: 40.406 ms
Index Scan using invoices_tenant_created_idx Execution Time: 0.018 ms

Schema chung + RLS

  • Quên WHERE tenant_id thì ra 0 dòng của tenant khác thay vì lộ dữ liệu.
  • WITH CHECK chặ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 BYPASSRLS riêng.

Chi phí phải quản lý

  • Đúng hay sai phụ thuộc vào role và việc set app.tenant_id theo 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.
  1. 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.
  2. ENABLE và FORCE ROW LEVEL SECURITY trên mọi bảng có tenant_id; một query trong CI fail khi thiếu.
  3. Chỉ set tenant bằng set_config('app.tenant_id', $1, true) bên trong BEGIN; không dùng SET mức session.
  4. Policy dùng current_setting(..., true) + NULLIF, để chưa set tenant thì ra 0 dòng.
  5. Mọi index và unique constraint bắt đầu bằng tenant_id.
  6. 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, INSERT chéo tenant bị từ chối.
  7. Kiểm kê role có rolsuper hoặc rolbypassrls đăng nhập được; chỉ công cụ quản trị được dùng.
  8. Xem EXPLAIN của query nóng bằng role của app, không bằng superuser.

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 sau
await 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
  1. Vì sao owner thấy 200.000 dòng sau ENABLE nhưng 0 dòng sau FORCE khi chưa set tenant?
  2. 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ới SET?
  3. Vì sao meta @> '{...}' dùng GIN index khi chạy bằng superuser nhưng không khi chạy bằng rls_app?