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且没有任何数据行被修改。