一句話:把「租戶目錄」升到平台層
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。
下方「設計動機」描述當初為何這樣設計的脈絡;現行架構為三層,shared auth_db 與 organization plugin 皆已不存在。
databaseHooks.user.create/update.after 持續同步 name/email/image。這是無交易保護的 dual-write,hook 沒 fire 就漂移、seed 還得繞過 hook 補資料 —— 本架構就是要消滅這個分庫前提。@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 解析)。這讓「區分租戶」與「租戶內角色」綁死在一張被借來逆用的表上。domain → db_name 對應。把 organization 的兩個職責拆到各自該在的層級,並消滅跨庫 user FK:
control_db(租戶以上)。user 與商業資料同庫、改用原生 FK,跨庫 user 投影表 + 同步 hook 連根拔除。createAccessControl(instance 邊界 = 租戶邊界),取代 organization plugin 與手刻角色表。control_db(平台層;由 CLI 管理,無 auth 表)| 表 | 欄位語意 | 說明 |
|---|---|---|
tenant | id、slug(unique)、db_name(unique)、status(active | suspended | archived | provisioning_failed)、created_at | 租戶目錄主表。 |
tenant_domain | id、tenant_id(FK → tenant.id)、domain(unique)、is_primary(bool) | 一個租戶可掛多個 domain(主域 + 別名);proxy 用 domain 反查 db_name。 |
subscription | tenant_id(PK,與 tenant 1:1)、account_holder(戶名)、started_at(訂閱日)、fee、currency、seat_limit、plan、status | seat_limit 現階段只存不強制(enforcement 列為未來 business logic)。 |
<tenant>_db,由 business schema 擴充)user、session、account、verification(原本在 auth_db)。user 增 role、banned、ban_reason、ban_expires;session 增 impersonated_by(admin plugin 規格)。.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 / member / invitation,以及 session.active_organization_id。角色由 admin plugin 存於 user.role。auth_db 的 organization/member/invitation/active_organization_id。packages/core/src/auth.ts 的 databaseHooks 同步 hook。packages/core/src/auth.ts 的 session.create.after(pin active org)hook。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 覆寫)。status ≠ active → 不寫 header → 下游 getCurrentTenantDb() 丟 TenantResolutionError(維持 fail-closed 行為)。localhost 走 DEV_TENANT_DB fallback(不是主路徑,只在 control_db 查無 domain 時生效)。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。baseURL 為該租戶 domain,secret 走共用 env。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 user(role=admin)。control_db 那筆,並丟棄半建的 DB 或標 status = provisioning_failed。tenant:list、tenant:suspend、tenant:set-domain。createAccessControlpackages/core/src/permissions-ac.ts 採 statement 模型(取代 flat permission string + ROLE_PERMS 表 + '*' 萬用):
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)、sales、op、accountant、customer。語意一對一沿用 ROLE_PERMS(如 sales 不能 approve 自己的 payable、accountant 不能 create —— 責任分離 invariant 不變)。admin({ ac, roles: { admin, sales, op, accountant, customer }, adminRoles: ["admin"] })。adminRoles 只列 admin,確保 sales/op/accountant 不會誤拿 admin plugin 的使用者管理端點(列使用者 / ban / impersonate)。requirePermission(perm) 呼叫 auth.api.userHasPermission({ body: { userId, permissions: { <resource>: [<action>] } } })(取代跨表 join 的 resolveRoleForSession)。getActiveMemberRole(dashboard 依角色變 UI 用)讀 user.role。
op / admin / sales / accountant),role 存租戶 DB 的 user。租戶 admin 白賺「建/停權員工帳號」能力。customer,薄權限片。「只能看自己的訂單」這類 owner-id 限制維持在 query 層(與 packages/core/src/permissions.ts:14-15 註解一致),不靠 AC。auth 表一下放就必須同時改成 per-host instance 才能跑,無法只搬表不換 instance;故 spec 原 P2(租戶 DB 合併)與原 P4(per-host auth + proxy)在實作時合併進同一份 auth-relocation plan。實際 plan 檔排序如下,狀態見 roadmap。
control_db schema + migrations + CLI(先不接 proxy)。getAuthForTenant + proxy 經 control_db 真實解析、刪 organization 機制、權限重構成 createAccessControl、bun run db:setup 改流程。*/admin-repo.ts 改讀同庫 user、seed 改寫、刪同步 hook。CLAUDE.md invariant 改寫、obsolete code 清掃、全面驗證。CLAUDE.md invariant 改寫:舊的「shared auth_db」「跨庫 user FK 投影」兩條 invariant 由本 spec 推翻,實作時同步改寫。ROLE_PERMS → createAccessControl 是一次性重構,需逐角色核對權限不漏不溢。control_db 連線 / env:沿用 POSTGRES_URL_BASE + 固定 control DB 名;db:setup 需先建 control_db 再建租戶 DB。bun run dev:reset),無 prod 資料故成本低。user.role 為字串(可逗號多角色);本 ERP 先採一人一角色。| 版本 | 日期 | 變更 | Commit |
|---|---|---|---|
| 1 | 2026-05-30 | 初版:control plane + auth 下放租戶 DB + admin plugin RBAC | — |
| 2 | 2026-05-30 | P2+P4 merged in implementation — auth relocation forces per-host auth; recorded actual phase sequencing | — |
| 3 | 2026-05-30 | 整理:對齊現行 code + 路徑搬移 | — |
| 4 | 2026-06-20 | 對齊 code:statement catalog 家改 permissions-ac.ts、範例 resource payment_request→payable、§3.1 proxy 路徑改 apps/web/src/proxy.ts(gate 函式仍在 permissions.ts 不變) | — |