《互联网产品的 AI 架构·模型接入与能力边界》11.1 密钥、租户与数据出域

从「回声」把合作方租户私信送进主站模型的事故出发,拆解密钥分级与轮换、租户隔离、出域字段白名单和区域路由四道边界,给出可运行的 TypeScript 实现与 11 条测试,说明每次放行为什么都要留下可追溯的决策记录,再复盘一次跨租户调用为什么会同时穿过四道默认值。

一次跨租户的模型调用

「回声」的内容流、私信和站内下单共用一套模型网关。某天,主站客服摘要里出现了一段合作方租户 B 的私信原文,而发起摘要请求的是主站租户 A。事故报告里没有攻击者,只有四个被默认值放大的缺口:网关托管密钥对所有租户可用、请求体直接带上了整段会话、出域没有字段白名单、区域路由在目标区域不可用时静默切到了另一个区域。

四个缺口分别对应四道边界。它们不是四个“最佳实践”,而是四个必须在代码里默认拒绝的开关。任何一道生效,这次事故都不会发生;四道同时失效,才让一段本不该离开租户 B 的文本,出现在了租户 A 的摘要里。

四道边界,而不是一把锁

边界要回答的问题失败时的默认动作
密钥分级这次调用用的是谁的凭证拒绝调用
租户隔离凭证与数据是否属于同一租户拒绝并告警
出域白名单哪些字段可以离开产品边界丢弃字段并记账
区域路由数据可以去哪个区域失败,不跨区兜底

这四道边界的顺序不能交换。先确认凭证归属,再确认租户一致,然后裁剪字段,最后才决定区域与模型。把顺序写进函数,比写进评审清单更可靠,因为函数会被测试,清单不会。

密钥分级:先画爆炸半径

密钥分三级:租户密钥由租户自己轮换,只能服务本租户;网关密钥由平台托管,用于没有自带密钥的租户;提供方密钥只存在于网关到模型提供方这一段,业务方永远看不到。分级的价值不是“多几把钥匙”,而是把爆炸半径写清楚:租户密钥泄露只影响一个租户,网关密钥泄露会影响所有托管租户,提供方密钥泄露则必须立刻全量轮换。

选择密钥必须是一个确定性函数:租户密钥优先于网关密钥,同级取最新创建,再按 id 升序。没有可用密钥时直接抛 auth 错误,绝不回退到“任意一把还能用的钥匙”。事故开关可以临时关闭网关密钥,强制租户使用自己的凭证,这比在告警群里口头约定可靠。

轮换不是删旧建新

轮换最常见的错误是“新建一把、删掉旧的”。在分布式系统里,旧密钥可能还在某个缓存、某个长连接或某个离线任务里使用,直接删除会造成难以解释的 401。正确做法是四步:影子创建、双钥接受、切换主用、旧钥退役,中间留出重叠窗口。窗口长度由最慢的调用方决定,不是拍脑袋的一天。

轮换计划本身也要可校验:新密钥必须属于同一租户、状态为 active、覆盖旧密钥的全部区域,并且不能在旧密钥退役前过期。任何一条不满足,计划阶段就失败,而不是等到线上切换时才发现某个区域没有钥匙。

租户隔离:身份只从会话来

租户隔离的第一原则是身份只从会话来,不从模型参数来,也不从请求体里的 tenantId 来。selectKey 把租户密钥绑定到 tenantId,跨租户的密钥直接进入拒绝列表;如果只剩网关密钥,事故开关可以临时关掉它,强制租户使用自己的凭证。拒绝列表不是日志装饰,它要能回答“为什么这次调用没有可用密钥”。

出域白名单:默认拒绝比列清单更难

出域白名单的难点不是“列出允许字段”,而是决定“默认允许还是默认拒绝”。「回声」选择默认拒绝:不在 allowedFields 里的字段一律丢弃,deniedFields 优先于允许,secret 类字段即使被误写进白名单也永远丢弃。代价是每次新增字段都要改策略,收益是事故不会因为某个新字段悄悄上线而扩大。

数组路径会归一化成 messages[].content,这样策略不必关心第几条消息;kept 与 dropped 排序后写入审计。丢弃必须留下记录,否则产品经理看到的是“模型怎么少了一段上下文”,而不是“哪个字段被策略拦住了”。裁剪函数只做字段级决定,不负责解释业务语义,语义解释留给调用方。

区域路由不做静默兜底

区域路由只做“租户显式同意内的选择”。请求没写区域时用 homeRegion;homeRegion 不在允许列表,或模型不支持该区域时,直接失败,不静默切到另一个区域。跨区不是性能问题,而是合规与合同问题:一次静默切换,可能把数据送到租户没有授权的司法辖区。

把四道边界收进一个模块

下面的模块把密钥选择、轮换计划、字段裁剪与区域路由放在一起。它不依赖任何云厂商 SDK,也不读取环境变量,所有输入都是显式参数,因此可以被确定性测试。

// file: src/security/egress.ts
// tier: L1
// verify: node --test test/security/egress.test.ts

import { AiError } from "../core/errors.ts";
import type { TenantId } from "../core/types.ts";

/** 密钥分三级:租户自带、网关托管、提供方直连。 */
export type KeyTier = "tenant" | "gateway" | "provider";
export type KeyState = "active" | "rotating" | "retired";

export interface SecretKey {
  id: string;
  tier: KeyTier;
  /** 租户密钥必须绑定租户;网关与提供方密钥不绑定。 */
  tenantId?: TenantId;
  regions: string[];
  state: KeyState;
  createdAt: number;
  /** 轮换宽限期的截止时间;到期后即使 state 还是 rotating 也不能再用。 */
  retireAt: number;
}

export type KeyRejectReason =
  "provider-key" | "retired" | "expired" | "tenant-mismatch" | "gateway-disabled" | "region-mismatch";

export interface KeySelection {
  key: SecretKey;
  rejected: Array<{ id: string; reason: KeyRejectReason }>;
}

export interface KeyRequest {
  tenantId: TenantId;
  region: string;
  now: number;
  /** 事故时可以临时关闭网关托管密钥,强制租户使用自己的密钥。 */
  acceptGatewayKeys?: boolean;
}

const TIER_RANK: Record<KeyTier, number> = { tenant: 0, gateway: 1, provider: 2 };

function rejectReason(key: SecretKey, request: KeyRequest): KeyRejectReason | null {
  if (key.tier === "provider") return "provider-key";
  if (key.state === "retired") return "retired";
  if (request.now >= key.retireAt) return "expired";
  if (key.tier === "tenant" && key.tenantId !== request.tenantId) return "tenant-mismatch";
  if (key.tier === "gateway" && request.acceptGatewayKeys === false) return "gateway-disabled";
  if (!key.regions.includes(request.region)) return "region-mismatch";
  return null;
}

/**
 * 选择顺序固定:租户密钥优先于网关密钥,同级取最新创建,再按 id 升序。
 * 没有可用密钥时抛 auth 错误,绝不回退到“任意一把还能用的钥匙”。
 */
export function selectKey(keys: readonly SecretKey[], request: KeyRequest): KeySelection {
  const rejected: KeySelection["rejected"] = [];
  const usable: SecretKey[] = [];
  for (const key of keys) {
    const reason = rejectReason(key, request);
    if (reason === null) usable.push(key);
    else rejected.push({ id: key.id, reason });
  }
  if (usable.length === 0) {
    throw new AiError("auth", `no usable key for tenant ${request.tenantId} in ${request.region}`, {
      cause: rejected,
    });
  }
  usable.sort((a, b) => {
    const byTier = TIER_RANK[a.tier] - TIER_RANK[b.tier];
    if (byTier !== 0) return byTier;
    if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt;
    return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
  });
  return { key: usable[0], rejected };
}

export type RotationStep = "create-shadow" | "accept-both" | "switch-primary" | "retire-old";

export interface RotationPlan {
  tenantId: TenantId;
  currentKeyId: string;
  nextKeyId: string;
  stages: Array<{ at: number; step: RotationStep }>;
}

export interface RotationInput {
  current: SecretKey;
  next: SecretKey;
  now: number;
  overlapMs: number;
}

/** 轮换不是“删旧建新”,而是影子创建、双钥接受、切换主用、旧钥退役四步。 */
export function planRotation(input: RotationInput): RotationPlan {
  const { current, next, now, overlapMs } = input;
  if (current.tier !== "tenant" || next.tier !== "tenant") {
    throw new AiError("invalid-request", "rotation only applies to tenant keys");
  }
  if (current.tenantId === undefined || next.tenantId !== current.tenantId) {
    throw new AiError("invalid-request", "rotation keys must belong to the same tenant");
  }
  if (next.state !== "active") throw new AiError("invalid-request", "next key must be active");
  if (overlapMs <= 0) throw new AiError("invalid-request", "rotation overlap must be positive");
  const missing = current.regions.filter((region) => !next.regions.includes(region));
  if (missing.length > 0) {
    throw new AiError("invalid-request", `next key is missing regions: ${missing.join(",")}`);
  }
  const retireOldAt = now + overlapMs;
  if (next.retireAt <= retireOldAt) {
    throw new AiError("invalid-request", "next key expires before the old key retires");
  }
  const switchAt = now + Math.floor(overlapMs / 2);
  return {
    tenantId: current.tenantId,
    currentKeyId: current.id,
    nextKeyId: next.id,
    stages: [
      { at: now, step: "create-shadow" },
      { at: now, step: "accept-both" },
      { at: switchAt, step: "switch-primary" },
      { at: retireOldAt, step: "retire-old" },
    ],
  };
}

export type FieldClass = "public" | "internal" | "personal" | "sensitive" | "secret";

const SECRET_NAMES = new Set(["apiKey", "api_key", "password", "token", "secret", "authorization", "cookie"]);
const SENSITIVE_NAMES = new Set(["idCard", "id_card", "bankCard", "bank_card", "health", "location"]);
const PERSONAL_NAMES = new Set(["email", "phone", "address", "name", "ip"]);

/** 字段分类只按叶子名判断,路径用于白名单匹配,不参与分类。 */
export function classifyField(path: string): FieldClass {
  const leaf = (path.split(".").pop() ?? path).replace(/\[[^\]]*\]/g, "");
  if (SECRET_NAMES.has(leaf)) return "secret";
  if (SENSITIVE_NAMES.has(leaf)) return "sensitive";
  if (PERSONAL_NAMES.has(leaf)) return "personal";
  return "internal";
}

export interface EgressPolicy {
  tenantId: TenantId;
  homeRegion: string;
  /** 租户显式同意的目的地区域;不在列表内一律拒绝。 */
  allowedRegions: string[];
  /** 允许出域的叶子路径;支持 `messages[].content` 与末尾 `*` 前缀匹配。 */
  allowedFields: string[];
  /** 拒绝优先于允许,用来兜底敏感子字段。 */
  deniedFields: string[];
}

export type EgressReason = "tenant-mismatch" | "region-denied" | "payload-not-object";

export interface EgressDecision {
  allowed: boolean;
  reason?: EgressReason;
  detail?: string;
  payload?: Record<string, unknown>;
  kept: string[];
  dropped: string[];
}

const DROP = Symbol("drop");

function normalizePath(path: string): string {
  return path.replace(/\[\d+\]/g, "[]");
}

function matchesPath(pattern: string, path: string): boolean {
  const wanted = normalizePath(pattern);
  const actual = normalizePath(path);
  return wanted === actual || (wanted.endsWith("*") && actual.startsWith(wanted.slice(0, -1)));
}

function isAllowed(path: string, policy: EgressPolicy): boolean {
  if (classifyField(path) === "secret") return false;
  if (policy.deniedFields.some((pattern) => matchesPath(pattern, path))) return false;
  return policy.allowedFields.some((pattern) => matchesPath(pattern, path));
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function filterValue(
  value: unknown,
  path: string,
  policy: EgressPolicy,
  kept: Set<string>,
  dropped: Set<string>,
): unknown | typeof DROP {
  if (Array.isArray(value)) {
    if (value.length === 0) return filterLeaf(value, path, policy, kept, dropped);
    const output: unknown[] = [];
    for (let index = 0; index < value.length; index += 1) {
      const filtered = filterValue(value[index], `${path}[${index}]`, policy, kept, dropped);
      if (filtered !== DROP) output.push(filtered);
    }
    return output.length > 0 ? output : DROP;
  }
  if (isRecord(value)) {
    const keys = Object.keys(value).sort();
    if (keys.length === 0) return filterLeaf(value, path, policy, kept, dropped);
    const output: Record<string, unknown> = {};
    for (const key of keys) {
      const childPath = path === "" ? key : `${path}.${key}`;
      const filtered = filterValue(value[key], childPath, policy, kept, dropped);
      if (filtered !== DROP) output[key] = filtered;
    }
    return Object.keys(output).length > 0 ? output : DROP;
  }
  return filterLeaf(value, path, policy, kept, dropped);
}

function filterLeaf(
  value: unknown,
  path: string,
  policy: EgressPolicy,
  kept: Set<string>,
  dropped: Set<string>,
): unknown | typeof DROP {
  if (path === "") return value;
  if (isAllowed(path, policy)) {
    kept.add(path);
    return value;
  }
  dropped.add(path);
  return DROP;
}

/** 出域前最后一道裁剪:拒绝优先、secret 永远丢弃、数组下标不参与匹配。 */
export function prepareEgress(
  request: { tenantId: TenantId; destinationRegion: string; payload: unknown },
  policy: EgressPolicy,
): EgressDecision {
  if (request.tenantId !== policy.tenantId) {
    return {
      allowed: false,
      reason: "tenant-mismatch",
      detail: `payload tenant ${request.tenantId} cannot use policy of ${policy.tenantId}`,
      kept: [],
      dropped: [],
    };
  }
  if (!policy.allowedRegions.includes(request.destinationRegion)) {
    return {
      allowed: false,
      reason: "region-denied",
      detail: `tenant ${policy.tenantId} has not approved ${request.destinationRegion}`,
      kept: [],
      dropped: [],
    };
  }
  if (!isRecord(request.payload)) {
    return { allowed: false, reason: "payload-not-object", detail: "payload must be an object", kept: [], dropped: [] };
  }
  const kept = new Set<string>();
  const dropped = new Set<string>();
  const filtered = filterValue(request.payload, "", policy, kept, dropped);
  const payload = filtered === DROP ? {} : (filtered as Record<string, unknown>);
  return { allowed: true, payload, kept: [...kept].sort(), dropped: [...dropped].sort() };
}

export type RegionReason = "region-not-allowed" | "model-region-mismatch" | "home-region-mismatch";

/**
 * 区域路由只做“显式同意内的选择”,不做静默跨区兜底。
 * 请求未指定区域时用租户 homeRegion;homeRegion 不可用时直接失败。
 */
export function routeRegion(
  requested: string | undefined,
  policy: EgressPolicy,
  modelRegions: readonly string[],
): { allowed: boolean; region?: string; reason?: RegionReason } {
  const requestedRegion = typeof requested === "string" ? requested : "";
  const explicit = requestedRegion !== "";
  const region = explicit ? requestedRegion : policy.homeRegion;
  if (!policy.allowedRegions.includes(region)) {
    return { allowed: false, reason: explicit ? "region-not-allowed" : "home-region-mismatch" };
  }
  if (!modelRegions.includes(region)) {
    return { allowed: false, reason: explicit ? "model-region-mismatch" : "home-region-mismatch" };
  }
  return { allowed: true, region };
}

决策顺序是固定的:先验租户与区域,再裁剪字段,最后才谈模型。任何一步失败都返回结构化原因,而不是抛一个笼统的 500。kept 与 dropped 都排序,测试可以直接比对,审计系统也能按字段聚合。

测试与真实输出

测试覆盖 11 个场景:密钥优先级、跨租户与过期密钥、事故开关、区域匹配、轮换四阶段、区域缺失、字段裁剪、嵌套拒绝、租户与区域前置拒绝、路由不兜底、字段分类。在实验工程根目录执行:

node --test test/security/egress.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

ℹ tests 11
ℹ pass 11
ℹ fail 0
ℹ duration_ms 135.49325

复盘:事故里少了哪几道

如果当时用的是租户密钥,B 的私信不会进入 A 的请求;如果出域白名单存在,messages[].content 之外的字段不会被带出;如果区域路由失败即失败,请求会在网关就被拦住。三道边界任何一道生效,事故都不会发生。安全设计里,“多层都失效”比“某一层特别强”更值得警惕。

小结

密钥分级决定爆炸半径,轮换决定切换是否可逆,租户隔离决定数据会不会串门,出域白名单决定字段会不会裸奔,区域路由决定数据会不会越境。五件事都不复杂,难的是把它们变成默认拒绝的代码,而不是事故后的检查项。

阅读导航:上一节:10.3 降级演练与用户可见性 · 下一节:11.2 提示注入与输入信任边界 。

常见错误与边界

  • 把密钥当成配置字符串,不记录归属、区域与到期时间。
  • 轮换时先删旧钥,再观察调用失败。
  • 用请求体里的 tenantId 做隔离依据,而不是会话身份。
  • 出域白名单只列字段名,不处理数组、嵌套与拒绝优先级。
  • 认为 secret 字段“不会有人加进白名单”,因此不做强制丢弃。
  • 区域不可用时静默切到默认区域,把合规问题当成可用性问题。
  • 只记录成功调用,不记录被拒绝的密钥与字段。
  • 把事故复盘写成“加强安全意识”,没有落到代码默认值。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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