Cada bug de permisos se ve igual en retrospectiva. Alguna función en lo profundo de la pila de llamadas asume que el llamador ya verificó isAdmin. No lo hizo. O se agrega un nuevo rol, y haces grep de role === 'editor' en 47 archivos esperando no haber omitido ninguno.

La seguridad basada en capabilities arregla esto haciendo la autoridad explícita. En lugar de preguntar “¿quién eres?” y buscar permisos, le entregas al llamador un token que literalmente codifica lo que le está permitido hacer. Sin token, sin acceso. El sistema de tipos puede hacer cumplir esto en tiempo de compilación.

Qué significa realmente la seguridad basada en capabilities

Una capability es un token inforgeable que otorga a quien lo posee el derecho a realizar una action específica. El término proviene de la investigación en sistemas operativos que data de la década de 1960, pero la idea se aplica al código de aplicaciones de la misma manera.

En un sistema tradicional basado en roles, un usuario tiene un rol, y verificas ese rol en el momento de la action:

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

Este patrón es simple hasta que deja de serlo. La verificación vive lejos de donde la autoridad fue otorgada originalmente. Terminas con verificaciones redundantes, verificaciones olvidadas, y lógica que depende implícitamente de verificaciones hechas por llamadores diez frames arriba en el stack.

Las capabilities invierten el modelo. La autoridad para eliminar un proyecto se convierte en un valor que debes poseer para siquiera llamar a la función:

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

Si no tienes una ProjectDeletionCapability, no puedes llamar a esta función. El sistema de tipos lo dice.

Por qué TypeScript es una buena opción

El structural typing de TypeScript suele ser una característica, pero para capabilities es un bug. Si ProjectDeletionCapability es solo una interfaz con un campo projectId: string, cualquier objeto con esa forma pasará. Necesitas nominal typing. Necesitas un tipo que no pueda construirse por accidente.

La forma más limpia de hacer esto en TypeScript es un branded type usando un símbolo privado:

declare const ProjectDeletionCapabilityBrand: unique symbol;

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

Como ProjectDeletionCapabilityBrand es un unique symbol, ningún código fuera de este module puede producir un valor que satisfaga la interfaz. El brand actúa como un sello en tiempo de compilación. Solo puedes construir la capability dentro del module que posee el símbolo.

Una implementación concreta

Aquí hay un patrón que funciona en código de producción sin convertir tu codebase en un proyecto de arte abstracto.

Primero, define un module de fábrica de capabilities. Este es el único lugar que puede acuñar nuevas 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;
}

Tu capa de autorización, sea la que sea, acuña capabilities después de verificar los claims del usuario:

// 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;
}

Tus funciones de dominio consumen capabilities directamente. Sin user IDs, sin verificaciones de roles, sin lookups a la base de datos:

// 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);
}

Si intentas pasar una FileReadCapability a writeFile, TypeScript se negará a compilar. El error es inmediato y local. No necesitas rastrear una jerarquía de roles para entender si una llamada es válida.

Componer capabilities sin perder seguridad

El código real necesita delegar. Un servicio podría poseer varias capabilities y pasar subconjuntos a funciones auxiliares. Puedes modelar esto con 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);
}

Si quieres ser más estricto, puedes definir una capability compuesta:

interface FileReadWriteCapability
  extends FileReadCapability,
    FileWriteCapability {}

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

La clave es que la autoridad fluye a través de valores, no de estado ambiental. Puedes rastrear cada capability de vuelta a donde fue acuñada.

Dónde este patrón falla

Las capabilities no son gratis. Cada operación protegida necesita un valor de capability pasado a través de la cadena de llamadas. En una aplicación profundamente estratificada, esto puede significar hacer pasar capabilities a través de cinco o seis funciones que no las usan directamente.

También está el problema de la revocación. Una capability, una vez acuñada, es solo un objeto de JavaScript. Vive hasta que el garbage collector la recolecta. Si necesitas revocar el acceso inmediatamente, digamos porque un usuario fue eliminado de un proyecto, no puedes destruir capabilities ya en vuelo. Necesitas una verificación fuera de banda, o necesitas envolver las capabilities en un proxy que valide contra una ACL en vivo en cada uso. Eso reintroduce exactamente el lookup que estabas tratando de evitar.

El audit logging también se vuelve más difícil. Con RBAC, registras el rol del usuario en el momento de la action. Con capabilities, la autoridad podría haber sido otorgada horas antes por un servicio diferente. Necesitas adjuntar metadata a la capability misma, por eso los ejemplos de arriba incluyen campos grantedAt y grantedBy.

Cuándo usar capabilities y cuándo omitirlas

Usa capabilities cuando tengas permisos finos y contextuales que son costosos de recalcular. Un usuario puede editar este documento específico hasta el viernes. Un servicio puede leer de este bucket pero no de aquel. La capability codifica el contexto. La función que la consume no necesita saber que el contexto existe.

Omite capabilities para roles globales y gruesos. Si tu app tiene tres niveles de permisos y nunca varían por recurso, RBAC es más simple y más fácil de auditar. No dejes que lo perfecto sea enemigo de lo razonablemente seguro.

FAQ

¿Cuál es la diferencia entre una capability y un token?

Un JWT o API token prueba identidad. Una capability prueba autoridad para una action específica. Puedes poner capabilities dentro de un token, pero los conceptos son distintos.

¿Pueden las capabilities funcionar con GraphQL o REST?

Sí. El servidor acuña capabilities después de autenticar la request, y luego las pasa a los resolvers o controllers. La capa de transporte no necesita cambiar.

¿Cómo almacenas las capabilities?

Usualmente no persistes capabilities. Persistes las reglas que determinan si una capability puede ser acuñada. Las capabilities mismas son valores de runtime de corta duración.

¿Esto reemplaza a los OAuth scopes?

No. Los OAuth scopes son capabilities gruesas delegadas a través de límites organizacionales. Este patrón es para autoridad fina dentro de tu propia aplicación. Pueden coexistir.

¿Puedes serializar capabilities?

Si serializas un branded type, pierdes el brand en la deserialización. Si necesitas pasar capabilities a través de procesos, fírmalas con una clave que el receptor confíe, o usa un capability server que las valide y las re-acuñe.