跳轉到內容
全部文章

基於 Postgres RLS 與工作區鑑權的多租戶 SaaS 資料隔離

將服務端即時工作區成員守衛與帶索引的 Postgres 行級安全(RLS)策略相結合,在 Web、移動端與後臺任務中徹底隔離租戶資料。

supastack 團隊約 3 分鐘閱讀

Should I use Postgres Row-Level Security or application-level workspace checks?

It depends on how your application connects to Postgres. If browser or mobile clients query the database directly through PostgREST, Row-Level Security (RLS) is mandatory. If all traffic flows through a server-side TypeScript boundary, explicit membership checks on every query are your primary defense, and RLS serves as defense-in-depth against forgotten WHERE clauses. Combine live session-to-membership resolution with indexed workspace_id policies. supastack enforces server-side workspace guards across web, mobile, and desktop, and provides a free RLS policy generator for Supabase tables.

應用層守衛與資料庫策略分別防禦不同故障

開發者常常把應用層鑑權與 Postgres 行級安全(RLS)視為二選一。實際上它們攔截的是不同層面的漏洞:服務端守衛在業務邏輯執行前強制執行角色層級、功能權益檢查和審計日誌,而 RLS 則保證即使漏寫了 WHERE workspace_id = ... 條件,也絕不會洩漏所有租戶的資料行。

無論你使用 Supabase 還是搭配 Drizzle 的自託管 Postgres,每張租戶業務表都應當包含非空的 workspace_id 外部索引鍵。工作區邊界文章詳細解釋了為什麼這一作用域必須在服務端推導,而絕不能採信客戶端傳入的引數。

  • 應用層守衛校驗會話有效性、成員角色(owner、admin、member)以及功能權益。
  • 資料庫 RLS 策略在直接客戶端查詢或原生 SQL 輔助函式中充當不可逾越的安全底線。
  • 兩層防禦共享同一個唯一事實來源:workspace_members 關聯表。

即時解析成員關係,而非信任過期的 Cookie

在瀏覽器 Cookie 或客戶端請求頭中儲存 currentWorkspaceId 便於頁面導航,但 Cookie 只是介面偏好而非訪問憑證。如果管理員將某位成員移出工作區,該使用者的瀏覽器裡依然留著舊的工作區 Cookie。

在每次請求時將當前活躍會話與 workspace_members 表進行關聯查詢。一旦成員記錄不存在,立即拒絕請求,而不是等待 JWT 或會話 Cookie 自然過期。不止於登入表單的身份認證一文追蹤了身份資訊如何流入這一檢查。

export async function requireWorkspaceViewer(requestedWorkspaceId?: string) {
  const session = await requireAuthenticatedSession();
  const membership = await db.query.workspaceMembers.findFirst({
    where: and(
      eq(workspaceMembers.userId, session.user.id),
      requestedWorkspaceId
        ? eq(workspaceMembers.workspaceId, requestedWorkspaceId)
        : undefined,
    ),
    with: { workspace: true },
  });

  if (!membership) throw new ForbiddenError("Not a member of this workspace");
  return { user: session.user, workspace: membership.workspace, role: membership.role };
}

編寫高效且命中索引的 Row-Level Security 策略

在每一行上直接呼叫 auth.uid() 的樸素 RLS 策略會迫使 Postgres 對每條記錄重複求值。將 (select auth.uid()) 包裹在標量子查詢中,可以讓查詢規劃器將其快取為 initPlan,再配合 (workspace_id, user_id) 複合索引,即可將成員查詢保持在亞毫秒級的索引掃描。

你可以使用免費的 Supabase RLS 策略生成器 為多租戶表一鍵生成可直接執行的 SELECT、INSERT、UPDATE 與 DELETE 策略,無需手寫樣板 SQL。

CREATE INDEX IF NOT EXISTS idx_workspace_members_lookup
  ON workspace_members (workspace_id, user_id);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "workspace_members_manage_documents"
ON documents FOR ALL TO authenticated
USING (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
  )
)
WITH CHECK (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
      AND wm.role IN ('owner', 'admin', 'member')
  )
);

關於 RLS 與工作區鑑權的常見問題

搭建多租戶 Postgres 的團隊在服務角色、後臺任務和查詢效能上總會遇到相同的邊界情況。

  • Drizzle ORM 或 Prisma 會繞過 RLS 嗎? — 是的,如果服務端使用 postgres 超級使用者或 Supabase service_role 金鑰連線就會繞過。因此 Server Actions 和 API 路由必須呼叫 requireWorkspaceViewer() 並用 workspaceId 限定每一次查詢。
  • 為什麼在 RLS 策略裡要把 (select auth.uid()) 用括號包起來? — 這樣能提示 Postgres 最佳化器每條語句只計算一次 auth.uid(),而不是每掃描一行計算一次,從而避免大表上的全表掃描效能雪崩。
  • 後臺任務應該如何訪問租戶資料? — 從已鑑權的服務端路由將驗證過的 workspaceId 傳入任務載荷,並將 Worker 的讀寫操作嚴格限定在該 workspaceId 下。
  • 如何驗證租戶隔離真正生效? — 編寫自動化反向測試,讓使用者 A 通過 Web、移動端和 API 路由請求或修改使用者 B 的記錄 ID,斷言返回 403 / 404 且沒有任何資料行被修改。

常見問題

14 個問題

supastack 是什麼?

supastack 是用於構建 AI 驅動 SaaS 產品的模組化基礎。它把通用基礎設施整合在一起,讓你專注於產品專屬的功能。

它支援 Web 和移動端嗎?

支援。supastack 包含 Next.js 與 Expo 應用,作為 Web 與移動端體驗的起點。

包含哪些認證方式?

Better Auth 覆蓋郵箱/密碼、OTP 與社交登入。個人工作區隨賬戶一起開通,產品記錄從第一天起就有邊界。

AI 是如何支援的?

supastack 包含可配置的多供應商 AI、用量追蹤、預算,以及加密的自帶金鑰(BYOK)支援。

訂閱如何處理?

對於基於該套件構建的產品,Stripe 計費、訂閱與許可權構成付費訪問的基礎。供應商金鑰僅存於服務端。

我的產品專屬程式碼放在哪裡?

產品專屬功能保留在獨立的程式碼庫中,產品通過配置接入 supastack 的基礎模組。

supastack 如何定價?

套件為一次性購買、終身更新:單棧 249 美元(Next.js(Web)、Expo(iOS 與 Android)、Tauri(macOS 與 Windows 桌面)倉庫任選其一),雙棧 398 美元(任選兩個倉庫),或完整套裝 399 美元(全部三個倉庫與完整 monorepo),每個額外開發者席位 75 美元。套件本身沒有訂閱。本站結賬後通過郵件傳送許可證金鑰,倉庫訪問通過 GitHub 邀請授予。

工作區如何界定我的資料?

使用者記錄按 workspaceId 劃定邊界。身份、許可權、AI 用量、計費事件與審計條目都留在個人工作區邊界內。

後臺任務如何工作?

Trigger.dev 是明確的任務執行時。JobRunner 契約只承諾 enqueue、status 與 cancel——工作流語義保持 Trigger.dev 專有且顯式。

郵件與營銷觸達有何不同?

事務性郵件覆蓋產品工作流,如任務完成通知。冷觸達與營銷活動屬於產品自身的範疇,不在本套件的郵件模組內。

移動端購買如何運作?

移動端已支援 RevenueCat 原生購買和恢復購買,訪問許可權由服務端驗證。釋出前需配置商店賬戶並完成裝置測試。Web 計費保持獨立。

AI 供應商金鑰存放在哪裡?

自帶金鑰憑證經過加密且僅存於服務端。金鑰在日誌中被脫敏;用量與預算在工作區級別保持可見。

結賬支援哪些付款方式?

Stripe 託管結賬接受銀行卡,在 Stripe 賬戶啟用後還可提供支付寶與微信支付——方便中國買家。錢包授權為非同步確認,付款完成後許可證頁面會立即更新。

我能申請退款嗎?

獲得程式碼倉庫訪問許可權後,supastack 將永久歸你所有,因此無法退款。但請放心,你將獲得包含終身更新的完整原始碼,可為你自己或客戶構建併發布無限數量的產品。你只需注意不能轉賣程式碼,也不能將倉庫作為啟動模板二次分發。唯一的例外是上線返現——上線一款用 supastack 打造的真實產品並公開分享,在購買後 60 天內達到 5,000 次自然瀏覽或 50 個自然按讚,即可退還已付款項的 50%;詳見條款。

TW