《互联网产品的 AI 架构·多能力编排与任务闭环》9.1 跨能力的权限传播

跨能力调用时,权限最容易在节点之间被悄悄放大:节点代理身份比用户更宽、租户范围在交接时丢失、数据标签被重新合并。本节把用户、编排器与节点代理三层身份拆开,用作用域集合交集实现只收窄不放大的传播,并给出可运行的 TypeScript 实现与示例运行结果。

一次不该发生的查询

「回声」私信客服链是 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 个,再分到各节点

上下文资源动作数据标签委派深度
用户47public/tenant/pii0
编排器45public/tenant1
search.rerank22public/tenant2
content.summarize11tenant2
chat.reply33public/tenant2
action.createOrder11tenant2

关键在最后三行:检索、摘要、回复、动作节点都从编排器上下文派生,而不是从前一个兄弟节点派生。若回复节点继承检索节点的 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 单价。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查