事后看来,每个权限漏洞都长得一样。调用栈深处的某个函数假设调用者已经检查了 isAdmin。但实际上并没有。又或者新增了一个角色,然后你在 47 个文件里 grep role === 'editor',祈祷自己没有漏掉任何一个。

基于能力的安全模型通过将权限显式化来解决这个问题。与其问“你是谁?”然后去查询权限,不如直接交给调用者一个令牌,这个令牌字面意义上编码了它被允许做什么。没有令牌,就没有访问权。类型系统可以在编译时强制执行这一点。

基于能力的安全模型到底是什么意思

能力(capability)是一种不可伪造的令牌,它授予持有者执行特定操作的权利。这个术语起源于 20 世纪 60 年代的操作系统研究,但其思想同样适用于应用程序代码。

在传统的基于角色的系统中,用户拥有一个角色,你在执行操作时检查该角色:

function deleteProject(user: User, projectId: string) {
  if (user.role !== 'admin') {
    throw new UnauthorizedError();
  }
  // ... delete logic
}

这个模式一开始很简单,直到它不再简单。检查逻辑离最初授予权限的地方很远。最终你会得到冗余的检查、被遗忘的检查,以及隐式依赖于调用栈上方十个帧之外的调用者所做的检查的逻辑。

能力颠覆了这个模型。删除项目的权限变成了一个你必须拥有才能调用该函数的值:

function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
  // No check needed. If you have the cap, you have the right.
  // ... delete logic
}

如果你没有 ProjectDeletionCapability,你就不能调用这个函数。类型系统就是这么规定的。

为什么 TypeScript 很合适

TypeScript 的结构类型(structural typing)通常是一个特性,但对于能力来说它是一个缺陷。如果 ProjectDeletionCapability 只是一个带有 projectId: string 字段的 interface,任何具有该形状的对象都能通过。你需要标称类型(nominal typing)。你需要一个不能被意外构造的类型。

在 TypeScript 中实现这一点的最干净方式是使用私有符号(private symbol)的 branded type:

declare const ProjectDeletionCapabilityBrand: unique symbol;

interface ProjectDeletionCapability {
  readonly [ProjectDeletionCapabilityBrand]: true;
  readonly projectId: string;
  readonly grantedAt: Date;
  readonly grantedBy: string;
}

因为 ProjectDeletionCapabilityBrand 是一个 unique symbol,该模块之外的任何代码都无法生成满足该 interface 的值。这个 brand 起到了编译时封印的作用。你只能在该拥有该符号的模块内部构造能力。

一个具体的实现

以下是一种能在生产代码中奏效的模式,而且不会把你的代码库变成抽象艺术项目。

首先,定义一个能力工厂模块。这是唯一能铸造新能力的地方:

// capabilities.ts
import { randomUUID } from 'crypto';

declare const FileReadCapabilityBrand: unique symbol;
declare const FileWriteCapabilityBrand: unique symbol;

export interface FileReadCapability {
  readonly [FileReadCapabilityBrand]: true;
  readonly fileId: string;
  readonly scope: 'public' | 'private';
}

export interface FileWriteCapability {
  readonly [FileWriteCapabilityBrand]: true;
  readonly fileId: string;
}

// The capability factory. This is the only way to create capabilities.
export function mintFileReadCapability(
  fileId: string,
  scope: 'public' | 'private'
): FileReadCapability {
  return { [FileReadCapabilityBrand]: true, fileId, scope } as FileReadCapability;
}

export function mintFileWriteCapability(fileId: string): FileWriteCapability {
  return { [FileWriteCapabilityBrand]: true, fileId } as FileWriteCapability;
}

你的授权层——不管它是什么——在验证用户的声明(claims)后铸造能力:

// auth.ts
import { mintFileReadCapability, mintFileWriteCapability } from './capabilities';

export async function getCapabilitiesForUser(userId: string, fileId: string) {
  const perms = await db.permissions.find({ userId, fileId });
  const caps = [];

  if (perms.canRead) {
    caps.push(mintFileReadCapability(fileId, perms.scope));
  }
  if (perms.canWrite) {
    caps.push(mintFileWriteCapability(fileId));
  }

  return caps;
}

你的领域函数直接消费能力。不需要用户 ID,不需要角色检查,不需要数据库查询:

// files.ts
import { FileReadCapability, FileWriteCapability } from './capabilities';

export async function readFile(cap: FileReadCapability): Promise<Buffer> {
  return storage.read(cap.fileId);
}

export async function writeFile(
  cap: FileWriteCapability,
  data: Buffer
): Promise<void> {
  return storage.write(cap.fileId, data);
}

如果你试图将 FileReadCapability 传给 writeFile,TypeScript 会拒绝编译。错误是即时且局部的。你不需要追踪整个角色层级来理解某个调用是否有效。

在不损失安全性的前提下组合能力

真实的代码需要委托。一个服务可能持有多个能力,并将子集传递给辅助函数。你可以用交叉类型(intersection types)来建模:

function publishDocument(
  readCap: FileReadCapability,
  writeCap: FileWriteCapability,
  docId: string
) {
  const draft = await readFile(readCap);
  const rendered = renderToPDF(draft);
  await writeFile(writeCap, rendered);
  await markPublished(docId);
}

如果你想更严格,可以定义一个组合能力:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
  // ...
}

关键在于权限通过值流动,而不是通过环境状态。你可以将每一个能力追溯到它被铸造的地方。

这个模式在何处失效

能力不是免费的。每个受保护的操作都需要一个能力值在调用链中传递。在一个深度分层的应用程序中,这意味着要将能力穿过五六个并不直接使用它们的函数。

还有撤销(revocation)问题。能力一旦被铸造,就只是一个 JavaScript 对象。它会一直存活到被垃圾回收。如果你需要立即撤销访问权限——比如说因为一个用户被从项目中移除——你无法销毁已经在传递中的能力。你需要一个带外检查,或者你需要将能力包装在一个代理(proxy)中,该代理在每次使用时对照实时 ACL 进行验证。这就重新引入了你想避免的确切查询操作。

审计日志也更难了。使用 RBAC 时,你在操作发生时记录用户的角色。使用能力时,权限可能是在几小时前由另一个服务授予的。你需要将元数据附加到能力本身,这就是为什么上面的例子包含了 grantedAtgrantedBy 字段。

何时使用能力,何时跳过它们

当你有细粒度的、上下文相关的权限,且重新计算代价高昂时,使用能力。一个用户可以编辑这个特定文档直到周五。一个服务可以从这个存储桶读取,但不能从另一个读取。能力编码了上下文。消费它的函数不需要知道上下文的存在。

对于粗粒度的全局角色,跳过能力。如果你的应用只有三个权限级别,并且它们从不随资源变化,RBAC 更简单且更易于审计。不要让完美成为合理安全的敌人。

常见问题

能力与令牌(token)有什么区别?

JWT 或 API token 证明身份。能力证明执行特定操作的权限。你可以将能力放在 token 内部,但这两个概念是不同的。

能力可以与 GraphQL 或 REST 一起工作吗?

可以。服务器在认证请求后铸造能力,然后将它们传递给解析器(resolvers)或控制器(controllers)。传输层不需要改变。

如何存储能力?

通常你不会持久化能力。你持久化的是决定一个能力是否可以被铸造的规则。能力本身是短命的运行时值。

这会取代 OAuth scopes 吗?

不会。OAuth scopes 是跨组织边界委托的粗粒度能力。这个模式用于你自己应用程序内部的细粒度权限。它们可以共存。

你能序列化能力吗?

如果你序列化一个 branded type,在反序列化时你会丢失 brand。如果你需要在进程间传递能力,用接收方信任的密钥对它们进行签名,或者使用一个能力服务器来验证并重新铸造它们。