1. Bối cảnh: SaaS multi-tenant và những bất biến sống còn
Một SaaS multi-tenant phục vụ nhiều khách hàng (tenant) trên cùng một hệ thống: Acme và Globex dùng chung code, chung database, nhưng dữ liệu của họ phải tuyệt đối tách biệt. Đây là mô hình kinh doanh phổ biến nhất của phần mềm hiện đại, và cũng là nơi một lỗi có thể phá huỷ cả công ty chỉ trong một sự cố. Nếu một người dùng của Acme nhìn thấy dù chỉ một hoá đơn của Globex, đó không phải 'bug nhỏ' — đó là vi phạm cô lập tenant (テナント分離), một sự cố bảo mật và pháp lý có thể chấm dứt hợp đồng và làm mất niềm tin không bao giờ lấy lại được. Bài này trình bày cách một agent QA end-to-end kiểm những bất biến đó bằng cách kết hợp UI, API và kiểm dữ liệu, trong khuôn khổ guardrail chặt chẽ.
Xuyên suốt bài, ta bám hai bất biến trụ cột. Thứ nhất là cô lập tenant: mọi truy vấn dữ liệu phải được lọc theo tenant_id, không có ngoại lệ, và không người dùng nào của tenant này thấy được dữ liệu của tenant khác. Thứ hai là đúng quyền theo RBAC: quyền thực tế của một người dùng không bao giờ vượt quá quyền mà role của họ cho phép, và cũng chỉ trong tenant của họ. Bên cạnh đó là bất biến về billing: đo lường (metering) đúng, tính tiền đúng, và mọi thao tác thanh toán phải idempotent để một lần bấm không bao giờ tính tiền hai lần. Ba nhóm bất biến này là oracle mà agent QA sẽ liên tục kiểm.
2. Cô lập tenant làm oracle: bất biến 'không rò dữ liệu chéo'
Cô lập tenant là oracle mạnh nhất và cũng khó kiểm đầy đủ nhất, vì nó là một bất biến phủ định: không được có bất kỳ đường nào để dữ liệu của tenant này lọt sang tenant khác. Không thể liệt kê hết mọi đường, nên chiến lược là kiểm theo lớp. Ở tầng dữ liệu, ta khẳng định mọi truy vấn đều mang điều kiện tenant_id. Ở tầng API, ta thử đăng nhập bằng tài khoản tenant A rồi cố tình yêu cầu định danh tài nguyên của tenant B, và oracle là hệ thống phải trả 403 hoặc 404 — tuyệt đối không phải 200 kèm dữ liệu. Ở tầng UI, ta kiểm rằng không màn hình nào, không bộ lọc nào, không đường dẫn sâu nào để lộ dữ liệu chéo. Agent QA đi qua cả ba lớp cho cùng một bất biến.
// tests/isolation/cross-tenant.spec.ts — oracle PHỦ ĐỊNH: không rò chéo
import { test, expect } from '@playwright/test';
import { db } from '../support/db';
test('user tenant A KHÔNG truy cập được tài nguyên tenant B', async ({ request }) => {
const aToken = await loginAs('alice@acme.test'); // tenant A
const bInvoice = await db.oneInvoiceOf('tenant-B'); // 1 hoá đơn thật của B
// Cố tình gọi bằng token A, xin resource của B
const res = await request.get(`/api/invoices/${bInvoice.id}`,
{ headers: { Authorization: `Bearer ${aToken}` } });
// Oracle: PHẢI bị chặn. 200 kèm data = sự cố nghiêm trọng.
expect([403, 404]).toContain(res.status());
if (res.status() === 200) {
throw new Error('TENANT LEAK: A đọc được hoá đơn của B — sự cố nghiêm trọng');
}
});Chú ý test trên dùng 404 hoặc 403 làm oracle, và có lý do tinh tế. Trả 403 (cấm) tiết lộ rằng tài nguyên tồn tại nhưng bạn không có quyền — đôi khi đây đã là rò rỉ thông tin. Nhiều hệ thống multi-tenant cẩn thận chọn trả 404 (không tồn tại) cho tài nguyên của tenant khác, để người dùng tenant A thậm chí không biết một hoá đơn của B có tồn tại hay không. Việc chọn 403 hay 404 là một quyết định thiết kế bảo mật mà QA cần biết để đặt oracle đúng. Điều tuyệt đối cấm là 200 kèm dữ liệu: đó là rò rỉ thật sự, và test phải hét lên rõ ràng khi gặp.
3. Ma trận RBAC: kiểm quyền theo role × hành động × tenant
RBAC (kiểm soát truy cập theo vai trò) trong multi-tenant là một không gian tổ hợp: mỗi role (admin, editor, viewer) có một tập hành động được phép, và điều đó phải đúng trong tenant của người dùng và sai ngoài tenant đó. Cách kiểm hiệu quả là dựng một ma trận: hàng là role, cột là hành động, ô là kết quả kỳ vọng (cho phép hay từ chối). Ma trận này chính là oracle — nó phát biểu rõ ràng ai được làm gì. Điều quan trọng là ma trận phải kiểm cả hai chiều: viewer phải bị từ chối khi cố xoá (quyền không được vượt role), và admin phải được phép khi cần (quyền không được thiếu). Nhiều bug bảo mật đến từ việc chỉ kiểm 'admin làm được' mà quên kiểm 'viewer không làm được'.
// tests/rbac/matrix.spec.ts — ma trận role × action, kiểm CẢ HAI chiều
const MATRIX: Record<string, Record<string, boolean>> = {
admin: { read: true, create: true, update: true, delete: true, billing: true },
editor: { read: true, create: true, update: true, delete: false, billing: false },
viewer: { read: true, create: false, update: false, delete: false, billing: false },
};
for (const [role, actions] of Object.entries(MATRIX)) {
for (const [action, allowed] of Object.entries(actions)) {
test(`${role} ${action} -> ${allowed ? 'ALLOW' : 'DENY'}`, async ({ request }) => {
const token = await loginWithRole(role, 'tenant-A');
const res = await callAction(request, action, token);
if (allowed) expect(res.status()).toBeLessThan(300); // được phép
else expect([403, 404]).toContain(res.status()); // bị từ chối
});
}
}Sinh test từ một ma trận có hai lợi ích lớn. Thứ nhất, nó đảm bảo tính đầy đủ: mọi ô role × action đều được kiểm, không sót trường hợp 'viewer xoá'. Thứ hai, bản thân ma trận trở thành tài liệu sống về chính sách phân quyền — khi ai đó muốn biết editor có được xoá không, họ đọc ma trận chứ không phải đọc code. Đây là nơi AI hữu ích: bạn mô tả chính sách RBAC bằng ngôn ngữ tự nhiên, AI dựng ma trận đầu tiên, rồi bạn — con người — review từng ô, đặc biệt các ô 'từ chối', vì đó là nơi bug bảo mật ẩn nấp. AI dựng khung nhanh; con người xác nhận đúng chính sách nghiệp vụ.
❓ Vì sao kiểm 'viewer KHÔNG xoá được' lại quan trọng ngang 'admin xoá được'?
Vì lỗ hổng leo thang quyền (privilege escalation) nằm ở chiều phủ định. Nếu chỉ kiểm 'admin xoá được', một bug cho phép viewer cũng xoá được sẽ lọt qua hoàn toàn — pipeline vẫn xanh vì admin vẫn hoạt động. Chiều 'ai KHÔNG được làm gì' mới là nơi bảo mật thật sự sống. Một hệ thống mà mọi role đều làm được mọi thứ vẫn 'chạy đúng' theo nghĩa chức năng, nhưng là thảm hoạ bảo mật. Vì thế ma trận RBAC phải cân bằng: mỗi ô 'cho phép' đi kèm các ô 'từ chối' tương ứng, và các ô từ chối thường là nơi tôi soi kỹ nhất.
4. Billing và metering: đúng số, đúng lần, idempotent
Billing trong SaaS là nơi lỗi biến trực tiếp thành tiền mất hoặc khách giận. Có ba nhóm bất biến. Thứ nhất là metering đúng: nếu tính tiền theo lượng dùng (số API call, số user, dung lượng), thì con số đo phải khớp thực tế — không tính thiếu (mất doanh thu) cũng không tính thừa (mất niềm tin). Thứ hai là tính tiền đúng: áp đúng gói, đúng đơn giá, đúng giảm giá, đúng thuế. Thứ ba, và thường bị bỏ sót nhất, là idempotency của thao tác thanh toán: một webhook thanh toán bị gửi lại, một người dùng bấm 'thanh toán' hai lần vì mạng chậm — hệ thống tuyệt đối không được tính tiền hai lần. Đây chính là bất biến idempotency (冪等性) trong ngữ cảnh billing.
// tests/billing/idempotency.spec.ts — webhook gửi lại KHÔNG tính tiền 2 lần
import { test, expect } from '@playwright/test';
test('webhook thanh toán trùng eventId -> chỉ ghi nợ MỘT lần', async ({ request }) => {
const eventId = 'evt_' + crypto.randomUUID();
const payload = { eventId, tenantId: 'tenant-A', amount: 4900, type: 'invoice.paid' };
// Gửi cùng một webhook 3 lần (mô phỏng retry của cổng thanh toán)
for (let i = 0; i < 3; i++) {
await request.post('/webhooks/billing', { data: payload,
headers: { 'x-signature': sign(payload) } });
}
// Oracle: chỉ đúng 1 bản ghi thanh toán cho eventId này
const charges = await request.get(`/api/charges?eventId=${eventId}`);
expect((await charges.json()).length).toBe(1);
// Oracle 2: số dư tenant chỉ bị trừ 4900 MỘT lần
const acct = await request.get('/api/tenants/tenant-A/account');
expect((await acct.json()).lastCharge).toBe(4900);
});
💬 Bình luận (0)
Chưa có bình luận nào. Hãy là người đầu tiên!