С задним умом любой баг с правами доступа выглядит одинаково. Какая-то функция глубоко в стеке вызовов предполагает, что вызывающая сторона уже проверила isAdmin. Но она не проверила. Или добавляется новая роль, и вы запускаете grep по role === 'editor' в 47 файлах, надеясь, что не пропустили ни одного.
Безопасность на основе capabilities исправляет это, делая полномочия явными. Вместо того чтобы спрашивать «кто ты?» и искать разрешения, вы передаёте вызывающей стороне токен, который буквально кодирует, что ей разрешено делать. Нет токена — нет доступа. Система типов может обеспечивать это на этапе компиляции.
Что на самом деле означает безопасность на основе capabilities
Capability — это неподделываемый токен, дающий владельцу право выполнить конкретное действие. Термин пришёл из исследований операционных систем, начиная с 1960-х годов, но идея применима и к прикладному коду.
В традиционной системе на основе ролей у пользователя есть роль, и вы проверяете эту роль в момент действия:
function deleteProject(user: User, projectId: string) {
if (user.role !== 'admin') {
throw new UnauthorizedError();
}
// ... delete logic
}
Этот паттерн прост, пока не перестаёт быть простым. Проверка находится далеко от места, где полномочия изначально были выданы. В итоге получаются избыточные проверки, забытые проверки и логика, которая неявно зависит от проверок, сделанных вызывающими сторонами на десять фреймов выше по стеку.
Capabilities переворачивают модель. Полномочие удалить проект становится значением, которое вы должны иметь, чтобы даже вызвать функцию:
function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
// No check needed. If you have the cap, you have the right.
// ... delete logic
}
Если у вас нет ProjectDeletionCapability, вы не можете вызвать эту функцию. Так говорит система типов.
Почему TypeScript — хороший выбор
Структурная типизация TypeScript обычно является фичей, но для capabilities это баг. Если ProjectDeletionCapability — это просто интерфейс с полем projectId: string, пройдёт любой объект с такой формой. Вам нужна номинальная типизация. Вам нужен тип, который нельзя случайно сконструировать.
Самый чистый способ сделать это в TypeScript — branded type с использованием приватного символа:
declare const ProjectDeletionCapabilityBrand: unique symbol;
interface ProjectDeletionCapability {
readonly [ProjectDeletionCapabilityBrand]: true;
readonly projectId: string;
readonly grantedAt: Date;
readonly grantedBy: string;
}
Поскольку ProjectDeletionCapabilityBrand — это unique symbol, никакой код за пределами этого модуля не может создать значение, удовлетворяющее интерфейсу. Бренд выступает как печать времени компиляции. Вы можете сконструировать capability только внутри модуля, которому принадлежит символ.
Конкретная реализация
Вот паттерн, который работает в продакшен-коде, не превращая вашу кодовую базу в проект абстрактного искусства.
Сначала определите модуль-фабрику capabilities. Это единственное место, которое может выпускать новые capabilities:
// 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;
}
Ваш слой авторизации, каким бы он ни был, выпускает capabilities после проверки 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;
}
Ваши доменные функции потребляют capabilities напрямую. Никаких user 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 откажется компилировать. Ошибка немедленная и локальная. Вам не нужно прослеживать иерархию ролей, чтобы понять, валиден ли вызов.
Композиция capabilities без потери безопасности
Реальный код нуждается в делегировании. Сервис может держать несколько capabilities и передавать подмножества вспомогательным функциям. Вы можете моделировать это с помощью пересечений типов:
function publishDocument(
readCap: FileReadCapability,
writeCap: FileWriteCapability,
docId: string
) {
const draft = await readFile(readCap);
const rendered = renderToPDF(draft);
await writeFile(writeCap, rendered);
await markPublished(docId);
}
Если хотите быть строже, можете определить составной capability:
interface FileReadWriteCapability
extends FileReadCapability,
FileWriteCapability {}
function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
// ...
}
Ключевой момент в том, что полномочия текут через значения, а не через окружающее состояние. Вы можете проследить каждый capability до места, где он был выпущен.
Где этот паттерн даёт сбой
Capabilities не бесплатны. Каждой защищённой операции нужно значение capability, пропущенное через цепочку вызовов. В глубоко многоуровневом приложении это может означать протягивание capabilities через пять или шесть функций, которые не используют их напрямую.
Есть ещё проблема отзыва. Capability, один раз выпущенный, — это просто JavaScript-объект. Он живёт, пока не будет собран сборщиком мусора. Если вам нужно немедленно отозвать доступ, скажем, потому что пользователя удалили из проекта, вы не можете уничтожить capabilities, уже находящиеся в обращении. Вам нужна out-of-band проверка, или нужно обернуть capabilities в прокси, который валидирует живой ACL при каждом использовании. Это заново вводит ту самую проверку, которую вы пытались избежать.
Аудит-логирование тоже усложняется. С RBAC вы логируете роль пользователя в момент действия. С capabilities полномочия могли быть выданы часами ранее другим сервисом. Вам нужно прикрепить метаданные к самому capability, поэтому в примерах выше есть поля grantedAt и grantedBy.
Когда использовать capabilities, а когда пропустить их
Используйте capabilities, когда у вас есть мелкозернистые, контекстные разрешения, которые дорого пересчитывать. Пользователь может редактировать этот конкретный документ до пятницы. Сервис может читать из этого бакета, но не из того. Capability кодирует контекст. Функция, которая его потребляет, не должна знать, что контекст существует.
Пропускайте capabilities для грубых глобальных ролей. Если у вашего приложения три уровня прав и они никогда не меняются в зависимости от ресурса, RBAC проще и легче аудитить. Не позволяйте совершенному быть врагом достаточно безопасного.
FAQ
В чём разница между capability и token?
JWT или API token подтверждают личность. Capability подтверждает полномочие на конкретное действие. Вы можете поместить capabilities внутрь token, но концепции различны.
Могут ли capabilities работать с GraphQL или REST?
Да. Сервер выпускает capabilities после аутентификации запроса, затем передаёт их в resolvers или controllers. Транспортный слой менять не нужно.
Как хранить capabilities?
Обычно вы не сохраняете capabilities персистентно. Вы сохраняете правила, определяющие, может ли быть выпущен capability. Сами capabilities — это недолговечные runtime-значения.
Заменяет ли это OAuth scopes?
Нет. OAuth scopes — это грубозернистые capabilities, делегированные через организационные границы. Этот паттерн — для мелкозернистых полномочий внутри вашего собственного приложения. Они могут сосуществовать.
Можно ли сериализовать capabilities?
Если вы сериализуете branded type, вы теряете бренд при десериализации. Если нужно передавать capabilities между процессами, подпишите их ключом, которому доверяет получатель, или используйте capability server, который валидирует и перевыпускает их.