跳到主要内容
返回

用 Hono、Better Auth、Drizzle 与 Postgres RLS 打造 Multi-Tenant 后端

后端

如何在共用的 Drizzle schema 上,以 Better Auth organizations、Hono request context 与 Postgres row-level security 隔离 tenants

当 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 一律以 organizationId filter。上手快,压力一上来就容易出错。
  • 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。


text
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。


src/auth.ts
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:


text
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。


ts
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:


src/permissions.ts
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_id foreign 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。

src/db/schema.ts
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:


sql
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 下验证,再部署依赖这条边界的代码。Drizzle entities.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。

src/db/rls.ts
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-level SET 定义 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_user query 但不设 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。


src/middleware/tenant.ts
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 Auth APIError;它不返回 null。把预期的 membership failures 映射成安全的 403,让意外 errors 到达 centralized error handler。

Zod OpenAPI route 同时 validate organization path parameter 与 JSON body。Handler 只读已验证的数据:


src/routes/projects.ts
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 violation 23505 到 409,意外 failures 到带 request ID 的 sanitized 500。
  • 调用者不能执行的已知操作适合 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 里仍然要按 organizationId filter。
  • Background jobs 与 webhooks — 没有 session cookie。用 authenticated 或 signed payloads,enqueue 之前授权 tenant scope,记录 actor 与 operation,让 retries idempotent,然后跑同一套 withTenant wrapper。单独一个 tenant ID 不是 authorization。
  • Support break-glass — 用单独、被审计的 admin connection 或 tool,不要在产品代码里洒 SET ROLE bypass。
  • 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 + BYPASSRLS role。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。


text
src/
  auth.ts
  auth-client.ts
  permissions.ts
  db/
    schema.ts
    auth-schema.ts
    rls.ts
  middleware/
    tenant.ts
  routes/
    projects.ts

  1. Sign up / sign in
  2. Create an organization
  3. Invite a member
  4. 为 UI navigation 设 active organization
  5. 在 withTenant 里 POST /organizations/:organizationId/projects
  6. List 同一个 explicit organization —— 另一个 org 的 projects 保持隐藏
  7. 以 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


Recap Q&A