《互联网产品的客户端架构·规模化与架构演进》8.1 数据分级与最小权限

数据分级只有落到字段、用途与流向上才可执行。本节把公开、内部、敏感、极敏感四级数据写成可测试的纯函数,演示字段级最大级别、用途绑定、流向上限、保留期审计与最小权限拒绝,并给出可复算的判据与常见反例,说明客户端判定与服务端复核的边界,让「能不能用、给谁用、留多久」在一次判定里同时回答。

通讯录接口返回四个字段,前端只该拿到一个

某招聘产品做「邀请同事」,客户端读取通讯录后生成邀请列表。上线两周后,安全团队在第三方 SDK 的流量里看到整表上传:姓名、手机号、邮箱,一个不少。

复盘时三方都觉得委屈。产品说只用了姓名;客户端说接口就返回这些字段;SDK 说调用方传了整表。翻出数据资产表,只有一行:

资产级别
通讯录敏感

这一行回答不了三个问题:哪些字段算敏感、这份数据允许给谁、能留多久。分级如果只停在资产名上,它就无法阻止任何一次具体的传输。

分级要回答四个问题

把数据分级当成「贴一个标签」,最后一定会得到一张没人用的 Excel。可执行的分级至少要回答四件事:

维度取值回答的问题
级别公开 / 内部 / 敏感 / 极敏感泄露后影响多大
字段资产内的每个字段各自定级这次请求到底碰了什么
用途核心服务 / 分析 / 个性化 / 广告 / 安全审计为什么现在要用
流向本机 / 自有服务 / 第三方 / 导出数据要去哪里

四个维度不是并列的标签,而是同一次判定里的四个输入。缺任何一个,拒绝理由就说不清楚。

级别含义示例字段允许的最高流向
public泄露无影响系统版本、屏幕尺寸导出
internal仅内部使用订单号、设备 ID第三方(需声明用途)
sensitive泄露造成困扰城市、邮箱、点击 ID导出
very-sensitive泄露造成实质伤害精确坐标、手机号、广告标识自有服务

这张表的关键不是级别名字,而是最后一列:级别直接决定流向上限。敏感数据不是「小心一点就能给第三方」,而是从规则上就不允许。

用途绑定不是一句声明

很多团队把「用途」写成隐私政策里的一段话,代码里没有任何约束。结果是同一个埋点 SDK 既做崩溃分析,又做广告归因,因为「数据已经采了,不用白不用」。

用途绑定要求每个资产显式列出允许的用途,请求必须命中其中之一。广告标识可以用于 ads 与 analytics,但不能因为「安全审计」听起来正当就顺手放行。用途是白名单,不是解释权。

把判定写成纯函数

下面的模块把级别、字段、用途、流向和保留期收进同一份资产清单,判定函数只做纯计算,方便在客户端、服务端和审计脚本里复用:

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

export type DataLevel = "public" | "internal" | "sensitive" | "very-sensitive";

export type Purpose =
  | "core-service"
  | "analytics"
  | "personalization"
  | "ads"
  | "security-audit";

export type Channel = "local" | "own-service" | "third-party" | "export";

export type FieldSpec = { name: string; level: DataLevel };

export type DataAsset = {
  id: string;
  title: string;
  fields: FieldSpec[];
  purposes: Purpose[];
  channels: Channel[];
  retentionDays: number | null;
};

export type AccessRequest = {
  assetId: string;
  fields: string[];
  purpose: Purpose;
  channel: Channel;
};

export type AccessDecision = {
  allowed: boolean;
  level: DataLevel | "unknown";
  reasons: string[];
};

export const DATA_LEVELS: DataLevel[] = [
  "public",
  "internal",
  "sensitive",
  "very-sensitive",
];

const LEVEL_RANK: Record<DataLevel, number> = {
  public: 1,
  internal: 2,
  sensitive: 3,
  "very-sensitive": 4,
};

const CHANNEL_CEILING: Record<Channel, number> = {
  local: 4,
  "own-service": 4,
  "third-party": 2,
  export: 3,
};

const RETENTION_LIMIT: Partial<Record<DataLevel, number>> = {
  sensitive: 365,
  "very-sensitive": 30,
};

export const ASSETS: DataAsset[] = [
  {
    id: "order-basic", title: "订单基础信息",
    fields: [
      { name: "orderId", level: "internal" },
      { name: "amount", level: "internal" },
      { name: "status", level: "internal" },
    ],
    purposes: ["core-service", "analytics", "security-audit"],
    channels: ["local", "own-service"], retentionDays: null,
  },
  {
    id: "device-profile", title: "设备画像",
    fields: [
      { name: "osVersion", level: "public" },
      { name: "screenSize", level: "public" },
      { name: "deviceId", level: "internal" },
    ],
    purposes: ["core-service", "analytics", "personalization"],
    channels: ["local", "own-service", "third-party", "export"], retentionDays: null,
  },
  {
    id: "precise-location", title: "精确位置",
    fields: [
      { name: "city", level: "sensitive" },
      { name: "latitude", level: "very-sensitive" },
      { name: "longitude", level: "very-sensitive" },
    ],
    purposes: ["core-service", "personalization", "ads"],
    channels: ["local", "own-service"], retentionDays: 30,
  },
  {
    id: "contact-book", title: "通讯录",
    fields: [
      { name: "displayName", level: "sensitive" },
      { name: "phone", level: "very-sensitive" },
      { name: "email", level: "sensitive" },
    ],
    purposes: ["core-service"],
    channels: ["local", "own-service"], retentionDays: 30,
  },
  {
    id: "ad-identifier", title: "广告标识",
    fields: [
      { name: "clickId", level: "sensitive" },
      { name: "idfa", level: "very-sensitive" },
    ],
    purposes: ["ads", "analytics"],
    channels: ["local", "own-service"], retentionDays: 30,
  },
];

export function levelRank(level: DataLevel): number {
  return LEVEL_RANK[level];
}

export function maxLevel(levels: DataLevel[]): DataLevel {
  return levels.reduce<DataLevel>(
    (strongest, level) => (LEVEL_RANK[level] > LEVEL_RANK[strongest] ? level : strongest),
    "public",
  );
}

export function assetById(id: string): DataAsset | undefined {
  return ASSETS.find((asset) => asset.id === id);
}

export function resolveFields(
  assetId: string,
  fields: string[],
): { level: DataLevel; unknown: string[] } | undefined {
  const asset = assetById(assetId);
  if (!asset) {
    return undefined;
  }
  const known = new Map(asset.fields.map((field) => [field.name, field.level]));
  const unknown = fields.filter((name) => !known.has(name));
  const levels = fields
    .filter((name) => known.has(name))
    .map((name) => known.get(name) as DataLevel);
  return { level: maxLevel(levels), unknown };
}

export function channelAllowed(level: DataLevel, channel: Channel): boolean {
  return LEVEL_RANK[level] <= CHANNEL_CEILING[channel];
}

export function accessDecision(request: AccessRequest): AccessDecision {
  const asset = assetById(request.assetId);
  if (!asset) {
    return { allowed: false, level: "unknown", reasons: ["unknown-asset"] };
  }
  if (request.fields.length === 0) {
    return { allowed: false, level: "unknown", reasons: ["no-fields"] };
  }
  const resolved = resolveFields(request.assetId, request.fields);
  if (!resolved) {
    return { allowed: false, level: "unknown", reasons: ["unknown-asset"] };
  }
  if (resolved.unknown.length > 0) {
    return {
      allowed: false,
      level: "unknown",
      reasons: resolved.unknown.map((name) => `unknown-field:${name}`),
    };
  }
  const reasons: string[] = [];
  if (!asset.purposes.includes(request.purpose)) {
    reasons.push("purpose-not-declared");
  }
  if (!asset.channels.includes(request.channel)) {
    reasons.push("channel-not-declared");
  }
  if (!channelAllowed(resolved.level, request.channel)) {
    reasons.push(`channel-ceiling:${resolved.level}`);
  }
  return { allowed: reasons.length === 0, level: resolved.level, reasons };
}

export function auditAssets(assets: DataAsset[] = ASSETS): string[] {
  const issues: string[] = [];
  for (const asset of assets) {
    const level = maxLevel(asset.fields.map((field) => field.level));
    for (const channel of asset.channels) {
      if (!channelAllowed(level, channel)) {
        issues.push(`${asset.id}:channel-ceiling:${channel}`);
      }
    }
    const limit = RETENTION_LIMIT[level];
    if (limit !== undefined && asset.retentionDays === null) {
      issues.push(`${asset.id}:retention-missing`);
    }
    if (limit !== undefined && asset.retentionDays !== null && asset.retentionDays > limit) {
      issues.push(`${asset.id}:retention-too-long:${asset.retentionDays}`);
    }
  }
  return issues;
}

判定函数返回的不是一个布尔值,而是 allowed 加 reasons。原因码让拒绝可解释,也让日志可以聚合:一周内 channel-ceiling:very-sensitive 出现了多少次,比「有一些违规」有用得多。

测试与真实输出

✔ 四个数据级别按由低到高排列 (0.916375ms)
✔ 最大级别在字段集合中胜出 (0.098292ms)
✔ 资产可以从 id 找回 (0.071291ms)
✔ 字段解析返回最大级别与未知字段 (0.100833ms)
✔ 字段级最小权限:只申请公开字段时级别为公开 (0.110791ms)
✔ 用途未声明时拒绝访问 (0.060125ms)
✔ 流向未声明时拒绝访问 (0.072541ms)
✔ 敏感字段流向第三方同时触发未声明与上限 (0.063917ms)
✔ 极敏感字段不允许导出 (0.065ms)
✔ 内部字段可以在声明过的第三方流向中使用 (0.103459ms)
✔ 极敏感字段在本机自有服务内允许使用 (0.06075ms)
✔ 未声明资产返回 unknown 级别 (0.056834ms)
✔ 空字段请求被拒绝而不是默认放行 (0.042667ms)
✔ 未知字段逐个列出后拒绝 (0.052583ms)
✔ 流向上限按级别判定 (0.052167ms)
✔ 默认资产清单通过分级审计 (0.086375ms)
✔ 审计能发现极敏感资产流向第三方 (0.053584ms)
✔ 审计能发现缺失与过长的保留期 (0.080875ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 130.146625

判据与反例

判据一:先解析字段,再谈授权

请求进来先解析字段集合,取出其中最高的级别。只申请 osVersion 时级别是 public,可以走第三方;一旦带上 deviceId,级别抬到 internal,规则随之变化。字段集合是判定的起点,不是附属信息。

反例一:用资产级标签代替字段级判定

「通讯录 = 敏感」看起来省事,实际会把 displayName 和 phone 绑在一起。更糟的是资产升级时所有字段一起升,业务只能整表申请或整表放弃,最后往往选择整表申请。

判据二:拒绝必须给出原因码

accessDecision 对未知资产、空字段、未知字段、用途未声明、流向未声明、流向上限分别给出原因。原因码不是给用户看的,是给开发和审计看的;没有它,排查只能靠猜。

反例二:空字段当成「全部字段」

空数组在不少接口里意味着「不筛选」,在权限判定里必须反过来:空字段意味着没有声明任何必要字段,直接拒绝。最小权限的第一条就是不允许隐式全量。

反例三:把「脱敏」当成降级通行证

手机号中间四位打码,仍然是手机号。脱敏是展示层控制,不改变数据级别;如果脱敏后的值仍可关联到个人,它依旧是 very-sensitive。要降级,得证明不可逆且不可关联,而不是改个显示格式。

判据三:审计与线上共用同一套规则

auditAssets 和 accessDecision 共用 channelAllowed 与保留期上限。审计脚本如果另写一套判断,迟早会出现「审计通过、线上拒绝」或更危险的反向偏差。

把判定放进请求链路

一次采集请求建议按固定顺序走:

  1. 业务声明资产 id、字段、用途、流向;
  2. 判定函数解析字段并取最大级别;
  3. 检查用途与流向是否在资产白名单内;
  4. 检查级别是否超过流向上限;
  5. 通过后写入访问日志,失败时记录原因码。

客户端判定只负责「不发起明显违规的请求」,它不是安全边界。服务端必须用同一份资产清单再判一次,因为客户端可以被改包、被 Hook,也可以被旧版本绕过。

保留期也是分级的一部分

敏感与极敏感数据必须声明保留期:敏感不超过一年,极敏感不超过三十天。没有保留期的敏感资产在审计里等同违规,因为「永久保存」不是一种策略,而是没人负责。

保留期到期后的删除要和清理任务绑定:本地缓存、埋点缓冲、画像存储分别清理,不能只删数据库里的一行。

一次「全部公开」的误判

某团队为了做分享排行,把用户昵称、头像、城市放进了公开接口。上线后发现城市加昵称足以定位到具体小区,被用户投诉。复盘时他们的理由是「这些信息本来就能看到」。

「本来能看到」不等于「公开级别」。公开级别要求任何人拿到都无法推断出更多信息;多个公开字段组合出敏感结论,是分级里最常见的漏洞。修正方式不是把接口下线,而是把城市降为 internal,排行榜只展示昵称与头像。

小结

数据分级不是文档工作,而是一组可执行的判定:

  • 级别决定流向上限,字段决定这次请求的真实级别;
  • 用途是白名单,流向是白名单,两者都必须命中;
  • 空字段、未知字段一律拒绝,拒绝要带原因码;
  • 客户端判定不是安全边界,服务端必须复核。

把「能不能用、给谁用、留多久」写进同一个函数,分级才真正开始工作。

阅读导航:上一节:7.3 从告警到定位的闭环 · 下一节:8.2 第三方 SDK 治理 。

常见错误与边界

  1. 把公开理解成「无主」:公开数据仍然有来源与用途,批量导出依旧要限流与审计。
  2. 只在客户端判定:改包与旧版本会绕过,服务端必须用同一份清单复核。
  3. 资产级一刀切:字段级判定才能支撑最小权限,否则业务只能整表申请。
  4. 用途写成布尔:ads: true 无法回答「为什么现在要用」,必须枚举具体目的。
  5. 忘记保留期:敏感资产没有保留期就是永久保存,审计应直接判违规。
  6. 脱敏即降级:可逆或可关联的脱敏不改变级别。
  7. 审计规则分叉:审计与线上必须调用同一个 channelAllowed。
  8. 忽略导出通道:导出到文件、剪贴板、分享卡片同样是数据流向。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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