Lập trình · 22/09/2026

Cô lập dữ liệu multi-tenant cho SaaS: Thiết kế tenant context, phân quyền và phòng rò rỉ chéo

Trong ứng dụng SaaS multi-tenant, lỗi nguy hiểm nhất thường không phải là truy vấn chậm mà là truy vấn đúng dữ liệu của nhầm khách hàng. Chỉ một câu lệnh thiếu điều kiện tenant_id, một cache key dùng chung hoặc một background job không mang tenant context cũng có thể làm lộ hóa đơn, hồ sơ nhân sự hay cấu hình nội bộ giữa các tổ chức. Vì vậy, cô lập tenant phải là invariant xuyên suốt hệ thống, không phải quy ước mà lập trình viên nhớ thêm vào từng controller.

Cô lập dữ liệu multi-tenant cho SaaS: Thiết kế tenant context, phân quyền và phòng rò rỉ chéo

Trong ứng dụng SaaS multi-tenant, lỗi nguy hiểm nhất thường không phải là truy vấn chậm mà là truy vấn đúng dữ liệu của nhầm khách hàng. Chỉ một câu lệnh thiếu điều kiện tenant_id, một cache key dùng chung hoặc một background job không mang tenant context cũng có thể làm lộ hóa đơn, hồ sơ nhân sự hay cấu hình nội bộ giữa các tổ chức. Vì vậy, cô lập tenant phải là invariant xuyên suốt hệ thống, không phải quy ước mà lập trình viên nhớ thêm vào từng controller.

Bài viết xây dựng một mô hình thực chiến cho backend SaaS: xác định tenant từ request, phân biệt authentication với authorization, chọn mô hình lưu trữ, khóa phạm vi truy vấn, dùng Row-Level Security (RLS) khi phù hợp, bảo vệ cache và object storage, truyền context qua hàng đợi, kiểm thử rò rỉ chéo và vận hành sự cố. Ví dụ dùng SQL và pseudocode để có thể áp dụng cho nhiều framework.

1. Tenant isolation là một invariant bảo mật

Tenant là ranh giới sở hữu dữ liệu, thường tương ứng với công ty, workspace, trường học hoặc một đơn vị kinh doanh. Người dùng có thể thuộc nhiều tenant và có vai trò khác nhau ở từng nơi. Do đó, biết người dùng là ai chưa đủ; hệ thống còn phải biết họ đang hành động trong tenant nào và quyền nào có hiệu lực trong tenant đó.

Một invariant hữu ích có thể phát biểu ngắn gọn: mọi thao tác đọc, ghi, xuất dữ liệu và side effect phải bị giới hạn bởi tenant đã được xác thực, trừ các luồng quản trị toàn cục được thiết kế và kiểm toán riêng. Invariant này phải tồn tại ở API, service, database, cache, search index, queue, file storage và công cụ hỗ trợ vận hành.

Khái niệmCâu hỏi cần trả lờiSai lầm phổ biến
AuthenticationNgười gọi là ai?Đăng nhập thành công rồi coi như được truy cập mọi tenant
Tenant resolutionRequest đang tác động tenant nào?Tin trực tiếp tenant_id do client gửi
AuthorizationDanh tính này được làm gì trong tenant?Chỉ kiểm tra role, không kiểm tra membership
Data scopingTruy vấn và side effect bị giới hạn ra sao?Dựa vào việc lập trình viên luôn nhớ thêm bộ lọc

2. Bắt đầu bằng threat model và đường đi dữ liệu

Trước khi chọn schema, hãy liệt kê tài sản cần bảo vệ và mọi đường có thể đọc hoặc thay đổi chúng. Ngoài REST/GraphQL còn có trang quản trị, webhook, import CSV, export báo cáo, scheduled job, consumer sự kiện, search, analytics, email template, bản sao lưu và script sửa dữ liệu. Nhiều sự cố xảy ra ở luồng phụ vì nó không đi qua middleware chính.

Threat model nên bao gồm người dùng hợp lệ cố đổi ID trên URL, token của tenant A gọi resource tenant B, nhân viên hỗ trợ dùng quyền vượt mức, message cũ bị replay, job chạy sau khi membership đã bị thu hồi và lỗi cấu hình làm mất tenant context. Đồng thời phân loại dữ liệu dùng chung thật sự, dữ liệu tenant và dữ liệu nhạy cảm cần tách mạnh hơn.

  • Vẽ luồng từ điểm vào đến database, cache, queue, file và hệ thống bên thứ ba.
  • Đánh dấu nơi tenant được xác định, nơi quyền được kiểm tra và nơi scope được áp dụng.
  • Ghi rõ fail-closed: thiếu hoặc mâu thuẫn tenant context phải bị từ chối.
  • Xác định thao tác hỗ trợ chéo tenant và yêu cầu phê duyệt, lý do, thời hạn cùng audit.

3. Chọn mô hình lưu trữ theo mức cô lập

Ba mô hình thường gặp là dùng chung bảng với cột tenant_id, schema riêng cho từng tenant, hoặc database riêng. Mô hình dùng chung vận hành đơn giản và tận dụng tài nguyên tốt nhưng đòi hỏi scoping chặt chẽ. Schema riêng tạo ranh giới rõ hơn song migration và connection routing phức tạp. Database riêng cho blast radius nhỏ và tùy biến mạnh, đổi lại chi phí provisioning, monitoring và backup cao hơn.

Mô hìnhPhù hợp khiRủi ro cần kiểm soát
Shared database, shared schemaNhiều tenant nhỏ, mô hình dữ liệu đồng nhấtQuên scope, index thiếu tenant_id, unique key sai phạm vi
Shared database, schema riêngCần cô lập logic cao hơn và số tenant vừa phảiSchema drift, migration hàng loạt, search path sai
Database riêngTenant lớn, yêu cầu compliance hoặc phục hồi riêngConnection explosion, fleet operation, chi phí
HybridPhân tầng khách hàng hoặc di chuyển dầnRouting phức tạp và hai bộ runbook

Không có mô hình nào tự động giải quyết authorization. Ngay cả database riêng, router vẫn có thể chọn nhầm connection. Quyết định nên dựa trên blast radius, yêu cầu phục hồi, quy mô tenant, chi phí vận hành và khả năng tự động hóa, thay vì chỉ nhìn vào số bảng.

4. Thiết kế khóa và ràng buộc có tenant ngay từ đầu

Với shared schema, mọi bảng sở hữu bởi tenant nên có tenant_id bắt buộc. Foreign key nên mang tenant vào khóa để database ngăn một invoice của tenant A tham chiếu customer của tenant B. Unique constraint cũng cần đúng phạm vi: email nhân viên có thể chỉ duy nhất trong tenant, không nhất thiết duy nhất toàn hệ thống.

create table customers (
  tenant_id uuid not null,
  id uuid not null,
  email text not null,
  primary key (tenant_id, id),
  unique (tenant_id, email)
);

create table invoices (
  tenant_id uuid not null,
  id uuid not null,
  customer_id uuid not null,
  primary key (tenant_id, id),
  foreign key (tenant_id, customer_id)
    references customers (tenant_id, id)
);

Index thường nên bắt đầu bằng tenant_id nếu hầu hết truy vấn luôn scope tenant, sau đó mới đến trạng thái, thời gian hoặc khóa tìm kiếm. Tuy nhiên phải đo query plan thật; tenant cực lớn có phân bố khác tenant nhỏ. ID ngẫu nhiên khó đoán không thay thế authorization, vì ID vẫn có thể rò qua log, link chia sẻ hoặc referrer.

5. Xây tenant context từ nguồn đáng tin cậy

Client có thể gửi subdomain, workspace slug hoặc tenant ID để biểu đạt tenant mong muốn, nhưng server phải đối chiếu với danh tính đã xác thực và bảng membership. Không lấy header X-Tenant-ID làm sự thật chỉ vì gateway đã chuyển tiếp nó; header bên ngoài phải bị xóa hoặc được ký/xác thực giữa các service.

identity = authenticate(request.credential)
requested = parseTenantHint(request.host, request.path)
membership = loadActiveMembership(identity.userId, requested)

if membership is null:
  deny(403)

context = TenantContext(
  tenantId = membership.tenantId,
  userId = identity.userId,
  roles = membership.roles,
  requestId = request.id
)

Tenant context nên bất biến trong một request và được truyền tường minh vào service/repository. Global mutable state dễ rò context giữa request trong worker sống lâu, coroutine hoặc test chạy song song. Khi đổi workspace, hãy tạo request mới hoặc context mới sau bước authorization, không sửa tenant âm thầm giữa transaction.

6. Scope truy vấn ở repository và ưu tiên API khó dùng sai

Một repository an toàn không cung cấp hàm chung như findById(id) cho dữ liệu tenant. Nó yêu cầu tenantId hoặc nhận một scoped repository đã khóa tenant. Với update/delete, điều kiện tenant phải nằm trong chính câu lệnh; không đọc record trước rồi cập nhật chỉ theo ID vì trạng thái có thể thay đổi và đường code sau dễ bỏ scope.

update invoices
set status = :status, updated_at = now()
where tenant_id = :tenant_id
  and id = :invoice_id;

-- zero affected rows means not found or not accessible;
-- do not retry without tenant scope.

ORM global scope giúp giảm lỗi lặp lại nhưng cần hiểu đường bypass: raw SQL, unscoped query, relationship tùy chỉnh, bulk update và admin tooling. Hãy giới hạn API bỏ scope vào module nhỏ, đặt tên gây chú ý và yêu cầu lý do audit. Code review nên tìm đường truy cập dữ liệu thay vì chỉ nhìn controller đã có middleware hay chưa.

7. Dùng Row-Level Security như lớp phòng thủ bổ sung

PostgreSQL Row-Level Security có thể buộc policy ngay tại database, nhờ đó truy vấn quên điều kiện tenant vẫn không nhìn thấy hàng ngoài phạm vi. Ứng dụng đặt tenant hiện tại vào session/transaction setting, policy so sánh setting đó với cột tenant_id. Đây là defense in depth hữu ích, nhưng cấu hình sai role hoặc connection pooling vẫn có thể phá kỳ vọng.

alter table invoices enable row level security;
alter table invoices force row level security;

create policy tenant_isolation on invoices
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid);

begin;
set local app.tenant_id = '...';
select * from invoices where status = 'open';
commit;

Dùng SET LOCAL trong transaction để context không sống sót trên pooled connection. Role ứng dụng không nên là superuser hay có quyền bỏ qua RLS. Kiểm tra cả owner behavior, policy cho USINGWITH CHECK, migration, maintenance job và replica đọc. RLS không thay thế authorization cấp nghiệp vụ: nó giới hạn hàng, nhưng không biết role nào được hoàn tiền hoặc xuất báo cáo.

8. Cache, search và object storage cũng phải có namespace tenant

Cache key customer:123 có thể trả dữ liệu tenant A cho tenant B nếu ID chỉ duy nhất trong tenant. Key cần chứa tenant và phiên bản schema, ví dụ v2:tenant:{tenantId}:customer:{id}. Tag invalidation, lock key, rate limit và idempotency key cũng phải xét tenant; nếu không, tenant này có thể làm sai trạng thái hoặc giới hạn của tenant khác.

Search index có thể dùng index riêng hoặc field tenant bắt buộc với filter được chèn từ server. Không nhận filter tenant do trình duyệt cung cấp rồi chuyển thẳng vào search engine. Với object storage, key nên có prefix tenant không đoán thay cho kiểm tra quyền; server vẫn phải authorize trước khi tạo signed URL, đặt thời hạn ngắn và tránh bucket public.

  • Không cache response cá nhân chỉ theo URL nếu tenant nằm ở header hoặc session.
  • Không dùng CDN cache chung cho nội dung riêng tư nếu cache key thiếu tenant/identity.
  • Đưa tenant ID vào metadata của file để hỗ trợ audit và lifecycle.
  • Kiểm tra export archive không chứa file tạm của tenant trước đó.

9. Queue, scheduled job và event phải mang context có thể kiểm chứng

Background worker không có HTTP middleware để suy ra tenant. Message phải mang tenant_id, actor hoặc service principal, resource ID, correlation ID và phiên bản payload. Consumer dùng tenant ID để mở scope, nhưng vẫn cần kiểm tra resource thuộc tenant. Nếu thao tác phụ thuộc quyền người dùng, quyết định rõ kiểm tra quyền tại lúc enqueue, lúc execute hay cả hai.

{
  "type": "invoice.export.requested",
  "version": 1,
  "tenant_id": "...",
  "actor_id": "...",
  "invoice_id": "...",
  "correlation_id": "..."
}

Job có thể chạy sau khi người dùng rời tổ chức. Với tác vụ nhạy cảm, consumer nên kiểm tra membership còn hiệu lực hoặc dùng service authorization có phạm vi hẹp. Dead-letter queue, retry và replay tool phải giữ tenant metadata. Scheduler quét nhiều tenant nên tạo từng unit công việc có scope rõ, giới hạn concurrency theo tenant để một khách hàng lớn không chiếm toàn bộ worker.

10. Luồng quản trị và hỗ trợ cần ranh giới riêng

Nhân viên hỗ trợ đôi khi cần xem tenant để xử lý sự cố, nhưng “admin có thể làm mọi thứ” tạo blast radius lớn. Nên tách control plane khỏi data plane, dùng just-in-time access, yêu cầu ticket/lý do, giới hạn thời gian và hiển thị rõ đang impersonate tenant nào. Thao tác ghi nguy hiểm có thể cần phê duyệt kép.

Audit log phải ghi actor thật, actor bị impersonate nếu có, tenant, action, resource, kết quả, request ID và lý do truy cập. Log không nên chứa toàn bộ payload nhạy cảm. Không cho phép support đổi tenant chỉ bằng tham số URL mà không tái kiểm tra quyền. Break-glass account cần bảo vệ mạnh, cảnh báo tức thời và diễn tập thu hồi.

11. Kiểm thử bằng ma trận hai tenant, không chỉ happy path

Bộ test tối thiểu tạo tenant A và B với các resource có ID tương tự, sau đó thử mọi thao tác bằng danh tính của A trên ID của B. Bao phủ list, detail, create relationship, update, delete, export, search, signed URL, webhook, bulk API và GraphQL node lookup. Kỳ vọng nên là không lộ sự tồn tại khi sản phẩm yêu cầu, hoặc trả 403 nhất quán theo chính sách.

  1. Test repository xác nhận mọi query tenant-owned đều nhận scope.
  2. Integration test chạy với đúng database role production và RLS bật.
  3. Property-based test sinh tenant/resource ngẫu nhiên để tìm tổ hợp bỏ sót.
  4. Test connection pool chứng minh tenant setting được reset sau commit, rollback và exception.
  5. Test cache với cùng resource ID ở hai tenant và thứ tự request đảo ngược.
  6. Test worker replay message sai tenant, membership bị thu hồi và job trùng.

Static analysis có thể cấm repository không scope hoặc raw SQL ngoài package cho phép. Tuy vậy, không nên dựa riêng vào pattern matching. Kiểm thử end-to-end và review threat model vẫn cần thiết vì rò rỉ có thể xuất hiện ở file, log, email hoặc analytics mà không đi qua query chính.

12. Observability và quy trình phản ứng sự cố

Metric nên có tenant dưới dạng thuộc tính được kiểm soát, nhưng tránh dùng tenant ID cardinality cao làm label trên mọi time series. Log có cấu trúc cần request ID, tenant ID, actor ID, route, policy decision và số hàng tác động; che dữ liệu nhạy cảm. Cảnh báo hữu ích gồm request có tenant mâu thuẫn, RLS violation, truy vấn tenant-owned thiếu context, support access bất thường và export lớn.

Khi nghi ngờ rò rỉ chéo tenant, ưu tiên chặn đường truy cập, bảo toàn log/audit, xác định tenant và loại dữ liệu bị ảnh hưởng, thu hồi signed URL/token liên quan rồi mới sửa dữ liệu. Không xóa dấu vết trong lúc chữa cháy. Sau sự cố, thêm regression test cho đúng failure path và tìm các đường tương tự trên cache, queue, search cùng công cụ admin.

Cô lập tenant tốt không phụ thuộc vào một bộ lọc duy nhất. Nó là chuỗi lớp bảo vệ độc lập: context đáng tin cậy, authorization, API dữ liệu khó dùng sai, ràng buộc database, RLS khi phù hợp và kiểm thử đối kháng.

13. Checklist triển khai theo từng giai đoạn

  • Mô hình: phân loại dữ liệu tenant/global, chọn chiến lược lưu trữ và định nghĩa invariant.
  • Danh tính: xác thực tenant hint qua membership; context bất biến và fail-closed.
  • Dữ liệu: khóa, foreign key, unique constraint và index đều xét tenant.
  • Ứng dụng: repository scoped, policy authorization, đường bypass bị giới hạn.
  • Hạ tầng: namespace tenant cho cache, search, file, queue và idempotency.
  • Phòng thủ: RLS với role đúng, transaction-local context và test connection pool.
  • Vận hành: audit support access, quan sát policy decision và runbook sự cố.
  • Xác minh: ma trận cross-tenant trong CI và kiểm thử định kỳ trên mọi đường xuất dữ liệu.

Nếu hệ thống hiện tại chỉ dựa vào điều kiện tenant_id viết tay, bước cải thiện đầu tiên không nhất thiết là tách database. Hãy lập bản đồ đường dữ liệu, chuẩn hóa tenant context, đưa scope vào repository, bổ sung composite constraint và tạo test hai tenant. Sau đó mới đánh giá RLS hoặc mô hình tách mạnh hơn dựa trên rủi ro thực tế. Cách tiếp cận theo lớp giúp giảm lỗi ngay trong khi vẫn cho phép kiến trúc phát triển theo quy mô sản phẩm.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.