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;屆時兩個 service 共用同一條 host 查表解析路徑(§3.1,後台不做 parse-label 免查 DB 優化),並共用 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.ts 的 organization/member/invitation)同時身兼兩職 —— (a) 租戶目錄(organization.dbName/customDomain/status 自訂欄位是 host→DB 的解析來源)、(b) 成員角色(member.role + session.activeOrganizationId,由 packages/core/src/permissions.ts 的 resolveRoleForSession 解析)。這讓「區分租戶」與「租戶內角色」綁死在一張被借來逆用的表上。

1.2 決策前提(已確認)

  • 保留 DB per tenant,不收斂成單一資料庫。
  • 租戶使用者彼此獨立:同一個「人」不需要一組身分跨多個租戶登入 → 不需要共享身分層。「一個人要進多家旅行社後台」被判定為罕見例外(非目標客群),以下列方式服務,不足以支撐共享身分層:
    • 該人在每個租戶各有獨立帳號(比照 Slack —— <workspace>.slack.com 至今仍是其標準 workspace URL,平台層 email 入口只負責導航、不共享身分)。
    • 平台層可提供「輸入 email → 寄信告知你在哪些租戶有帳號」的導航入口(純加法,不動 packages/core)。紅線:不得在 control_db 建全域 email 索引 —— 那等於把本節否決的共享身分層原地復活,且以最敏感的欄位復活;需要時改用對租戶 DB 扇出查詢。
    • 該入口的結果一律只寄到信箱、不顯示在畫面上,且不論 email 是否存在都回同一則訊息 —— 否則它就是一個「這個 email 屬於哪些公司」的未授權枚舉 oracle。
  • 平台方(SaaS 營運者)要進所有租戶,不算共享身分層:那是控制平面對租戶 DB 的存取問題,設計見 §3.4。
  • 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 plugin 的 createAccessControl(instance 邊界 = 租戶邊界),取代 organization plugin 與手刻角色表。

2. 資料模型

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

表欄位語意說明
tenantid、slug(unique)、db_name(unique)、status(active | suspended | archived | provisioning_failed)、created_at租戶目錄主表。
tenant_domainid、tenant_id(FK → tenant.id)、domain(unique)、is_primary(bool)、kind(both | storefront | admin,default both,CHECK)一個租戶可掛多個 domain(主域 + 別名 + 後台域);proxy 用 domain 反查 db_name,並用 kind 決定該 host 服務哪一面(§3.1)。
subscriptiontenant_id(PK,與 tenant 1:1)、account_holder(戶名)、started_at(訂閱日)、fee、currency、seat_limit、plan、statusseat_limit 現階段只存不強制(enforcement 列為未來 business logic)。

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

  • 搬入 better-auth 核心表:user、session、account、verification(原本在 auth_db)。
  • admin plugin 欄位:user 增 role、banned、ban_reason、ban_expires;session 增 impersonated_by(admin plugin 規格)。
  • 商業表 user FK 一律原生 .references(() => user.id):orders.user_id、consent_signatures.user_id、audit_log.actor_user_id、member_profiles.user_id,以及 payables 的 created_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_db 的 organization/member/invitation/active_organization_id。
  • 商業 DB 的跨庫 user 投影表,與其同步 script、packages/core/src/auth.ts 的 databaseHooks 同步 hook。
  • packages/core/src/auth.ts 的 session.create.after(pin active org)hook。

3. 邏輯 / 存取層

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

host → 查 control_db.tenant_domain(in-memory cache:正面命中 TTL 60s、負面(查無)TTL 10s,上限 1000 筆滿了整批清)→ 取 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 行為)。
  • 查詢失敗也 fail-closed 不 throw:control_db 抖動 / 連線錯誤時 proxy 吞錯、不寫 header(該次不快取,恢復即自癒)——不讓 control DB 故障變成全站 500 掛在 proxy 層。
  • dev(localhost / 127.0.0.1 / *.localhost)從不查 control_db:一律直接用 DEV_TENANT_DB / DEV_TENANT_MODULES env;非 localhost 只查 control_db、不吃 env。兩條路徑互斥,沒有「查無才 fallback」的混合語意。

Host 模型:前台可自有 domain,後台恆在平台 domain

租戶解析一律靠 host,不靠登入身分——因為公開店面(/、/tour/[slug]、/journal、/booking)是匿名可達且要被搜尋引擎索引的,訪客在解析當下還沒有身分可用。

面向Host憑證涵蓋
前台(租戶自有域)<租戶自有 domain>per-tenant
前台(未自備,備援)<tenant>.<平台 apex>*.<平台 apex> 萬用
後台<tenant>.admin.<平台 apex>*.admin.<平台 apex> 萬用

後台恆掛平台 domain(不隨租戶自有域走),理由依重要性排序:

  1. 租戶 DNS 故障時後台仍可用。前台掛掉是預期內的損失,但若後台也綁在租戶自有域上,租戶 DNS 設錯 / 忘記續約 / 換註冊商出錯時,他連登入查訂單、通知客戶、自救的入口都沒有。
  2. 憑證成本:所有租戶後台共用一張 *.admin.<平台 apex> 萬用憑證,per-tenant 工作量為零;只有前台自有域需要逐租戶簽發。
  3. 後台不是白牌面(租戶知道自己用這套系統),掛平台 domain 無品牌顧慮;前台則相反,白牌是自有域存在的理由。

分面的真相來源是 control_db.tenant_domain.kind('both' | 'storefront' | 'admin'),與 db_name / status / 模組旗標同源於 §3.1 那一次查詢。proxy 據此擋下不該出現的面:kind='storefront' 的 host 上, staff-tier surface(/admin* 與 /guide*)一律 404(不 redirect —— 不對外洩漏後台位置,也不把訪客 彈去另一個 domain)。嚮導 portal 納入是因為它同屬 staff-tier(2FA 一律強制,見 guide-portal),適用同一條「租戶自有域故障時仍要能用」的理由。

/2fa-setup 雖也是 staff-only(客戶會被導去 /member)但刻意不納入:它在前台 host 上對客戶只是 無害轉址,納入只會把它變成 404,沒有實益。

反向亦然:kind='admin' 的 host 上公開店面一律 404(/、/about、/tour*、/booking*、 /checkout*、/consent*、/journal*、/member*、/orders*、/sign-up)。動機是 SEO 重複內容 —— 後台 host 若同時供得出店面,同一份行程/文章會有兩個可索引 URL。登入鏈刻意留著(/sign-in、 /two-factor、/forgot-password、/reset-password、/2fa-setup):後台未登入時就是 redirect 到 /sign-in,擋掉等於員工登不進後台。此閘用 denylist 而非 allowlist —— 漏列一條只是少擋一頁(無害), 漏放行一條會把後台弄壞。

同一動機下,robots.txt 在 kind='admin' 的 host 上回整站 Disallow: / 且不宣告 sitemap / host (那兩個欄位的 origin 來自 request host,在後台 host 上會邀請爬蟲去抓一份全是 404 的 sitemap)。分面經 proxy 注入的 x-tenant-host-kind header 下傳給需要它的 surface,讀端用 getCurrentHostKind(),缺 header 或未知值同樣降級 'both'。

kind 預設 'both'(單一 host 通吃)是刻意的向後相容:.run.app 與 localhost 都是一個 host 服務全部, 預設值讓這些環境的行為完全不變,要分離時才顯式標記(tenant set-domain --kind)。未知值一律降級為 'both'(fail-open) —— 這個欄位只管路由分面,不是安全邊界;後台真正的防線是 RBAC 權限閘。

新租戶的兩列 domain 由 tenant create 一次建齊:主域(--domain,kind='storefront'、is_primary) +後台域(<slug>.admin.<PLATFORM_ROOT_DOMAIN>,kind='admin'、非 primary),同一交易同生同死 —— 只建一半會產出「後台無 host 可進」或「主域已收窄但後台不存在」的半殘租戶。後台域刻意不是 primary,因為 getPrimaryDomainForDbName() 餵的是對外連結(訂單信、密碼重設),那些要指前台。PLATFORM_ROOT_DOMAIN 未設(dev / staging)就只建單列 'both',維持既有單一 host 行為。

不做「後台 parse subdomain label 免查 control_db」優化

後台 host 同樣走 tenant_domain 查表,不從 <tenant>.admin.… 的 label 直接推 db_name。tenant_domain 原生支援一租戶多 domain,後台 host 就是多一列,零 schema 變更。

放棄該優化是刻意的,因為它會拆掉兩個掛在同一次查詢上的東西:

  • tenant.status(停權)的檢查只存在於 proxy 這一處 —— 下游 getCurrentTenantDbName() 無條件信任 x-tenant-db。跳過查表 = 停權後後台照常可登入,而後台正是停權最該切斷的面(能改訂單、碰金流、匯出 PII)。
  • x-tenant-modules(模組授權)同源於該次查詢,parse label 拿不到,仍得再查一次。

省下的成本趨近於零(正面 cache TTL 60s、per-instance),代價卻是第二條解析路徑 = 第二個 anti-tamper 安全面。

3.2 per-host better-auth instance

  • auth 非 singleton,經 getAuthForTenant(dbName):用 Map 快取 betterAuth instance(比照 packages/core/src/db/business.ts 的 getScopedDb 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_db 插 tenant + tenant_domain + subscription;(2) CREATE DATABASE <db_name>;(3) 跑租戶 migrations(auth + business 合併後的那套);(4) setup-extras。
  • 首位 admin 已拆成獨立子命令 tenant:create-admin --slug <s> --email <e>(印一次性強隨機密碼;staging/prod 永不 seed 帳號)。--role 有 denylist:customer 與 guide 不可經此建立(customer 走公開自助註冊、guide 走嚮導邀請流程)。
  • 任一步失敗 → rollback control_db 那筆,並丟棄半建的 DB 或標 status = provisioning_failed。
  • 其他:tenant:list、tenant:suspend、tenant:set-domain。

3.4 平台操作員存取租戶(支援 / 除錯)

平台方(SaaS 營運者)為了處理租戶回報的 bug,必須能進入租戶後台。這不是共享身分層問題(§1.2),而是控制平面對租戶 DB 的受控存取。

平台操作員在每個租戶 DB 各有一列 user,掛專用角色 platform_support。

不採「不建 user 列、另開不帶 FK 的稽核旁路」,因為 audit_log.actor_user_id 是指向同庫 user 的原生 FK —— 不存在於該租戶 DB 的操作者,其行為在稽核軌跡裡寫不進去。那個原生 FK 正是「auth 表與商業表同庫」換來的好處(§1.1),不為平台方打穿。

權限形狀:

  • platform_support 本身唯讀:涵蓋租戶業務資料的 read statements,零摩擦(「只看 log 猜」不是可行的除錯方式)。
  • PII 明文沿用既有 reveal 路徑:持久化遮罩 → 點擊解密 → 自動寫 traveler.decrypt 稽核(見 travelers)。平台方與嚮導、租戶 admin 走同一條路,不需要新機制、不需要事前申請;摩擦是零,但每次解密都留痕。
  • 寫入一律走代登入(session.impersonated_by,admin plugin 內建):以租戶某位使用者的身分操作,權限自然等同該使用者。

代登入而非直接給平台角色寫入權,有兩個實益:

  1. 除錯效果更好且風險更低。租戶回報「我按不到這個按鈕」時,用 admin 身分登入是重現不出來的——RBAC 是 statement 模型,各角色畫面本就不同。要重現必須「用他的眼睛看」,而那比 admin 權限更窄。
  2. 租戶的稽核紀錄讀得懂:「這筆訂單被標記為已付,操作者是張三,impersonated_by 是平台的某某」,而不是冒出一個租戶不認識的帳號在動他的金流。

稽核是平台方的責任盾牌,不只是約束:租戶日後質疑「你們的人是不是看了我們的客戶名單」,沒有紀錄時平台無法自證未讀取。個資法下平台是受託處理者,須能證明有適當安全維護措施。

對照組:在此設計落地前,平台方進租戶的唯一方式是 ops 憑證直連 psql / CLI(§3.3)—— 無限權限、零稽核、租戶完全看不見。psql 直連保留為最後手段,但上述路徑到位後應罕用。

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):內建 6 角色——admin(含 adminAc.statements)、sales、op、accountant、customer、guide(嚮導受限 login,見 guide-portal)。語意一對一沿用 ROLE_PERMS(如 sales 不能 approve 自己的 payable、accountant 不能 create —— 責任分離 invariant 不變)。
  • 掛載:per-tenant auth instance 用 admin({ ac, roles: { admin, sales, op, accountant, customer, guide }, adminRoles: ["admin"] })。adminRoles 只列 admin,確保 sales/op/accountant/guide 不會誤拿 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 plane:control_db schema + migrations + CLI(先不接 proxy)。
  2. P2 auth relocation(spec 原 P2+P4 合併):auth 表併入租戶 DB、admin plugin 欄位、getAuthForTenant + proxy 經 control_db 真實解析、刪 organization 機制、權限重構成 createAccessControl、bun 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_PERMS → createAccessControl 是一次性重構,需逐角色核對權限不漏不溢。
  • 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 後台:租戶目錄管理維持 CLI(§3.3)。平台操作員進租戶的權限與稽核形狀已定於 §3.4(platform_support 唯讀 + 代登入寫入),該 console 落地時依此實作,不要另訂一套。
  • seat enforcement:YAGNI(seat_limit 只存不強制)。