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