Ваша функция processPayment имеет доступ к базе данных, платёжному шлюзу и журналу аудита только потому, что она выполняется в том же процессе. Если злоумышленник находит баг инъекции в одном HTTP-обработчике, он получает всё это в наследство. Функция никогда не просила эти полномочия. Она просто предполагала их наличие по той причине, где была развёрнута.

Это окружающая полномочность (ambient authority), и это модель по умолчанию почти в каждой системе, которую мы создаём. Безопасность на основе возможностей (capability-based security) задаёт другой вопрос: что, если функция могла бы делать только то, что ей явно передали?

Проблема окружающей полномочности

Большинство приложений используют списки контроля доступа (access control lists), проверяемые на границе системы. Запрос попадает в API-шлюз, JWT проходит валидацию, роли проверяются, и затем запрос входит в зону доверия, где каждая функция имеет неявный доступ ко всему. Подключение к базе данных — это глобальный синглтон. S3-клиент импортируется из общего модуля. Любой код, выполняющийся внутри процесса, может вызвать любой из них.

Это работает, пока не перестаёт.

Баг десериализации в служебной функции, скомпрометированная зависимость или атака confused deputy превращают одну небольшую брешь в полный доступ к системе. Радиус поражения ограничен не тем, что нужно было конкретной операции. Он ограничен тем, что было у всего процесса.

Безопасность на основе возможностей переворачивает модель. Полномочие — это не свойство того, кто ты. Это свойство того, что ты держишь.

Что такое безопасность на основе возможностей?

Безопасность на основе возможностей — это модель, в которой права доступа представлены невозможными к подделке токенами, называемыми capabilities, которые должны быть явно переданы функциям, которым они нужны. Capability — это одновременно ссылка на ресурс и разрешение на его использование. Вы не можете запросить доступ по имени. Вы можете использовать только тот capability, которым уже обладаете.

Это не ролевое управление доступом (RBAC) с дополнительными шагами. В RBAC у пользователя есть роль, и система проверяет эту роль в момент доступа. В безопасности на основе возможностей нет централизованной проверки. Если вы держите capability, вы можете его использовать. Если нет — не можете. Сам capability является доказательством авторизации.

Эта модель восходит к исследованиям операционных систем в 1970-х, но она становится всё более актуальной, поскольку мы строим более изолированные системы. Модули WebAssembly, микросервисы, браузерные API и sandboxed-плагины используют паттерны, похожие на capabilities, даже если не используют это название.

Как capabilities работают на практике

Вот как это выглядит в коде. В модели окружающей полномочности любая функция может вызывать базу данных:

// Ambient authority: any code in this module can use db
import { db } from './db';

async function getUser(id: string) {
  return db.query('SELECT * FROM users WHERE id = ?', [id]);
}

async function processOrder(orderId: string) {
  const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
  // What if a bug here lets an attacker query arbitrary tables?
  return order;
}

Обе функции совместно используют глобальное подключение db с полным доступом. В модели capability вы передаёте ограниченный capability:

// A capability is just a constrained handle
interface UserReadCap {
  getUser(id: string): Promise<User>;
}

interface OrderReadCap {
  getOrder(id: string): Promise<Order>;
}

async function getUser(id: string, db: UserReadCap) {
  return db.getUser(id);
}

async function processOrder(orderId: string, orders: OrderReadCap) {
  return orders.getOrder(orderId);
}

getUser не может трогать заказы. processOrder не может трогать пользователей. Компилятор обеспечивает это. Скомпрометированная реализация processOrder может только эксфильтровать заказы, но не удалить таблицы или прочитать хеши паролей. Capability — это граница поведения.

В языках с более строгими системами типов вы можете зайти дальше. В Rust capability может владеть файловым дескриптором. Система типов гарантирует, что он не может быть продублирован без разрешения, а borrow checker гарантирует, что он не переживёт своей валидности:

use std::fs::File;
use std::io::{self, Write};

fn write_log(file: &mut File, msg: &str) -> io::Result<()> {
    // This function can ONLY write to the file it was handed.
    // It cannot open new files. It cannot read the filesystem.
    writeln!(file, "{}", msg)
}

&mut File — это capability. Функция не может создать другой из ниоткуда.

Почему безопасность на основе возможностей не является стандартом

Безопасность на основе возможностей имеет реальные издержки. Самая очевидная — эргономика. Каждая сигнатура функции растёт. Вы должны протягивать capabilities через цепочки вызовов, что ощущается как dependency injection, доведённый до логического экстремума. В большой кодовой базе это может стать утомительным.

Обработка ошибок тоже усложняется. В модели окружающей полномочности сбой подключения к базе данных — это глобальная проблема, решаемая при инициализации. В модели capability каждая функция, получающая capability, должна учитывать, что произойдёт, если этот capability будет отозван или станет невалидным посреди операции.

Есть также стоимость отладки. Когда доступ запрещён в ACL-системе, вы проверяете политику. Когда отсутствует capability, вы прослеживаете обратно по цепочке вызовов, чтобы найти, кто должен был его передать. Это другой набор навыков, и большинство разработчиков к нему не привыкли.

Эти компромиссы объясняют, почему безопасность на основе возможностей в основном ограничивалась операционными системами, браузерами и высокозащищёнными средами. Это не бесплатно.

Где сегодня встречается безопасность на основе возможностей

Вы уже использовали безопасность на основе возможностей, даже если не знали её названия. В браузере fetch — это глобальная функция, но она ограничена same-origin policy и CORS. Service Worker получает конкретные event-capabilities. Модуль WebAssembly должен быть явно наделён доступом к памяти и хост-функциям.

В облачной инфраструктуре условия политик AWS IAM и scoped-токены похожи на capabilities. Предподписанный S3 URL — это capability: невозможный к подделке токен, который предоставляет конкретную операцию над конкретным ресурсом на конкретное время. Service accounts в Kubernetes с узко ограниченными RBAC-привязками тоже движутся в этом направлении.

Тренд направлен к более мелким изолированным блокам с меньшей окружающей полномочностью. Контейнеры убрали её из ОС. WebAssembly убрал её из браузерного процесса. Следующий шаг — убрать её из наших собственных функций.

Как начать использовать capabilities в своём коде

Вам не нужно переписывать всё приложение. Начните с границ, где радиус поражения имеет наибольшее значение.

Изолируйте слой доступа к данным в объекты-capabilities. Вместо экспорта глобального пула базы данных экспортируйте функции, возвращающие ограниченные хендлы:

// capabilities.ts
export interface UserStore {
  getById(id: string): Promise<User | null>;
  updateEmail(id: string, email: string): Promise<void>;
}

export interface AuditLog {
  record(event: AuditEvent): Promise<void>;
}

// Hand out capabilities at the application boundary
function createUserStore(db: Pool): UserStore {
  return {
    async getById(id) {
      const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
      return row ? mapUser(row) : null;
    },
    async updateEmail(id, email) {
      await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
    }
  };
}

Затем передавайте обработчику только те capabilities, которые ему нужны:

async function handleProfileUpdate(
  req: Request,
  users: UserStore,
  audit: AuditLog
) {
  const user = await users.getById(req.userId);
  await users.updateEmail(req.userId, req.body.email);
  await audit.record({ type: 'email_changed', userId: req.userId });
}

handleProfileUpdate не может отправлять письма. Он не может удалять аккаунты. Он может делать только то, что ему передали. Если в этом обработчике есть баг десериализации, злоумышленник не может перейти к платёжной системе, потому что capability для этого никогда не передавался.

Часто задаваемые вопросы о безопасности на основе возможностей

Разве это не просто dependency injection?

Это выглядит похоже, но намерение другое. Dependency injection — это про тестируемость и модульность. Capabilities — про безопасность и принцип наименьших привилегий (least privilege). DI-контейнер всё ещё может внедрять глобальное подключение к базе данных. Capability ограничен ровно тем, что вызывающей стороне разрешено делать.

Заменяет ли это аутентификацию?

Нет. Аутентификация отвечает на вопрос «кто ты?» Capabilities отвечают на вопрос «что ты можешь делать?» Вам всё ещё нужно проверять личность на границе. После этого capabilities ограничивают, к чему может прикасаться аутентифицированный код.

Какие языки хорошо поддерживают это?

Любой язык с системой типов может выражать capabilities как интерфейсы или трейты. Rust и TypeScript работают хорошо. В динамически типизированных языках, таких как Python или JavaScript без строгих интерфейсов, вы теряете compile-time проверку, но паттерн всё равно улучшает ясность кода и ограничивает случайное неправильное использование.

Окружающая полномочность — это привычка, а не закон. Безопасность на основе возможностей сложнее внедрить, чем понять, но направление индустрии ясно. Меньшие границы. Меньше неявной мощи. Функции, которые могут делать только то, что им явно передали.

Начните с одного обработчика. Передайте ему один capability вместо подключения к базе данных. Посмотрите, что сломается. В большинстве случаев то, что ломается, — это скрытое предположение, о котором вы не знали, что делает ваш код.