Sự cố 2 giờ sáng: Khi rào cản dữ liệu bị sụp đổ
Điện thoại rung bần bật trên bàn làm việc lúc nửa đêm. Sếp gửi một ảnh màn hình từ khách hàng VIP kèm dòng tin nhắn ngắn gọn: “Tại sao họ lại thấy doanh thu của đối thủ?”. Mồ hôi hột bắt đầu chảy. Đây chính là cơn ác mộng lớn nhất khi vận hành SaaS: Rò rỉ dữ liệu chéo (Cross-tenant data leak).
Hệ thống cũ của mình chạy theo cách truyền thống. Mọi bảng đều có cột tenant_id và mình lọc dữ liệu bằng câu lệnh WHERE ở tầng API. Chỉ cần một phút lơ là khi viết code, hoặc một hàm mới quên thêm điều kiện lọc, toàn bộ bí mật kinh doanh của khách hàng sẽ bị phơi bày. Việc quản lý thủ công hàng trăm subdomain như tenant-a.com trên Nginx cũng nhanh chóng trở thành một mớ hỗn độn khi quy mô tăng từ 10 lên 500 khách hàng.
Lỗ hổng từ tư duy ‘Lọc ở tầng Application’
Sau đêm trắng fix lỗi, mình nhận ra hai điểm yếu chết người trong kiến trúc cũ:
- Quản lý Subdomain cồng kềnh: Cấu hình DNS và Server thủ công không thể scale. Nó tốn của team vận hành khoảng 30 phút cho mỗi khách hàng mới.
- Phụ thuộc vào sự cẩn thận của con người: Việc lọc dữ liệu nằm hoàn toàn ở code Next.js. Nếu một junior developer quên check
tenant_id, hậu quả sẽ cực kỳ thảm khốc.
Để xử lý triệt để, mình đã tái cấu trúc hệ thống. Mình kết hợp Next.js Middleware để xử lý routing động và Supabase Row Level Security (RLS) để khóa chặt dữ liệu ngay từ tầng Database.
3 mô hình Multi-tenancy: Đâu là lựa chọn tối ưu?
Có ba hướng đi phổ biến khi thiết kế database cho SaaS:
- Database-per-tenant: Mỗi khách một DB. Cực kỳ an toàn nhưng chi phí vận hành cao ngất ngưởng và bảo trì migration là một cực hình.
- Schema-per-tenant: Một DB, nhiều Schema. Cách này khá ổn nhưng các công cụ ORM hiện nay vẫn chưa hỗ trợ chuyển đổi schema động một cách mượt mà.
- Shared Database (Row-level separation): Dùng chung bảng, phân biệt bằng ID. Đây là lựa chọn của mình vì nó tiết kiệm tài nguyên, dễ mở rộng, miễn là có cơ chế bảo mật tầng thấp bảo vệ.
Triển khai thực tế: Từ Subdomain đến Database
1. Xử lý Subdomain động với Next.js Middleware
Thay vì cấu hình DNS cho từng cái tên, mình dùng Middleware để “đánh chặn” request. Hệ thống sẽ tự động xác định tenant dựa trên URL người dùng đang truy cập.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host');
// Trích xuất subdomain (ví dụ: client-a.saas.com -> client-a)
const currentHost = hostname?.replace(`.mysaas.com`, '');
if (currentHost && !['www', 'mysaas.com'].includes(currentHost)) {
// Rewrite nội bộ sang cấu trúc folder /_tenants/[subdomain]
return NextResponse.rewrite(new URL(`/_tenants/${currentHost}${url.pathname}`, req.url));
}
return NextResponse.next();
}
Với cách này, bạn chỉ cần tạo folder app/_tenants/[subdomain]/page.tsx. Next.js sẽ lo phần còn lại. Việc onboard khách hàng mới giờ đây diễn ra trong tích tắc, hoàn toàn tự động.
2. Thiết lập Database “nội bất xuất, ngoại bất nhập”
Cấu trúc bảng cần sự liên kết chặt chẽ. Mỗi bảng dữ liệu nghiệp vụ bắt buộc phải có tenant_id để định danh chủ sở hữu.
-- Bảng danh sách khách hàng doanh nghiệp
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE -- subdomain định danh
);
-- Bảng dữ liệu người dùng, gắn chặt với một tenant
CREATE TABLE profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id),
full_name TEXT,
PRIMARY KEY (id)
);
3. Row Level Security (RLS): Lớp giáp sắt cuối cùng
Đây là kỹ thuật giúp mình ngủ ngon. Thay vì hy vọng code Next.js viết đúng, mình ép Database phải tự kiểm tra quyền truy cập. Đầu tiên, hãy bật RLS:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
Sau đó, tạo một chính sách (Policy) để Postgres chỉ trả về dữ liệu thuộc về tenant_id của người dùng đang đăng nhập. Một mẹo nhỏ là lưu tenant_id vào JWT để tránh phải query bảng profiles liên tục, giúp tăng tốc độ truy vấn lên gấp 2-3 lần.
CREATE POLICY "Tenant isolation policy"
ON profiles
FOR ALL
USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
Giờ đây, dù bạn viết supabase.from('profiles').select('*') mà quên thêm filter, Supabase cũng chỉ trả về đúng dữ liệu của tenant đó. Nếu Tenant A cố tình truy cập ID của Tenant B, kết quả trả về sẽ là rỗng. An toàn tuyệt đối.
Trong quá trình debug các cấu hình JSON phức tạp từ Supabase, mình thường dùng toolcraft.app (như công cụ JSON Formatter) để kiểm tra nhanh. Nó tiện lợi hơn nhiều so với việc mở các extension nặng nề trong VS Code khi đang cần xử lý gấp.
Kiểm soát truy cập ở tầng Application
Dù Database đã an toàn, chúng ta vẫn cần một lớp kiểm tra ở UI. Khi người dùng đăng nhập, hãy đối chiếu tenant_id của họ với subdomain hiện tại. Nếu nhân viên của Công ty A cố tình login vào trang của Công ty B, hãy chặn đứng họ ngay lập tức.
const { data: profile } = await supabase.from('profiles').select('tenant_id').single();
if (profile?.tenant_id !== currentTenantIdFromUrl) {
return redirect('/unauthorized');
}
Lời kết: Đừng đánh cược với dữ liệu khách hàng
Xây dựng SaaS không chỉ là làm ra tính năng, mà là xây dựng niềm tin. Việc đẩy trách nhiệm bảo mật xuống tầng Database thông qua RLS là cách bền vững nhất để bảo vệ dữ liệu. Đừng cố gắng tự xây dựng lại bánh xe dự phòng. Hãy tận dụng Middleware của Next.js và sức mạnh của PostgreSQL trên Supabase để tập trung vào việc phát triển tính năng thực tế cho khách hàng.

