一次跨租户的模型调用
「回声」的内容流、私信和站内下单共用一套模型网关。某天,主站客服摘要里出现了一段合作方租户 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字段“不会有人加进白名单”,因此不做强制丢弃。 - 区域不可用时静默切到默认区域,把合规问题当成可用性问题。
- 只记录成功调用,不记录被拒绝的密钥与字段。
- 把事故复盘写成“加强安全意识”,没有落到代码默认值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。