Đang tải nội dung…
Đang tải nội dung…
Có, trong đúng mô hình role và context. Một policy không tự biến app thành an toàn.
Backend Security & Multi-Tenancy · Phần 1Bối cảnh hư cấu, bằng chứng lab thật. Fortex trong tuyến truyện này là bối cảnh hư cấu; Tom và Ben là nhân vật. Các cuộc trao đổi minh họa quá trình suy nghĩ. Số đo và assertion thuộc lab Docker được dẫn trong bài, không phải số liệu production của Fortex.
Hành trình ở Fortex · Chương 7/8
Fortex mở cổng quản lý cho các đối tác bán hàng. Ben làm màn hình xuất hóa đơn, tái sử dụng repository sẵn có và thêm một query phục vụ export. Khi rà lại, cậu thấy query mới thiếu điều kiện tenant.
“Em sửa WHERE rồi. Em thêm test để không quên nữa,” Ben nói với Tom. Lần này cậu tự đem lỗi đến trước khi có ai hỏi.
Tom đọc bản sửa. “Tốt. Nếu một đường đọc khác lại quên thì mình còn lớp nào đỡ được?”
“RLS. Đẩy filter xuống database, query nào cũng phải qua.”
“Em thử dưới role nào?”
Ben nhìn tài khoản đang mở trong công cụ SQL. Cậu đã bắt đầu nghĩ theo nhiều lớp, nhưng vẫn đang coi mọi người vào database đều đi qua cùng một cửa. Tom đề nghị giữ nguyên query thiếu WHERE và thay người chạy nó trong lab.
Ben giữ ca lỗi làm baseline thay vì xóa khỏi bộ thử. Cậu muốn lớp bảo vệ mới phải chịu được chính query mình đã viết thiếu, không chỉ một query mẫu vốn đã có filter đúng.
Một bảng invoices chứa hóa đơn của nhiều công ty. Phần lớn query đều có WHERE tenant_id = .... Một màn hình export mới quên thêm điều kiện này. Nếu role kết nối được phép đọc cả bảng, PostgreSQL sẽ trả cả bảng; nó không biết người gọi đang thuộc công ty nào.
Có thể kiểm soát query ở repository, thêm test và review kỹ. Row-Level Security, hay RLS, bổ sung một điểm thực thi chính sách ngay ở database. Nó hữu ích khi lỗi cần phòng là bỏ sót filter. Nhưng RLS cũng rất dễ trông như đang hoạt động trong code mà thực tế bị role đặc quyền đi vòng qua.
“Mình kiểm tra cả ghi chéo tenant nữa nhé,” Ben nói. “Chỉ giấu row lúc đọc chưa đủ.”
Tom giao cậu viết kỳ vọng cho từng role. Cậu phải tách runtime, owner và quyền đặc biệt trước khi kết quả COUNT có ý nghĩa.
Lab có bốn hóa đơn: hai của tenant 1, hai của tenant 2. Role ứng dụng là NOSUPERUSER NOBYPASSRLS, không sở hữu bảng. Khi chưa bật policy, SELECT count(*) FROM invoices trả 4.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::int
);
USING quy định row nào được nhìn thấy hoặc làm đối tượng của thao tác theo policy. WITH CHECK kiểm tra row mới khi INSERT hoặc UPDATE. Thêm filter đọc mà bỏ qua đường ghi sẽ không đủ cho một chính sách tenant hoàn chỉnh. Chi tiết quyền và cách ghép policy nằm trong tài liệu RLS PostgreSQL 17.
Với policy này, context không có hoặc rỗng trở thành NULL; phép so sánh không đúng với row nào, nên SELECT trả 0. Một context sai định dạng số sẽ gây lỗi cast. Đó là hành vi cần có test và cách xử lý, thay vì mặc định mọi lỗi context đều trả danh sách rỗng.
Ben định đọc tenant từ header rồi đặt vào context. Tom hỏi: “Nếu anh đổi header, điều gì chứng minh anh được vào tenant mới?”
Cậu quay lại session và membership phía server. Rồi tự hỏi thêm: “Connection được dùng lại thì context cũ sống đến lúc nào?” Tom để cậu nối bài snapshot trước đây với ranh giới transaction của lần này.
Trong một transaction của request:
BEGIN;
SET LOCAL ROLE app_user; -- chỉ là cách harness mô phỏng app role
SET LOCAL app.tenant_id = '1';
SELECT id, amount FROM invoices; -- cố ý không có tenant WHERE
COMMIT;
Ngoài production, ứng dụng nên kết nối bằng role ít quyền ngay từ đầu; đừng cho app quyền superuser chỉ để chạy SET ROLE. Tenant phải được suy ra từ session và membership đã xác thực phía server, không lấy nguyên một header do client tự chọn. Giá trị được gắn bằng API tham số hóa phù hợp của driver, không nối chuỗi SQL.
Dùng SET LOCAL trong transaction giúp context kết thúc khi commit hoặc rollback, tránh để tenant của request trước bám vào connection pool. Mọi query của request ấy phải dùng cùng connection. Một helper lấy connection khác để SELECT sẽ không có context vừa đặt.
Có giới hạn quan trọng: custom setting app.tenant_id không tự là một chứng chỉ danh tính. Nếu attacker được chạy SQL tùy ý bằng app role, họ có thể thay đổi setting. Mẫu này phòng lỗi quên filter trong ứng dụng đáng tin cậy, không thay thế chống SQL injection và không cô lập một ứng dụng đã bị chiếm quyền. Ranh giới mạnh hơn có thể cần role riêng, schema hoặc database riêng tùy mô hình đe dọa.
Ben chạy query dưới app role, rồi owner. Cùng câu SQL, khác số row. Tom không gọi đó là policy hỏng; anh yêu cầu Ben đối chiếu đúng ngoại lệ về quyền. Sau FORCE, hai người kiểm tra lại non-superuser owner, không đánh đồng nó với superuser.
Image PostgreSQL 17.6 được pin digest. Lab tạo riêng app_user và data_owner, đều không phải superuser và không có BYPASSRLS. Dữ liệu chỉ tồn tại trong container dùng một lần.
| Nhánh / điểm đo | Kết quả quan sát |
|---|---|
| App role, no policy | 4 |
| App role + RLS, tenant 1 | 2 |
| Missing tenant context | 0 |
| Owner, normal RLS | 4 |
| Owner + FORCE, tenant 1 | 2 |
App role thiếu WHERE nhìn thấy 4 row trước policy, còn 2 row sau policy với tenant 1. Không có context nhìn thấy 0. Thử INSERT hóa đơn của tenant 2 trong context tenant 1 bị PostgreSQL từ chối; lab kiểm tra lại ID đó thực sự không tồn tại.
Owner mặc định vẫn đọc 4 row. Sau ALTER TABLE invoices FORCE ROW LEVEL SECURITY, owner không phải superuser trong lab đọc 2 row. FORCE không khiến superuser hoặc role BYPASSRLS mất đặc quyền. Không được thử bằng tài khoản quản trị rồi suy ra policy dành cho app đã được kiểm tra.
Lỗi từ chối ghi thật, assertion và câu SQL đều có trong report và raw logs. Source kèm make reproduce ở cuối bài.
“Giờ em có thể ghi ‘tenant được cô lập’ chưa?” Ben hỏi.
“Em ghi rõ cô lập trước lỗi nào,” Tom đáp. “Quên filter trong ứng dụng đáng tin cậy khác với cho người ta chạy SQL tùy ý.”
Ben sửa phạm vi đề xuất. Cậu giữ repository filter và test cũ, thêm RLS cùng bộ test role/context, rồi liệt kê các view, function và quyền cần review trước khi dùng ở Fortex.
| Điểm cần kiểm tra | Vì sao đáng lo | Cách tiếp cận |
|---|---|---|
| Table owner, superuser, BYPASSRLS | Có thể không chịu policy như app role | Tách role migrate, owner và runtime; test dưới đúng role |
| Connection pool | Context sai tenant hoặc không có context | Transaction, SET LOCAL, cùng connection, test rollback |
| Nhiều policy | Policy permissive có thể ghép bằng OR | Review toàn bộ tập policy và quyền, không chỉ policy vừa thêm |
| View và hàm SECURITY DEFINER | Quyền thực thi có thể khác người gọi | Review owner và security mode từng đối tượng |
| Unique/FK errors | Constraint check có thể làm lộ sự tồn tại dữ liệu | Thiết kế key và lỗi public; không coi RLS là che mọi side channel |
| TRUNCATE | Không phải thao tác lọc từng row bằng RLS | Không cấp quyền này cho runtime role |
RLS có thể làm query plan khác đi; index cần phù hợp cả điều kiện tenant lẫn truy vấn nghiệp vụ. Đừng kết luận “có index là đủ”: lab về selectivity và query plan cho thấy chi phí còn phụ thuộc số row cần lấy. Bài hiện tại không đo overhead RLS trên tải lớn hay đầy đủ mọi kiểu view, function và join.
Với SaaS dùng bảng chung, RLS thêm một lớp chống bỏ sót filter đáng giá. Đổi lại bạn phải coi role, connection context và policy như một phần của mô hình dữ liệu có version và có test. Nếu không kiểm soát những phần đó, một dòng ENABLE ROW LEVEL SECURITY rất dễ mang lại cảm giác an toàn hơn khả năng bảo vệ thực tế.
Ben khép sổ bằng câu: “Một policy đúng cần người thực thi đúng và danh tính đầu vào đáng tin.” Cậu không chỉ gửi Tom câu SQL; cậu gửi cả bảng role, đường đặt context và trường hợp rollback cần kiểm tra.
Đến bài tiếp theo, Tom đổi vai: anh đưa Ben yêu cầu retry sau timeout và nhờ cậu dẫn buổi thiết kế. Câu hỏi Ben để dành từ bài tồn kho đã quay lại.
Chương trước: Database đã commit, vì sao event vẫn mất? · Chương tiếp: Timeout không có nghĩa thất bại: retry sao để không ghi hai lần? · Bắt đầu hành trình
PostgreSQL 17.6 · digest pinned · isolated Docker
Tải source Docker Lab (.zip)COMMIT e4881db7ab326ce553bbb0172c44fab421517f08
Đọc kỹ README trước khi chạy. Các lệnh thực nghiệm chuẩn trong Docker Lab:
make setupmake seedmake reproducemake verifymake cleanupMôi trường Docker độc lập, giới hạn tài nguyên và tự động dọn dẹp sau khi chạy. Số liệu trong bài viết là kết quả lần chạy chính thức; mỗi lần chạy mới đều lưu lịch sử riêng.