> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

# Multi-Tenant 架构最佳实践

> 本文是**通用方法论**。retaintive V1 实际落地口径 → 看 [`why-tenant.md`](/system-design/multi-tenant/why-tenant.md)(原上线方案已归档到 `docs/archive/multi-tenant-v1-plan/`)。Final / long-term refactor → 看 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md)。

***

## 一、北极星原则

**Tenant 是头等公民。Auth ≠ Isolation。Strategic 早做，Tactical 延后。**

Tenant 不是某张表上的一个外键。它是整个系统的第一维度 — schema、API、queue、observability、billing、admin、deploy region 都要按它切。Auth 只证明"用户合法登录"，不证明"这个用户只能看自己 tenant 的数据"。隔离必须在每一条数据访问路径上独立强制（DB query / background job / cache / log / search index）。

Strategic 决策（隔离模型、命名层级、tier 映射、provider 抽象）改起来贵 — 早。Tactical 实现（RLS policy、rate limit 数值、cache TTL）可以增量上 — 延后。

### 5 条硬规则

1. **隔离**：跨 tenant 绝对不串。同 tenant 内，跨 location 允许（一个 tenant 自己的多个门店 / 仓库 / 子团队可以互看）。
2. **计费**：默认 1 tenant = 1 billing customer。Stripe 接入后通常是 1 tenant = 1 Stripe Customer；manual invoice / reseller / enterprise contract 可以通过 `billing_accounts` 表表达(**Final phase only**,V1 不建,字段挂 `tenants` 表里),不要把 billing exception 混进数据隔离边界。**升级路径见下方 [Stripe 计费层升级路径](#stripe-计费层升级路径)** — BPO 真要分账时切 Stripe Connect Connected Accounts。
3. **BPO**：BPO（Business Process Outsourcing）公司是 tenant，end-clients 是 tenant\_clients（业务字段，不是隔离层）。BPO 一家代运营 50 个品牌 = 1 个 tenant + 50 个 tenant\_clients；不是 50 个 tenant。
4. **扩张**：tenant ↔ location 是 1:N。N 改字段不动 schema — 加店、关店、改 vertical 全是 INSERT/UPDATE 行，不是 ALTER TABLE。
5. **Placement ↔ 部署**：tier 是商业包装,placement 是基础设施路由。不要把 tier hardcode 成 Pool/Silo。少量高价值客户可以 all-Silo；大量低价 self-serve / free trial 才需要 Pool/Hybrid。

### Stripe 计费层升级路径

**Decision**:Stripe 计费抽象分 3 层 — V1 用最简单的 Customer 层,BPO 出现后才升 Connected Accounts。

**Why this**:Stripe 有 3 个层次的 billing model,**不同业务复杂度对应不同层**。一开始就上 Connect = over-engineering,但等真有 BPO 客户再迁也是工程量。提前预留字段才能平滑升级。

**3 层 Stripe 模型**:

| 层                                         | Stripe 对象                                  | 适用场景                                  | retaintive 何时启用                                                |
| ----------------------------------------- | ------------------------------------------ | ------------------------------------- | -------------------------------------------------------------- |
| **L1: Customer**                          | 1 个 Stripe Customer                        | 1 个客户付钱给 1 个 SaaS                     | **第一个 billing integration 的默认模型** — Sarah / Glow / United 都是这层 |
| **L2: Subscription with multiple Prices** | 1 Customer + N 个 Subscription              | 1 个客户多个 tier 混合(SMB + 加购 Pro feature) | 第一个客户想"基础套餐 + 加购通话分析"分开计费时                                     |
| **L3: Connect Connected Accounts**        | 1 Platform Account + N 个 Connected Account | BPO 平台代运营,end-client 自己付费             | 第一个 BPO 客户想给 end-client 单独开账时                                  |

**升级判定**:

```
当前 V1 identity-only 状态:
  tenants 表没有 billing_provider_* 字段

第一个 L1 Stripe integration:
  tenants.billing_provider_customer_id = 'cus_AAA'

未来 L3 触发:
  TaskUs (BPO) 想给 Airbnb / Stripe / Doordash (end-clients) 单独开账单
  → TaskUs 升级成 Stripe Connect Platform Account
  → 每个 end-client 在 retaintive 这边是 tenant_clients 行
  → 每个 end-client 在 Stripe 那边对应一个 Connected Account
  
  schema 变化:
    tenants                — 不变 (TaskUs 仍是 1 行)
    tenant_clients          — 新表,end-client 业务字段
    tenant_clients.billing_provider_account_id  — 这里挂 Connected Account ID
```

**字段预留**:当前 V1 identity-only **没有**落 billing 字段。第一个 L1 billing / Stripe integration 触发时,先在 `tenants` 或 future `billing_accounts` 加 `billing_provider_customer_id` (nullable 字段),表达默认的 1 tenant = 1 Stripe Customer。`billing_provider_account_id` 只属于未来 Connect/BPO 路径:若建 `tenant_clients` 或 `billing_accounts`,Day 1 就预留 nullable 字段,不要把 Connect account id 混进当前 identity-only V1。

**Why not 一开始就上 Connect**:Connect 比 Customer 复杂很多 — 需要处理 onboarding KYC、payout、reverse transfer、platform fee 等。3 个客户都是 L1 时上 Connect = 3 倍 onboarding 复杂度换 0 业务价值。

**Why not 等 BPO 来再改 flow**:加字段不可怕,改 billing flow 才贵。第一个 L1 billing integration 可以只落 `billing_provider_customer_id`,但 webhook / invoice pipeline 要把 Customer 与 Connect 的边界留出来;一旦写成"永远假设全是 Customer",未来要插 Connect 就等于重构 webhook handler。**预留扩展点是廉价保险**。

**Reference**:

- [Stripe Connect — Accounts v2 API (2025-12 update)](https://docs.stripe.com/connect/accounts-v2)
- [Stripe Multiple Separate Accounts](https://docs.stripe.com/multiple-accounts)
- [Building Multi-Tenant SaaS with Stripe Connect (2026)](https://dev.to/diven_rastdus_c5af27d68f3/building-a-multi-tenant-saas-with-stripe-connect-in-2026-jjn)

**Consequences**:

- V1 webhook handler 只接 `customer.subscription.*` events;预留 `account.*` event handler 的 hook 但不实现。
- L1 → L3 升级时,**单个 tenant 不能从 Customer 转 Connected Account 直接迁数据** — 必须 Stripe 端新建 Connected Account + retaintive 端 link 到 tenant\_clients。这就是为什么 `tenant_clients.billing_provider_account_id` 是 nullable 而不是 inherits from `tenants.billing_provider_customer_id`。

***

## 二、Strategic — 架构设计层 (改不了的)

### 隔离模型谱系

**Pattern**：用 Pool / Bridge / Silo / Hybrid 四档表示隔离强度。选择哪档不是“架构高级程度”问题,而是 business model / compliance / cost / automation 成熟度问题。

**Why this**：

- Pool（共享表 + tenant\_id + RLS）— compute 共享,单位成本最低。代价是每条数据访问路径都必须严守 tenant scope,RLS / query audit / tenant-aware cache 必须成熟。
- Silo（每 tenant 独立 DB / 独立 Neon branch/project）— 合规友好（PCI / HIPAA / SOC2 audit 范围小）、性能可预测、blast radius 限于单 tenant。代价是每 tenant 都有独立 endpoint / secret / migration / observability 维度。
- Bridge（共享 cluster + 每 tenant 独立 schema）— PG 的 schema-per-tenant，介于两者之间。schema 数量上限 \~1000 后元数据查询变慢。
- Hybrid — 同一个 SaaS 里部分 tenant 走 Pool、部分 tenant 走 Silo。通常在同时服务低价 self-serve 和高价值 enterprise 时才值得。

**When all-Silo works**：客户数量少、客单价高、合规叙事重要、且 branch/project provisioning / migration / secret / monitoring **自动化到位**时,all-Silo 是合理默认。

> **硬前提:没自动化的 all-Silo 5 个客户能撑,10 个开始崩**。手工 `pg_dump` + 手工切 secret + 手工跑 schema migration,在 3 个客户时还行,10 个就 human error 频出(漏跑 migration → schema drift / 拷错 secret → 客户连别人的 DB)。详见下方 [Silo 自动化必备的 7 件事](#silo-自动化必备的-7-件事)。

**When all-Silo hurts**：客户数量很多且价格很低时,每个 tenant 独立 compute / endpoint / secret / migration / observability 的固定成本会吃掉毛利。例:大量低价 self-serve tenants 如果都要独立 always-on compute、独立 secret、独立 migration fan-out 和独立 monitoring，固定成本会比共享 Pool 高很多；这时 Pool 通过共享 compute 才有意义。

**Why not 全 Pool**：大客户合规 / 性能 / blast radius 要求满足不了。一个 noisy tenant 把 connection pool 占满全停服。

**Reference**：

- AWS SaaS Tenant Isolation Strategies whitepaper — Pool/Silo 命名来源
- AWS Serverless SaaS Reference Architecture — basic/standard/premium = Pool, platinum = Silo
- Azure Multitenant Solution Architecture — 同样 Pool/Silo 谱系

**Consequences**：

- Placement 决定 connection string。code 里必须用 tenant resolver 拿 connection，不能 hardcode DB URL。
- 如果支持 Pool / Silo 互转,必须有自动化迁移工具([计费 tier ↔ 隔离强度](#计费-tier--隔离强度))。如果产品选择 all-Silo,迁移工具可以先聚焦 `createTenant` / `promoteTenantToSilo` / `archiveTenant`。

### 命名与层级

**Decision**：`tenants → locations → phone_lines`（或 `users / orders / tickets` 等业务实体）。需要 BPO 场景再加 `tenant_clients` 作为业务字段挂在 `locations` 或独立表。

**Why this**：

- `tenant` 是计费 + 隔离单位（通常 1:1 with Stripe Customer；manual billing 阶段可以先为空）。
- `location` 是运营单位（一家店 / 一个仓库 / 一个 region office）。同 tenant 跨 location 允许互看。
- `phone_lines` 是设备 / 资源单位。一个 location 多个 phone\_line 很常见（前台 / 销售 / 客服分机）。
- `tenant_clients`（可选）— BPO 给 end-client 提供服务时，end-client 是业务字段（"这条 ticket 属于哪个 end-client"），不是隔离层（BPO 员工要能跨 end-client 查询和汇总）。

**Why not `franchise_id` / `site_id` / `account_id` 当一级隔离**：这些是某个 vertical 或某个 provider 的本地概念（franchise 是连锁概念，site 是 RingCentral 概念，account 是 OAuth 概念）。绑死了换 vertical / 换 provider 就改 schema。

**Why not 把 BPO 的 end-client 当 tenant**：BPO 业务模型是"一个甲方运营多个品牌"，账单付一份。end-client 当 tenant 意味着每个 end-client 一个 Stripe Customer — 跟 BPO 实际付款关系不符。

**Reference**：

- Slack — `workspace`（tenant 等价）+ `channel`（location 等价的子粒度）
- Notion — `workspace` + `teamspace`
- Linear — `workspace` + `team`
- Salesforce — `Account`（tenant 等价，付款方）+ `Contact`（end-client 等价，业务字段）
- AWS Serverless SaaS reference — 统一用 `tenantId` 命名

**Consequences**：

- 所有 schema 字段名必须 `tenant_id`，不是 `org_id` / `account_id` / `customer_id`。混用就是混乱的开始。
- 业务字段（`tenant_client_id`、`location_id`）可以加 index，但不参与 RLS policy。RLS 只看 `tenant_id`。

### 权限 resolve 顺序 (tenant\_members vs location\_members)

**Decision**:两张成员表都存在,但**职责不同**。每个请求按下面 3 步顺序判,任何一步 fail 就 reject。

**Why this**:Glow Beauty 老板登录看 3 家店 = tenant 级权限;SoHo 店的前台只看 SoHo = location 级权限。两张表同时存在,系统必须有明确 resolve 顺序,否则会出现 "员工是 location\_members 里 SoHo 的 staff,但 tenant\_members 里没他,他能不能登录?" 这类歧义。

**3 层 resolve 顺序**:

```
请求进来 (JWT → user_id, header → tenant_id, URL/body → location_id):

Step 1: tenant_members 校验 (这个 user 在这个 tenant 有 role 吗?)
  SELECT role FROM tenant_members
  WHERE user_id = $1 AND tenant_id = $2 AND status = 'active'
  → 没记录 → 403 Forbidden (这个 user 跟这个 tenant 没关系)
  → 拿到 tenant_role: 'owner' | 'admin' | 'viewer'

Step 2: location 维度过滤 (这个请求是单 location 还是跨 location?)
  if request 带 location_id:
    if tenant_role >= 'admin': 直接放行 (admin 跨 location 互看)
    if tenant_role == 'viewer':
      SELECT role FROM location_members
      WHERE user_id = $1 AND location_id = $3 AND status = 'active'
      → 没记录 → 403 (viewer 没 location-level 授权)
      → 拿到 location_role: 'manager' | 'staff'
  else (跨 location 聚合视图):
    require tenant_role >= 'admin' (viewer 不能跨 location 聚合)

Step 3: 业务表 query 自动注入隔离
  SET LOCAL app.tenant_id = $tenant_id (RLS 用)
  SELECT ... FROM contacts WHERE ... [AND location_id = $3 if 单 location]
```

**3 种 user 实例对照**:

| User          | tenant\_members            | location\_members             | 可见范围                                         |
| ------------- | -------------------------- | ----------------------------- | -------------------------------------------- |
| Glow 老板 Alice | `tenant=Glow, role=owner`  | (无)                           | Glow 全 3 店(聚合 / 单店 / 对比都可)                   |
| SoHo 经理 Bob   | `tenant=Glow, role=viewer` | `location=SoHo, role=manager` | 只能看 SoHo 单店;切到 Brooklyn 403                  |
| Glow 客服 Carol | `tenant=Glow, role=admin`  | (无)                           | Glow 全 3 店(admin 跨 location 但不算 owner,不能改账单) |
| Sarah 单店老板    | `tenant=Sarah, role=owner` | (无)                           | Sarah 唯一 location                            |

**Why not 单一张表合并 tenant + location role**:tenant 内可能有跨 location 的角色(老板 / 客服 / 财务 = 跨店),也有限制到单 location 的角色(前台 / 店长)。混一张表 → 每个 user 跨 location 都要 N 行,query 变复杂,permission audit 更难。

**Why not 只用 tenant\_members**:某些 vertical (大客户 / BPO) 需要把员工限制到具体 location,tenant\_members 不够。

**Why not 只用 location\_members**:tenant 级行为(改 Stripe 卡 / 改 tier / 改 SSO)没法挂 location,必须 tenant\_members。

**Consequences**:

- API endpoint 默认走 Step 1 + Step 3,跨 location query 自动走 Step 2 多一层。
- Admin impersonate(SaaS 提供方员工 debug) → 绕过 Step 1-2,但必须记 audit log。

#### 权限 / 访问 scope 支持矩阵 (V1 vs Final)

下表是这套设计在 V1 / Final 两个阶段能覆盖的访问场景。**字段级 / 行级 / 临时权限不在 multi-tenant scope 内**,真要做时单独设计。

| 场景                                             | V1 支持? | Final 支持? | 怎么实现                                                                                          |
| ---------------------------------------------- | :----: | :-------: | --------------------------------------------------------------------------------------------- |
| 1 个 tenant 多个 user 同时登录                        |    ✅   |     ✅     | `tenant_members`,1 tenant 对多 user                                                             |
| 不同 user 不同 tenant role(owner / admin / viewer) |    ✅   |     ✅     | `tenant_members.role`,中间件取出来塞 request context                                                 |
| Owner 能改 billing,viewer 不能                     |    ✅   |     ✅     | route handler 按 `tenantRole` 判断                                                               |
| 1 个 user 跨多个 tenant(连锁加盟商管几家店)                 |    ✅   |     ✅     | `tenant_members` 多行,前端 tenant switcher 切 active tenant                                        |
| 跨 location 限制(店员 Carol 只看 SoHo,不看 Brooklyn)    |    ✅   |     ✅     | V1 沿用现有 `store_members` 表(viewer + store\_members 共同限定);Final 改名 `location_members` 语义更清,功能等价 |
| 字段级脱敏(店员看不到电话号码全文)                             |    ❌   |     ❌     | 业务逻辑层做,需要业务表加 `pii_visible_to` 字段 + UI 渲染层脱敏                                                  |
| 行级"只看本人接的电话"                                   |    ❌   |     ❌     | 业务表加 `assigned_to` + 业务 query 加 `WHERE assigned_to = userId`                                  |
| 临时降权 / 临时升权(代班)                                |    ❌   |     ❌     | 单独 feature,需要 `tenant_member_overrides` 表 + TTL                                               |
| Admin impersonate(SaaS 员工 debug 客户问题)          |    ❌   |     ✅     | Final 加 `impersonation_audit` 表 + 显式切 context flow,见 §Security Admin impersonation            |

**Why "字段级 / 行级" 不在 multi-tenant scope**:multi-tenant 解决的是 "谁能看到哪个客户的数据" 这一层。客户内部不同员工看到的不同字段 / 不同行 = 业务层 RBAC,跟 tenant 隔离是正交的两件事。混在一起 design 会让 tenant 隔离变得复杂、行级权限也做不深。

### Silo 自动化必备的 7 件事

**Decision**:如果产品方向是 all-Silo / Silo-first,这 7 件事必须自动化。每一件没做就是手工卡点 — 5 个客户能撑,10 个 human error 频出。

**Why this**:Silo 模式把"运维成本"从"共享一份基础设施"变成"per-tenant N 份"。手工管 N 份的临界点是 5-10 个客户。WorkOS / Northflank 2026 SaaS 产线指南原话:"automated provisioning covers the full onboarding sequence, each step needs to be idempotent"。

**自动化 7 件**:

| # | 自动化项                                                 | 触发                                        | 不做的后果                                      |
| - | ---------------------------------------------------- | ----------------------------------------- | ------------------------------------------ |
| 1 | **Stripe Customer 创建 + Subscription 监听**             | 客户 sign up / 销售关单                         | 手工去 Stripe portal 建,容易漏字段;tier 升降不同步       |
| 2 | **Neon project / branch provision**                  | `tenants.status = 'provisioning'`         | 手工 Neon UI 创建,5 分钟一个;10 个客户 1 小时           |
| 3 | **Secret 写入 AWS Secrets Manager**                    | Neon 创建完                                  | 手工拷 DB URL,容易泄漏 / 拷错 / 落进日志                |
| 4 | **DB schema migration fan-out**                      | 每次 schema 改                               | N 个 Silo DB 都要跑;漏一个 = schema drift,出错时排查很痛 |
| 5 | **Smoke test + status=active 切换**                    | provision 完                               | 客户登录失败才发现没 ready;onboarding 体验差            |
| 6 | **Provider connection rebind**(OAuth / API key)      | tenant 换 CPaaS / 重新接入                     | 手工挪 OAuth token,容易漏 revoke 旧 token         |
| 7 | **Churn 软删 + 期满 cascade delete + drop Neon project** | `tenants.status = 'churned'` + 30/60/90 天 | 数据法务风险;GDPR 不合规;旧 Neon project 一直 idle 烧钱  |

**业界 stack 推荐**:

- **Terraform / Pulumi** — infra-as-code,project template 模板化(创 Neon project + IAM + secret 一起跑)
- **Stripe Webhook handler** — raw body 验签 + idempotent (用 Stripe event\_id 去重) + 入 SQS 异步处理
- **SQS + DLQ** — webhook 入队避免 Stripe 超时;失败重试 + 死信告警
- **Provisioning job table** (`tenant_provisioning_jobs`) — 每步 job 记 status + retry,失败可断点续传

**触发条件**:第 6 个 Silo 客户来之前必须 4 件做完(1, 2, 3, 4)。第 10 个之前必须 7 件全做完。否则 ops 撑不住。

**Reference**:

- [Northflank 2026 SaaS Platform Deployment Guide](https://northflank.com/blog/multi-tenant-saas-platform-deployment)
- [Kodekx — Practical Multi-Tenant SaaS Provisioning and Automated Onboarding](https://kodekx-solutions.medium.com/practical-multi-tenant-saas-provisioning-and-automated-onboarding-3bb6fdd3e84f)
- [Stripe Connect webhooks — raw body verification + idempotency](https://docs.stripe.com/connect/webhooks)

**Consequences**:

- 当前 V1 identity-only 不建 `tenant_provisioning_jobs`;等 onboarding 自动化 / self-serve / Silo provision 真正触发时,这张表是 7 件事的执行 ledger。前 1-2 个客户可以手工建档,但进入自动化前要把 job table 和幂等流程补上 — 后期接 Stripe webhook 时只是切 trigger,不改 pipeline。
- Future `getDbForTenant()` 的 `connection_secret_ref` 字段是自动化的 SoT — 自动化脚本写 secret + 写这个字段是同一个 atomic op。

**Anti-pattern**:

- "先手工建,等客户多了再自动化" — 客户增长不是线性的,客户 10 个来时一周内可能就崩。
- 自动化脚本不 idempotent — provision 失败重试就会建 2 个 Neon project。
- Stripe webhook 直接同步建 Neon project — Stripe 30s 超时,Neon provision 可能 2 min,会 timeout 重发 → 建多个 project。**永远 webhook → SQS → worker**。

### Tenant lifecycle

**Decision**：把 tenant 当成有状态机的实体。8 类生命周期变化都要有显式的代码路径 + audit log 记录。

**Why this**：默认状态 `active` 跑着没事。出问题都是状态转换没做好 — churn 之后数据还在被查询、M\&A 之后两 tenant 的数据混了、tier 降级之后 silo 还活着没回收。

**8 类生命周期变化**：

| # | 变化                                 | 改动级别 | 操作                                                                       |
| - | ---------------------------------- | ---- | ------------------------------------------------------------------------ |
| 1 | 新开 1 个 location                    | ⭐    | `INSERT INTO locations` 一行                                               |
| 2 | 关 1 个 location                     | ⭐    | `UPDATE locations SET status='closed'`（软删）                               |
| 3 | 改 vertical（同 tenant 多业务线）          | ⭐    | `INSERT INTO locations` 带新 vertical 字段                                   |
| 4 | 改付款人 / 换信用卡                        | ⭐    | `UPDATE tenants SET billing_provider_customer_id = ?`（Stripe webhook 触发） |
| 5 | 拆品牌独立账单                            | ⭐⭐   | `INSERT INTO tenants` 新建 + `UPDATE locations SET tenant_id = ?` 转移子集     |
| 6 | 升 tier basic→premium（同 Pool 内）     | ⭐    | `UPDATE tenants SET tier = ?`（Stripe webhook 触发）                         |
| 7 | placement 变化,例如 shared pool → Silo | ⭐⭐⭐⭐ | [计费 tier ↔ 隔离强度](#计费-tier--隔离强度)                                         |
| 8 | M\&A 合并 / churn / GDPR delete      | ⭐⭐⭐  | `UPDATE locations.tenant_id` 全改一边 + 软删 / cascade delete                  |

**状态机**：

```
provisioning → active → suspended → churned → deleted
                ↑          ↓
                └──── reactivated
```

- `provisioning` — 新建中，Stripe Customer 已创但 DB / silo 还没准备好。API 拒访问。
- `active` — 正常服务。
- `suspended` — 欠费 / 政策违规 / 临时停服。读可降级（只读模式），写禁。
- `churned` — 已退订。30 / 60 / 90 天保留期内可恢复。
- `deleted` — GDPR 触发或保留期结束。silo project drop，pool 数据 cascade delete。不可恢复。

**Why not 把状态藏在多个 boolean 列**（`is_active` / `is_paying` / `is_deleted`）：状态机不显式 = 转换规则散在代码各处 = 一定有 case 漏掉。一个枚举 + 转换函数收敛所有路径。

**Reference**：Stripe Subscription status 模型、AWS Control Tower account lifecycle。

**Consequences**：

- 每个 API endpoint 进来先 load tenant，check status，不是 `active` 就返回对应错误（`402 Payment Required` / `403 Suspended` / `410 Deleted`）。
- 所有 background job 也要 check status，churned tenant 的 cron 必须停。

### 计费 tier ↔ 隔离强度

**Decision**：tier 是计费/包装概念，placement 是部署/路由概念。两者用映射表关联，不是同一个字段。

**Why this**：

- Stripe 接入后可以是 tier 的 source of truth。V1 / enterprise contract 可以先 manual。
- Placement（shared pool / Neon branch / Neon project / region）由 Control Plane 决定。tier 可以影响 placement,但不能在业务代码里 hardcode。
- 升 tier 不一定触发 placement 变化。同 placement 内升降 tier 只改 entitlement / feature / rate limit。只有 placement 变化才需要迁移。

**Tier ↔ placement 映射示例**：

| Tier                              | Possible placement           | 说明              |
| --------------------------------- | ---------------------------- | --------------- |
| launch / manual                   | shared\_pool 或 neon\_branch  | V1 可先 manual 决策 |
| standard                          | shared\_pool 或 neon\_branch  | 看价格和客户数量        |
| premium / enterprise              | neon\_branch / neon\_project | 合规、隔离、性能叙事更强    |
| free trial / low-price self-serve | shared\_pool                 | 只有低价大规模时才值得     |

**Pool ↔ Silo 迁移流程**（只有支持 Pool/Hybrid 时才需要完整实现）：

Pool → Silo（升 platinum）：

1. Provision 独立 Neon project（自动化脚本，\~2 min）
2. 启动 dual-write — 应用层同时写 pool DB + silo DB
3. 用 `COPY (SELECT ... WHERE tenant_id = ?)` 或 store-scoped export 回填历史数据到 silo
4. 切读 — 该 tenant 所有 query 走 silo connection
5. 切写 + 清理 pool DB — `DELETE FROM ... WHERE tenant_id = ?`，cascade 删全部 pool 数据

Silo → Pool（降级或 churn）：

- 降级：反向 Step 1-5。**警告**：PCI / SOC2 强度降级，需 tenant 显式同意，记 audit log。
- Churn 软删：`tenants.status = 'churned'`。Silo project 保留，30 / 60 / 90 天宽限期后 cascade delete。
- GDPR delete：立即 cascade delete + drop silo project。不走宽限期。

**One function, all paths**：

```typescript
// 伪代码，universal pattern
migrateTenant(tenantId, fromPlacement, toPlacement, options)
// 覆盖：Pool↔Silo、跨 region、churn 软删、M&A 合并、GDPR delete
```

所有 placement 变化都走这个 function。不允许散在各 API handler 里手写 pg\_dump。

**Why not Stripe webhook 直接驱动迁移**：webhook 是 trigger，迁移是异步 long-running job（可能跑 1 小时）。Webhook handler 只写 `tenants.tier`，发 SQS message。后台 worker 跑迁移。

**Reference**：Stripe Subscription webhook docs、AWS DMS migration pattern、Neon project provisioning API。

**Consequences**：

- Placement 变化 SLA = 几小时到一天，不是即时。前端要显示"升级中"状态。
- 必须有 `dryRun` mode — 不真迁，只 print 影响的 row count + 估计耗时。
- 迁移失败要可重试 + 可回滚。

### Provider adapter 层

**Decision**：把外部 provider（telephony / payment / messaging / CRM）抽象成一层 adapter。Core business logic 不直接调 provider SDK。

**Why this**：

- 行业现实 — telephony 有 RingCentral / Twilio / Genesys / Five9 / Amazon Connect，payment 有 Stripe / Adyen / Braintree。同一类 provider 概念差很多（账号 / 子账号 / org / instance）。
- 客户带 provider 来 — enterprise 客户经常有自己的 Twilio / Genesys 合同，要求 SaaS 接入他们的实例。绑死一家就接不了。
- AI / MCP 友好 — adapter 层用统一抽象词（`PhoneNumber` / `Call` / `Message`），AI agent 能跨 provider 推理。

**抽象模型**：

| 概念        | 我们的统一词        | Provider 各家叫法                                                                         |
| --------- | ------------- | ------------------------------------------------------------------------------------- |
| 顶层账号      | `Account`     | Twilio Account / RC Account / Genesys Org / Five9 Domain / Amazon Connect Instance    |
| 子账号 / 子组织 | `Subaccount`  | Twilio Subaccount / RC Site / Genesys Site / Five9 Campaign / Connect Routing Profile |
| 电话号码      | `PhoneNumber` | E.164 字符串（统一）                                                                         |
| 通话        | `Call`        | telephony\_session\_id（抽象出来）                                                          |
| 消息        | `Message`     | provider 各自 ID（抽象出来）                                                                  |

**Why not 直接用 RingCentral SDK 满天飞**：换 provider 要改几百处。adapter 一层，换 provider = 加一个 adapter 实现。

**Why not 完全抽象到不可见**：abstraction leak 是常态。某些 provider-specific 字段（call recording URL / DTMF 行为）必须暴露。Adapter 允许 `provider_metadata` 字段透传原始 payload。

**Reference**：Twilio 自家也用 adapter pattern 支持多 CPaaS、Vonage 的 Provider Switch、CrushBank 的 ITSM connector pattern。

**Consequences**：

- Adapter interface 一旦稳定，加 provider = 实现 interface + 注册。Core 0 改动。
- Adapter 层就是 AI / MCP 接口的天然来源 — 统一抽象词直接变 MCP tool spec。

### API platform + AI / MCP

**Decision**：把 multi-tenant API 当 platform 设计，不是当内部 CRUD。2026 年这一层 = AI agent 的入口。

**Why this**：

- 抽象词稳定 = AI 友好。`POST /tenants/:id/locations` 比 `POST /createStoreForFranchise` 好太多。AI agent 推理路径短。
- Webhook + MCP 是同一类 — outbound event delivery。MCP server 暴露 tool spec 给 LLM，webhook 暴露 event spec 给客户后端。设计原则相同（idempotent、tenant-scoped、auth-required）。
- Self-serve API key + scope = enterprise 入场券。每个 tenant 能自助生成 API key，绑 scope（`read:calls` / `write:tasks` / `admin:locations`）。

**API 层 checklist**：

| 维度                    | 要求                                              |
| --------------------- | ----------------------------------------------- |
| 路由 tenant-scoped      | `/tenants/:tenantId/...` 或 header `X-Tenant-Id` |
| Auth tenant-bound     | API key / JWT 必须绑 tenant，不能跨 tenant 用           |
| Idempotent write      | 写操作支持 `Idempotency-Key` header（Stripe pattern）  |
| Pagination + filter   | 所有 list 都有 cursor + filter                      |
| Rate limit per tenant | 见 §3.3                                          |
| MCP tool spec         | 抽象词稳定后，自动生成 MCP server tool list                |

**Why not 等 AI agent 火了再加抽象**：抽象层不是临时贴的。API shape 一旦发布出去，外部客户 / AI agent 都在用，改成本指数级涨。Day 1 就当 platform 设计。

**Reference**：Stripe API design、Linear GraphQL schema、Anthropic MCP spec、Twilio webhook patterns。

**Consequences**：

- 内部 service-to-service 也走 API（不是直接 DB query）。这样 tenant scope 强制在 API 层，DB 层 RLS 是 defense in depth。
- 加 AI agent / MCP server 几乎 0 改动 — API 已经是抽象词层。

***

## 三、Tactical — 技术实现层 (AWS 5 pillar)

> 每个 pillar 写 **Principle**（原则）+ **When to enable**（何时启用）+ **Anti-pattern**（反模式）。Current / Delta / Owner / Next PR 在 [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md) 里填。

### Security

**Principle**：Defense in depth — 每一层独立强制 tenant scope。Auth → API → Service → DB,每层失效其他层仍能挡。

**Auth ≠ Isolation**:JWT 解出 `tenant_id` 之后,必须通过 tenant resolver 选对 placement / DB。光"用户登录了"不证明"用户只能看自己 tenant 的数据"。

#### Pool / Silo / Hybrid 三种模式下 RLS 的作用

RLS 是**数据库强制每条 query 必须满足 policy 的兜底**。在不同模式下用法不同:

| 模式                               | RLS 角色                                  | 强度     | 为什么                                                                                                                                                                  |
| -------------------------------- | --------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Pool**(共享 DB)                  | **必备 (Day 1)**                          | 强制     | N 个 tenant 在一张表,代码漏 `WHERE tenant_id` 就跨 tenant 泄漏。RLS 在 DB 层兜底,挡住所有这种 bug                                                                                           |
| **Silo**(独立 DB / Neon project)   | **Optional defense-in-depth**           | 推荐但不强求 | DB 物理只有 1 个 tenant 的数据,理论无 cross-tenant 泄漏可能。但 RLS 能挡 1 个 2nd-order bug:`getDbForTenant()` 路由错把 tenant\_A 连到 tenant\_B 的 DB → RLS policy 因 `app.tenant_id` 不匹配仍 deny |
| **Hybrid**(同一份代码同时跑 Pool + Silo) | **业务表 Day 1 写 RLS,Silo DB 上 policy 也开** | 必备     | 一份代码两种 DB,Pool 必须 RLS;Silo 也开 RLS 让 promote / 数据迁移时 schema 一致                                                                                                        |

#### Hybrid 怎么设计:一套代码,运行时按 tenant 路由

Hybrid 不是"Pool 和 Silo 两套代码",是**一套代码,运行时根据 tenant 路由到不同 DB**。3 个 design invariant:

```
1. 同一份业务 schema 跑在两种 DB 上
   ├─ Pool DB: 业务表带 tenant_id NOT NULL + RLS policy
   └─ Silo DB: 业务表也带 tenant_id NOT NULL + RLS policy (冗余但简化)

2. 每条请求进来:
   resolveTenantContext(req) → tenant_id
   getDbForTenant(tenant_id) → 拿对应 connection (Pool 或 Silo)
   db.execute("SET LOCAL app.tenant_id = '...'")   ← RLS 用这个 session var
   业务代码: SELECT * FROM contacts (RLS 自动按 tenant_id 过滤,代码无感)

3. 业务代码不知道 tenant 在 Pool 还是 Silo
   ├─ 代码读起来像 single-tenant app
   ├─ Pool 模式 RLS 强制隔离
   └─ Silo 模式 connection 自带边界 + RLS 兜底
```

**关键设计 invariant**:**业务表 schema 永远带 `tenant_id` 字段**(无论 Pool 还是 Silo)。Silo 模式下这个字段"看似冗余",但有 3 个好处:

1. 同一份 query / migration 在两种 DB 上都跑得通(代码一份)
2. 客户从 Pool promote 到 Silo 时数据格式不用转(直接 `COPY ... WHERE tenant_id`)
3. M\&A / 政策变化要把 Silo 客户合回 Pool 时,数据原生支持

> **不这样做的代价**:Silo 业务表不带 `tenant_id` → Pool/Silo 两套 schema 差异 → 两套 query 代码 → 客户在 Pool/Silo 之间流动几乎不可能。这是早期 Hybrid 设计最常见的 trap,业界 RLS articles 反复警告。

#### RLS 实施细节

- PG `CREATE POLICY` per table,policy 用 `current_setting('app.tenant_id')` 拿当前 session 的 tenant\_id。
- 应用层每个 connection 进来先 `SET LOCAL app.tenant_id = '...'`。**用 `SET LOCAL` 不是 `SET`** — `SET` 是 session 级,pgbouncer / connection pool 复用 connection 时上一次的值漏过来。`SET LOCAL` 是 transaction 级,跨 transaction 自动清理。
- Bypass RLS 只给特定 superuser role(migration、admin tool)。普通 app role 不能 bypass。

#### GDPR delete + Admin impersonation

- **GDPR delete**:cascade delete 所有 tenant 数据 + audit log 留 retention 字段("this tenant was deleted on YYYY-MM-DD by user X",不留 PII)。Silo placement 多一步 drop Neon project。
- **Admin impersonation**:所有 admin(包括 SaaS 提供方员工)跨 tenant 操作必须 (1) 显式触发 impersonate flow,(2) 记 audit log(who / when / which tenant / what action),(3) 时间窗口受限(默认 1 hour)。

**8 治理项中归这里的**:GDPR delete / Audit log per tenant(跟 Op Excellence 共享)/ Admin impersonation。

**When to enable**:

| 项                   | 时机                                                             |
| ------------------- | -------------------------------------------------------------- |
| RLS — Pool 模式       | Day 1                                                          |
| RLS — Silo 模式       | 客户 ≥ 3 时加上(防 resolver 路由 bug 误连别人 DB)                          |
| RLS — Hybrid 准备     | 业务表加 `tenant_id` 字段时同步加 RLS policy(为未来 Pool↔Silo migration 铺路) |
| Audit log           | 第一个付费客户之前                                                      |
| GDPR delete tool    | 第一个 EU tenant 之前                                               |
| Admin impersonation | 客服团队 onboard 第二个员工之前                                           |

**Anti-pattern**:

- "Auth 拿到 user\_id 就够了,DB 层不用过滤 tenant"。错。BOLA(OWASP API1)的标准场景。
- "RLS 太复杂,应用层过滤就行"。错。任何 dev 写 `SELECT * FROM contacts` 漏掉 WHERE 就是 cross-tenant 泄漏。RLS 是兜底。
- "Silo 模式不用 RLS,DB 已经隔离了"。**半对**。`getDbForTenant()` 路由 bug → 跨 tenant 数据访问;RLS 在那种 2nd-order failure 下兜底。
- "Silo 业务表不加 `tenant_id` 字段,反正是物理隔离的"。错。这把 Hybrid 路径堵死了 — Pool/Silo 之间客户流动 = 数据格式转换 = 几乎不可行。
- "admin tool 跨 tenant 不记 log,反正只有我们自己用"。错。员工内鬼 / 凭证泄漏后这是唯一追溯路径。

**Reference**:

- OWASP API Top 10 (BOLA / Broken Auth)、CVE-2024-10976(见 §4)、Stanford CS253
- [AWS Multi-tenant PostgreSQL RLS](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/)
- [Crunchy Data — Row Level Security for Tenants in Postgres](https://www.crunchydata.com/blog/row-level-security-for-tenants-in-postgres)
- [Nile — Shipping multi-tenant SaaS using Postgres RLS](https://www.thenile.dev/blog/multi-tenant-rls)

### Operational Excellence

**Principle**：每个 tenant 看起来像独立产品。Observability、deploy、runbook、kill switch 都能 per-tenant 操作。

**Per-tenant observability**：

- Log / metric / trace 都带 `tenant_id` tag。Datadog / Sentry / CloudWatch query 能按 tenant 切。
- Per-tenant dashboard — 客户 X 的 P50 / P99 / error rate 单独看得见，不混在全局聚合里。

**Audit log per tenant**：

- 所有 write / admin action / config change 入 audit log，按 tenant 分区（DB partition 或 log stream）。
- 客户合规要求时直接导出该 tenant 的 audit log，不需要全局 grep。

**Admin / migration tool**：

- Pool↔Silo 迁移工具([计费 tier ↔ 隔离强度](#计费-tier--隔离强度))— 一个 CLI / API，覆盖所有 placement 变化。
- Tenant lifecycle CLI — `tenant suspend <id>` / `tenant churn <id>` / `tenant restore <id>` 一行命令。

**Per-tenant kill switch**：

- 单 tenant 出问题（DDOS attack / bug 触发 / 客户主动暂停）能 kill 单 tenant 而不影响其他人。
- Implementation：`tenants.status = 'suspended'`，所有 API entry point check 一下。

**Lifecycle 状态机**:见 [Tenant lifecycle](#tenant-lifecycle)。状态机的 transition log 本身就是 audit log 的一部分。

**8 治理项中归这里的**：

- Lifecycle 状态机
- Audit log per tenant（也跟 Security 共享）
- Migration tool (Pool↔Silo)

**When to enable**：

- `tenant_id` tag — Day 1。所有 log / metric 加这个 tag 几乎 0 成本。
- Per-tenant dashboard — 第一个客户问"我的 P99 是多少"之前。
- Audit log — 第一个付费客户。
- Migration tool — 第一个 platinum tenant 之前（手动 pg\_dump 在客户 3 家以前能撑，第 4 家就崩了）。

**Anti-pattern**：

- 全局 dashboard 一锅炖，问"客户 X 慢吗" → 答不出。
- 手工 pg\_dump + manual 切换 — 第 5 次就出 human error。
- 没有 kill switch — 一个 tenant 的 bug 触发全平台限流。

**Reference**：AWS Well-Architected Operational Excellence pillar、Honeycomb 的 high-cardinality observability、Stripe 的 dashboard per Connect Account。

### Reliability

**Principle**：一个 tenant 不能把其他 tenant 拖死（noisy neighbor）。Rate limit、queue partition、circuit breaker 都按 tenant 切。

**Noisy neighbor 防护**：

- Per-tenant rate limit — token bucket，每 tenant 独立 quota。premium tier 更高 quota。
- Per-tenant queue partition — SQS FIFO MessageGroupId = tenant\_id，or 每 tier 一组独立 queue。
- Connection pool quota — pool 模式下，单 tenant 占连接数有 hard cap。

**DR failover**：

- `tenants.primary_region` 挂时，自动切到 `tenants.replica_region`。RTO / RPO 按 tier 区分（premium 5 min RTO，basic 1 hour 也接受）。
- Failover 必须可自动 + 可手动触发。手动触发是 region partial outage 时管理员判断的口子。

**Per-tenant feature flag**：

- LaunchDarkly / Unleash / 自建 — feature gate 能按 tenant 开关，不只是按 percentage。
- 用途：beta 客户先开新 feature、出问题的 tenant 单点关一个 feature 缓解。

**Provider unbind**：

- 客户离开时（churn / 换 provider），所有 provider 绑定要能干净解绑 — webhook 取消、token revoke、queue stop。
- 反向：客户来时 provider rebind 也是同一套流程，可重入。

**8 治理项中归这里的**：

- Per-tenant feature flags
- Region failover
- Provider unbind

**When to enable**：

- Per-tenant rate limit — 拿到第一个客户用 API 之后立刻。
- Per-tenant feature flag — 进 closed beta 之前。
- DR failover — 拿到第一个企业合同（一定会问 RPO / RTO）。
- Provider unbind — 第一个 churn 之前（必然会发生）。

**Anti-pattern**：

- 全局 rate limit — 一个 tenant 写脚本爆调，把其他 tenant 全 throttle。
- SQS queue 不 partition — 一个 tenant 灌 100 万 message，其他 tenant 的 message 卡 4 小时。
- Feature flag 只支持 percentage — 想给客户 X 单独开 / 关一个 feature 做不到。
- Churn 客户的 webhook 没取消 — 半年后还在收 event，被客户法务投诉"为什么还在调我们 API"。

**Reference**：AWS Well-Architected Reliability pillar、Stripe 的 tenant-aware rate limiting、Cloudflare 的 per-zone limits。

### Performance Efficiency

**Principle**：Pool 模式下，单 tenant 的 query / cache / connection 都不能挤压其他人。Hot tenant 是异常，不是常态。

**Caching strategy**：

- Cache key 必须含 `tenant_id`。`SELECT * FROM contacts WHERE id = ?` cache key = `contact:${tenantId}:${contactId}`。漏掉 tenant\_id = cross-tenant cache poisoning。
- Per-tenant cache size cap — 单 tenant 不能占 cache memory 超过 X%。
- TTL 按 data type 分（不是全局 60s）。

**Sharding（Pool 内部分片）**：

- 当 Pool DB 撑不住，按 `tenant_id` hash 分片到多个 DB instance。
- 一致性 hash + virtual node，加 / 减 shard 不需要全量迁移。
- 跨 shard query 禁（admin 才允许）。

**Connection pool strategy**：

- Per-tenant connection limit — 单 tenant 最多占 X 个 connection（pool size 20 时 cap 5）。
- pgbouncer transaction pooling — 多 tenant 共享 connection，配合 `SET LOCAL` 用。

**Query plan / index**：

- 所有 index 第一个字段 = `tenant_id`。`(tenant_id, created_at)` 不是 `(created_at)`。
- PG `partial index` 给大 tenant 单独优化（如 `WHERE tenant_id = 'big-tenant'`）。

**When to enable**：

- Cache key 含 tenant\_id — Day 1。这个不补救。
- Per-tenant cache cap — 第一次有 tenant 撑爆 cache memory。
- Sharding — 单 DB 撑不住的那一天（通常 1000+ tenant 或单表 1 亿+ 行）。
- Per-tenant connection limit — 第一次 noisy neighbor 把 pool 占满。

**Anti-pattern**：

- Cache key 不含 tenant\_id — A tenant 的数据缓存里给 B tenant 看到。`Cache poisoning` 是教科书漏洞。
- Index 不带 tenant\_id 第一字段 — `WHERE tenant_id = ?` 走 full scan，pool 模型下越加 tenant 越慢。
- 全局 connection pool 不限单 tenant — noisy tenant 直接 OOM。

**Reference**：PG 多租户索引最佳实践、Redis tenant-aware caching、AWS DynamoDB partition key design。

### Cost Optimization

**Principle**：成本能按 tenant 归因。Pool 模式下大家共享基础设施，但每 tenant 的 query 数 / storage 量 / network egress 能算出来 → 才能算单 tenant 单位经济。

**Per-tenant cost attribution**：

- DB query 量 / storage 量按 `tenant_id` 聚合（PG `pg_stat_statements` + tenant\_id tag、Neon usage API）。
- Network egress / CDN bandwidth 按 tenant 域名 / API key 切。
- AI / LLM token 用量按 tenant 计（每次 call OpenAI / Anthropic 记 token + tenant\_id）。

**Tier 经济学**：

- 每 tier 的成本 = `(共享基础设施 / tenant 数) + (per-tenant 变量成本)`。Pool 模式下共享基础设施 amortize，所以基础 tier 能定低价。
- Silo 模式（platinum）每 tenant 有 fixed 成本（独立 Neon project / 独立 connection pool）。pricing 必须覆盖。
- 临界点 — 算清楚 "Pool tenant 升到 Silo 的成本差" → 决定 platinum 起步价。

**Tier 越高 margin 越高 vs. 越低**：取决于 silo 模式的成本结构。如果 silo 的 fixed 成本被 platinum 价格 cover 还有富余 → high tier high margin。如果 silo 实际烧钱（独立 DR / 独立 24x7 oncall）→ basic tier 反而 high margin（amortize 充分）。两种都见过。

**When to enable**：

- Per-tenant cost attribution — 想做 tier pricing 之前。盲定价 = 不知道哪 tier 烧钱。
- Silo placement cost model — 第一个 platinum 客户之前。
- AI token attribution — 加 AI feature 之前。LLM 成本 spike 是 SaaS startup 最常见的 cost surprise。

**Anti-pattern**：

- 成本只看总账单，不按 tenant 归因 — 一个客户烧钱不知道是哪个。pricing 凭感觉。
- 给 enterprise 客户白嫖 silo（"反正 Neon free tier"）— 几个客户后 paid plan 上来一算账亏的。
- AI feature 不限 token — 一个客户的 agent 死循环烧 1000 美金 token，公司付账。

**Reference**：AWS Well-Architected Cost Optimization pillar、Stripe Connect platform fee model、Snowflake per-customer cost attribution。

***

## 四、业界参考 + 概念附录

### Pool / Bridge / Silo 一图速查

```
Pool（共享 DB + tenant_id + RLS）
  ↓ 单位成本最低
  ↓ 新 tenant 0 op
  ↓ blast radius = 整个 DB
  ↓
Bridge（共享 cluster + schema per tenant）
  ↓ 折中
  ↓ schema 数量 ~1000 后变慢
  ↓
Silo（独立 DB / 独立 cluster per tenant）
  ↓ 单位成本最高
  ↓ 新 tenant = provision 一套
  ↓ blast radius = 单 tenant
```

### 业界对照表

| 厂商                            | 顶层（tenant 等价） | 二级（location 等价） | 三级 / 业务字段              | 隔离模型                                           |
| ----------------------------- | ------------- | --------------- | ---------------------- | ---------------------------------------------- |
| Slack                         | Workspace     | Channel         | —                      | Hybrid（大客户 Enterprise Grid = 独立 Workspace 联邦）  |
| Notion                        | Workspace     | Teamspace       | Page                   | Pool                                           |
| Linear                        | Workspace     | Team            | Project                | Pool                                           |
| Stripe                        | Customer      | —               | Subscription / Invoice | Pool（账户层）+ Silo（Connect 平台账户）                  |
| Salesforce                    | Account       | —               | Contact / Opportunity  | Hybrid（大客户独立 Org）                              |
| WorkOS                        | Organization  | Directory       | User                   | Pool                                           |
| AWS Serverless SaaS reference | tenantId      | —               | —                      | basic/standard/premium = Pool, platinum = Silo |
| Twilio                        | Account       | Subaccount      | PhoneNumber            | Hybrid（Subaccount 隔离 sub-tenant）               |
| RingCentral                   | Account       | Site            | Extension / Phone      | Provider 层级（不是 SaaS 多租户）                       |
| Genesys                       | Org           | Site            | Queue                  | Provider 层级                                    |
| Five9                         | Domain        | Campaign        | Skill                  | Provider 层级                                    |
| Amazon Connect                | Instance      | Routing Profile | Queue                  | Provider 层级                                    |
| Ariel（多租户 SaaS framework）     | tenant        | —               | —                      | Pool with RLS                                  |

### CVE-2024-10976

PostgreSQL RLS bypass — 影响 PG `<17.1` / `<16.5` / `<15.9` / `<14.14` / `<13.17` / `<12.21`。攻击者在 RLS 启用的表上 `SET ROLE` 后能绕过 policy。Neon 当前已 patched（PG 16.5+）。自己跑 PG 的 SaaS 必须升级。详见 [PG CVE-2024-10976](https://www.postgresql.org/support/security/CVE-2024-10976/)。

### 概念 glossary（人话）

`tenant_id`
:   一行数据或一个 request "属于哪家客户"的字段。通常 1 个 tenant = 1 个 billing customer。Pool 模式里它必须出现在业务表和 RLS policy 中；Silo 模式里它至少存在于 Control Plane / request context,业务表可选。

`location_id`
:   tenant 内部的运营单位 ID（一家店 / 一个仓库 / 一个 region office）。同 tenant 跨 location 允许互看。为什么重要：业务扩张（开新店）= 加 `location` 行，不动 schema。

`RLS`（Row Level Security）
:   PostgreSQL 内置功能，给表加 policy，让数据库强制每个 query 必须满足某条件。例：`CREATE POLICY tenant_isolation ON contacts USING (tenant_id = current_setting('app.tenant_id'))`。为什么重要：应用层漏过滤时，DB 层兜底。Defense in depth。

`SET LOCAL` vs `SET`
:   PG 设置 session 变量的两种方式。`SET` = session 级，跨 query 持续，pgbouncer 复用 connection 时上一次的值漏给下一次。`SET LOCAL` = transaction 级，COMMIT / ROLLBACK 自动清。为什么重要：连接池 + multi-tenant 必须用 `SET LOCAL`，否则上 tenant 的设置会泄到下 tenant。

Defense in Depth
:   每一层独立强制 tenant scope，任一层失效其他层仍能挡。Auth 层、API 层、Service 层、DB 层（RLS）。为什么重要：单层防御一旦被绕（bug、依赖更新、配置错误），数据全泄。多层防御要求攻击者同时绕过所有层。

CPaaS（Communications Platform as a Service）
:   Twilio / RingCentral / Vonage 这类提供电话 / 短信 / 视频 API 的平台。为什么重要：multi-tenant SaaS 不绑死单一 CPaaS — 客户带自己的 Twilio / Genesys 合同进来很常见。Provider adapter ([Provider adapter 层](#provider-adapter-层)) 就是为这个。

BPO（Business Process Outsourcing）
:   一家公司代另一家公司运营某个业务环节（call center、客服、销售）。SaaS 视角：BPO 是 tenant（付账户主），end-clients 是 tenant\_clients（业务字段）。为什么重要：BPO 业务模型决定了 tenant 必须是计费单位，end-client 不是 — 否则 Stripe Customer 数量爆炸而且和真实付款关系不符。

### 指针

retaintive 自己的隔离层级(`tenants` / `locations` / `phone_lines`)、当前哪些表满足 / 缺什么、迁移路径、未填的 gap → [`final/migration-plan.md`](/system-design/multi-tenant/final/migration-plan.md)。

Store-level isolation 的具体落地（`store_id` UUID 作为单一隔离键、PhoneStoreAssignments、电话移店场景）→ [`store-level-isolation.md`](/system-design/store-level-isolation.md)。

***

## 五、完整 Hybrid 8 维度图

Overview 里给的是 3 面简版 (数据 / 接入 / 运维)。这里是工程团队需要的完整 8 维度。

这张图是 **generic Hybrid reference**，不是 retaintive V1 的默认部署图。真实成本要按当时的 Neon / AWS / Cloudflare pricing、compute scale-to-zero、branch 数量、迁移自动化和 oncall 成本单独算；这里不写固定 dollar 数字，避免把示例当成预算。

```
                            Sarah          Glow Beauty      United Airlines
                          (1 店 SMB)      (3 店 Pro)       (10 site Platinum)
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
接入面 (Access Plane)
  登录 SSO              │ 共享 Cognito  │ 共享 Cognito    │ ☆ 独立 SAML SSO
  API endpoint          │ 共享          │ 共享            │ ☆ 独立 (api-united.x)
  Rate limit            │ 共享配额      │ 共享配额        │ ★ 独占配额
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
数据面 (Data Plane)
  Neon DB               │ 共享 main DB  │ 共享 main DB    │ ★ 独立 proj-united
  DynamoDB              │ 共享 table    │ 共享 table      │ ★ 独立 table
  S3 bucket             │ 共享 (前缀隔离)│ 共享 (前缀隔离)│ ★ 独立 bucket
  AI prompt / model     │ 共享 prompt   │ 共享 prompt     │ ☆ 独立 prompt key
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
运维面 (Control Plane)
  AWS Account           │ 共享 prod acct│ 共享 prod acct  │ ☆ 可独立 sub-account
  CloudWatch            │ 共享 namespace│ 共享 namespace  │ ★ 独立 namespace
  Audit log             │ 共享表        │ 共享表          │ ★ 独立流 (合规)
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
计费 tier               │ low-price     │ mid-market      │ enterprise
基础设施成本/tenant     │ shared compute│ shared + higher │ dedicated compute
                         │ 摊薄后低      │ quota / support │ + endpoint / secret
                         │               │                 │ + migration / monitor
─────────────────────── ┼───────────────┼─────────────────┼──────────────────────
                         └─── Pool 区 ────┘                └─── Silo 区 ─────┘
                         低价或 trial 才需要                高价值 / 合规 / 大 BPO
                         成熟 Pool                           才需要 Silo

★ = 必须独立 (合规 / 性能硬要求)
☆ = 按需独立 (客户选,默认共享)
共享 = Pool 默认行为,靠 tenant_id 强制隔离
```

**3 个关键认知**:

1. Hybrid 不是开关,是 8 维度逐项决定。每一维可独立选 Pool / Silo。
2. Platinum tier ≠ 全部独立。AWS Account 通常仍共享 (太贵);Neon / S3 / 监控是必须独立 (合规硬要求)。
3. `placement` 这个概念在不同时期用不同 schema 表达,见下方演进表。

### Placement 字段演进 (V1 → 中期 → Hybrid)

**为什么这一节单独存在**:`placement` 在 4 份文档里出现 3 个不同名字 — overview 说 `tenants.placement` 单字段,本文 §五 说 8 维度 `placement_profile`,v1-launch-plan 说独立表 `tenant_database_placements`。**3 个名字不是矛盾,是 3 个时期的演进**:

| 时期                          | schema 抽象                        | 字段                                                                                                                                                                                                     | 为什么                                                                                                                                                           |
| --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **原 V1 方案(已 DEFERRED,未落地)** | 独立表 `tenant_database_placements` | `placement_type` (`shared_pool` / `neon_branch` / `neon_project` / `external`)<br>`placement_key` (`pool_orange_theory` / `silo_&lt;tenant&gt;`)<br>`connection_secret_ref` (AWS Secrets Manager path) | Control Plane 路由的最小抽象。1 个 tenant 1 row,routing 是 atomic op。原方案详见已归档的 [`rollout.md`](../../../archive/multi-tenant-v1-plan/rollout.md) §2 Control Plane Schema |
| **中期 (单字段简化)**              | `tenants.placement` TEXT 单字段     | `'pool'` / `'silo'` (overview / migration-plan 用这个)                                                                                                                                                    | Final 业务模型描述时简化讲法;实际仍走 `tenant_database_placements` 表                                                                                                         |
| **Hybrid (8 维度组合)**         | `tenants.placement_profile` 命名组合 | `'smb-pool'` / `'pro-pool'` / `'platinum-silo'` / `'platinum-air-gapped'` 等命名组合                                                                                                                        | 客户开始要"数据 Silo 但 SSO 共享 Cognito"这种细粒度组合时启用                                                                                                                     |

**关键**:V1 实际落地是 identity-only,**没有建任何 placement 表或字段** — placement 抽象整体 DEFERRED,首个需要分库 routing(Silo/Pool)的客户来时再落,届时优先按 `tenant_database_placements` 独立表方案。overview / migration-plan 提到"placement"是为了讲业务模型容易讲。

**触发升级**:

| 现在                               | 触发条件                                                         | 升级到                                                        |
| -------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------- |
| `tenant_database_placements` 表   | ~~V1 上线即用~~ 已 DEFERRED — 首个需要 DB routing(Silo/Pool 分库)的客户来时建 | (默认)                                                       |
| `tenants.placement` 单字段 alias    | 业务文档想简化讲解时                                                   | 文档约定,不改 schema                                             |
| `tenants.placement_profile` 命名组合 | 第一个客户提"数据 Silo 但 SSO 共享" / "数据 Pool 但独立 rate limit" 这种细粒度组合  | 加 profile 名进枚举,8 维度组合落进 `tenant_database_placements` 多 row |

**Why not 一开始就 placement\_profile 16 组合**:V1 客户 1-3 个,没人要细粒度组合。一开始就上 16 组合 = over-engineering,徒增维护成本。

→ 原方案表结构(包括 `placement_type` 枚举值 / `connection_secret_ref` 字段)见已归档的 [`rollout.md`](../../../archive/multi-tenant-v1-plan/rollout.md) §2.3 `tenant_database_placements`(DEFERRED,未落地)。

***

## 六、Provider adapter 完整 DDL

Overview 给的是 2 张新表的职责。这里是完整建表语句 + 索引 + 约束。

```sql
-- adapter 表 1: 凭证 + provider 顶层连接
CREATE TABLE provider_connections (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id          UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  provider_type      TEXT NOT NULL CHECK (provider_type IN (
                       'ringcentral', 'twilio', 'genesys', 'five9', 'amazon_connect'
                     )),
  provider_account_id TEXT NOT NULL,    -- 他们的顶层 ID (各家叫法不同)
  credentials_kms    TEXT NOT NULL,     -- OAuth refresh token / API key, KMS 加密
  status             TEXT NOT NULL DEFAULT 'active' CHECK (status IN (
                       'active', 'disconnected', 'revoked', 'expired'
                     )),
  connected_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_sync_at       TIMESTAMPTZ,
  UNIQUE (tenant_id, provider_type, provider_account_id)
);

CREATE INDEX idx_provider_connections_tenant ON provider_connections(tenant_id);
CREATE INDEX idx_provider_connections_status ON provider_connections(status)
  WHERE status = 'active';

-- adapter 表 2: location 跟 provider 的绑定 (多对多)
CREATE TABLE location_provider_mappings (
  id                        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  location_id               UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
  provider_connection_id    UUID NOT NULL REFERENCES provider_connections(id) ON DELETE CASCADE,
  external_site_id          TEXT NOT NULL,  -- 他们的 site/subaccount/queue/campaign ID
  external_site_name        TEXT,           -- 他们那边的显示名 (sync 缓存)
  is_primary                BOOLEAN NOT NULL DEFAULT TRUE,  -- 多 provider 时哪个主
  sync_status               TEXT DEFAULT 'ok',
  created_at                TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (provider_connection_id, external_site_id)
);

CREATE INDEX idx_location_mappings_location ON location_provider_mappings(location_id);
CREATE INDEX idx_location_mappings_primary ON location_provider_mappings(location_id)
  WHERE is_primary = TRUE;
```

### Adapter interface (TypeScript)

```typescript
interface ProviderAdapter {
  type: 'ringcentral' | 'twilio' | 'genesys' | 'five9' | 'amazon_connect';
  
  // 拉取 provider 的 site/subaccount 列表
  listExternalSites(connection: ProviderConnection): Promise<ExternalSite[]>;
  
  // 拉取某 site 下的 phone numbers
  listPhones(connection: ProviderConnection, externalSiteId: string): Promise<Phone[]>;
  
  // Webhook 标准化
  parseWebhook(rawPayload: unknown): NormalizedCallEvent | NormalizedSmsEvent;
  
  // 健康检查
  testConnection(connection: ProviderConnection): Promise<HealthCheck>;
}

// 5 个 adapter 各自实现这个接口
class RingCentralAdapter implements ProviderAdapter { ... }
class TwilioAdapter implements ProviderAdapter { ... }
class GenesysAdapter implements ProviderAdapter { ... }
```

### 多 provider 数据合并策略

同一个 location 挂多 provider 时,业务表里 `calls` / `messages` 怎么去重?

```
3 个策略 (按场景选):

1. last-write-wins (默认)
   - call_id 用 (provider_type, provider_call_id) 复合
   - 简单, 但 2 个 provider 同一通话可能存 2 条记录

2. primary-only
   - 只接受 is_primary=true 的 provider 写入
   - 主备 failover 场景适用 (Genesys 主 + AC 溢出)

3. cross-provider dedupe
   - 用 phone+timestamp 模糊匹配 (±5s 窗口)
   - 复杂, 适合双轨 A/B test 场景
```

***

## 七、MCP server 实现细节

Overview 给的是 MCP 时序流。这里是工程实现细节。

### Tenant 注入机制

```typescript
// MCP server 启动时根据 API key 解析 tenant
async function authMiddleware(apiKey: string): Promise<TenantContext> {
  const row = await db.query(
    'SELECT id, tier, placement FROM tenants WHERE api_key = $1 AND status = $2',
    [apiKey, 'active']
  );
  if (!row) throw new UnauthorizedError();
  return { tenantId: row.id, tier: row.tier, placement: row.placement };
}

// 每个 MCP tool 包装,自动注入 tenant
function withTenant<TArgs, TResult>(
  toolHandler: (ctx: TenantContext, args: TArgs) => Promise<TResult>
) {
  return async (mcpRequest: McpRequest) => {
    const ctx = await authMiddleware(mcpRequest.apiKey);
    
    // AI 调用时根本看不到 tenant 参数
    // 它只能传 args, ctx 由 server 强制注入
    return toolHandler(ctx, mcpRequest.args);
  };
}

// Tool 实现
export const searchContactsTool = withTenant(async (ctx, { query }) => {
  const db = await getDbForTenant(ctx.tenantId);  // 路由到 Pool 或 Silo
  // Pool placement: query must include tenant_id / RLS.
  // Silo placement: DB is already tenant-scoped, but service code still uses ctx for audit/rate limit.
  return searchContacts(db, ctx, query);
});
```

### Tool 列表 (建议初始 6 个)

| Tool                                 | 输入            | 输出         | 用途                 |
| ------------------------------------ | ------------- | ---------- | ------------------ |
| `search_contacts(query)`             | 关键字           | 联系人列表      | "找一下姓王的客户"         |
| `get_call_summary(call_id)`          | call ID       | 通话纪要 + 关键点 | 老板想看具体一通电话         |
| `list_pending_tasks(location_id?)`   | 可选 location   | 任务列表       | "今天还有啥要跟进"         |
| `create_task(contact_id, due, note)` | 联系人 + 截止 + 备注 | 创建的 task   | AI 主动提醒            |
| `send_sms(phone, body)`              | 号码 + 内容       | 发送状态       | AI 发短信             |
| `get_dashboard(period)`              | 时段            | KPI 指标     | AI summary "本周怎么样" |

### Rate limit per tenant + tier

```typescript
const RATE_LIMITS = {
  smb:      { rpm: 60,    burst: 10  },
  pro:      { rpm: 300,   burst: 50  },
  platinum: { rpm: 6000,  burst: 500 },  // 大客户独占额度
};
```

→ Rate limit 也是 placement\_profile 的一个维度 (见 §5)。
