當 isolation 只剩 WHERE organization_id = ? 的習慣時,multi-tenancy 在 production 就會失效。少一個 filter、workspaces 之間出現 confused deputy,或某個 background job 忘了帶 tenant context,都足以洩漏資料。
一個 production-grade 的服務需要三層彼此一致:identity / membership(Better Auth organization plugin)、request context(每個受保護的 Hono handler 都收到已驗證的 userId 與 organizationId,絕不能只靠 client 可控的 header),以及 data boundary(即使 application code 忘了 filter,PostgreSQL row-level security 也會拒絕跨 tenant 的 rows)。
這篇 note 假設 typed stack 見 用 Hono、Drizzle、Zod OpenAPI 與 SST 打造 Backend APIs。這裡聚焦 tenant isolation。
1. 這裡所說的 Production Multi-Tenant 是什麼
Tenant 就是一個 organization:帶有 members、roles,以及自己業務資料的 SaaS workspace。用戶可以屬於多個 organizations。Session 會追蹤哪個 organization 是 active。
- 做法是用 shared database 與 shared schema。每個 tenant 擁有的 row 都帶
organization_id。這是常見的 SaaS 模型,也直接對應 Better Auth 的 organization plugin。 - Soft isolation — application code 一律以
organizationIdfilter。上手快,壓力一上來就容易出錯。 - Database-enforced isolation — Postgres RLS 在受信任的 tenant-context path 下強制同一條規則。忘了
WHERE時回傳零 rows,而不是另一個 tenant 的資料。
這個設計要對付的威脅:
- 新 query 或 admin script 漏掉 tenant filters
- URL 裡洩漏屬於另一個 org 的 resource IDs(
/projects/:id) - 未驗證 membership 就接受
X-Organization-Id - Connection pooling 重用仍帶著另一個 tenant settings 的 session
- Break-glass admin paths 在 production 悄悄關掉 isolation
非目標:schema-per-tenant、database-per-tenant,以及 billing 或 metering。Better Auth 裡有 teams 與 dynamic access control;v1 先停在 static roles。
2. 架構
Request path 在一般 session auth 之上,再加上 tenant resolution 與 RLS-scoped transaction。
Client
→ session cookie
→ Hono (or Lambda authorizer + Hono)
→ Better Auth getSession
→ read organizationId from the route
→ verify membership/permission for that organizationId
→ open DB transaction
→ set_config('app.organization_id', ...)
→ Drizzle queries under RLS
→ PostgreSQL- Better Auth organization 管理 orgs、members、invitations、
activeOrganizationId,以及 roles 或 permissions。 - Hono middleware 掛上 typed tenant context,並拒絕沒有 explicit route organization membership 的 requests。
- Drizzle schema 在每個 tenant-owned table 上放
organization_id與 RLS policies。 - PostgreSQL 是最後一道防線。
如果用了基礎筆記裡的 API Gateway Lambda authorizer 模式,只傳遞小的 identity identifiers,例如 userId 與 sessionId。Business route 仍然提供 organizationId,API 在打開 RLS transaction 之前,為那個確切的 organization 驗證 membership。
Failure: 未做那次驗證,就把 identity 或 tenant 從 client 可控的 header 拷過來。
3. Better Auth Organization 作為 Tenancy Control Plane
Organization plugin 是 membership 與 workspace 層。安裝 @better-auth/drizzle-adapter,在 server 與 client 上啟用 plugin,然後 generate 並 apply migration,讓 organization、member、invitation 與 session.activeOrganizationId 存在於 PostgreSQL。
import { drizzleAdapter } from "@better-auth/drizzle-adapter"
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
import { ac, owner, admin, member } from "./permissions"
import { db } from "./db"
import { sendOrganizationInvitation } from "./email"
const appURL = process.env.APP_URL
if (!appURL) throw new Error("APP_URL is required")
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
plugins: [
organization({
ac,
roles: { owner, admin, member },
requireEmailVerificationOnInvitation: true,
disableOrganizationDeletion: true,
async sendInvitationEmail(data) {
const inviteLink = `${appURL}/accept-invitation/${data.id}`
await sendOrganizationInvitation({
to: data.email,
organization: data.organization.name,
inviter: data.inviter.user.name,
inviteLink,
})
},
}),
],
})- 在 client 上啟用
organizationClient,用同一套ac與 roles。 - Invitation callback 必須只把 opaque invitation ID 交給預定收件人。Acceptance 需要 authenticated session,且 email 與 invitation 吻合。
- 要求 email verification,並關掉直接刪除 organization,讓 deletion 走明確的 archival workflow。
- Lifecycle:create organization → invite member → accept invitation → set-active organization → 在那個 tenant 裡工作 → 經明確 workflow leave 或 archive。
加了或改了 plugin 之後,generate Better Auth Drizzle schema,generate SQL migration,審閱它,然後 apply:
npx auth@latest generate
npx drizzle-kit generate
npx drizzle-kit migrate- 第一條命令寫 schema definitions;它 不會 改 PostgreSQL。
- 在 CI 裡把 CLI pin 到應用使用的 Better Auth 版本,不要讓
@latest自己往前走。
Active organization 是 session state,但把它當成 UI preference,而不是業務 mutations 的 authorization input。Clients 呼叫 organization.setActive;server 把 activeOrganizationId 存在 session 上。Business routes 仍然帶明確的 organization ID,並為那個確切 ID 驗證 membership。
export const auth = betterAuth({
databaseHooks: {
session: {
create: {
before: async (session) => {
const organization = await getInitialOrganization(session.userId)
return { data: { ...session, activeOrganizationId: organization?.id } }
},
},
},
},
plugins: [organization({ ac, roles: { owner, admin, member } })],
})預設 roles 是 owner、admin 與 member。對 domain actions,定義 access controller 並傳進 plugin:
import { createAccessControl } from "better-auth/plugins/access"
import {
defaultStatements,
adminAc,
memberAc,
ownerAc,
} from "better-auth/plugins/organization/access"
const statement = {
...defaultStatements,
project: ["create", "read", "update", "delete"],
} as const
export const ac = createAccessControl(statement)
export const member = ac.newRole({
project: ["create", "read"],
...memberAc.statements,
})
export const admin = ac.newRole({
project: ["create", "read", "update", "delete"],
...adminAc.statements,
})
export const owner = ac.newRole({
project: ["create", "read", "update", "delete"],
...ownerAc.statements,
})- Route handlers 與 middleware 呼叫
auth.api.hasPermission,帶上 request headers 以及 route 使用的 同一個 explicit organization ID。 - 產品超出 static roles 時,再打開 teams 與 dynamic access control。第一天先關掉。
4. Domain Schema:每一列業務資料都屬於 Tenant
Auth tables 保持 global:user、account、session、verification、organization、member、invitation。Business tables 是 tenant-owned。
- 如果這列屬於一個 workspace,它就有非空的
organization_idforeign key 指向organization.id,需要時做 tenant-scoped uniqueness,以及圍繞真實 tenant access paths 設計的 indexes。 - Better Auth organization IDs 是 strings,所以 domain foreign keys 應該是
text,不是uuid,除非 ID generation 已被自訂。 - Uniqueness 是
(organization_id, slug),不是 global slug。
import { sql } from "drizzle-orm"
import {
index,
pgPolicy,
pgRole,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
import { organization } from "./auth-schema"
// Provisioned by infrastructure as a restricted, non-owner login role.
export const appUser = pgRole("app_user").existing()
export const projects = pgTable(
"projects",
{
id: uuid("id").defaultRandom().primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "restrict" }),
name: text("name").notNull(),
slug: text("slug").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("projects_org_created_at_idx").on(
table.organizationId,
table.createdAt
),
uniqueIndex("projects_org_slug_uidx").on(table.organizationId, table.slug),
pgPolicy("projects_app_access", {
as: "permissive",
to: appUser,
for: "all",
using: sql`true`,
withCheck: sql`true`,
}),
pgPolicy("projects_tenant_isolation", {
as: "restrictive",
to: appUser,
for: "all",
using: sql`${table.organizationId} = current_setting('app.organization_id', true)`,
withCheck: sql`${table.organizationId} = current_setting('app.organization_id', true)`,
}),
]
)- 加上 policy 會在 Drizzle 裡為 table 啟用 RLS。如果需要 RLS 但還沒有 policies,用
pgTable.withRLS(...)—— 沒有 policy 時,Postgres 預設拒絕 row access。 - Permissive policy 允許正常操作。Restrictive policy 會用
AND與每一條適用 policy 組合,所以將來的 permissive support policy 不能把 tenant isolation 變成可選。 - Table owners 預設繞過 RLS;
FORCE ROW LEVEL SECURITY讓 owner 也受 policies 約束。Superusers 以及帶BYPASSRLS的 roles 即使有FORCE也永遠繞過。
在 Drizzle 之外 provision 產品 runtime role,不要讓它擁有 schemas 或 tables,並在審閱過的 custom migration 裡加上 grants 與 FORCE:
ALTER ROLE app_user
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE "user", account, session, verification, member, invitation, projects
TO app_user;
GRANT SELECT, INSERT, UPDATE ON TABLE organization TO app_user;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;- Login credential 由 infrastructure 或 secret manager 提供,不要 commit 進 migration。Policies 不授予 table privileges:只 grant runtime 需要的 DML。不要 grant
TRUNCATE、DDL、寬泛的REFERENCES,或 role-management privileges。 - RLS 不管
TRUNCATE。Unique 與 foreign-key checks 會繞過 row filtering,所以按 tenant 限定 constraints,並避免暴露原始 conflict details。如果 table 用 identity 或 serial,審閱 sequence grants。 - 對已有 table,用 expand and contract:加上 tenant column、backfill、強制
NOT NULL、加上 policies 與 grants、在app_user下驗證,再部署依賴這條邊界的程式碼。Drizzleentities.roles替代不了 attributes、grants、membership、default privileges 或FORCE RLS的 custom SQL。
5. 用 Drizzle 做 Postgres RLS
RLS 只有在 request path 以 restricted role 連線時才有用。分開 credentials:migration owner(擁有 schema changes,從不服務產品流量)、app_user(Better Auth 與業務 queries 使用的 non-owner login;沒有 BYPASSRLS),以及 backup role(嚴格控制的 read access 加上 BYPASSRLS,用於完整 logical backups)。
- Request pool 直接以
app_user連線。意外跑在 tenant wrapper 之外的 query 會 fail closed:它有普通 DML privileges,但current_setting(..., true)回傳NULL,所以 restrictive tenant policy 不放行任何 rows。 - Wrapper 在 transaction-local setting 裡設置 tenant,並在同一條 transaction 裡跑每一條 tenant query。
import { sql } from "drizzle-orm"
import { db } from "./client"
type Db = typeof db
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0]
export async function withTenant<T>(
organizationId: string,
fn: (tx: Tx) => Promise<T>
): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`
select set_config('app.organization_id', ${organizationId}, true)
`)
return fn(tx)
})
}- 面對 pooled connections 時,transaction-local settings 很重要。Neon、PgBouncer 與 warm Lambda environments 會重用 connections。
set_config(..., true)會在 commit 或 rollback 時被 PostgreSQL 還原。 - 不要在
finally裡發 reset SQL:失敗的 statement 會讓 transaction aborted,cleanup SQL 可能蓋掉原來的 error。 - 永遠不要用
ALTER ROLE、ALTER DATABASE或 session-levelSET定義app.organization_id,也不要依賴 pool reset hooks。 - Application code 仍然要寫明確的 filters。RLS 是 defense in depth,不是寫 ambient queries 的許可證。
Failure: 把 RLS 當成對抗 SQL injection 的保護,或當成對抗能用另一個 tenant ID 呼叫 set_config 的被攻破 runtime。它只保護受信任的 context-propagation path 下漏掉的 filters。
在稱系統 production-ready 之前先測:
- 同一用戶、兩個 organizations —— 在 A 建立後呼叫 org B 的 list route —— A 的 project 不得出現
- 並發切換 session 的 active org —— 明確的 org A request 必須仍綁定到 A
- 以
app_userquery 但不設app.organization_id—— 零 rows - Insert 帶不匹配的
organization_id—— 被withCheck拒絕 - Membership 被撤銷 —— explicit membership check 失敗
- Commit 與 roll back tenant A,重用 pooled connection 給 tenant B —— 沒有 A 的 rows
- 在
withTenant裡拋 SQL error —— 保住原來的 error - 以 migration owner 跑並開著
FORCE RLS—— rows 仍被過濾 - 以
app_user嘗試TRUNCATE—— permission denied
6. Hono Request Pipeline
Tenant routes 上的 middleware 順序:CORS → 要求 session + explicit organization membership → validate path 與 JSON body → 為同一個 organization 檢查 permission → handler。
import { APIError } from "better-auth/api"
import { createMiddleware } from "hono/factory"
import type { Context } from "hono"
import { auth } from "../auth"
type Variables = {
userId: string
organizationId: string
memberRole: string
}
export const requireTenant = createMiddleware<{ Variables: Variables }>(
async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
return c.json({ code: "UNAUTHORIZED", message: "Authentication is required." }, 401)
}
const organizationId = c.req.param("organizationId")
if (!organizationId) {
return c.json({ code: "ORGANIZATION_REQUIRED", message: "An organization ID is required." }, 400)
}
const denied = await bindTenant(c, session.user.id, organizationId)
if (denied) return denied
await next()
}
)
async function bindTenant(
c: Context<{ Variables: Variables }>,
userId: string,
organizationId: string
) {
try {
const { role } = await auth.api.getActiveMemberRole({
headers: c.req.raw.headers,
query: { organizationId },
})
c.set("userId", userId)
c.set("organizationId", organizationId)
c.set("memberRole", role)
return null
} catch (error) {
if (error instanceof APIError && error.statusCode < 500) {
return c.json({ code: "FORBIDDEN", message: "You cannot access this organization." }, 403)
}
throw error
}
}- 優先用
POST /organizations/:organizationId/projects,而不是 ambient tenant state。另一個 tab 可能在 request 進行中改掉 session 的 active organization;explicit route ID 對該 request 保持不可變。 - Middleware 為那個 ID 驗證 membership,permission checks 用同一個 ID,
withTenant收到同一個 ID。 - Organization 或 membership 無效時,
getActiveMemberRole拋 Better AuthAPIError;它不回傳null。把預期的 membership failures 映射成安全的403,讓意外 errors 到達 centralized error handler。
Zod OpenAPI route 同時 validate organization path parameter 與 JSON body。Handler 只讀已驗證的資料:
app.openapi(createProjectRoute, async (c) => {
const { organizationId } = c.req.valid("param")
const body = c.req.valid("json")
const allowed = await auth.api.hasPermission({
headers: c.req.raw.headers,
body: { organizationId, permissions: { project: ["create"] } },
})
if (!allowed.success) {
return c.json(
{ code: "FORBIDDEN", message: "Missing project:create permission." },
403
)
}
const [project] = await withTenant(organizationId, (tx) =>
tx
.insert(projects)
.values({ organizationId, name: body.name, slug: body.slug })
.returning()
)
return c.json(project, 201)
})- 共享的 error handler 應把 malformed requests 映射到
400,authentication 到401,已知 authorization failures 到403,被 tenant 藏起的 resource lookups 到404,PostgreSQL unique violation23505到409,意外 failures 到帶 request ID 的 sanitized500。 - 呼叫者不能執行的已知操作適合
403;未知 resource ID 回傳404,避免洩漏它屬於另一個 tenant。 - Membership 與 permission 在業務 transaction 正前方檢查。把
hasPermission裡statusCode < 500的APIError映射到403。預設 revocation contract 允許已經授權的 in-flight request 做完;revocation 擋住後續 requests。
Failure: 要求立刻 revocation,卻不在與 mutation 同一條 database transaction 裡重新檢查或鎖定 membership。
7. 會破壞 Isolation 的 Production Edge Cases
這些是 demo 裡看起來沒問題、真實流量下會失敗的情況。
- Stale active org — client UI 在
setActive切到 B 之後仍可能顯示 org A。只把它當成 display/navigation state;explicit business routes 才是權威。 - Organization deletion — Better Auth 會 hard-delete organization membership 與 invitation rows。其他 sessions 可能留下 stale active ID。這個例子關掉直接刪除,並用
ON DELETE RESTRICT;archival workflow 必須 revoke sessions、處理 domain retention,並按明確順序刪除。 - Invitation security — 接受 invitation 需要吻合的 authenticated email。只把 opaque IDs 發給收件人,要求 email verification,讓 invitations 過期,永遠不要 log action URLs。
- URL 裡的跨 tenant IDs —
GET /projects/:id必須在 tenant transaction 裡按 id 載入。有了 RLS,錯誤的 tenant 得到 not found,而不是另一個 org 的 row。Query 裡仍然要按organizationIdfilter。 - Background jobs 與 webhooks — 沒有 session cookie。用 authenticated 或 signed payloads,enqueue 之前授權 tenant scope,記錄 actor 與 operation,讓 retries idempotent,然後跑同一套
withTenantwrapper。單獨一個 tenant ID 不是 authorization。 - Support break-glass — 用單獨、被審計的 admin connection 或 tool,不要在產品程式碼裡灑
SET ROLEbypass。 - Connection pooling — 只在帶 local settings 的 transactions 裡設 tenant context。安全來自 transaction 結束,不是 pooler 的 reset hook。對 transaction poolers 遵循 provider 的 prepared-statement 指引。
- Backups 與 restores —
pg_dump設row_security=off並不會繞過 RLS;rows 會被過濾時它會 error。完整 logical backups 用專用的 read +BYPASSRLSrole。Cluster roles 分開備份(例如pg_dumpall --globals-only),並測試 restore 會重建 grants、policies、ENABLE RLS與FORCE RLS。 - Lambda authorizer caching — identity 只能在產品接受的 revocation guarantees 內被 cache。Tenant membership 與 permission 仍要對照 API 裡 explicit route organization 檢查。
8. 最小架構切片
先定 folder 形狀,然後端到端實作並 integration-test 這條 path,再加 teams、dynamic roles 或花哨的 admin tooling。
src/
auth.ts
auth-client.ts
permissions.ts
db/
schema.ts
auth-schema.ts
rls.ts
middleware/
tenant.ts
routes/
projects.ts- Sign up / sign in
- Create an organization
- Invite a member
- 為 UI navigation 設 active organization
- 在
withTenant裡POST /organizations/:organizationId/projects - List 同一個 explicit organization —— 另一個 org 的 projects 保持隱藏
- 以 member 呼叫 admin-only organization route ——
403
如果第 6 或第 7 步失敗,系統還不是 multi-tenant。它只是帶一張 organization table 的 single-tenant app。只有 invitation delivery、list/admin routes 與 isolation tests 都能跑時,這個切片才算完成。
9. 結語
Better Auth organizations 提供 membership UX。Postgres RLS 提供不信任每位 query 作者都會記得 tenant filter 的 data plane。Hono 是兩者交會之處 — typed context、明確的 middleware,以及在碰業務 tables 前開啟 tenant-scoped transaction 的 handlers。
- 從 shared schema、static roles,以及每個 tenant-owned table 上的 RLS 開始。
- 當真實產品需求出現時,再加入 teams 或 dynamic access control。
- Deploy、OpenAPI 與 Lambda 細節留在 Backend APIs 筆記;這篇 note 只談 isolation contract。
Production 門檻很簡單:忘了 WHERE clause,絕不能變成跨 tenant 事故。
References
- Better Auth Drizzle adapter
- Better Auth organization plugin
- Drizzle ORM row-level security
- PostgreSQL row security policies
- PostgreSQL configuration functions
- PostgreSQL logical backups with pg_dump