跳轉到內容
全部文章

你的工作區是一條安全邊界。

為什麼每次讀寫都要做歸屬檢查,以及為什麼任務 URL 絕不應授予訪問權。

supastack 團隊約 4 分鐘閱讀

How do I keep tenant data isolated in a multi-tenant SaaS?

It depends on your risk tolerance, but the core rule is the same: derive the tenant on the server from the authenticated session, never from a request body or URL. Require workspace membership on every read and write, scope queries in one place, and test cross-tenant requests that must fail. Treat invitations, role changes, API keys, and webhooks as boundary events with their own checks. For higher assurance, add row-level security and an audit log. supastack implements workspace membership and ownership checks, but your schema and queries still determine whether the boundary holds.

在服務端推導歸屬

記錄 ID 標識了某個東西,卻不能證明誰可以讀它。supastack 按 workspaceId 為使用者記錄劃定邊界。服務端解析已認證使用者的工作區,並在讀取或修改記錄時帶上這個邊界。

不要把瀏覽器發來的工作區 ID 當作歸屬證據。瀏覽器可以請求動作,但可信的服務端上下文必須確定這是誰的動作。該規則對任務狀態、已儲存的供應商配置、用量記錄與每個產品自有資源都必須成立。佈局與元件可以過濾渲染什麼,但渲染不是授權——檢查屬於記錄真正被讀或寫的地方。

本文其餘部分都出自這條規則:歸屬是推匯出來的,絕不靠請求宣告。一旦客戶端發來的 workspaceId 在任何地方被信任,其他每個檢查就都淪為裝飾。認證一文講了會話本身在這條邊界上游如何建立。

export async function getProjectForViewer(projectId: string) {
  const viewer = await requireWorkspaceViewer();
  const [project] = await db
    .select()
    .from(projects)
    .where(
      and(
        eq(projects.id, projectId),
        eq(projects.workspaceId, viewer.workspace.id),
      ),
    )
    .limit(1);

  if (!project) throw new ForbiddenError("Project not found in workspace");
  return project;
}

成員關係也是邊界的一部分

範圍回答記錄屬於哪個工作區;成員關係回答這個使用者是否屬於那個工作區。兩者都在服務端解析。supastack 用 owner、admin、member 三種角色建模個人與組織工作區——工作區指南記錄了這個模型。

角色加了第二條軸:member 能讀的,可能只有 admin 能改。把兩項檢查分開——“屬於這個工作區”和“可以執行這個動作”——這樣降級或移除就在和其他一切相同的邊界上生效。邀請與移除屬於同一條生命週期,不是管理員的事後補記。

成員關係的變更還必須立即生效。被移除成員的下一個請求應該在邊界處失敗,而不是等快取的令牌恰好過期——這也是檢查要住在記錄被讀取的地方,而不是早前簽發的憑證裡的又一個原因。

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "workspace_members_select_projects"
ON projects FOR SELECT TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM workspace_members wm
    WHERE wm.workspace_id = projects.workspace_id
      AND wm.user_id = (SELECT auth.uid())
  )
);

測試那些必須失敗的請求

一個讓所有者取回記錄的測試只證明瞭正常路徑,證明不了隔離性。再配上第二個工作區請求同一記錄、一個匿名請求,以及一次試圖替換所有者的寫入。

既斷言外部行為也斷言持久化結果。被拒絕的請求不得改動其他工作區的資料,錯誤響應不得洩露金鑰或資料庫原始細節。這些檢查讓邊界在實現變化時仍可評審。

  • 第二個工作區請求同一個記錄 ID——被拒絕。
  • 匿名請求打到同一個端點——被拒絕。
  • 已被移出工作區的成員重試——被拒絕。
  • 一次寫入指定了別的所有者或工作區——被拒絕,且沒有任何資料被改動。
  • 錯誤響應保持籠統——不洩露金鑰,不洩露 schema 細節。

把規則帶進你的產品

新增產品表時,先做介面之前決定它的歸屬。在持久化模型中包含 workspaceId,通過既有服務端邊界解析工作區,並在列表與詳情操作中都使用該上下文。

後臺 worker 需要同樣的謹慎。入隊任務必須攜帶足夠的可信上下文以載入正確的記錄,而供應商憑證留在服務端。按裸 ID 載入行的定時任務也仍然在這條邊界之內。一條一致的歸屬規則比一堆按路由而異的例外更好維護。

API 端面也屬於同一條邊界。工作區 API 金鑰與簽名的出站 Webhook 隨身攜帶著工作區範圍——金鑰絕不能變成跨租戶讀取的途徑,Webhook 載荷絕不能捎上鄰居的記錄。API 模組指南講了這個端面的形狀。

關於工作區隔離的常見問題

多租戶隔離在每個專案上都會引出同樣那幾個問題。這幾個值得早點定下來。

  • 應用層範圍限定夠嗎,還是需要行級安全? —— 對每次讀寫都做服務端強制限界,已覆蓋大多數 SaaS 的威脅模型。資料庫 RLS 是縱深防禦的補充,但替代不了邊界檢查——worker 和 API 金鑰仍然需要同一條規則。
  • 跨工作區共享應該怎麼做? —— 顯式建模——一個邀請、一行成員記錄——而不是放寬範圍。“任何持有連結的人”應該是一個自帶檢查的設計功能,而不是一場意外。
  • 多租戶應用最先壞掉的是什麼? —— 方便捷徑:恢復連結、管理員工具、按裸 ID 載入記錄的後臺 worker。審計每一條繞過正常範圍就碰到記錄的程式碼路徑。
  • 怎麼給已有表加範圍限定? —— 加 workspaceId、回填、在讀寫中強制執行,然後才動 UI。先發布反向測試,讓邊界是被證明的而不是被假設的。
  • 頁面受保護後服務端 action 還需要檢查嗎? —— 需要。頁面守衛只是藏起介面,並不會為操作授權。每個 action 都獨立解析會話與工作區。

常見問題

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