事后看来,每个权限漏洞都长得一样。调用栈深处的某个函数假设调用者已经检查了 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 时,你在操作发生时记录用户的角色。使用能力时,权限可能是在几小时前由另一个服务授予的。你需要将元数据附加到能力本身,这就是为什么上面的例子包含了 grantedAt 和 grantedBy 字段。
何时使用能力,何时跳过它们
当你有细粒度的、上下文相关的权限,且重新计算代价高昂时,使用能力。一个用户可以编辑这个特定文档直到周五。一个服务可以从这个存储桶读取,但不能从另一个读取。能力编码了上下文。消费它的函数不需要知道上下文的存在。
对于粗粒度的全局角色,跳过能力。如果你的应用只有三个权限级别,并且它们从不随资源变化,RBAC 更简单且更易于审计。不要让完美成为合理安全的敌人。
常见问题
能力与令牌(token)有什么区别?
JWT 或 API token 证明身份。能力证明执行特定操作的权限。你可以将能力放在 token 内部,但这两个概念是不同的。
能力可以与 GraphQL 或 REST 一起工作吗?
可以。服务器在认证请求后铸造能力,然后将它们传递给解析器(resolvers)或控制器(controllers)。传输层不需要改变。
如何存储能力?
通常你不会持久化能力。你持久化的是决定一个能力是否可以被铸造的规则。能力本身是短命的运行时值。
这会取代 OAuth scopes 吗?
不会。OAuth scopes 是跨组织边界委托的粗粒度能力。这个模式用于你自己应用程序内部的细粒度权限。它们可以共存。
你能序列化能力吗?
如果你序列化一个 branded type,在反序列化时你会丢失 brand。如果你需要在进程间传递能力,用接收方信任的密钥对它们进行签名,或者使用一个能力服务器来验证并重新铸造它们。