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())
)
);讓恢復有用,但不授予訪問
任務 URL 讓使用者能在重新整理後回到原處。它之所以有用,正因為保留了工作的引用。已認證的 API 在返回狀態或結果之前,仍必須驗證所請求的任務屬於當前工作區。
同樣的原則適用於提交恢復。冪等鍵幫助識別重複請求、防止重複工作,但它既不是會話也不是訪問令牌。重試已儲存的提交仍要求認證、工作區歸屬與相應許可權。
方便的捷徑正是隔離被悄悄侵蝕的地方。幫助合法使用者回到工作的連結——重新整理之後、來自完成郵件——必須和任何其他請求穿過同一個已認證檢查。從請求到結果的管線正是按這個原則使用任務級冪等鍵。
測試那些必須失敗的請求
一個讓所有者取回記錄的測試只證明瞭正常路徑,證明不了隔離性。再配上第二個工作區請求同一記錄、一個匿名請求,以及一次試圖替換所有者的寫入。
既斷言外部行為也斷言持久化結果。被拒絕的請求不得改動其他工作區的資料,錯誤響應不得洩露金鑰或資料庫原始細節。這些檢查讓邊界在實現變化時仍可評審。
- 第二個工作區請求同一個記錄 ID——被拒絕。
- 匿名請求打到同一個端點——被拒絕。
- 已被移出工作區的成員重試——被拒絕。
- 一次寫入指定了別的所有者或工作區——被拒絕,且沒有任何資料被改動。
- 錯誤響應保持籠統——不洩露金鑰,不洩露 schema 細節。
把規則帶進你的產品
新增產品表時,先做介面之前決定它的歸屬。在持久化模型中包含 workspaceId,通過既有服務端邊界解析工作區,並在列表與詳情操作中都使用該上下文。
後臺 worker 需要同樣的謹慎。入隊任務必須攜帶足夠的可信上下文以載入正確的記錄,而供應商憑證留在服務端。按裸 ID 載入行的定時任務也仍然在這條邊界之內。一條一致的歸屬規則比一堆按路由而異的例外更好維護。
API 端面也屬於同一條邊界。工作區 API 金鑰與簽名的出站 Webhook 隨身攜帶著工作區範圍——金鑰絕不能變成跨租戶讀取的途徑,Webhook 載荷絕不能捎上鄰居的記錄。API 模組指南講了這個端面的形狀。
關於工作區隔離的常見問題
多租戶隔離在每個專案上都會引出同樣那幾個問題。這幾個值得早點定下來。
- 應用層範圍限定夠嗎,還是需要行級安全? —— 對每次讀寫都做服務端強制限界,已覆蓋大多數 SaaS 的威脅模型。資料庫 RLS 是縱深防禦的補充,但替代不了邊界檢查——worker 和 API 金鑰仍然需要同一條規則。
- 跨工作區共享應該怎麼做? —— 顯式建模——一個邀請、一行成員記錄——而不是放寬範圍。“任何持有連結的人”應該是一個自帶檢查的設計功能,而不是一場意外。
- 多租戶應用最先壞掉的是什麼? —— 方便捷徑:恢復連結、管理員工具、按裸 ID 載入記錄的後臺 worker。審計每一條繞過正常範圍就碰到記錄的程式碼路徑。
- 怎麼給已有表加範圍限定? —— 加
workspaceId、回填、在讀寫中強制執行,然後才動 UI。先發布反向測試,讓邊界是被證明的而不是被假設的。 - 頁面受保護後服務端 action 還需要檢查嗎? —— 需要。頁面守衛只是藏起介面,並不會為操作授權。每個 action 都獨立解析會話與工作區。