跳至主要內容

當 isolation 只剩 WHERE organization_id = ? 的習慣時,multi-tenancy 在 production 就會失效。少一個 filter、workspaces 之間出現 confused deputy,或某個 background job 忘了帶 tenant context,都足以洩漏資料。

一個 production-grade 的服務需要三層彼此一致:

  1. Identity / membership — 用戶是誰、屬於哪些 organizations、哪個 org 是 active、持有什麼 role(Better Auth organization plugin)
  2. Request context — 每個受保護的 Hono handler 都收到已驗證的 userIdorganizationId(絕不能只靠 client 可控的 header)
  3. Data boundary — 即使 application code 忘了 filter,PostgreSQL row-level security 也會拒絕跨 tenant 的 rows

這篇 note 假設你已有 typed backend:HonoBetter Auth sessions、DrizzlePostgreSQL。這套基礎 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 databaseshared schema。每個 tenant 擁有的 row 都帶 organization_id。這是常見的 SaaS 模型,也直接對應 Better Auth 的 organization plugin。

Isolation 有軟硬之分:

  • Soft isolation — application code 一律以 organizationId filter。上手快,壓力一上來就容易出錯。
  • Database-enforced isolationPostgres 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,例如 userIdsessionId。業務 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,讓 organizationmemberinvitationsession.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 workflow

Active 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 是 owneradminmember。對 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 ROLEALTER 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_user query 但不設定 app.organization_id — 零 rows
  • 以不匹配的 organization_id insert — 被 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
  → handler

Typed 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 23505409,以及非預期失敗為附帶 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 URLsGET /projects/:id 必須在 tenant transaction 內按 id 載入。有了 RLS,錯誤的 tenant 得到 not found,而不是另一個 org 的 row。Query 裡仍要按 organizationId filter。
  • Background jobs and webhooks — 沒有 session cookie。使用 authenticated 或 signed payloads,enqueue 前授權 tenant scope,記錄 actor 與 operation,讓 retries idempotent,然後跑同一個 withTenant wrapper。單獨的 tenant ID 不是 authorization。
  • Support break-glass — 使用獨立、可審計的 admin connection 或工具,不要在產品程式碼裡到處灑 SET ROLE bypass。
  • Connection pooling — 只在帶 local settings 的 transactions 內設定 tenant context。安全性來自 transaction 結束,而不是 pooler 的 reset hook。對 transaction poolers 遵循 provider 的 prepared-statement 指引。
  • Backups and restorespg_dump 設定 row_security=off 不會 bypass RLS;當 rows 會被 filter 時它會報錯。完整 logical backups 使用專用的 read + BYPASSRLS role。另外備份 cluster roles(例如 pg_dumpall --globals-only),並測試 restore 能重建 grants、policies、ENABLE RLSFORCE 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 這條路徑:

  1. Sign up / sign in
  2. Create an organization
  3. Invite a member
  4. Set the active organization for UI navigation
  5. POST /organizations/:organizationId/projects inside withTenant
  6. List the same explicit organization — another org's projects remain hidden
  7. 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 事故。



References

閱讀下一篇筆記
Frontend Regression Tests