当 isolation 只剩 WHERE organization_id = ? 的习惯时,multi-tenancy 在 production 就会失效。少一个 filter、workspaces 之间出现 confused deputy,或某个 background job 忘了带 tenant context,都足以泄漏数据。
一个 production-grade 的服务需要三层彼此一致:
- Identity / membership — 用户是谁、属于哪些 organizations、哪个 org 是 active、持有什么 role(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 backend:Hono、Better Auth sessions、Drizzle 与 PostgreSQL。这套基础 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。
Isolation 有软硬之分:
- 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
这篇 note 的非目标: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,并拒绝对 route 里明确指定的 organization 没有 membership 的请求。
- Drizzle schema 在每个 tenant-owned table 放上
organization_id与 RLS policies。 - PostgreSQL 是最后一道防线。
若使用基础 note 里的 API Gateway Lambda authorizer 模式,只传递小型 identity identifiers,例如 userId 与 sessionId。业务 route 仍提供 organizationId,API 在开启 RLS transaction 前,为那个确切的 organization 验证 membership。绝不要在未经该验证的情况下,从 client 可控的 header 复制 identity 或 tenant。
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,
})
},
}),
],
})import { createAuthClient } from "better-auth/client"
import { organizationClient } from "better-auth/client/plugins"
import { ac, owner, admin, member } from "./permissions"
export const authClient = createAuthClient({
plugins: [
organizationClient({
ac,
roles: { owner, admin, member },
}),
],
})Invitation callback 只能把 opaque invitation ID 送到预定收件人。Acceptance 需要一个 authenticated session,且其 email 与 invitation 相符。要求 email verification,并停用直接删除 organization,让删除走明确的 archival workflow。
新增或更改 plugin 之后,generate Better Auth Drizzle schema、generate SQL migration、review,再 apply:
npx auth@latest generate
npx drizzle-kit generate
npx drizzle-kit migrate第一条指令写入 schema definitions;它不会改动 PostgreSQL。在 CI 里,把 CLI pin 到应用实际使用的 Better Auth 版本,不要让 @latest 独立漂移。
Lifecycle
Happy path 很短:
create organization
→ invite member
→ accept invitation
→ set-active organization
→ work inside that tenant
→ leave or archive through an explicit workflowActive organization 是 session state,但应视为 UI preference,而不是业务 mutations 的 authorization input。Clients 调用 organization.setActive;server 把 activeOrganizationId 存到 session。业务 routes 仍携带明确的 organization ID,并为那个确切 ID 验证 membership。
若要在 session 建立时 seed 一个 active org,在同一个 auth configuration 加上 database hook:
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 and permissions
默认 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 用 request headers,以及与 route 相同的明确 organization ID,调用 auth.api.hasPermission:
const result = await auth.api.hasPermission({
headers: c.req.raw.headers,
body: {
organizationId,
permissions: {
project: ["create"],
},
},
})
if (!result.success) {
return c.json(
{ code: "FORBIDDEN", message: "Missing project:create permission." },
403
)
}当产品超出 static roles 时,才启用 teams 与 dynamic access control。第一天先关掉它们。
4. Domain Schema:每一行业务数据都属于 Tenant
Auth tables 保持 global:user、account、session、verification、organization、member、invitation。业务 tables 是 tenant-owned。
规则很简单:若 row 属于某个 workspace,它就有一个非 null 的 organization_id foreign key 指向 organization.id,在需要处加上 tenant-scoped uniqueness,以及围绕真实 tenant access paths 设计的 indexes。
Better Auth organization IDs 是 strings,所以 domain foreign keys 应是 text 而非 uuid,除非已自定义 ID generation。
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 变成可选。
重要的 Postgres caveat: table owners 默认会 bypass RLS;FORCE ROW LEVEL SECURITY 会让 owner 也受 policies 约束。Superusers 与带 BYPASSRLS 的 roles 即使有 FORCE 也永远 bypass。
在 Drizzle 之外 provision 产品 runtime role,不要让它拥有 schemas 或 tables,并在经 review 的 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,所以每次 migration 只 grant runtime 需要的 DML。不要 grant TRUNCATE、DDL、宽松的 REFERENCES,或 role-management privileges。RLS 不管 TRUNCATE,而 unique/foreign-key checks 会 bypass row filtering,所以按 tenant 收窄 constraints,并避免暴露 raw conflict details。若日后某个 table 使用 identity 或 serial sequences,请另外 review 其 sequence grants。
Migration discipline 仍然重要。对既有 table,用 expand and contract:加入 tenant column、backfill、强制 NOT NULL、加上 policies 与 grants、在 app_user 下验证,再部署依赖此 boundary 的代码。像 entities.roles 这类 Drizzle role discovery,不能取代 attributes、grants、membership、default privileges 或 FORCE RLS 的 custom SQL。
5. 用 Drizzle 做 Postgres RLS
RLS 只有在 request path 以受限 role 连接时才有用。分开 credentials:
- Migration owner — 拥有 schema changes,绝不拿来跑产品流量
app_user— Better Auth 与业务 queries 使用的非 owner login;在 tenant tables 上受 policies 约束;没有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 可能掩盖原始错误。绝不要用 ALTER ROLE、ALTER DATABASE 或 session-level SET 定义 app.organization_id,也不要依赖 pool reset hooks。
Application code 仍然要写明确的 filters:
const rows = await withTenant(organizationId, (tx) =>
tx.select().from(projects).where(eq(projects.organizationId, organizationId))
)RLS 是 defense in depth,不是让你写 ambient queries 的许可。这条 policy 防护的是在受信任的 context-propagation path 下漏掉 filters。它不防护 SQL injection,也不防护能以另一个 tenant ID 调用 set_config 的 compromised runtime。
在宣称系统 production-ready 之前的测试清单:
- 同一用户、两个 organizations — 在 A 建立后调用 org B 的 list route — A 的 project 不可出现
- 并行切换 session 的 active org — 明确指定 org A 的请求必须仍绑定 A
- 以
app_userquery 但不设定app.organization_id— 零 rows - 以不匹配的
organization_idinsert — 被withCheck拒绝 - Membership 被撤销 — 明确的 membership check 失败
- Commit 与 roll back tenant A,重用 pooled connection 给 tenant B — 没有 A 的 rows
- 在
withTenant内抛出 SQL error — 保留原始错误 - 以带
FORCE RLS的 migration owner 执行 — rows 仍被 filter - 以
app_user尝试TRUNCATE— permission denied
6. Hono Request Pipeline
Tenant routes 上的 middleware 顺序:
CORS
→ require session + explicit organization membership
→ validate path and JSON body
→ check permission for the same organization
→ handlerTyped context 让 handlers 保持诚实:
import { APIError } from "better-auth/api"
import { createMiddleware } from "hono/factory"
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
)
}
try {
const { role } = await auth.api.getActiveMemberRole({
headers: c.req.raw.headers,
query: { organizationId },
})
c.set("userId", session.user.id)
c.set("organizationId", organizationId)
c.set("memberRole", role)
} catch (error) {
if (error instanceof APIError && error.statusCode < 500) {
return c.json(
{
code: "FORBIDDEN",
message: "You cannot access this organization.",
},
403
)
}
throw error
}
await next()
}
)优先用 POST /organizations/:organizationId/projects,而不是 ambient tenant state。另一个标签页可能在请求进行中改掉 session 的 active organization;明确的 route ID 对该请求保持不可变。Middleware 为该 ID 验证 membership,permission checks 用同一个 ID,withTenant 也收到同一个 ID。
当 organization 或 membership 无效时,getActiveMemberRole 会抛出 Better Auth APIError;它不会返回 null。把预期的 membership failures 映射成安全的 403,让非预期错误到达 centralized error handler。
Zod OpenAPI route 同时验证 organization path parameter 与 JSON body。Handler 只读已验证的数据:
import { APIError } from "better-auth/api"
import { createRoute, z } from "@hono/zod-openapi"
const CreateProjectSchema = z.object({
name: z.string().trim().min(1).max(120),
slug: z
.string()
.trim()
.min(1)
.max(80)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})
const createProjectRoute = createRoute({
method: "post",
path: "/organizations/{organizationId}/projects",
middleware: [requireTenant] as const,
request: {
params: z.object({ organizationId: z.string().min(1) }),
body: {
content: {
"application/json": { schema: CreateProjectSchema },
},
required: true,
},
},
responses: {
201: { description: "Project created" },
400: { description: "Invalid request" },
401: { description: "Authentication required" },
403: { description: "Membership or permission denied" },
409: { description: "Project slug already exists" },
},
})
app.openapi(createProjectRoute, async (c) => {
const { organizationId } = c.req.valid("param")
const body = c.req.valid("json")
try {
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
)
}
} catch (error) {
if (error instanceof APIError && error.statusCode < 500) {
return c.json(
{ code: "FORBIDDEN", message: "Permission check failed." },
403
)
}
throw error
}
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 或 invalid requests 映射为 400、authentication 为 401、已知的 authorization failures 为 403、对调用端隐藏的 tenant resource lookups 为 404、PostgreSQL unique violation 23505 为 409,以及非预期失败为附带 request ID 的 sanitized 500。对已知但调用端不能执行的操作,403 是合适的;对未知 resource ID 返回 404,可避免泄露它属于另一个 tenant。
Membership 与 permission 在业务 transaction 正前方检查。默认的 revocation 契约允许已授权、进行中的请求完成;撤销会挡住后续请求。若产品需要立即撤销,必须在与 mutation 同一个 database transaction 里重新检查或锁定 membership。
7. 会破坏 Isolation 的 Production Edge Cases
这些情况在 demo 里看起来没问题,在真实流量下却会失败:
- Stale active org — client UI 在
setActive切到 B 之后仍可能显示 org A。只把它当 display/navigation state;明确的业务 routes 才是权威来源。 - Organization deletion — Better Auth 会 hard-delete organization membership 与 invitation rows。其他 sessions 可能仍保留过期的 active ID。这个例子停用直接删除,并使用
ON DELETE RESTRICT;archival workflow 必须撤销 sessions、处理 domain retention,并按明确顺序删除。 - Invitation security — 接受 invitation 需要匹配的 authenticated email。只把 opaque IDs 送到收件人、要求 email verification、让 invitations 过期,且绝不 log action URLs。
- Cross-tenant IDs in URLs —
GET /projects/:id必须在 tenant transaction 内按 id 加载。有了 RLS,错误的 tenant 得到 not found,而不是另一个 org 的 row。Query 里仍要按organizationIdfilter。 - Background jobs and webhooks — 没有 session cookie。使用 authenticated 或 signed payloads,enqueue 前授权 tenant scope,记录 actor 与 operation,让 retries idempotent,然后跑同一个
withTenantwrapper。单独的 tenant ID 不是 authorization。 - Support break-glass — 使用独立、可审计的 admin connection 或工具,不要在产品代码里到处洒
SET ROLEbypass。 - Connection pooling — 只在带 local settings 的 transactions 内设定 tenant context。安全性来自 transaction 结束,而不是 pooler 的 reset hook。对 transaction poolers 遵循 provider 的 prepared-statement 指引。
- Backups and restores —
pg_dump设定row_security=off不会 bypass RLS;当 rows 会被 filter 时它会报错。完整 logical backups 使用专用的 read +BYPASSRLSrole。另外备份 cluster roles(例如pg_dumpall --globals-only),并测试 restore 能重建 grants、policies、ENABLE RLS与FORCE RLS。 - Lambda authorizer caching — identity 只可在产品接受的 revocation 保证范围内缓存。Tenant membership 与 permission 仍要在 API 里,针对明确的 route organization 检查。
8. 最小架构切片
目标文件夹形状:
src/
auth.ts
auth-client.ts
permissions.ts
db/
schema.ts
auth-schema.ts
rls.ts
middleware/
tenant.ts
routes/
projects.ts在加入 teams、dynamic roles 或华丽的 admin tooling 之前,先端到端实现并 integration-test 这条路径:
- Sign up / sign in
- Create an organization
- Invite a member
- Set the active organization for UI navigation
POST /organizations/:organizationId/projectsinsidewithTenant- List the same explicit organization — another org's projects remain hidden
- Call an admin-only organization route as a member —
403
若步骤 6 或 7 失败,系统还不是 multi-tenant。它只是一个带 organization table 的 single-tenant 应用。只有当 invitation delivery、list/admin routes 与 isolation tests 都可执行时,这个切片才算完成。
9. 结语
Better Auth organizations 提供 membership UX:创建、邀请、roles、active workspace。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 事故。