一句話:把「租戶目錄」升到平台層
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。
下方「設計動機」描述當初為何這樣設計的脈絡;現行架構為三層,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 解析)。這讓「區分租戶」與「租戶內角色」綁死在一張被借來逆用的表上。<workspace>.slack.com 至今仍是其標準 workspace URL,平台層 email 入口只負責導航、不共享身分)。packages/core)。紅線:不得在 control_db 建全域 email 索引 —— 那等於把本節否決的共享身分層原地復活,且以最敏感的欄位復活;需要時改用對租戶 DB 扇出查詢。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)、kind(both | storefront | admin,default both,CHECK) | 一個租戶可掛多個 domain(主域 + 別名 + 後台域);proxy 用 domain 反查 db_name,並用 kind 決定該 host 服務哪一面(§3.1)。 |
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(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 覆寫)。status ≠ active → 不寫 header → 下游 getCurrentTenantDb() 丟 TenantResolutionError(維持 fail-closed 行為)。*.localhost)從不查 control_db:一律直接用 DEV_TENANT_DB / DEV_TENANT_MODULES env;非 localhost 只查 control_db、不吃 env。兩條路徑互斥,沒有「查無才 fallback」的混合語意。租戶解析一律靠 host,不靠登入身分——因為公開店面(/、/tour/[slug]、/journal、/booking)是匿名可達且要被搜尋引擎索引的,訪客在解析當下還沒有身分可用。
| 面向 | Host | 憑證涵蓋 |
|---|---|---|
| 前台(租戶自有域) | <租戶自有 domain> | per-tenant |
| 前台(未自備,備援) | <tenant>.<平台 apex> | *.<平台 apex> 萬用 |
| 後台 | <tenant>.admin.<平台 apex> | *.admin.<平台 apex> 萬用 |
後台恆掛平台 domain(不隨租戶自有域走),理由依重要性排序:
*.admin.<平台 apex> 萬用憑證,per-tenant 工作量為零;只有前台自有域需要逐租戶簽發。分面的真相來源是 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 行為。
後台 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 安全面。
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。tenant:create-admin --slug <s> --email <e>(印一次性強隨機密碼;staging/prod 永不 seed 帳號)。--role 有 denylist:customer 與 guide 不可經此建立(customer 走公開自助註冊、guide 走嚮導邀請流程)。control_db 那筆,並丟棄半建的 DB 或標 status = provisioning_failed。tenant:list、tenant:suspend、tenant:set-domain。平台方(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 猜」不是可行的除錯方式)。traveler.decrypt 稽核(見 travelers)。平台方與嚮導、租戶 admin 走同一條路,不需要新機制、不需要事前申請;摩擦是零,但每次解密都留痕。session.impersonated_by,admin plugin 內建):以租戶某位使用者的身分操作,權限自然等同該使用者。代登入而非直接給平台角色寫入權,有兩個實益:
admin 身分登入是重現不出來的——RBAC 是 statement 模型,各角色畫面本就不同。要重現必須「用他的眼睛看」,而那比 admin 權限更窄。impersonated_by 是平台的某某」,而不是冒出一個租戶不認識的帳號在動他的金流。稽核是平台方的責任盾牌,不只是約束:租戶日後質疑「你們的人是不是看了我們的客戶名單」,沒有紀錄時平台無法自證未讀取。個資法下平台是受託處理者,須能證明有適當安全維護措施。
對照組:在此設計落地前,平台方進租戶的唯一方式是 ops 憑證直連 psql / CLI(§3.3)—— 無限權限、零稽核、租戶完全看不見。psql 直連保留為最後手段,但上述路徑到位後應罕用。
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):內建 6 角色——admin(含 adminAc.statements)、sales、op、accountant、customer、guide(嚮導受限 login,見 guide-portal)。語意一對一沿用 ROLE_PERMS(如 sales 不能 approve 自己的 payable、accountant 不能 create —— 責任分離 invariant 不變)。admin({ ac, roles: { admin, sales, op, accountant, customer, guide }, adminRoles: ["admin"] })。adminRoles 只列 admin,確保 sales/op/accountant/guide 不會誤拿 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 先採一人一角色。platform_support 唯讀 + 代登入寫入),該 console 落地時依此實作,不要另訂一套。seat_limit 只存不強制)。