線稿索引所有 spec最後更新 2026-06-20
specs/tenant-architecture.md

租戶架構

一句話:把「租戶目錄」升到平台層 control_db、把 auth 下放進每個租戶自己的 DB,讓 user 與商業資料同庫、商業表用原生 FK 直接指同庫 user;租戶內 RBAC 用 better-auth admin plugin 內建的 Access Control。這是多租戶三層架構(control plane / 租戶 DB / 租戶內 RBAC)的 SSOT。

部署拓撲演進:本文的租戶解析 / auth 設計假設「單一 Next app」。前後台分離成兩個 Cloud Run service + 前台 API-first 的決策見 app-topology;屆時後台改 parse subdomain 解析、前台維持 custom domain 查表,兩者共用 packages/core

1. 目標 / 問題

下方「設計動機」描述當初為何這樣設計的脈絡;現行架構為三層,shared auth_db 與 organization plugin 皆已不存在。

1.1 設計動機

  • auth 與商業資料分庫會逼出去正規化投影 + dual-write 同步:若 auth 表獨立一個 DB、商業表在另一個租戶 DB,商業表無法原生 FK 到 user,只能在商業 DB 維護一張 user 投影表、靠 better-auth databaseHooks.user.create/update.after 持續同步 name/email/image。這是無交易保護的 dual-write,hook 沒 fire 就漂移、seed 還得繞過 hook 補資料 —— 本架構就是要消滅這個分庫前提。
  • organization plugin 被超載:better-auth 的 organization plugin(@drizzle/auth/schema.tsorganization/member/invitation)同時身兼兩職 —— (a) 租戶目錄organization.dbName/customDomain/status 自訂欄位是 host→DB 的解析來源)、(b) 成員角色member.role + session.activeOrganizationId,由 packages/core/src/permissions.tsresolveRoleForSession 解析)。這讓「區分租戶」與「租戶內角色」綁死在一張被借來逆用的表上。

1.2 決策前提(已確認)

  • 保留 DB per tenant,不收斂成單一資料庫。
  • 租戶使用者彼此獨立:同一個「人」不需要一組身分跨多個租戶登入 → 不需要共享身分層。
  • dev/staging/prod 各自獨立部署(各自一套 DB),環境由部署天然隔開,registry 不需要 env 欄位 —— 只需要 domain → db_name 對應。
  • 現在就把長期最好維護的架構做對:目前只有單一租戶、無 staging、無 prod 資料,遷移成本此刻最低。

1.3 目標

organization 的兩個職責拆到各自該在的層級,並消滅跨庫 user FK:

  1. 租戶目錄升到平台層 control_db(租戶以上)。
  2. auth 下放進每個租戶 DB,使 user 與商業資料同庫、改用原生 FK,跨庫 user 投影表 + 同步 hook 連根拔除。
  3. 租戶內 RBAC 用 better-auth admin plugincreateAccessControl(instance 邊界 = 租戶邊界),取代 organization plugin 與手刻角色表。

2. 資料模型

2.1 control_db(平台層;由 CLI 管理,無 auth 表)

欄位語意說明
tenantidslug(unique)、db_name(unique)、statusactive | suspended | archived | provisioning_failed)、created_at租戶目錄主表。
tenant_domainidtenant_id(FK → tenant.id)、domain(unique)、is_primary(bool)一個租戶可掛多個 domain(主域 + 別名);proxy 用 domain 反查 db_name
subscriptiontenant_id(PK,與 tenant 1:1)、account_holder(戶名)、started_at(訂閱日)、feecurrencyseat_limitplanstatusseat_limit 現階段只存不強制(enforcement 列為未來 business logic)。

2.2 租戶 DB(<tenant>_db,由 business schema 擴充)

  • 搬入 better-auth 核心表:usersessionaccountverification(原本在 auth_db)。
  • admin plugin 欄位userrolebannedban_reasonban_expiressessionimpersonated_by(admin plugin 規格)。
  • 商業表 user FK 一律原生 .references(() => user.id)orders.user_idconsent_signatures.user_idaudit_log.actor_user_idmember_profiles.user_id,以及 payablescreated_by_user_id / submitted_by_user_id / approved_by_user_id 等,皆指向同租戶 DB 的 user
  • 不含 organization plugin 相關表:organization / member / invitation,以及 session.active_organization_id。角色由 admin plugin 存於 user.role

2.3 不存在的東西

  • auth_dborganization/member/invitation/active_organization_id
  • 商業 DB 的跨庫 user 投影表,與其同步 script、packages/core/src/auth.tsdatabaseHooks 同步 hook。
  • packages/core/src/auth.tssession.create.after(pin active org)hook。

3. 邏輯 / 存取層

3.1 租戶解析(apps/web/src/proxy.ts

host → 查 control_db.tenant_domain(加 cache)→ 取 db_name + 檢查對應 tenant.status

  • status = active → 寫 x-tenant-db = db_name(維持 NextResponse.next({ request: { headers } }) anti-tamper 覆寫)。
  • 查無 domain 或 status ≠ active → 不寫 header → 下游 getCurrentTenantDb()TenantResolutionError(維持 fail-closed 行為)。
  • devlocalhostDEV_TENANT_DB fallback(不是主路徑,只在 control_db 查無 domain 時生效)。

3.2 per-host better-auth instance

  • auth 非 singleton,經 getAuthForTenant(dbName):用 Map 快取 betterAuth instance(比照 packages/core/src/db/business.tsgetScopedDb pool),drizzleAdapter 指向該租戶 DB。
  • /api/auth/[...all] route handler 與 getCurrentSession()packages/core/src/permissions.ts)依 x-tenant-db 取對應 instance。
  • 每個租戶在自己 domain → cookie/session 天然隔離;baseURL 為該租戶 domain,secret 走共用 env。

3.3 Provisioning CLI(control plane 管理)

ops 憑證直連,無 web 後台(未來要 web 再加)。指令:

  • tenant:create --slug <s> --domain <d> →(交易性):(1) control_dbtenant + tenant_domain + subscription;(2) CREATE DATABASE <db_name>;(3) 跑租戶 migrations(auth + business 合併後的那套);(4) setup-extras + 建首位 admin user(role=admin)。
  • 任一步失敗 → rollback control_db 那筆,並丟棄半建的 DB 或標 status = provisioning_failed
  • 其他:tenant:listtenant:suspendtenant:set-domain

4. UI 與權限

4.1 RBAC:better-auth admin plugin + createAccessControl

packages/core/src/permissions-ac.ts 採 statement 模型(取代 flat permission string + ROLE_PERMS 表 + '*' 萬用):

  • statement catalog(resource → actions),例:order: ["read","create","update","review","assign","reassign"]payable: ["read","create","submit","approve","mark_paid"]departure/member/trip 等。'*' admin 萬用以 adminAc.statements 攤平。
  • 角色定義ac.newRole):admin(含 adminAc.statements)、salesopaccountantcustomer。語意一對一沿用 ROLE_PERMS(如 sales 不能 approve 自己的 payable、accountant 不能 create —— 責任分離 invariant 不變)。
  • 掛載:per-tenant auth instance 用 admin({ ac, roles: { admin, sales, op, accountant, customer }, adminRoles: ["admin"] })adminRoles 只列 admin,確保 sales/op/accountant 不會誤拿 admin plugin 的使用者管理端點(列使用者 / ban / impersonate)。

4.2 權限閘

requirePermission(perm) 呼叫 auth.api.userHasPermission({ body: { userId, permissions: { <resource>: [<action>] } } })(取代跨表 join 的 resolveRoleForSession)。getActiveMemberRole(dashboard 依角色變 UI 用)讀 user.role

4.3 各對象落位

  • 後台 OP / 租戶 admin / 業務 / 會計 → admin plugin 自訂角色(op / admin / sales / accountant),role 存租戶 DB 的 user。租戶 admin 白賺「建/停權員工帳號」能力。
  • 租戶客戶 → 同樣是租戶 DB 的 better-auth user,角色 customer,薄權限片。「只能看自己的訂單」這類 owner-id 限制維持在 query 層(與 packages/core/src/permissions.ts:14-15 註解一致),不靠 AC。

5. 開放議題

5.1 實作分階段(背景)

auth 表一下放就必須同時改成 per-host instance 才能跑,無法只搬表不換 instance;故 spec 原 P2(租戶 DB 合併)與原 P4(per-host auth + proxy)在實作時合併進同一份 auth-relocation plan。實際 plan 檔排序如下,狀態見 roadmap

  1. P1 control planecontrol_db schema + migrations + CLI(先不接 proxy)。
  2. P2 auth relocation(spec 原 P2+P4 合併):auth 表併入租戶 DB、admin plugin 欄位、getAuthForTenant + proxy 經 control_db 真實解析、刪 organization 機制、權限重構成 createAccessControlbun run db:setup 改流程。
  3. P3 拔投影表:FK 改原生、各 */admin-repo.ts 改讀同庫 user、seed 改寫、刪同步 hook。
  4. P4 測試遷移:vitest 直測原生 FK、e2e 改 per-tenant 種子。
  5. P5 收尾:文件、CLAUDE.md invariant 改寫、obsolete code 清掃、全面驗證。

5.2 待確認 / 注意事項

  • CLAUDE.md invariant 改寫:舊的「shared auth_db」「跨庫 user FK 投影」兩條 invariant 由本 spec 推翻,實作時同步改寫。
  • statement 遷移ROLE_PERMScreateAccessControl 是一次性重構,需逐角色核對權限不漏不溢。
  • control_db 連線 / env:沿用 POSTGRES_URL_BASE + 固定 control DB 名;db:setup 需先建 control_db 再建租戶 DB。
  • dev 既有資料:單租戶 dev 資料 + seed 需重灌(bun run dev:reset),無 prod 資料故成本低。
  • 單一 role 字串:admin plugin 預設 user.role 為字串(可逗號多角色);本 ERP 先採一人一角色。
  • 平台層 web 後台 / seat enforcement:皆 YAGNI,現階段不做(CLI only、seat 只存不強制)。

Changelog

版本日期變更Commit
12026-05-30初版:control plane + auth 下放租戶 DB + admin plugin RBAC
22026-05-30P2+P4 merged in implementation — auth relocation forces per-host auth; recorded actual phase sequencing
32026-05-30整理:對齊現行 code + 路徑搬移
42026-06-20對齊 code:statement catalog 家改 permissions-ac.ts、範例 resource payment_requestpayable、§3.1 proxy 路徑改 apps/web/src/proxy.ts(gate 函式仍在 permissions.ts 不變)