# 权限与租户隔离

## 本章解决什么问题

用户已经登录，却把地址中的文档 id 换成别人的 id，就读到了私有资料；管理员属于甲团队，却能修改乙团队资源；知识库检索返回了其他租户的段落，模型把它们写进回答。这些都是授权失败。本章目标是建立默认拒绝的资源访问规则，并让规则覆盖查询、修改和下游 AI 检索。前置是服务端会话、成员关系与参数化查询。

前端的路由守卫、菜单过滤和按钮禁用只决定界面如何展示，不能作为授权执行点。攻击者不需要点击按钮，可以直接调用接口；普通用户也可能使用旧版本客户端。服务器必须根据自己的可信身份上下文，在每次敏感操作时判断主体、动作、资源和环境。

## 把角色与资源归属组合起来

认证回答“你是谁”，授权回答“你对这个资源可以做什么”。角色是常见的授权输入，却不是全部答案。member 可以读取团队公开文档，但只能修改自己的文档；admin 可以管理所在租户的文档，却不应该自然成为所有租户的管理员。这需要把角色、tenant_id、owner_id、visibility 等属性组合起来，而不是写一个笼统的 `isAdmin`。

用户选择租户时，前端可以发送目标 tenant_id，但服务端要验证当前用户仍是该租户成员，再把它写入可信上下文。资源查询中的 tenant_id 应来自这个已验证的上下文。请求体里的 user_id、owner_id、role 不能覆盖会话结果。系统管理员如确实需要跨租户操作，应使用单独能力、审计和明确入口，不能让普通团队管理员共享同一个布尔字段。

默认拒绝意味着没有明确匹配允许规则时就失败，而不是“没有发现禁止项就成功”。对不可见资源返回 404 有时可以减少枚举信息，403 则能更直白地说明已知资源的权限不足；选择哪种语义需要一致，不应该依靠响应差异暴露私有资源是否存在。[OWASP 授权指南](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html) 强调逐次检查与最小权限。

## 查询接口就是授权边界

数据访问函数最好接收可信 session 与资源 id，然后在 SQL 中同时限定租户和成员条件。先无条件取出整条记录，再忘记在某个分支检查 tenant_id，是很常见的越权来源。修改操作更应把归属条件放进 UPDATE 或 DELETE，检查 rowCount 是否为一，避免“检查之后、修改之前”资源状态已经变化。

这并不意味着一条 SQL 能解决所有时间竞争。权限撤销在并发事务中有可见性时点，涉及高价值操作时可能需要锁定成员记录或设计额外审批状态。基本原则是先把通常路径收紧，再按业务风险定义授权结果对哪一个时刻负责，不要随口承诺权限撤销在所有分布式节点上瞬间生效。

PostgreSQL Row-Level Security 可作为额外防线，但应用角色必须正确配置。超级用户、BYPASSRLS 角色以及通常情况下的表拥有者会绕过行策略；必须用实际应用角色验证。`USING` 控制可见旧行，`WITH CHECK` 限制新增或修改后的行。连接池里若用会话变量传递租户，应在同一事务用事务局部设置，避免上一请求上下文残留；设置值必须来自已验证身份。[PostgreSQL RLS 文档](https://www.postgresql.org/docs/18/ddl-rowsecurity.html) 说明了这些例外。

## 完整示例：团队可读，所有者或管理员可改

环境：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 接口必须从会话解析器得到它，不能照抄成可由请求体构造。

```js authorization.mjs
// 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`，无依赖。本实验不访问数据库，刻意将事实查询与规则计算分开；它不能证明调用方传入的事实可信，真实集成仍需服务器构造上下文。

```js 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 判断。

<details><summary>参考答案（含完整可运行实现）</summary>

成员表角色 CHECK 增加 editor。读取条件保持原本规则；修改条件增加 `(m.role = 'editor' AND d.visibility = 'team')`，同时保留 owner 与 admin 规则。新增测试分别覆盖 editor 修改 team 成功、读取他人 private 失败、修改他人 private 失败以及跨租户失败。若策略继续增长，可提取统一授权服务或策略模块，但仓储查询仍需保留资源范围条件。





```js authorization-editor.mjs
// 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(); }
```


</details>

## 可验证的验收标准

普通成员能读团队文档但不能改他人文档；私有文档对非所有者隐藏；租户管理员无法跨租户；成员撤销后旧会话失去该租户访问；查询、修改、下载与检索都有归属条件。至少保留一个“把 id 换成别人资源”的负向测试，而不只测管理员成功路径。

## 三个自测问题与答案

1. 登录有效是否意味着仍是某团队成员？答案：不意味着，全局身份与租户成员关系需要分别验证。
2. 隐藏编辑按钮是否完成授权？答案：没有，服务端修改语句仍必须执行角色和归属条件。
3. 启用 RLS 后用超级用户测试能证明隔离有效吗？答案：不能，超级用户会绕过策略，应使用真实应用角色检查。


## 本章验证记录

编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过：`authorization-policy.mjs`。以下文件未连接 PostgreSQL 实际执行，SQL 行为、查询计划与并发保证仍需在专用练习数据库验证：`authorization.mjs`、`authorization-editor.mjs`。

## 本章官方参考

- [OWASP Authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)：最小权限、默认拒绝与逐次验证。
- [PostgreSQL Row Security Policies](https://www.postgresql.org/docs/18/ddl-rowsecurity.html)：USING、WITH CHECK 和绕过角色。
- [OWASP IDOR Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html)：资源标识与访问范围。
- [PostgreSQL 配置设置函数](https://www.postgresql.org/docs/18/functions-admin.html)：set_config 的事务局部范围。
