一次不该发生的查询
「回声」私信客服链是 support.assist → chat.reply。用户问「我上周那单退款到哪了」,检索节点按用户 id 查到了订单,回复节点却引用了一个不属于该用户的订单号。排查后发现,检索节点拿的是用户上下文,回复节点拿的是编排器的服务身份;服务身份有租户内的宽读权限,于是越权查询在模型层看起来完全正常。
这不是模型出错,而是权限在节点之间被放大了。单能力调用只有一个身份,跨能力调用至少有三种身份:发起请求的用户、负责调度的编排器、真正执行某个能力的节点代理。三者中任何一个把权限当成「服务账号的权限」而不是「用户权限的子集」,越权就会发生。
本节讨论跨能力的权限传播。它不改变三能力顺序编排的成本与延迟基线——示例样本仍是 4,240 → 3,786 微元、1,200 → 1,269ms;权限收敛决定的是「能不能调用、能看多少数据」,而不是单次调用本身的价目表。
三层身份,三条边界
| 身份 | 代表谁 | 可以决定什么 | 不能决定什么 |
|---|---|---|---|
| 用户身份 subject | 终端用户 | 本次任务允许访问的资源与动作 | 具体由哪个能力执行 |
| 编排器身份 orchestrator | 调度逻辑 | 调用哪些能力、以什么顺序 | 越过用户权限做外部动作 |
| 节点代理身份 actor | 单个能力 | 只执行本节点需要的动作 | 改变主体、租户或扩大范围 |
三条边界必须同时成立:主体不能换,租户不能换,有效权限只能是父上下文的子集。编排器可以「代表用户」调用节点,但代表不等于替代;节点代理再可信,也不能拿到用户没有的权限。
权限是集合,传播是交集
一个作用域写成「资源 + 动作集合」。用户可能有 order: read/create/refund、message: read/send、catalog: read、policy: read,共 4 个资源、7 个动作。编排器声明它需要 5 个动作,节点的声明再从中取交集。
传播函数只做三件事:对作用域求交、对数据标签求交、对过期时间取较早值。任何节点申请但父上下文没有的动作,进入 droppedScopes,而不是进入有效权限。这条规则让「权限放大」变成一个可检测的事件,而不是一个只能靠评审发现的习惯问题。
示例:7 个动作收敛到 5 个,再分到各节点
| 上下文 | 资源 | 动作 | 数据标签 | 委派深度 |
|---|---|---|---|---|
| 用户 | 4 | 7 | public/tenant/pii | 0 |
| 编排器 | 4 | 5 | public/tenant | 1 |
| search.rerank | 2 | 2 | public/tenant | 2 |
| content.summarize | 1 | 1 | tenant | 2 |
| chat.reply | 3 | 3 | public/tenant | 2 |
| action.createOrder | 1 | 1 | tenant | 2 |
关键在最后三行:检索、摘要、回复、动作节点都从编排器上下文派生,而不是从前一个兄弟节点派生。若回复节点继承检索节点的 2 个动作,它就没有 message: send,正常的答复动作会被自己拒绝。兄弟节点之间不互相授权,这是串行链路上最容易写错的一处。
申请超集时裁剪并留痕
节点声明「我需要 order: read/refund 和 audit: read」,而编排器只有 order: read/create。示例运行的结果是:有效动作只剩 1 个,order: refund 与 audit: read 进入 dropped,数据标签 pii 与 secret 也被丢弃,amplificationAttempted 为 true。
默认策略是裁剪而不是抛错,因为一次裁剪通常意味着节点配置漂移;直接失败会让整条链路不可用。但裁剪必须留痕:记录被丢弃的作用域、标签与节点 id,才能区分「配置写宽了」和「有人在试探」。对不可逆动作,可以在部署配置里把默认策略改成拒绝。
租户、标签、过期与委派深度
租户不一致直接拒绝,不裁剪。租户是硬边界,裁剪会把一次跨租户调用伪装成一次普通越权;示例中节点代理属于 tenant-b,父上下文属于 tenant-a,结果是 tenant-mismatch,上下文为 null。
数据标签只收窄:用户有 public/tenant/pii,检索节点拿 public/tenant,摘要节点只拿 tenant。过期时间取父上下文与节点声明的较早值。委派深度默认上限 4,超过就拒绝,防止一条编排链无限转发同一个用户身份。
实现
下面的模块把作用域规范化、交集、子集判断与传播结果放在一起。它不访问数据库,也不读取环境变量,所有输入都是显式参数,因此可以被确定性测试。
// file: src/orch/permission.ts
// tier: L1
// verify: node --test test/orch/permission.test.ts
import { AiError } from "../core/errors.ts";
/** 身份只分三类:终端用户、编排器、能力节点。节点不能冒充用户。 */
export type PrincipalKind = "user" | "orchestrator" | "node";
export interface Principal {
id: string;
kind: PrincipalKind;
tenantId: string;
}
/** 一条权限 = 资源 + 允许的动作集合。资源与动作都必须是稳定标识。 */
export interface Scope {
resource: string;
actions: readonly string[];
}
/**
* 某一次节点调用看到的安全上下文。
* subject 是最终用户,actor 是当前节点代理,两者不能混用。
*/
export interface PermissionContext {
runId: string;
subject: Principal;
actor: Principal;
tenantId: string;
scopes: readonly Scope[];
dataLabels: readonly string[];
expiresAt: number;
delegation: readonly string[];
}
/** 节点声明自己需要的权限;与父上下文求交集后才成为有效权限。 */
export interface NodeGrant {
actor: Principal;
scopes: readonly Scope[];
dataLabels: readonly string[];
expiresAt: number;
}
export type PropagationReason =
| "tenant-mismatch"
| "principal-kind"
| "expired"
| "delegation-depth";
export interface PropagationResult {
/** 被拒绝时为 null;调用方必须按“没有上下文”处理,而不是复用父上下文。 */
context: PermissionContext | null;
effectiveScopes: Scope[];
droppedScopes: Scope[];
droppedDataLabels: string[];
parentActions: number;
effectiveActions: number;
amplificationAttempted: boolean;
narrowed: boolean;
denied: boolean;
reason?: PropagationReason;
delegationDepth: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `permission: ${message}`);
}
function nonEmpty(value: string, field: string): string {
const trimmed = value.trim();
if (trimmed === "") fail(`${field} 不能为空`);
if (trimmed.includes("\u0000")) fail(`${field} 不能包含 NUL`);
return trimmed;
}
function canonicalActions(actions: readonly string[]): string[] {
const seen = new Set<string>();
for (const action of actions) seen.add(nonEmpty(action, "action"));
return [...seen].sort();
}
/** 同一资源的动作合并去重并排序,保证同一集合每次得到同一表示。 */
export function canonicalScopes(scopes: readonly Scope[]): Scope[] {
const byResource = new Map<string, Set<string>>();
for (const scope of scopes) {
const resource = nonEmpty(scope.resource, "resource");
const actions = byResource.get(resource) ?? new Set<string>();
for (const action of canonicalActions(scope.actions)) actions.add(action);
byResource.set(resource, actions);
}
return [...byResource.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([resource, actions]) => ({ resource, actions: [...actions].sort() }));
}
function scopeMap(scopes: readonly Scope[]): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const scope of canonicalScopes(scopes)) map.set(scope.resource, new Set(scope.actions));
return map;
}
/** 父集合与请求集合的交集;请求中超出父集合的部分进入 dropped。 */
export function intersectScopes(
parent: readonly Scope[],
requested: readonly Scope[],
): { effective: Scope[]; dropped: Scope[] } {
const parentMap = scopeMap(parent);
const effective: Scope[] = [];
const dropped: Scope[] = [];
for (const scope of canonicalScopes(requested)) {
const allowed = parentMap.get(scope.resource) ?? new Set<string>();
const kept = scope.actions.filter((action) => allowed.has(action));
const lost = scope.actions.filter((action) => !allowed.has(action));
if (kept.length > 0) effective.push({ resource: scope.resource, actions: kept });
if (lost.length > 0) dropped.push({ resource: scope.resource, actions: lost });
}
return { effective: canonicalScopes(effective), dropped: canonicalScopes(dropped) };
}
/** 请求的每个动作都能在父集合里找到,才算真正的子集。 */
export function isScopeSubset(requested: readonly Scope[], granted: readonly Scope[]): boolean {
return intersectScopes(granted, requested).dropped.length === 0;
}
export function scopeActionCount(scopes: readonly Scope[]): number {
return canonicalScopes(scopes).reduce((total, scope) => total + scope.actions.length, 0);
}
export function can(
context: PermissionContext,
resource: string,
action: string,
now = 0,
): boolean {
if (context.expiresAt <= now) return false;
const actions = scopeMap(context.scopes).get(resource);
return actions !== undefined && actions.has(action);
}
function deny(
parent: PermissionContext,
reason: PropagationReason,
grantedScopes: readonly Scope[],
grantedLabels: readonly string[],
): PropagationResult {
const dropped = canonicalScopes(grantedScopes);
return {
context: null,
effectiveScopes: [],
droppedScopes: dropped,
droppedDataLabels: [...new Set(grantedLabels)].sort(),
parentActions: scopeActionCount(parent.scopes),
effectiveActions: 0,
amplificationAttempted: dropped.length > 0,
narrowed: true,
denied: true,
reason,
delegationDepth: parent.delegation.length,
};
}
/**
* 把一个节点需要的权限收敛进父上下文。
* 结果只会是父集合的子集;租户、数据标签、过期时间与委派深度都只能收窄。
*/
export function propagatePermission(
parent: PermissionContext,
grant: NodeGrant,
now: number,
maxDelegationDepth = 4,
): PropagationResult {
if (parent.tenantId !== parent.subject.tenantId) fail("subject 与上下文租户不一致");
if (parent.tenantId !== parent.actor.tenantId) fail("actor 与上下文租户不一致");
if (!Number.isFinite(now)) fail("now 必须是有限数");
if (!Number.isInteger(maxDelegationDepth) || maxDelegationDepth < 0) {
fail("maxDelegationDepth 必须是 >= 0 的整数");
}
const grantedScopes = canonicalScopes(grant.scopes);
const grantedLabels = [...new Set(grant.dataLabels.map((label) => nonEmpty(label, "dataLabel")))].sort();
if (grant.actor.kind === "user") return deny(parent, "principal-kind", grantedScopes, grantedLabels);
if (grant.actor.tenantId !== parent.tenantId) {
return deny(parent, "tenant-mismatch", grantedScopes, grantedLabels);
}
if (parent.expiresAt <= now || grant.expiresAt <= now) {
return deny(parent, "expired", grantedScopes, grantedLabels);
}
if (parent.delegation.length >= maxDelegationDepth) {
return deny(parent, "delegation-depth", grantedScopes, grantedLabels);
}
const intersection = intersectScopes(parent.scopes, grantedScopes);
const parentLabels = new Set(parent.dataLabels);
const effectiveLabels = grantedLabels.filter((label) => parentLabels.has(label));
const droppedLabels = grantedLabels.filter((label) => !parentLabels.has(label));
const effective = intersection.effective;
const effectiveActions = scopeActionCount(effective);
const parentActions = scopeActionCount(parent.scopes);
const delegation = [...parent.delegation, `${parent.actor.id}->${grant.actor.id}`];
return {
context: {
runId: parent.runId,
subject: parent.subject,
actor: grant.actor,
tenantId: parent.tenantId,
scopes: effective,
dataLabels: effectiveLabels,
expiresAt: Math.min(parent.expiresAt, grant.expiresAt),
delegation,
},
effectiveScopes: effective,
droppedScopes: intersection.dropped,
droppedDataLabels: droppedLabels,
parentActions,
effectiveActions,
amplificationAttempted: intersection.dropped.length > 0 || droppedLabels.length > 0,
narrowed: effectiveActions < parentActions || effectiveLabels.length < parentLabels.size,
denied: false,
delegationDepth: delegation.length,
};
}
propagatePermission 的返回值里,context 在拒绝时为 null。调用方必须按「没有上下文」处理,不能悄悄回退到父上下文,否则一次拒绝会变成一次静默放行。
示例样本上的运行结果
把上面的上下文输入模块,得到的结果与文章中的表一致:用户 4 资源 / 7 动作,编排器 4 / 5,检索 2 / 2,摘要 1 / 1,回复 3 / 3,动作 1 / 1。所有节点的有效动作都没有超过编排器,编排器没有超过用户。
拒绝路径也在示例样本上跑过:跨租户返回 tenant-mismatch,用户身份冒充返回 principal-kind,过期授权返回 expired,委派深度超过 4 返回 delegation-depth。四种拒绝都返回 context: null 与 effectiveActions: 0,不会留下半套权限。
把权限检查放进编排引擎
第一,上下文由编排器在每次调用前构造,节点不能自己声明有效权限;节点只能提交 NodeGrant 作为申请。
第二,检查顺序固定:主体与租户一致、授权未过期、委派深度未超限、作用域求交、标签求交。顺序固定才能让拒绝原因稳定,排障时不用猜是哪一步先失败。
第三,委派链写入审计。orchestrator-1->search、orchestrator-1->reply 这样的字符串不参与授权判断,但它能回答「这次调用是谁代表谁发起的」。
第四,拒绝不降级。权限失败不是模型失败,不能重试、不能换模型、不能回退到服务身份。
小结
跨能力权限传播的核心不是「传一个 token」,而是三层身份与三条边界:用户身份不能换、租户不能换、有效权限只能收窄。示例样本里,用户的 7 个动作先收敛到编排器的 5 个,再按节点分到 2、1、3、1 个;任何申请超集的节点都会被裁剪并留痕。
这套机制不改动三能力编排的 4,240 → 3,786 微元与 1,200 → 1,269ms 基线,但它决定了这条链路能不能安全地跑在多租户环境里。
阅读导航:上一节:8.3 编排经济性复核 · 下一节:9.2 外部动作的授权边界 。
常见错误与边界
- 把服务身份当成用户身份。节点权限再可信,也不能超过发起请求的用户。
- 串行链路让下游继承上游节点的上下文。兄弟节点应从编排器派生,否则会丢动作。
- 节点自己构造权限上下文。有效权限必须由编排器计算并签名。
- 申请超集时直接扩大有效权限。裁剪并留痕,严重场景再改成拒绝。
- 只裁剪动作、不裁剪数据标签。标签泄漏同样会造成跨范围数据暴露。
- 过期时间取父上下文或节点声明的较大值。应取较早值。
- 委派链无限延长。默认上限 4,超过即拒绝。
- 拒绝后回退到父上下文。失败关闭,不能静默放行。
- 把权限检查放在模型提示里。提示是数据,权限判断必须在服务端代码里。
- 以为权限收敛会改变模型账单。它改变的是可调用范围,不是 token 单价。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。