关键要点
从会话中解析租户,而不是从请求中解析。URL段或标题可以选择哪个但服务器必须验证该成员资格。
Next.js中间件是大门,不是门卫。把它用于重定向和廉价支票;在节点API或服务器代码中,在数据旁边进行真正的授权。
每个服务器动作和路由处理程序都是一个公共端点。每次都要在每个里面授权。
让数据库拒绝跨租户读取。具有事务本地设置的PostgreSQL行级安全性意味着被遗忘的过滤器不返回任何内容,而是返回其他人的数据。
权限包括(action, scope),而不是角色名。“可以批准购买在分支3中“是单位授权的,而且转账支票两头都有。
首先编写攻击者的测试:按ID跨租户、跨分支、自我升级、删除最后一个所有者,以及在原始JSON中编辑字段。
我是Mohammadreza Alamnejad,一名程序员和网络安全专家,UI/UX设计者和开发者,也是德黑兰Arka工作室的创始人。我的工作室建成了纳布扎,一个面向咖啡馆和餐馆的运营平台,拥有多个组织、分支机构和仓库。它的公共安全页面描述了这篇文章的原则:行锁定到他们的组织,具有行级安全性,分支和仓库范围检查敏感操作,最后一个所有者不能被删除,任何人都不能授予自己更多的权限。
以下代码是一个我推荐这类产品的参考架构,而不是NABZA的源代码。它将Next.js (App Router)用于web应用程序,并在PostgreSQL前面使用一个节点API(这里是Fastify,但Express的工作方式相同)。我之前发布了一份发布前安全检查清单;这个帖子是租户和RBAC列表中项目的实现方。
1.模型:组织、分支、成员、任务
该域如下所示:
一;一个组织(租户)是一个企业:一个品牌,可能有很多分店。
A 树枝属于一个组织。仓库属于分公司。
A 成员资格将用户链接到组织。
A 作用是按组织定义的命名权限包。
一;一个作业赋予成员一个角色,可以是整个组织的角色,也可以是一个分支的角色。
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL
);
CREATE TABLE branches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL
);
CREATE TABLE memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
user_id uuid NOT NULL,
is_owner boolean NOT NULL DEFAULT false,
UNIQUE (org_id, user_id)
);
CREATE TABLE roles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL,
permissions text[] NOT NULL – e.g. {‘sales.create’,‘inventory.transfer.approve’}
);
CREATE TABLE role_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
membership_id uuid NOT NULL REFERENCES memberships(id) ON DELETE CASCADE,
role_id uuid NOT NULL REFERENCES roles(id),
org_id uuid NOT NULL REFERENCES organizations(id),
branch_id uuid REFERENCES branches(id), – NULL = all branches in the org
UNIQUE NULLS NOT DISTINCT (membership_id, role_id, branch_id) – PostgreSQL 15+
);
两个设计笔记。首先,每个租户拥有的表都带有org_id,即使它可以通过联接派生。多余?稍微。但是它使得行级安全策略简单而快速,并且它使得“哪个租户拥有该行?”一个你永远不需要思考的问题。第二,branch_id IS NULL意思是“组织范围的”保持模型小;值班主管获得一个分支机构的角色分配,所有者或区域经理获得一个NULL。因为branch_id能够NULL,唯一性规则使用NULLS NOT DISTINCT所以同一组织范围的任务不能被插入两次。
2.请求流:谁决定什么
Browser ──► Next.js middleware (cheap: is there a session cookie? redirect if not)
──► Next.js server code (Server Components / Actions / Route Handlers)
──► Node API (authenticate, load membership + grants, authorize)
──► PostgreSQL (RLS: rows outside app.org_id are invisible)
每一层都有一项工作。我看到的最大错误是把批准第一层的决策是因为方便,然后假设后面的一切都是安全的。
3.Next.js中间件:大门,而不是守卫
Next.js中间件在路由之前运行,非常适合重定向。对于依赖于数据库的授权决策来说,这是一个糟糕的地方,并且您不应该将中间件检查视为保护路由的唯一方式。
// middleware.ts
import { NextResponse, type NextRequest } from ‘next/server’;
export function middleware(req: NextRequest) {
const hasSession = req.cookies.has(‘sid’);
if (!hasSession && !req.nextUrl.pathname.startsWith(‘/login’)) {
const url = req.nextUrl.clone();
url.pathname = ‘/login’;
url.searchParams.set(‘next’, req.nextUrl.pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: [‘/((?!_next/static|_next/image|favicon.ico|api/health).*)’],
};
这就是中间件在这里所做的一切:没有会话,直接登录。它并不决定你属于哪个组织或者你可以做什么。
选择活动组织。拥有多个成员资格的用户需要一种切换方式。像这样的路段/o/[orgSlug]/…对于导航来说是好的,但是把它当作请求使用一个成员,而不是一个证明。节点API会对其进行检查。
4.服务器操作和路由处理程序是公共端点
服务器动作看起来像一个函数调用,但它是一个HTTP端点,任何人都可以通过精心制作的输入来调用它。路由处理程序也是如此。所以他们每个人都授权,没有人相信来自客户的id。
// app/o/[orgSlug]/inventory/actions.ts
‘use server’;
import { api } from ‘@/lib/api’; // server-only fetch wrapper that forwards the session cookie
export async function approveTransfer(transferId: string) {
// Don’t pass orgId or branchId from the client. The API derives them.
return api.post(/transfers/${encodeURIComponent(transferId)}/approve);
}
将业务逻辑和授权保留在节点API中意味着规则只存在于一个地方,无论请求来自Next.js应用程序、移动客户端还是集成密钥。
5.节点API:构建一次请求上下文
对于每个请求,对会话进行身份验证,解析所请求的组织,验证成员资格,并加载用户的授权。将结果放在请求上,并将其显式传递给服务。
// api/plugins/context.ts
import fp from ‘fastify-plugin’;
export type Grant = { permission: string; branchId: string | null };
export type Ctx = {
userId: string;
orgId: string;
membershipId: string;
isOwner: boolean;
grants: Grant[];
};
declare module ‘fastify’ {
interface FastifyRequest { ctx: Ctx }
}
export default fp(async (app) => {
app.addHook(‘preHandler’, async (req, reply) => {
const session = await app.sessions.verify(req.cookies.sid); // your session store
if (!session) return reply.code(401).send({ error: ‘unauthenticated’ });
const requestedOrg = req.headers['x-org-slug']; // a request, not a claim const m = await app.db.oneOrNone( `SELECT m.id, m.org_id, m.is_owner FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = $1 AND o.slug = $2`, [session.userId, requestedOrg], ); // Same response for "no such org" and "not your org": don't leak existence. if (!m) return reply.code(404).send({ error: 'not_found' }); const grants = await app.db.any<Grant>( `SELECT unnest(r.permissions) AS permission, ra.branch_id AS "branchId" FROM role_assignments ra JOIN roles r ON r.id = ra.role_id WHERE ra.membership_id = $1`, [m.id], ); req.ctx = { userId: session.userId, orgId: m.org_id, membershipId: m.id, isOwner: m.is_owner, grants };});
});
你可以用https://www.iissbbs.com /AsyncLocalStorage让上下文变得含蓄,而且效果很好。我还是更喜欢传球ctx显式地进入服务:它出现在函数签名、代码审查和测试中,并且后台作业不可能意外地在陈旧的请求上下文中运行。
6.授权:can(ctx, permission, scope)
授权功能故意小。
// api/authz.ts
import type { Ctx } from ‘./plugins/context’;
export class Forbidden extends Error { status = 403; }
export function can(ctx: Ctx, permission: string, scope?: { branchId?: string }) {
if (ctx.isOwner) return true;
return ctx.grants.some(
(g) =>
g.permission === permission &&
(g.branchId === null || (scope?.branchId !== undefined && g.branchId === scope.branchId)),
);
}
export function assertCan(ctx: Ctx, permission: string, scope?: { branchId?: string }) {
if (!can(ctx, permission, scope)) throw new Forbidden(permission);
}
注意这种不对称性:分支范围的授权只在调用者名称一根树枝,而且是正确的。没有分支范围的操作需要整个组织的授权。这防止了分支1中的主管仅仅因为他们在某处拥有权限而调用“组织级”端点。
加载目标,然后授权它的范围
切勿根据客户发送的分行ID进行授权。加载资源(在租户事务内部,见下文)并使用它的分支。
// api/routes/transfers.ts
app.post(‘/transfers/:id/approve’, async (req) => {
return withTenant(req.ctx, async (tx) => {
const t = await tx.oneOrNone(SELECT id, from_branch_id, to_branch_id, status FROM transfers WHERE id = $1,
[req.params.id],
);
if (!t) throw new NotFound(); // RLS already hid other tenants’ rows
// A transfer touches two places: check both ends. assertCan(req.ctx, 'inventory.transfer.approve', { branchId: t.from_branch_id }); assertCan(req.ctx, 'inventory.transfer.receive_into', { branchId: t.to_branch_id }); const updated = await tx.result( `UPDATE transfers SET status = 'approved', approved_by = $2 WHERE id = $1 AND status = 'requested'`, // guarded transition [t.id, req.ctx.userId], ); if (updated.rowCount === 0) throw new Conflict('already_processed'); await audit(tx, req.ctx, 'transfer.approve', t.id, { status: t.status }, { status: 'approved' }); return { ok: true };});
});
7.使用RLS在数据库中确定租户范围
应用程序检查是必要的;它们是不够的。即使查询忘记了,数据库也应该拒绝返回另一个租户的行WHERE org_id = $1.
ALTER TABLE transfers ENABLE ROW LEVEL SECURITY;
ALTER TABLE transfers FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON transfers
USING (org_id = nullif(current_setting(‘app.org_id’, true), ‘’)::uuid)
WITH CHECK (org_id = nullif(current_setting(‘app.org_id’, true), ‘’)::uuid);
current_setting(…, true)返回NULL当设置丢失时,以及org_id = NULL从不为真,因此在没有租户上下文的情况下运行的查询将返回没有行。这就是你想要的失败关闭行为。
设置每个事务的值,从不设置每个连接的值:
// api/db/withTenant.ts
export async function withTenant(ctx: Ctx, fn: (tx: Tx) => Promise): Promise {
return db.tx(async (tx) => {
// third argumenttrue= local to this transaction
await tx.none(SELECT set_config('app.org_id', $1, true), [ctx.orgId]);
return fn(tx);
});
}
有了连接池,会话级SET泄漏到下一个获得该连接的请求中。set_config(…, true)(相当于SET LOCAL)在提交或回滚时消失。
还有两个容易被忽略的要求:
应用程序的数据库角色必须不拥有这些表,并且不能是超级用户或拥有BYPASSRLS;所有者绕过政策,除非FORCE ROW LEVEL SECURITY被设置,超级用户总是绕过它们。
迁移和管理工具使用与应用程序不同的角色。
你们能将分支范围也推入RLS,但是我通常让数据库负责租户边界,让应用程序负责分支和权限规则。租户泄漏是灾难性的,用SQL表达起来很简单;分支规则更加丰富,也更容易在代码中测试。
8.响应中的字段级修订
一些数据依赖于许可,而不仅仅是范围:采购成本、利润、工资。在构建响应时移除它,而不是在React组件中。
// api/serializers/dailySummary.ts
export function serializeDailySummary(ctx: Ctx, s: DailySummary, branchId: string) {
const base = { branchId, openOrders: s.openOrders, alerts: s.alerts };
if (!can(ctx, ‘finance.view’, { branchId })) return base; // no keys, not nulls
return { …base, netSales: s.netSales, grossMargin: s.grossMargin };
}
完全忽略密钥,而不是发送null;anull仍然告诉客户该字段存在,并邀请一些人想知道为什么。
9.角色管理规则:不自我升级,保留所有者
角色编辑通常是授权系统崩溃的地方,因为变化权限本身就是被许可的。
export async function assignRole(ctx: Ctx, tx: Tx, input: { membershipId: string; roleId: string; branchId: string | null }) {
assertCan(ctx, ‘members.manage’, input.branchId ? { branchId: input.branchId } : undefined);
if (input.membershipId === ctx.membershipId && !ctx.isOwner) {
throw new Forbidden(‘cannot_change_own_roles’);
}
const role = await tx.one(SELECT permissions FROM roles WHERE id = $1, [input.roleId]);
// You can only hand out permissions you hold yourself, in that scope.
for (const p of role.permissions) {
assertCan(ctx, p, input.branchId ? { branchId: input.branchId } : undefined);
}
// …insert assignment + audit event
}
export async function removeMember(ctx: Ctx, tx: Tx, membershipId: string) {
assertCan(ctx, ‘members.manage’);
// Lock the org’s owner rows first so two concurrent removals can’t both pass.
const owners = await tx.any(SELECT id FROM memberships WHERE org_id = $1 AND is_owner FOR UPDATE, [ctx.orgId],
);
const isTargetOwner = owners.some((o) => o.id === membershipId);
if (isTargetOwner && owners.length === 1) throw new Conflict(‘last_owner’);
// …delete + audit event
}
在计数之前锁定所有者行很重要:如果没有它,两个所有者同时删除对方可能都会成功。同样的不变量也应该在有人降级一个主人,不仅仅是当他们搬走的时候。
10.测试:首先编写攻击者的测试
这些是我在特性测试之前写的测试,使用Vitest和supertest相对于真实的PostgreSQL(在容器中)而不是模仿。RLS不能用模拟数据库测试。
// test/authz.spec.ts
import { describe, it, expect, beforeAll } from ‘vitest’;
import { seed, loginAs, http } from ‘./helpers’;
describe(‘tenant and branch isolation’, () => {
let s: Awaited<ReturnType>;
beforeAll(async () => { s = await seed(); }); // orgA (branch1, branch2), orgB
it(‘cannot read another tenant’s transfer by id’, async () => {
const a = await loginAs(s.orgA.owner, ‘org-a’);
const res = await http(a).get(/transfers/${s.orgB.transferId});
expect(res.status).toBe(404); // not 403: don’t confirm it exists
});
it(‘branch-1 supervisor cannot approve a transfer out of branch 2’, async () => {
const sup = await loginAs(s.orgA.branch1Supervisor, ‘org-a’);
const res = await http(sup).post(/transfers/${s.orgA.transferFromBranch2}/approve);
expect(res.status).toBe(403);
});
it(‘cannot use an org slug you are not a member of’, async () => {
const a = await loginAs(s.orgA.owner, ‘org-b’);
const res = await http(a).get(‘/branches’);
expect(res.status).toBe(404);
});
it(‘cannot grant yourself a role’, async () => {
const mgr = await loginAs(s.orgA.branch1Manager, ‘org-a’);
const res = await http(mgr).post(‘/members/assign’).send({
membershipId: s.orgA.branch1Manager.membershipId, roleId: s.orgA.roles.owner, branchId: null,
});
expect(res.status).toBe(403);
});
it(‘cannot remove the last owner’, async () => {
const owner = await loginAs(s.orgA.owner, ‘org-a’);
const res = await http(owner).delete(/members/${s.orgA.owner.membershipId});
expect(res.status).toBe(409);
});
it(‘omits financial fields for users without finance.view’, async () => {
const cashier = await loginAs(s.orgA.branch1Cashier, ‘org-a’);
const res = await http(cashier).get(/branches/${s.orgA.branch1}/daily-summary);
expect(res.body).not.toHaveProperty(‘netSales’);
expect(res.body).not.toHaveProperty(‘grossMargin’);
});
it(‘a query without tenant context returns nothing’, async () => {
const rows = await s.appRoleDb.any(‘SELECT id FROM transfers’); // no set_config
expect(rows).toHaveLength(0);
});
});
最后一个测试是我最喜欢的:它证明了当应用程序代码忘记设置租户时,数据库本身会关闭失败。
11.我在每个多租户Next.js应用程序中检查的缺陷
在共享密钥下缓存每租户数据。跨请求缓存的任何内容(unstable_cacheRedis层、CDN)必须在密钥中包含组织(对于权限相关的响应,还包含用户或角色),或者干脆不被缓存。React的每个请求cache()很好;交叉请求缓存是泄漏发生的地方。
已验证页面的静态呈现。确保读取会话的页面是动态的。用某人的数据构建的静态生成的“仪表板”对每个人来说都是一个漏洞。
信任表单中的id。隐藏输入orgId或者branchId是用户输入。在服务器上导出它们。
没有租户的后台工作。工人、克隆工作和出口必须在内部运行withTenant一次一个组织。使用RLS旁路角色循环“所有订单”的作业是等待bug的泄漏。
搜索和分析索引。如果您将数据复制到搜索引擎或仓库中,租户过滤器也必须存在,应用于服务器端。
文件和签名的URL。存储路径应该包括租户,URL签名应该在签名时检查请求者的范围。
确认存在的错误消息。对于“不存在”和“不是你的”,返回相同的404。
具有组织范围权力的集成密钥。API键应该被限定范围(例如一个分支),散列存储,并且只显示一次。
代码中的角色名称。 if (user.role === ‘manager’)分散在代码库中会产生漂移。检查权限,而不是角色名。
没有权限更改审计。每个分配、删除和所有权变更都应该写一个审计事件,包括参与者、时间和之前/之后的状态。
包扎
这个架构故意让人厌烦:中间件重定向,API决定,数据库拒绝。每一层都假设它前面的一层可能是错误的。当一个新的端点、新的工作或新的客户不可避免地忘记一些东西时,这就是为什么以后快速移动是安全的。