Skip to content
DevRecall
← Roadmap
14

Multi-tenancy & Data Isolation

drafted

Silo/pool/bridge, tenant context propagation, Postgres RLS, tenant-aware cache, noisy neighbor, per-tenant migration, chống cross-tenant leak.

0/45 rated · mastery 0%

Bài học

10 bài · ~142 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~18 phút· 5 câuverify

    Mô hình tenancy: silo, pool, bridge

    Tenant là gì trong một nền tảng B2B2C, ba mô hình silo/pool/bridge áp dụng theo từng tầng (compute, storage, search), schema-per-tenant và cái giá của 2.000 schema (đo thật trên Postgres 18), cách chọn mô hình theo số liệu, và cách trả lời khi khách enterprise đòi database riêng.

  2. 02~14 phút· 6 câuverify

    Tenant resolution, membership và authorization

    Tenant của một request đến từ đâu (subdomain, custom domain, JWT claim, API key, header) và nguồn nào tin được, active tenant khi user thuộc nhiều tenant, bảng memberships và role theo tenant, ba cấp B2B2C (platform staff, merchant user, end customer), 404 thay vì 403, vì sao UUID không chữa IDOR, và sửa một lỗi IDOR thật ở nhiều lớp (demo Express chạy thật).

  3. 03~12 phút· 4 câuverify

    Tenant context với AsyncLocalStorage

    Mang tenant từ middleware xuống repository mà không truyền tham số qua mọi hàm: AsyncLocalStorage run vs enterWith, bốn cách context bị mất hoặc bị nhầm sang tenant khác (đo thật trên Node 24), job và Kafka consumer tự dựng lại context, nightly job qua nhiều tenant không để một tenant lỗi chặn cả vòng, và per-tenant config/feature flag nạp vào context.

  4. 04~14 phút· 4 câuverify

    Thiết kế schema pooled và data access layer

    Schema dùng chung cho nhiều tenant: tenant_id ở mọi bảng và đầu mọi index, composite FK chặn tham chiếu chéo tenant, unique theo tenant và covert channel của unique toàn cục (chạy thật), nơi đặt tenant filter (query thủ công, repository, ORM global filter, RLS) và cách ép nó trong CI, cùng time zone và tiền tệ theo tenant.

  5. 05~13 phút· 5 câuverify

    Row-Level Security trong PostgreSQL

    RLS cho bảng pooled: ENABLE vs FORCE, policy USING vs WITH CHECK, truyền tenant bằng set_config(..., true) trong transaction, bẫy chuỗi rỗng của current_setting sau lần set đầu tiên, ai bypass RLS (superuser, BYPASSRLS, owner, view mặc định, SECURITY DEFINER) và vì sao session-level SET làm lộ dữ liệu qua connection pool. Mọi output chạy thật trên PostgreSQL 18.6.

  6. 06~13 phút· 4 câuverify

    RLS ở production: hiệu năng, rollout và SQL Server

    Chi phí thật của RLS (EXPLAIN ANALYZE trên 2 triệu dòng): policy đơn giản gần như miễn phí, policy subquery chậm 50 lần, hàm không LEAKPROOF làm mất expression index; round-trip của withTenantTx; rollout RLS vào codebase lớn không làm sập production; lập trường RLS vs filter ở app; và RLS của SQL Server với SESSION_CONTEXT (chạy thật trên SQL Server 2022: sysadmin cũng bị lọc, node-mssql không reset connection).

  7. 07~13 phút· 5 câuverify

    Kênh phụ: cache, search và file storage

    Những kho dữ liệu không có RLS: Redis key thiếu tenant làm merchant B thấy banner của merchant A (tái hiện thật), wrapper cache theo tenant với generation key để invalidate cả tenant trong O(1), CDN và Cache-Control, Elasticsearch cho nhiều tenant (index riêng, shared index, routing, filtered alias: chạy thật trên ES 9.5 cho thấy IDF lệch, _id trùng và alias không chặn ghi), và object storage theo prefix với presigned URL.

  8. 08~14 phút· 5 câuverify

    Noisy neighbor, fairness và cost attribution

    Cái giá của pool: một tenant chiếm tài nguyên chung. Tìm ra tenant ồn ào (pg_stat_activity có comment tenant, còn pg_stat_statements gộp mất tenant: đo thật trên PG 18), rate limit và quota theo tenant, fair queue thay FIFO (mô phỏng: shop nhỏ chờ 20 giây xuống 0), giới hạn concurrency theo tenant xuyên nhiều pod bằng Redis, xử lý sự cố import sáng thứ Hai, và gán chi phí cho tenant mà không làm nổ cardinality của Prometheus.

  9. 09~16 phút· 4 câuverify

    Vận hành per-tenant: migration, di chuyển tenant, connection và xoá dữ liệu

    Những việc chỉ tồn tại khi có nhiều tenant: migration cho N schema/DB bị lỗi ở tenant thứ 13 (chạy thật: 19 lên version mới, 1 kẹt lại, không có trạng thái dở dang), chuyển tenant lớn sang DB riêng bằng logical replication có row filter (chạy thật: bẫy replica identity làm UPDATE ở nguồn lỗi), pool per DB làm cạn max_connections và LRU pool, và xoá/export toàn bộ dữ liệu một tenant một cách chứng minh được, kể cả crypto-shredding cho backup.

  10. 10~14 phút· 4 câuverify

    Kiểm thử leak, incident response và thiết kế isolation end-to-end

    Chứng minh không có cross-tenant leak bằng hệ thống thay vì code review: fixture hai tenant, leak test tự sinh từ route table (chạy thật: bắt được IDOR và 403 lộ tồn tại), test tầng DB bằng role app, lint cho kênh phụ và tenant honeypot ở production; quy trình xử lý sự cố 'khách thấy hoá đơn của công ty khác'; kể một bug isolation theo STAR; và thiết kế isolation qua mọi lớp của một nền tảng B2B2C.

Tóm tắt & cheat sheet của track

TL;DR

  • Multi-tenancy = một hệ thống phục vụ nhiều tenant, dữ liệu và cấu hình cách ly logic hoặc vật lý. Ba mô hình (thuật ngữ AWS SaaS Lens): silo (tài nguyên riêng mỗi tenant), pool (dùng chung, phân biệt bằng tenant_id), bridge (lai: ví dụ app chung, DB/schema riêng, hoặc tenant lớn silo còn lại pool).
  • Lỗi đắt nhất không phải chậm mà là cross-tenant leak. Phòng thủ nhiều lớp: tenant context từ nguồn tin cậy (token/subdomain đã xác thực) → propagate (AsyncLocalStorage) → data layer bắt buộc filter (repository/RLS) → mọi kênh phụ (cache key, search index, queue, file storage, log) cũng mang tenant.
  • Noisy neighbor là cái giá của pool: quota, rate limit per tenant, fair queuing, tách tenant lớn ra.
  • Vận hành: migration phải chạy cho mọi tenant (hoặc mọi schema/DB), onboarding/offboarding, xoá dữ liệu tenant, cost attribution.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
TenantĐơn vị khách hàng được cách ly (công ty, merchant, shop)Merchant A và B cùng chạy trên platform
SiloMỗi tenant một stack/DB riêngEnterprise tenant có DB riêng, key KMS riêng
PoolMọi tenant chung table, cột tenant_idorders(tenant_id, id, ...)
BridgeKết hợp: chung compute, tách storage; hoặc tier-basedFree/Pro ở pool, Enterprise ở silo
Schema-per-tenantMột DB, mỗi tenant một schematenant_42.orders
Tenant resolutionXác định tenant của requestSubdomain acme.app.com, claim tenant_id trong JWT
Tenant contextTenant hiện tại đi theo request qua mọi lớpAsyncLocalStorage<{ tenantId }>
Row-Level SecurityDB tự thêm điều kiện theo policyUSING (tenant_id = current_setting('app.tenant_id')::bigint)
Tenant-aware cache keyKey cache luôn chứa tenantt:42:product:123:v3
Noisy neighborMột tenant chiếm tài nguyên chung làm tenant khác chậmImport 5M sản phẩm làm DB CPU 100%
Cross-tenant leakTenant A thấy/sửa được dữ liệu tenant BGET /orders/991 không check tenant

Cơ chế hoạt động

Tenant context từ edge tới database

Chọn mô hình isolation

Trade-offs

Tiêu chíSilo (DB per tenant)Bridge (schema per tenant / tier)Pool (shared table)
IsolationMạnh nhất, blast radius nhỏTrung bìnhLogic — phụ thuộc code/RLS
Chi phí / tenantCaoTrung bìnhThấp nhất
OnboardingChậm (provision)Trung bìnhTức thì (insert row)
MigrationN lần, dễ lệch versionN schema, catalog phìnhMột lần
Noisy neighborGần như khôngCòn chung computeRủi ro cao nhất
Backup/restore 1 tenantDễKhá dễKhó (phải trích xuất theo tenant_id)
Cross-tenant analyticsKhóKhóDễ
Connection poolBùng nổ số poolCó thể dùng search_path (cẩn thận pooler)Một pool

Pitfalls

  • Lấy tenant_id từ body/query param/header do client gửi mà không đối chiếu với identity đã xác thực.
  • Chỉ filter theo tenant_id ở list endpoint, quên ở GET /:id, UPDATE, DELETE, export, webhook, admin tool.
  • Cache key, Elasticsearch query, S3 prefix, queue message thiếu tenant.
  • SET app.tenant_id (session-level) sau PgBouncer transaction mode → context dính sang request khác.
  • Owner của table/superuser bypass RLS; view tạo bởi owner cũng bypass (trừ security_invoker, PG 15+).
  • Background job/consumer không có HTTP request → tenant context rỗng → code "fallback" chạy không filter.
  • Unique constraint toàn cục (email) thay vì theo tenant (tenant_id, email), và lỗi unique có thể lộ sự tồn tại dữ liệu của tenant khác.
  • Log/trace không có tenant_id → không điều tra được incident theo tenant.

Cheat sheet

  • Nguồn tenant đáng tin: claim trong token đã verify / subdomain map qua DB + kiểm tra membership. Không tin header thô.
  • Propagate: AsyncLocalStorage.run() ở middleware; job/message mang tenantId trong payload rồi run() lại ở consumer.
  • DB: tenant_id trong mọi table nghiệp vụ, đầu mọi index, trong unique và composite FK (tenant_id, parent_id).
  • RLS: ENABLE + FORCE ROW LEVEL SECURITY, app role không phải owner, set_config('app.tenant_id', $1, true) trong transaction, policy USING + WITH CHECK.
  • Cache: t:{tenantId}:...; ES: filter tenant_id bắt buộc (alias có filter / routing) hoặc index per tenant cho tenant lớn.
  • Noisy neighbor: rate limit & quota per tenant, fair queue, statement_timeout, tách tenant lớn.
  • Test: tạo 2 tenant trong mọi integration test, gọi mọi endpoint bằng token tenant A với id của tenant B → expect 404.

Personal notes (Notion)

Ghi chú cá nhân — có thể sai hoặc lỗi thời, dùng để tham khảo.

Interview Questions

Showing 45 / 45

// BAD
const key = `price:${sku}`;
// GOOD
const key = `t:${ctx.tenantId}:price:${sku}`;
import { AsyncLocalStorage } from 'node:async_hooks';
type Ctx = { tenantId: string; userId: string };
export const als = new AsyncLocalStorage<Ctx>();

app.use((req, res, next) => {
  const { tenantId, sub } = req.auth; // đã verify JWT + membership
  als.run({ tenantId, userId: sub }, next);
});

export function currentTenant(): string {
  const ctx = als.getStore();
  if (!ctx) throw new Error('No tenant context');
  return ctx.tenantId;
}
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint)
  WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::bigint);

-- per request, inside a transaction:
BEGIN;
SELECT set_config('app.tenant_id', '42', true);  -- true = local to transaction
SELECT * FROM orders WHERE id = 991;              -- only visible if tenant 42
COMMIT;
export async function withTenantTx<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query(`SELECT set_config('app.tenant_id', $1, true)`, [currentTenant()]);
    const r = await fn(client);
    await client.query('COMMIT');
    return r;
  } catch (e) { await client.query('ROLLBACK'); throw e; }
  finally { client.release(); }
}
ALTER TABLE orders ADD CONSTRAINT orders_tenant_id_id UNIQUE (tenant_id, id);
ALTER TABLE order_items
  ADD CONSTRAINT order_items_order_fk
  FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id);
consumer.run({
  eachMessage: async ({ message }) => {
    const tenantId = message.headers?.['tenant-id']?.toString();
    if (!tenantId) throw new Error('Missing tenant-id header'); // → DLQ
    await als.run({ tenantId, userId: 'system' }, () => handle(message));
  },
});
CREATE FUNCTION sec.fn_tenant(@TenantId int)
  RETURNS TABLE WITH SCHEMABINDING
AS RETURN SELECT 1 AS ok WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS int);
GO
CREATE SECURITY POLICY sec.TenantPolicy
  ADD FILTER PREDICATE sec.fn_tenant(TenantId) ON dbo.Orders,
  ADD BLOCK PREDICATE sec.fn_tenant(TenantId) ON dbo.Orders AFTER INSERT
  WITH (STATE = ON);
const als = new AsyncLocalStorage<{ tenantId: string }>();
let lastTenant: string | undefined;            // "fallback"

app.use(async (req, res, next) => {
  const auth = await verify(req);              // awaits before entering context
  als.enterWith({ tenantId: auth.tenantId });
  lastTenant = auth.tenantId;
  next();
});

// legacy callback-based pool captured at module load
legacyPool.acquire((err, conn) => {
  const t = als.getStore()?.tenantId ?? lastTenant;
  conn.query(sql, [t]);
});
async function getHomepage(req: Request) {
  const key = `homepage:${req.query.locale ?? 'en'}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const data = await loadHomepage(currentTenant(), req.query.locale);
  await redis.set(key, JSON.stringify(data), 'EX', 300);
  res.setHeader('Cache-Control', 'public, max-age=300');
  return data;
}
-- migration run as role app_owner (table owner)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY p ON orders USING (tenant_id = current_setting('app.tenant_id')::bigint);
CREATE VIEW order_summary AS
  SELECT tenant_id, date_trunc('day', created_at) d, sum(total) FROM orders GROUP BY 1, 2;
// reporting service connects as app_owner
await db.query('SELECT * FROM order_summary');
router.get('/orders/:id', auth, async (req, res) => {
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
  if (!order.rows[0]) return res.status(404).end();
  res.json(order.rows[0]);
});

router.put('/orders/:id/address', auth, async (req, res) => {
  await db.query('UPDATE orders SET address = $1, tenant_id = $2 WHERE id = $3',
    [req.body.address, req.body.tenantId, req.params.id]);
  res.status(204).end();
});
SELECT (created_at AT TIME ZONE t.time_zone)::date AS local_day, sum(total_minor) AS revenue_minor
FROM orders o JOIN tenants t ON t.id = o.tenant_id
WHERE o.tenant_id = $1
GROUP BY 1 ORDER BY 1;