Khi nói tới bảo mật SaaS, người ta thường hình dung một kẻ tấn công đang dò lỗ hổng. Nhưng trong các hệ thống
multi-tenant mà chúng tôi vận hành, dữ liệu suýt rò rỉ thường đến từ những việc rất bình thường: một lập trình
viên viết đúng nghiệp vụ nhưng quên điều kiện WHERE tenantId, một route được bật cache "cho nhanh",
hoặc một kiểm tra quyền chỉ nằm ở trình duyệt. Không có kỹ thuật tấn công phức tạp nào cả. Vấn đề là mặc định của
hệ thống chưa đủ an toàn.
Bài viết này đi qua những điểm dữ liệu multi-tenant dễ rò rỉ nhất: phân quyền, cache, upload, đồng hồ client, rate-limit và danh tính. Mỗi điểm đều có đánh đổi. Chúng tôi sẽ nói rõ các đánh đổi đó thay vì hứa hẹn "an toàn tuyệt đối", vì thứ đó không tồn tại trong vận hành thực tế.
1. Nhiều doanh nghiệp: mọi kiểm soát phải nằm ở server
Z-Auto là nền tảng quản lý showroom ô tô. Mỗi showroom là một tenant, với kho xe, khách hàng CRM, hợp đồng đặt cọc và lịch lái thử riêng. Z-Cloud Workspace cũng là hệ thống multi-tenant, gồm 11 ứng dụng dùng chung một tầng danh tính. Trong cả hai hệ thống, chỉ cần một câu truy vấn thiếu điều kiện tenant là dữ liệu của showroom A có thể xuất hiện trên màn hình của showroom B.
Ba nguồn rò rỉ phổ biến nhất mà chúng tôi thường gặp là:
- Query thiếu
tenantId— thường xuất hiện ở endpoint mới, viết vội, được copy từ một endpoint không cần lọc tenant. - Route cache sai — dữ liệu riêng tư bị proxy giữ lại và phát cho người khác. Chúng tôi sẽ kể một sự cố thật ở mục 3.
- Kiểm tra quyền ở frontend — nút bị ẩn, nhưng API vẫn trả lời cho bất kỳ ai gọi bằng
curl.
Trình duyệt là môi trường của người dùng, không phải của bạn. Mọi thứ chạy ở đó — kiểm tra quyền, kiểm tra kiểu file, đếm ngược thời gian — đều là gợi ý về trải nghiệm, không phải kiểm soát bảo mật. Kiểm soát chỉ tồn tại khi nó chạy trên máy bạn kiểm soát.
Nguyên tắc mà chúng tôi lặp lại trong mọi code review: nếu một quyết định ảnh hưởng tới tiền, điểm số, quyền truy cập hay dữ liệu của tenant khác, quyết định đó phải được tính lại ở server, bất kể client đã tính rồi hay chưa.
2. Phân quyền: một nguồn sự thật duy nhất
Trong Z-EDU, toàn bộ danh sách quyền nằm ở packages/config/src/permissions.ts: một file dùng chung
cho cả backend NestJS và frontend Next.js trong monorepo pnpm. Không có hằng số quyền nào được khai báo rải rác
ở nơi khác. Lý do rất thực dụng: khi quyền được định nghĩa ở hai chỗ, sớm muộn chúng cũng lệch nhau. Và khi lệch,
lỗi thường nghiêng về phía mở rộng quyền chứ không phải thu hẹp.
Backend chặn bằng decorator @RequirePermission và @PermissionScope. Guard đọc metadata,
đối chiếu với quyền thực tế của người dùng lấy từ JWT/DB, và quan trọng nhất: ràng buộc phạm vi dữ liệu theo tenant.
// permissions.ts — nguồn sự thật duy nhất, dùng chung cả BE lẫn FE
export const PERMISSIONS = {
COURSE_UPDATE: 'course:update',
ATTEMPT_READ: 'attempt:read',
} as const;
// courses.controller.ts
@Controller('courses')
export class CoursesController {
@Patch(':id')
@RequirePermission(PERMISSIONS.COURSE_UPDATE)
@PermissionScope('tenant') // guard buộc phải có tenantId trong ngữ cảnh
async update(
@CurrentUser() user: AuthUser, // user.tenantId lấy từ token, KHÔNG lấy từ body
@Param('id') id: string,
@Body() dto: UpdateCourseDto,
) {
// Điều kiện tenantId nằm ngay trong mệnh đề where, không phải kiểm tra sau khi đọc.
// updateMany + count = 0 -> không tồn tại HOẶC không thuộc tenant này: trả 404 như nhau.
const res = await this.prisma.course.updateMany({
where: { id, tenantId: user.tenantId },
data: dto,
});
if (res.count === 0) throw new NotFoundException();
return this.prisma.course.findFirst({ where: { id, tenantId: user.tenantId } });
}
}
Hai chi tiết đáng chú ý. Thứ nhất, tenantId lấy từ token đã xác thực, không bao giờ lấy từ body
hay query string — nếu client gửi được tenantId thì client chọn được tenant. Thứ hai, khi bản ghi
không thuộc tenant hiện tại, chúng tôi trả 404 chứ không 403. Trả 403
vô tình xác nhận "ID này có tồn tại, chỉ là anh không được xem" — một kênh rò rỉ nhỏ nhưng đủ để dò ID.
Frontend vẫn nhận danh sách quyền, nhưng chỉ dùng để ẩn/hiện menu và nút. Đó là trải nghiệm, không phải bảo mật. Ẩn nút "Xoá hợp đồng" giúp người dùng không bấm nhầm; nó không ngăn được ai mở DevTools, đọc bundle JavaScript, tìm endpoint rồi gọi thẳng. Nếu API không tự chặn, nút bị ẩn không có giá trị bảo vệ.
| Kiểm soát | Client (gợi ý UX) | Server (kiểm soát thật) |
|---|---|---|
| Phân quyền | Ẩn/hiện menu, nút | Guard @RequirePermission chặn request |
| Cô lập tenant | Không có vai trò gì | tenantId từ token, nằm trong where |
| Kiểu & kích thước file | Lọc trong input file để báo sớm | HeadObject sau khi upload |
| Hạn nộp bài | Đếm ngược cho người dùng thấy | So expiresAt chốt lúc bắt đầu |
| Tính điểm, tính tiền | Hiển thị dự kiến | Tính lại toàn bộ, bỏ qua số client gửi |
| Rate-limit | Chặn double-click | Đếm theo IP qua Redis |
3. Cache: nơi dữ liệu rò rỉ mà không ai đụng vào code
Đây là một trong những sự cố đáng nhớ nhất của chúng tôi. Nó không đến từ logic nghiệp vụ, mà đến từ cấu hình cache.
Z-EDU có ba tầng cache. Tầng ngoài cùng là Cache-Control đặt qua decorator @HttpCache
trên các route công khai, để CDN/Traefik trả thẳng, request thậm chí không tới được API. Đây là tầng chịu tải chính,
và cũng chính vì hiệu quả của nó mà chúng tôi từng có ý định bật nhanh cho tất cả route @Public.
Vấn đề là @Public trong hệ thống của chúng tôi chỉ có nghĩa "không bắt buộc JWT", không có nghĩa là
"ai xem cũng giống nhau". Có những route @Public vẫn trả dữ liệu riêng của một người, được bảo vệ
bằng token trong URL hoặc bằng ngữ cảnh phiên làm bài. Ví dụ điển hình là
GET /exams/public/attempts/:id, tức bài làm của một thí sinh. Nếu route đó bị gắn
Cache-Control: public, proxy có thể giữ phản hồi lại và phát cho người tiếp theo truy cập cùng URL.
Dữ liệu rò rỉ dù code nghiệp vụ không sai.
// SAI: quét toàn bộ route @Public rồi gắn cache đại trà
@Public()
@HttpCache({ public: true, maxAge: 60 }) // <-- thảm hoạ nếu route trả dữ liệu cá nhân
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* bài làm của MỘT thí sinh */ }
// ĐÚNG: opt-in từng route, và phân biệt rõ public vs private
@Public()
@HttpCache({ public: true, maxAge: 300 }) // nội dung ai xem cũng giống nhau
@Get('exams/public/:slug')
getExam(@Param('slug') slug: string) { /* đề thi công khai */ }
@Public()
@NoStore() // Cache-Control: no-store, private
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* mặc định: KHÔNG cache */ }
Hai khái niệm cần tách bạch dứt khoát:
Cache-Control: public— bất kỳ cache dùng chung nào (CDN, reverse proxy) được phép lưu và phát lại cho người khác. Chỉ dùng cho dữ liệu mà mọi người ẩn danh đều thấy giống hệt nhau.Cache-Control: private— chỉ cache của trình duyệt người dùng đó được lưu. CDN không được giữ. Dùng cho dữ liệu gắn với một người.no-store— không lưu ở đâu hết. Dùng cho thứ nhạy cảm thật sự.
Vary là công cụ bổ trợ: nó bảo cache rằng phản hồi phụ thuộc vào header nào (ví dụ
Vary: Authorization, Vary: Accept-Language), nên hai request khác nhau ở header đó
phải là hai entry cache khác nhau. Nhưng đừng coi Vary là lưới an toàn. Nó dễ bị bỏ sót, và nếu
dữ liệu phân biệt theo token nằm trong URL thì Vary chẳng cứu được gì. Nguyên tắc bền hơn là
mặc định không cache, opt-in từng route một, và người thêm @HttpCache phải trả lời
được câu hỏi: "phản hồi này có giống hệt nhau với mọi người ẩn danh không?" Nếu còn ngập ngừng thì không cache.
Đánh đổi ở đây rất rõ: opt-in từng route nghĩa là bạn bỏ lỡ vài cơ hội cache và phải chịu thêm tải. Chúng tôi chấp nhận đánh đổi đó, vì cái giá của chiều ngược lại là phát bài làm của thí sinh này cho thí sinh khác.
4. Upload file: đừng tin Content-Type của client
Trong Z-EDU, trình duyệt PUT thẳng file lên S3 (RustFS, S3-compatible) qua presigned URL.
API không nằm giữa thread byte — nó chỉ ký URL và, quan trọng hơn, kiểm tra lại ở bước đăng ký.
Vì sao không tin Content-Type client gửi? Vì đó chỉ là một chuỗi trong header HTTP, do client
tự khai. Bất kỳ ai cũng gửi được Content-Type: image/png kèm nội dung là một file thực thi,
hoặc khai Content-Length nhỏ trong lúc đăng ký rồi upload file 5GB. Kích thước và kiểu file
chỉ có ý nghĩa khi được đọc từ object đã nằm trên storage, tức là từ nguồn mà client không viết lại được.
Đó là việc của HeadObject.
@Post('uploads/presign')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async presign(@CurrentUser() user: AuthUser, @Body() dto: PresignDto) {
const key = `t/${user.tenantId}/${randomUUID()}`; // key luôn có tiền tố tenant
const url = await this.s3.getSignedUrl('putObject', {
Key: key, Expires: 300, // URL ký sống 5 phút
});
return { key, url };
}
@Post('uploads/commit')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async commit(@CurrentUser() user: AuthUser, @Body() dto: CommitDto) {
if (!dto.key.startsWith(`t/${user.tenantId}/`)) throw new ForbiddenException();
// Sự thật nằm ở đây: đọc metadata TỪ STORAGE, không từ những gì client khai.
const head = await this.s3.headObject({ Key: dto.key });
if (head.ContentLength > MAX_SIZE) throw new BadRequestException('File quá lớn');
if (!ALLOWED_TYPES.includes(head.ContentType ?? '')) throw new BadRequestException('Kiểu file không hợp lệ');
return this.files.register({ key: dto.key, tenantId: user.tenantId, size: head.ContentLength });
}
Nếu client khai gian, object vẫn có thể nằm trên S3, nhưng sẽ không được đăng ký vào DB. Nó chỉ là file rác và job dọn dẹp định kỳ sẽ xoá sau đó. Kẻ tấn công tốn băng thông của họ, còn hệ thống không cấp quyền sử dụng file.
Chiều tải xuống cũng vậy: bucket không mở công khai. File được phục vụ qua URL ký ngắn hạn, cấp sau khi API đã kiểm tra người dùng có quyền đọc file đó và file thuộc đúng tenant. URL ngắn hạn không phải là bảo mật hoàn hảo — trong khoảng thời gian còn hiệu lực, ai cầm được URL đều tải được. Đánh đổi nằm ở thời hạn: quá ngắn thì link chết giữa chừng khi mạng chậm, quá dài thì cửa sổ rò rỉ rộng ra. Vài phút là điểm cân bằng hợp lý cho phần lớn trường hợp.
Lưu ý: HeadObject đọc ContentType mà S3 lưu, và giá trị này vẫn bắt nguồn từ lúc upload.
Nó giúp chặn việc khai gian ở bước đăng ký, nhưng chưa phải kiểm tra nội dung thật của file. Với dữ liệu nhạy cảm
hơn, lớp tiếp theo nên là đọc magic bytes hoặc quét virus trong worker. Đây là phòng thủ theo mức độ rủi ro, không
phải một công tắc bật/tắt duy nhất.
5. Không tin đồng hồ của client
Trong Z-EDU, khi thí sinh bắt đầu một bài test, server chốt expiresAt ngay tại thời điểm đó và lưu
vào bản ghi lượt làm bài. Client nhận về mốc thời gian đó và chỉ làm đúng một việc: đếm ngược tới nó cho người
dùng nhìn. Khi nộp bài, server so thời điểm nhận request với expiresAt đã lưu — không đọc bất kỳ
trường thời gian nào client gửi lên. Nộp muộn quá 60 giây bị từ chối.
Vì sao có 60 giây khoan dung? Vì đồng hồ trên máy client có thể lệch, mạng có thể chậm, và request còn đi qua
nhiều tầng proxy. Nếu từ chối tuyệt đối theo mili-giây, thí sinh trung thực nhưng mạng kém có thể mất bài. Con số
60 giây là một đánh đổi có chủ đích: chấp nhận một cửa sổ nhỏ có thể bị lạm dụng để tránh phạt oan người dùng
thật. Quan trọng nhất là quyết định này nằm ở server, có giới hạn và đo được, chứ không phụ thuộc vào
Date.now() của trình duyệt.
Mở rộng ra: mọi mốc thời gian trong hệ thống lưu ở UTC, chỉ quy đổi UTC+7 khi hiển thị và khi sinh lịch học hay cắt kỳ báo cáo. Trộn múi giờ trong tầng lưu trữ là một nguồn bug âm ỉ, và trong nghiệp vụ tính hạn nộp hay chốt sổ, một bug múi giờ có hậu quả không khác gì một lỗ hổng bảo mật.
Quy tắc chung: mọi thứ quyết định tiền, điểm, hoặc quyền đều tính ở server. Client được phép biết kết quả, không được phép quyết định kết quả.
6. Rate-limit và triết lý fail-open
Z-EDU rate-limit theo IP và đếm qua Redis. Khi Redis gặp sự cố, chúng tôi chọn fail-open: request vẫn đi qua thay vì bị chặn. Lý do là rate-limit ở đây dùng để giảm lạm dụng, không phải để xác thực danh tính. Nếu fail-closed, một lỗi ở tầng phụ trợ có thể làm toàn bộ API từ chối người dùng thật. Triết lý này cũng xuất hiện ở các phần khác của Z-EDU: Redis lỗi thì cache xuống thẳng DB (chậm hơn, nhưng đúng), ghi tiến độ chuyển sang ghi thẳng DB (có thể mất vài giây vị trí video, nhưng không mất điểm).
async consume(ip: string): Promise<boolean> {
try {
const n = await this.redis.incr(`rl:${ip}`);
if (n === 1) await this.redis.expire(`rl:${ip}`, WINDOW_SEC);
return n <= LIMIT;
} catch (err) {
// Redis chết -> FAIL-OPEN. Không tự làm sập API vì một tầng phụ trợ.
this.logger.error({ err }, 'rate-limit backend down, failing open');
this.metrics.increment('ratelimit.fail_open'); // phải có cảnh báo trên chỉ số này
return true;
}
}
Nhưng fail-open là một đánh đổi có ý thức, không phải mặc định vô hại. Nó đổi một phần khả năng chống lạm dụng lấy tính sẵn sàng. Trong lúc Redis lỗi, ai đó có thể gửi request nhiều hơn bình thường. Chúng tôi chấp nhận vì cái giá của việc API ngừng phục vụ toàn bộ tenant lớn hơn. Phần bù lại là đếm số lần fail-open và dựng cảnh báo trên chỉ số đó. Fail-open âm thầm mới là nguy hiểm.
Ranh giới rất rõ ràng: fail-open cho các cơ chế bảo vệ hiệu năng, fail-closed cho các cơ chế quyết định quyền. Nếu dịch vụ kiểm tra quyền không trả lời được, câu trả lời phải là "từ chối", không phải "cho qua". Nếu cổng thanh toán không xác nhận được giao dịch, đơn hàng phải ở trạng thái chờ, không được đánh dấu đã thanh toán. Nếu không xác thực được JWT vì không lấy được khoá công khai, request phải bị từ chối. Câu hỏi để phân loại: "Nếu thành phần này im lặng, hệ quả tệ hơn là chặn nhầm người dùng thật, hay là cho qua nhầm kẻ không có quyền?" Câu trả lời quyết định hướng fail.
7. Danh tính tập trung: khoá một chỗ, cắt sạch quyền
Z-Cloud Workspace có 11 ứng dụng tích hợp — chat, mail, docs, files, tasks, calendar, meetings, base, workflow, board, contacts. Nếu mỗi ứng dụng tự quản lý tài khoản, thì khi một nhân viên nghỉ việc, đội IT phải nhớ khoá tài khoản ở 11 chỗ. Trong thực tế, họ sẽ quên ít nhất một chỗ — và cái chỗ bị quên thường là chỗ có dữ liệu nhạy nhất.
Chúng tôi giải quyết bằng SSO qua Keycloak/OIDC: một lần đăng nhập cho toàn bộ ứng dụng, và quan trọng hơn, một nơi duy nhất để thu hồi. Vô hiệu hoá tài khoản trong Keycloak là cắt quyền trên cả 11 app. Đây là lợi ích thường bị bỏ quên khi nói về SSO — người ta hay nhắc tới sự tiện lợi của "đăng nhập một lần", trong khi giá trị bảo mật quan trọng hơn nằm ở "thu hồi một lần".
Đánh đổi là danh tính tập trung tạo ra một điểm phụ thuộc chung. Nếu Keycloak gặp sự cố, người dùng mới không đăng nhập được. Bù lại, phiên đang hoạt động vẫn chạy tới khi token hết hạn. Vì vậy thời hạn token là tham số cần cân nhắc kỹ: token dài thì thu hồi quyền có độ trễ; token ngắn thì hệ thống phụ thuộc nhiều hơn vào độ sẵn sàng của IdP. Không có lựa chọn miễn phí, chỉ có lựa chọn phù hợp với mô hình rủi ro.
Phía Z-Auto, mỗi showroom có storefront riêng trên subdomain, hoặc trỏ tên miền riêng (custom domain).
Điều này thêm một bề mặt cần cẩn thận: tenant được xác định từ host. Việc phân giải host thành tenant
phải làm ở server và phải tra từ danh sách domain đã đăng ký, chứ không suy diễn bằng cách cắt chuỗi. Và một khi
đã có tenantId từ host cho các trang công khai, dữ liệu quản trị vẫn phải lấy tenantId
từ token của người đăng nhập — hai nguồn này không được lẫn vào nhau. Tầng CDN/WAF đứng trước API Gateway (OpenResty)
trong Z-Cloud Workspace cũng đóng vai trò tương tự: nó là lớp lọc, không phải lớp phân quyền.
Cuối cùng, chat trong Z-Cloud Workspace mã hoá đầu-cuối (E2E) — nghĩa là ngay cả chúng tôi, với tư cách nhà vận hành, cũng không đọc được nội dung. Đây là lựa chọn thiết kế có cái giá của nó: mất khả năng tìm kiếm phía server, mất khả năng khôi phục khi người dùng mất khoá. Chúng tôi chấp nhận cái giá đó cho kênh chat vì tính riêng tư của nội dung trao đổi nội bộ đáng giá hơn.
8. Checklist bảo mật multi-tenant
Đây là danh sách chúng tôi dùng khi review một endpoint mới trong hệ thống multi-tenant. Nó được giữ ngắn có chủ đích, vì checklist quá dài thường không được đọc đến nơi đến chốn.
tenantIdluôn lấy từ token đã xác thực, không bao giờ từ body/query/header do client kiểm soát.- Điều kiện tenant nằm trong mệnh đề
where, không phải kiểm tra sau khi đã đọc bản ghi ra. Trả404thay vì403để không xác nhận sự tồn tại của ID. - Mặc định không cache;
@HttpCachelà opt-in từng route. Chỉ đặtpublickhi phản hồi giống hệt nhau với mọi người ẩn danh. Route trả dữ liệu cá nhân — kể cả route@Public— luônprivatehoặcno-store. - Một nguồn sự thật cho quyền (như
permissions.ts), backend chặn bằng guard; frontend chỉ ẩn/hiện UI. - Không tin metadata client khai: kiểm lại kích thước và kiểu file bằng
HeadObjectsau upload; key của object có tiền tố tenant; tải file bằng URL ký ngắn hạn. - Không tin đồng hồ client: mốc hết hạn chốt ở server, lưu UTC; mọi tính toán về tiền, điểm, quyền đều làm lại ở server.
- Phân loại đúng hướng fail: rate-limit và cache fail-open (kèm metric và cảnh báo); phân quyền, xác thực, thanh toán fail-closed.
- Danh tính tập trung: một nơi để thu hồi quyền. Rời tổ chức là khoá một chỗ, cắt sạch mọi ứng dụng.
- Viết test cho đường tấn công, không chỉ đường hạnh phúc: mỗi endpoint nên có ít nhất một test dùng token của tenant A gọi vào tài nguyên của tenant B và kỳ vọng
404.
Kết luận
Không có hệ thống nào an toàn tuyệt đối. Điều thực tế hơn mà chúng tôi theo đuổi là: làm cho lựa chọn an toàn trở thành mặc định. Cache tắt sẵn và phải bật thủ công. Guard bắt buộc khai báo phạm vi. Metadata file luôn được đọc lại từ storage. Mốc thời gian luôn được chốt ở server.
Vì rò rỉ dữ liệu trong SaaS multi-tenant thường không đến từ một cuộc tấn công xuất sắc. Nó thường đến từ một lập trình viên giỏi nhưng đang vội, vào cuối ngày làm việc. Mặc định an toàn của hệ thống là thứ còn bảo vệ bạn trong những lúc như vậy.