权限与租户隔离
在每次资源访问中同时核对身份、成员关系、角色和归属,将租户隔离落实到 SQL、缓存和 AI 检索。
建议先读:登录与会话PostgreSQL 数据建模事务、并发与幂等
本页内容
本章解决什么问题#
用户已经登录,却把地址中的文档 id 换成别人的 id,就读到了私有资料;管理员属于甲团队,却能修改乙团队资源;知识库检索返回了其他租户的段落,模型把它们写进回答。这些都是授权失败。本章目标是建立默认拒绝的资源访问规则,并让规则覆盖查询、修改和下游 AI 检索。前置是服务端会话、成员关系与参数化查询。
前端的路由守卫、菜单过滤和按钮禁用只决定界面如何展示,不能作为授权执行点。攻击者不需要点击按钮,可以直接调用接口;普通用户也可能使用旧版本客户端。服务器必须根据自己的可信身份上下文,在每次敏感操作时判断主体、动作、资源和环境。
把角色与资源归属组合起来#
认证回答“你是谁”,授权回答“你对这个资源可以做什么”。角色是常见的授权输入,却不是全部答案。member 可以读取团队公开文档,但只能修改自己的文档;admin 可以管理所在租户的文档,却不应该自然成为所有租户的管理员。这需要把角色、tenant_id、owner_id、visibility 等属性组合起来,而不是写一个笼统的 isAdmin。
用户选择租户时,前端可以发送目标 tenant_id,但服务端要验证当前用户仍是该租户成员,再把它写入可信上下文。资源查询中的 tenant_id 应来自这个已验证的上下文。请求体里的 user_id、owner_id、role 不能覆盖会话结果。系统管理员如确实需要跨租户操作,应使用单独能力、审计和明确入口,不能让普通团队管理员共享同一个布尔字段。
默认拒绝意味着没有明确匹配允许规则时就失败,而不是“没有发现禁止项就成功”。对不可见资源返回 404 有时可以减少枚举信息,403 则能更直白地说明已知资源的权限不足;选择哪种语义需要一致,不应该依靠响应差异暴露私有资源是否存在。OWASP 授权指南 强调逐次检查与最小权限。
查询接口就是授权边界#
数据访问函数最好接收可信 session 与资源 id,然后在 SQL 中同时限定租户和成员条件。先无条件取出整条记录,再忘记在某个分支检查 tenant_id,是很常见的越权来源。修改操作更应把归属条件放进 UPDATE 或 DELETE,检查 rowCount 是否为一,避免“检查之后、修改之前”资源状态已经变化。
这并不意味着一条 SQL 能解决所有时间竞争。权限撤销在并发事务中有可见性时点,涉及高价值操作时可能需要锁定成员记录或设计额外审批状态。基本原则是先把通常路径收紧,再按业务风险定义授权结果对哪一个时刻负责,不要随口承诺权限撤销在所有分布式节点上瞬间生效。
PostgreSQL Row-Level Security 可作为额外防线,但应用角色必须正确配置。超级用户、BYPASSRLS 角色以及通常情况下的表拥有者会绕过行策略;必须用实际应用角色验证。USING 控制可见旧行,WITH CHECK 限制新增或修改后的行。连接池里若用会话变量传递租户,应在同一事务用事务局部设置,避免上一请求上下文残留;设置值必须来自已验证身份。PostgreSQL RLS 文档 说明了这些例外。
完整示例:团队可读,所有者或管理员可改#
环境:Node 22.22 或 24、PostgreSQL 16+、pg 8。练习目录执行 npm.cmd init -y、npm.cmd install pg@8,设置自己的 DATABASE_URL。PowerShell 可用 $env:DATABASE_URL='postgresql://course:course_password@127.0.0.1:5432/ai_course'。保存为 authorization.mjs,执行 node authorization.mjs。临时表只存在于该连接;session 固定值用于测试可信身份,真实 HTTP 接口必须从会话解析器得到它,不能照抄成可由请求体构造。
// authorization.mjs
import pg from 'pg';
import assert from 'node:assert/strict';
if (!process.env.DATABASE_URL) throw new Error('请设置 DATABASE_URL');
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
function requireIdentity(session) {
if (!session || !Number.isInteger(session.userId) || !Number.isInteger(session.tenantId)) {
throw Object.assign(new Error('请先登录'), { status: 401 });
}
}
function invisible() {
return Object.assign(new Error('资源不存在或不可访问'), { status: 404 });
}
async function readDocument(db, session, documentId) {
requireIdentity(session);
const result = await db.query(`SELECT d.id, d.title, d.visibility
FROM auth_documents AS d
JOIN auth_memberships AS m
ON m.tenant_id = d.tenant_id AND m.user_id = $2
WHERE d.id = $1 AND d.tenant_id = $3
AND (d.visibility = 'team' OR d.owner_id = $2 OR m.role = 'admin')`,
[documentId, session.userId, session.tenantId]);
if (result.rowCount !== 1) throw invisible();
return result.rows[0];
}
async function renameDocument(db, session, documentId, title) {
requireIdentity(session);
if (typeof title !== 'string' || title.trim().length < 1 || title.trim().length > 80) {
throw new Error('标题不合法');
}
const result = await db.query(`UPDATE auth_documents AS d SET title = $4
WHERE d.tenant_id = $1 AND d.id = $2
AND EXISTS (
SELECT 1 FROM auth_memberships AS m
WHERE m.tenant_id = d.tenant_id AND m.user_id = $3
AND (m.role = 'admin' OR d.owner_id = $3)
)
RETURNING d.id, d.title`,
[session.tenantId, documentId, session.userId, title.trim()]);
if (result.rowCount !== 1) throw invisible();
return result.rows[0];
}
try {
await client.query(`
CREATE TEMP TABLE auth_memberships (
tenant_id integer NOT NULL, user_id integer NOT NULL,
role text NOT NULL CHECK (role IN ('member', 'admin')),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TEMP TABLE auth_documents (
id integer PRIMARY KEY, tenant_id integer NOT NULL, owner_id integer NOT NULL,
title text NOT NULL,
visibility text NOT NULL CHECK (visibility IN ('private', 'team')),
FOREIGN KEY (tenant_id, owner_id)
REFERENCES auth_memberships(tenant_id, user_id)
);
INSERT INTO auth_memberships VALUES
(1, 10, 'admin'), (1, 20, 'member'), (2, 30, 'admin');
INSERT INTO auth_documents VALUES
(101, 1, 10, '团队资料', 'team'),
(102, 1, 10, '个人草稿', 'private'),
(201, 2, 30, '乙团队私有资料', 'private');
`);
const adminA = { userId: 10, tenantId: 1 };
const memberA = { userId: 20, tenantId: 1 };
assert.equal((await readDocument(client, memberA, 101)).title, '团队资料');
await assert.rejects(() => readDocument(client, memberA, 102), { status: 404 });
await assert.rejects(() => readDocument(client, adminA, 201), { status: 404 });
await assert.rejects(() => renameDocument(client, memberA, 101, '越权修改'), { status: 404 });
await renameDocument(client, adminA, 101, '已审核团队资料');
assert.equal((await readDocument(client, memberA, 101)).title, '已审核团队资料');
// 撤销成员后,即使手里的会话对象还在,也不能读取团队文档。
await client.query('DELETE FROM auth_memberships WHERE tenant_id = $1 AND user_id = $2', [1, 20]);
await assert.rejects(() => readDocument(client, memberA, 101), { status: 404 });
console.log('团队读取、私有隔离、跨租户拒绝、修改权限与成员撤销检查通过');
} finally {
await client.end();
}
预期输出一条检查通过说明。成员二十不是任何测试文档的 owner,所以删除其成员关系不会触发 owner 外键失败;实际成员离职还需处理归属移交。这一细节说明数据模型与权限流程必须一起设计。
逐段理解权限路径#
readDocument 把角色判断与资源范围放在同一查询里,返回的只是公开字段,不把 owner、内部标签或其他租户资料全部发送后再让前端筛掉。renameDocument 同时检查租户、成员关系和允许动作,即使 member 能读 team 文档,也不能因此修改它,避免把“可见”误当“可写”。
撤销成员后的自检说明会话有效与租户授权有效是两件事。用户还可以保有全局登录态,但不能继续访问已经离开的团队。若为性能缓存成员关系,缓存键、时效和撤销失效机制必须明确;缓存命中不是忽略当前权限变化的理由。
对于 AI 应用,权限要在内容进入模型之前执行。向量检索必须带租户和可见范围过滤,必要时对候选文档再做数据库归属验证;不能把所有团队片段发给模型,再用提示词要求“不要泄漏”。模型输出、引用列表、下载 URL 与后台重建索引也都属于同一资源边界。
先描述一次授权判断的完整输入#
在“团队文档与任务助手”里,一次授权判断至少需要主体、动作、资源和当前上下文。主体是服务器验证过的用户;动作可以是读取、修改、分享或运行解析;资源包含租户、所有者、可见性和状态;上下文可能包含当前成员关系、服务账号范围以及操作发生时间。只有 user.role 的函数缺少资源,通常不能表达私有文档与团队公开文档的区别。
这些输入的可信程度不同。会话解析器提供用户编号,成员关系来自数据库,资源归属从查询结果得到;用户填写的 tenantId 只表示“想在这个团队操作”,并不证明已经获准。模型生成的工具参数更不能升级为可信上下文。若模型请求读取 documentId=201,工具执行层仍应在调用者的租户范围内查找,而不是因为请求来自模型就使用管理员数据库权限读取全部资料。
授权函数可以默认返回拒绝,成功时返回允许的动作集合或一个明确结果。失败原因的内部分类有助于审计,比如无成员关系、资源租户不符、角色不足;公开响应却可以统一成不可见,避免泄漏存在性。不要为了调试方便把“资源属于另一个用户张某”直接返回给无权访问的调用者,内部证据和对外解释应有不同范围。
从一个误判看到角色判断的不足#
假设甲团队管理员打开乙团队文档。若代码只判断 role 等于 admin 就允许,团队角色被错误扩展为全局权限。加入 tenant_id 相等后,仍要检查当前用户是否还拥有该角色;长期会话里缓存的 role 可能是在离职之前签发的。再加入当前成员关系后,读取与修改也不能混用:成员能读公开文档,不等于能改其内容。
这条推导说明权限判断应从业务能力出发,而不是从界面按钮出发。界面可以根据服务器返回的 allowedActions 显示编辑入口,但点击后的接口仍要重新判定,因为页面加载到提交之间状态可能变化。allowedActions 改善交互,不是可永久携带的授权证明。
一个规模较小的产品可以先把规则写成清楚的函数和受限查询;当共享链接、审批、组织层级、文档标签逐渐增加,再考虑统一策略系统。过早引入抽象策略语言,会让开发者连一条“为什么允许”都难以跟踪;规则散落在每个路由里,则会让新增入口漏检。选型应围绕规则数量、解释需求与团队维护能力。
基础实验:纯策略函数的输入与默认行为#
保存为 authorization-policy.mjs,Node 22.22 或 24 执行 node authorization-policy.mjs,无依赖。本实验不访问数据库,刻意将事实查询与规则计算分开;它不能证明调用方传入的事实可信,真实集成仍需服务器构造上下文。
// authorization-policy.mjs
import assert from 'node:assert/strict';
function can(context,action,document) {
// 缺失任何关键事实时默认拒绝,不假设公共资源或游客权限。
if (!context?.userId || !context.membership || !document) return false;
if (context.tenantId !== document.tenantId) return false;
if (context.membership.tenantId !== document.tenantId) return false;
if (context.membership.userId !== context.userId) return false;
const role=context.membership.role;
if (!['member','editor','admin'].includes(role)) return false;
const owner=document.ownerId===context.userId;
if (action==='read') return owner || role==='admin' || document.visibility==='team';
if (action==='update') return owner || role==='admin' || (role==='editor' && document.visibility==='team');
return false; // 未注册的动作不能通过默认分支。
}
const editor={userId:20,tenantId:1,membership:{userId:20,tenantId:1,role:'editor'}};
const team={id:101,tenantId:1,ownerId:10,visibility:'team'};
const privateDoc={...team,id:102,visibility:'private'};
assert.equal(can(editor,'read',team),true);
assert.equal(can(editor,'update',team),true);
assert.equal(can(editor,'read',privateDoc),false);
assert.equal(can(editor,'update',privateDoc),false);
assert.equal(can(editor,'delete',team),false);
assert.equal(can({...editor,tenantId:2},'read',team),false);
assert.equal(can({...editor,membership:null},'read',team),false);
console.log('editor 可编辑团队文档;私有、跨租户、未知动作与撤销成员均被拒绝');
输入中的 action 没有默认允许值,document 缺失也返回 false;返回值始终是布尔值,不夹带数据库连接或 HTTP 响应。这样可以用大量小样本解释规则。纯函数本身不能阻止一个调用者伪造 admin 上下文,因此不能直接暴露为接收客户端角色的“权限接口”。测试规则与验证事实来源是两个不同层次,二者都要存在。
查询层如何避免漏掉资源范围#
对于单条查询,把 tenant_id 放进 WHERE,只查询当前主体能看到的行;返回零行时按约定映射为 404 或 403。对于列表,权限过滤必须在分页和计数之前完成,否则页面可能得到空洞页、错误总数或敏感数量。不能先查询全站最近二十篇文档,再在 JavaScript 中过滤当前用户可见项,这样既降低结果完整性,也让未授权记录经过了应用内存与日志路径。
批量修改尤其容易漏检。用户提交十个 id,其中九个合法、一个属于其他团队,接口应明确选择全部拒绝还是只处理合法项。若合同要求全部成功,应在事务里检查匹配的授权资源数量,再执行修改并验证影响行数;不能无声丢掉一个 id 后仍返回“十项全部完成”。如果允许部分成功,逐项结果也不能泄漏用户本不该知道的资源详情。
对 UPDATE 使用带成员条件的 SQL,rowCount 为零意味着本次没有获准修改到资源,不能把“语句执行没有报错”当成允许。对于受版本限制的编辑,条件还应包含 version,并考虑如何区分版本冲突与权限变化。函数参数应命名为 actor 或 context,提示调用者这是可信上下文;一个普通对象的 TypeScript 类型无法单独建立安全边界。
原练习的数据库参考实现:editor 只可修改团队文档#
保存为 authorization-editor.mjs,安装 pg 8,设置专用练习库的 DATABASE_URL 后执行 node authorization-editor.mjs。下面独立创建临时成员表和文档表,保留读取与修改的不同规则,并实际断言修改被拒绝时内容没有改变。
完整代码已收录在本章末尾的练习参考答案中;可先阅读说明,再展开复制运行。
RLS 的能力与不能替代的工作#
启用行级安全后,如果没有适用策略,普通受限角色默认不能访问行;但超级用户或拥有绕过能力的连接不同。生产应用不应为了迁移方便长期使用表拥有者账号执行业务请求。迁移账号需要建表能力,业务账号需要最小读写能力,两种职责可以分离。RLS 是否工作,必须用实际连接字符串对应的应用角色验证,而不是在管理员控制台看到策略存在就下结论。
USING 判断旧行是否可被操作,WITH CHECK 判断写入后的新行是否仍满足条件。只限制读取而不限制更新后的 tenant_id,可能允许一条当前可见记录被转移到别的租户。数据库策略与复合外键可以互相补充:策略限制操作范围,外键限制关联合法性,应用判断更高层的角色与动作。它们不是互相替代的三种写法。
如果用自定义配置变量传递租户,SET 会影响会话后续语句,连接归还池以后仍可能残留。事务中的 SET LOCAL 或 set_config(name,value,true) 只在当前事务范围生效,但仍要保证设置与查询使用同一 Client。第三个参数 true 表示事务局部,返回设置后的值;变量值必须来自服务器已经核对的成员上下文。这个机制不是防止任意 SQL 注入的万能盾牌,攻击者若已经能在同角色下任意执行 SQL,可能操纵上下文,因此参数化与最小权限仍然必要。
AI 检索与工具调用中的授权链#
向量检索不只是一个搜索性能问题。应先限定租户和允许的文档集合,或在可靠的服务端检索路径中完成等价约束,再把内容交给模型。若先把全库片段发给外部重排模型,再过滤不属于当前租户的结果,秘密已经离开边界,最终回答不显示也不能撤回这个事实。日志、检索调试页面和引用预览同样要受控。
只做最后结果过滤还可能影响召回:全库最相似的二十条里只有一条属于当前团队,过滤后用户会误以为知识库没有资料。授权过滤与检索候选空间应共同设计,保证安全同时保留合理的搜索质量。权限条件发生变化时,索引中的可见范围元数据也要更新,或通过数据库二次确认阻止使用过时索引。
模型工具调用代表用户提出的操作建议,不拥有独立越权资格。删除文档、读取私有内容和创建收费任务都应通过相同业务服务,携带原用户和租户上下文。系统后台维护任务如确实需要跨租户能力,应使用明确受限的服务身份、任务范围和审计记录,不能把一个全局管理员密钥顺手交给所有工具处理器。
调试与生产撤销的时间语义#
排查权限问题时,记录主体编号、租户、动作、资源编号、策略版本与内部拒绝原因,避免记录文档正文或会话令牌。先确认资源本身的真实归属,再确认当前成员关系,最后检查角色分支。前端显示了 admin 不等于数据库仍是 admin;数据库角色正确也不等于当前请求选择了正确租户。
权限缓存引入时间窗口。假设成员关系缓存五分钟,用户离职后旧节点可能继续允许访问五分钟;如果产品要求更快撤销,就需要主动失效、版本检查或降低缓存范围。不要把缓存 TTL 当成无关性能参数,它直接改变安全语义。对于已经开始执行的长任务,撤销后是否继续由业务决定,应在提交时与执行时的授权策略中明确写出。
测试不能只覆盖一个管理员与一个普通用户。至少应覆盖同租户不同所有者、同角色不同租户、刚撤销成员、未知角色、资源改为私有以及批量中混入非法 id。最有价值的测试是修改一个决定权限的事实后,结果确实改变,同时数据库内容不被意外修改。这样权限规则未来演进时,回归测试才真正保护资源边界。
所有权移交与共享链接不是普通字段编辑#
文档标题改变只影响内容,owner_id 或 tenant_id 改变会改变谁拥有访问能力,因此不应放进一个可以任意更新字段的通用 PATCH。团队文档助手可提供专门的移交动作,验证发起者有移交权限、新所有者仍属于同一团队、目标状态允许移交,并在事务中保存审计记录。将整个请求对象展开进 UPDATE,会让普通编辑能力悄悄变成修改权限边界的能力。
跨团队迁移还可能涉及对象文件路径、向量索引、缓存键与历史任务。仅更新数据库一列,旧团队的缓存或下载链接可能仍然有效,新团队的检索索引又尚未建立。可以把迁移建模为明确状态,先限制操作,再复制或更新关联资源,最后发布新归属。过程中的失败与回滚应有方案,不能把一个影响多系统的动作伪装成无副作用的字段修改。
共享链接则引入一种不同的主体:持有链接的人可能没有登录,但被授予读取某个资源的有限能力。链接应不可预测、有有效期、可撤销,并限制到特定动作和版本;随机 id 本身不等于这种明确授权。是否允许下载、是否允许查看后续版本、是否可被搜索引擎索引,都是需要定义的产品语义。共享接口也不能因为允许公开读取一个文档,就顺带暴露整个团队目录。
把能力返回前端时仍保留服务端决定权#
后端可以在文档详情中返回 canEdit、canShare 或 allowedActions,让界面少猜规则。前端根据它决定是否展示按钮和提示,这有助于避免前后端各维护一套角色映射。但能力结果与资源一样有获取时刻:成员被降级、文档被归档以后,旧页面仍可能显示按钮。提交失败时应显示当前限制并刷新能力,而不是因为按钮曾出现就要求服务端继续放行。
对于拒绝访问的产品体验,安全与可理解性可以同时考虑。当前用户主动访问自己刚退出的团队时,可以在团队选择页说明成员关系已经失效;对于随意猜测的私有文档 id,则返回统一不可见。关键在于额外解释是否建立在用户已知且有权获知的上下文上,而不是把所有拒绝都扩展成可供枚举的详细说明。
边界、练习与参考解答#
缓存键若只有 document_id 而没有租户、权限视图或版本,可能把管理员读取的私有结果交给普通成员。预签名下载链接一旦生成,通常具有一段独立有效期;生成前要鉴权,期限与传播范围也需按业务限制。后台任务不能盲目信任几小时前排队时的角色,需明确哪些任务在执行时重新检查授权,哪些任务代表已确认的系统操作。
练习:加入 editor 角色,允许修改 team 文档,但不允许读别人的 private 文档。提示:读取与修改是两个独立策略,不能把 editor 简单加入所有 admin 判断。
参考答案(含完整可运行实现)
成员表角色 CHECK 增加 editor。读取条件保持原本规则;修改条件增加 (m.role = 'editor' AND d.visibility = 'team'),同时保留 owner 与 admin 规则。新增测试分别覆盖 editor 修改 team 成功、读取他人 private 失败、修改他人 private 失败以及跨租户失败。若策略继续增长,可提取统一授权服务或策略模块,但仓储查询仍需保留资源范围条件。
// authorization-editor.mjs
import pg from 'pg';
import assert from 'node:assert/strict';
if (!process.env.DATABASE_URL) throw new Error('请设置 DATABASE_URL');
const db=new pg.Client({connectionString:process.env.DATABASE_URL});
await db.connect();
const actor={userId:20,tenantId:1}; // 练习固定身份,生产从会话与成员服务构造。
async function read(id) {
const r=await db.query(`SELECT d.id,d.title FROM editor_docs d
JOIN editor_members m ON m.tenant_id=d.tenant_id AND m.user_id=$2
WHERE d.id=$1 AND d.tenant_id=$3
AND (d.visibility='team' OR d.owner_id=$2 OR m.role='admin')`,[id,actor.userId,actor.tenantId]);
if (r.rowCount!==1) throw Object.assign(new Error('资源不可见'),{status:404});
return r.rows[0];
}
async function update(id,title) {
if (typeof title!=='string' || !title.trim() || title.trim().length>80) throw new Error('标题无效');
const r=await db.query(`UPDATE editor_docs d SET title=$4
WHERE d.tenant_id=$1 AND d.id=$2 AND EXISTS (
SELECT 1 FROM editor_members m WHERE m.tenant_id=d.tenant_id AND m.user_id=$3
AND (m.role='admin' OR d.owner_id=$3 OR (m.role='editor' AND d.visibility='team')))
RETURNING d.id,d.title`,[actor.tenantId,id,actor.userId,title.trim()]);
if (r.rowCount!==1) throw Object.assign(new Error('资源不可修改'),{status:404});
return r.rows[0];
}
try {
await db.query(`
CREATE TEMP TABLE editor_members(tenant_id integer,user_id integer,
role text NOT NULL CHECK(role IN ('member','editor','admin')),PRIMARY KEY(tenant_id,user_id));
CREATE TEMP TABLE editor_docs(id integer PRIMARY KEY,tenant_id integer NOT NULL,
owner_id integer NOT NULL,title text NOT NULL,visibility text NOT NULL CHECK(visibility IN ('team','private')),
FOREIGN KEY(tenant_id,owner_id) REFERENCES editor_members(tenant_id,user_id));
INSERT INTO editor_members VALUES(1,10,'admin'),(1,20,'editor'),(2,30,'admin');
INSERT INTO editor_docs VALUES(101,1,10,'团队正文','team'),(102,1,10,'私有正文','private'),(201,2,30,'其他团队','team');
`);
assert.equal((await update(101,'编辑后的标题')).title,'编辑后的标题');
await assert.rejects(()=>read(102),{status:404});
await assert.rejects(()=>update(102,'非法覆盖'),{status:404});
await assert.rejects(()=>update(201,'跨团队覆盖'),{status:404});
assert.equal((await db.query('SELECT title FROM editor_docs WHERE id=$1',[102])).rows[0].title,'私有正文');
await db.query('DELETE FROM editor_members WHERE tenant_id=$1 AND user_id=$2',[1,20]);
await assert.rejects(()=>read(101),{status:404});
console.log('editor 编辑团队文档成功;私有和跨团队修改失败;撤销后失去读取权限');
} finally { await db.end(); }
可验证的验收标准#
普通成员能读团队文档但不能改他人文档;私有文档对非所有者隐藏;租户管理员无法跨租户;成员撤销后旧会话失去该租户访问;查询、修改、下载与检索都有归属条件。至少保留一个“把 id 换成别人资源”的负向测试,而不只测管理员成功路径。
三个自测问题与答案#
- 登录有效是否意味着仍是某团队成员?答案:不意味着,全局身份与租户成员关系需要分别验证。
- 隐藏编辑按钮是否完成授权?答案:没有,服务端修改语句仍必须执行角色和归属条件。
- 启用 RLS 后用超级用户测试能证明隔离有效吗?答案:不能,超级用户会绕过策略,应使用真实应用角色检查。
本章验证记录#
编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过:authorization-policy.mjs。以下文件未连接 PostgreSQL 实际执行,SQL 行为、查询计划与并发保证仍需在专用练习数据库验证:authorization.mjs、authorization-editor.mjs。
本章官方参考#
- OWASP Authorization:最小权限、默认拒绝与逐次验证。
- PostgreSQL Row Security Policies:USING、WITH CHECK 和绕过角色。
- OWASP IDOR Prevention:资源标识与访问范围。
- PostgreSQL 配置设置函数:set_config 的事务局部范围。