Todo bug de permissão parece óbvio em retrospectiva. Alguma função no fundo da call stack assume que o caller já verificou isAdmin. Não verificou. Ou um novo role é adicionado, e você faz grep por role === 'editor' em 47 arquivos na esperança de não ter perdido nenhum.
Capability-based security corrige isso tornando a autoridade explícita. Em vez de perguntar “quem é você?” e consultar permissões, você entrega ao caller um token que literalmente codifica o que ele tem permissão para fazer. Sem token, sem acesso. O type system pode impor isso em compile time.
O que capability-based security realmente significa
Uma capability é um unforgeable token que concede ao holder o direito de realizar uma action específica. O termo vem da pesquisa em operating systems que remonta aos anos 1960, mas a ideia se aplica ao application code da mesma forma.
Em um sistema tradicional baseado em roles, um usuário tem um role, e você verifica esse role no ponto da action:
function deleteProject(user: User, projectId: string) {
if (user.role !== 'admin') {
throw new UnauthorizedError();
}
// ... delete logic
}
Esse padrão é simples até deixar de ser. A verificação fica longe de onde a autoridade foi originalmente concedida. Você acaba com verificações redundantes, verificações esquecidas, e lógica que implicitamente depende de verificações feitas por callers dez frames acima na stack.
As capabilities invertem o modelo. A autoridade para deletar um projeto se torna um value que você deve possuir para sequer chamar a função:
function deleteProject(cap: ProjectDeletionCapability, projectId: string) {
// No check needed. If you have the cap, you have the right.
// ... delete logic
}
Se você não tem uma ProjectDeletionCapability, não pode chamar essa função. O type system diz isso.
Por que TypeScript é uma boa escolha
O structural typing do TypeScript geralmente é uma feature, mas para capabilities é um bug. Se ProjectDeletionCapability for apenas uma interface com um campo projectId: string, qualquer objeto com esse shape vai passar. Você precisa de nominal typing. Você precisa de um type que não possa ser construído por acidente.
A forma mais limpa de fazer isso em TypeScript é um branded type usando um private symbol:
declare const ProjectDeletionCapabilityBrand: unique symbol;
interface ProjectDeletionCapability {
readonly [ProjectDeletionCapabilityBrand]: true;
readonly projectId: string;
readonly grantedAt: Date;
readonly grantedBy: string;
}
Como ProjectDeletionCapabilityBrand é um unique symbol, nenhum código fora desse module pode produzir um value que satisfaça a interface. O brand atua como um selo de compile time. Você pode construir a capability apenas dentro do module que possui o symbol.
Uma implementação concreta
Aqui está um padrão que funciona em production code sem transformar seu codebase em um projeto de arte abstrata.
Primeiro, defina um capability factory module. Este é o único lugar que pode mint novas 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;
}
Sua authorization layer, seja qual for, mint capabilities após verificar as claims do usuário:
// 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;
}
Suas domain functions consomem capabilities diretamente. Sem user IDs, sem role checks, sem database lookups:
// 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);
}
Se você tentar passar uma FileReadCapability para writeFile, TypeScript vai recusar a compilação. O erro é imediato e local. Você não precisa trace através de uma role hierarchy para entender se uma call é válida.
Compondo capabilities sem perder safety
Código real precisa delegar. Um service pode manter várias capabilities e passar subsets para helper functions. Você pode modelar isso com 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);
}
Se quiser ser mais estrito, você pode definir uma composite capability:
interface FileReadWriteCapability
extends FileReadCapability,
FileWriteCapability {}
function publishDocumentV2(cap: FileReadWriteCapability, docId: string) {
// ...
}
A chave é que a autoridade flui através de values, não de ambient state. Você pode trace cada capability de volta aonde foi minted.
Onde esse padrão falha
Capabilities não são de graça. Cada operação protegida precisa de um capability value passado através da call chain. Em uma aplicação profundamente layered, isso pode significar threading capabilities através de cinco ou seis functions que não as usam diretamente.
Há também o revocation problem. Uma capability, uma vez minted, é apenas um JavaScript object. Ela existe até ser garbage collected. Se você precisar revogar o acesso imediatamente, digamos porque um usuário foi removido de um projeto, você não pode destruir capabilities já em flight. Você precisa de uma out-of-band check, ou precisa wrap capabilities em um proxy que valida contra uma live ACL a cada uso. Isso reintroduz exatamente o lookup que você estava tentando evitar.
Audit logging também fica mais difícil. Com RBAC, você loga o role do usuário no momento da action. Com capabilities, a autoridade pode ter sido concedida horas atrás por um service diferente. Você precisa anexar metadata à própria capability, por isso os exemplos acima incluem campos grantedAt e grantedBy.
Quando usar capabilities e quando ignorá-las
Use capabilities quando você tem permissões fine-grained e contextuais que são caras de recompute. Um usuário pode editar esse documento específico até sexta-feira. Um service pode ler desse bucket mas não daquele. A capability codifica o contexto. A função que a consome não precisa saber que o contexto existe.
Ignore capabilities para coarse global roles. Se sua aplicação tem três níveis de permissão e eles nunca variam por resource, RBAC é mais simples e mais fácil de auditar. Não deixe o perfeito ser inimigo do razoavelmente seguro.
FAQ
Qual é a diferença entre uma capability e um token?
Um JWT ou API token prova identidade. Uma capability prova autoridade para uma action específica. Você pode colocar capabilities dentro de um token, mas os conceitos são distintos.
Capabilities podem funcionar com GraphQL ou REST?
Sim. O server mint capabilities após autenticar a request, depois as passa para resolvers ou controllers. A transport layer não precisa mudar.
Como você armazena capabilities?
Normalmente você não persiste capabilities. Você persiste as regras que determinam se uma capability pode ser minted. As próprias capabilities são short-lived runtime values.
Isso substitui OAuth scopes?
Não. OAuth scopes são capabilities coarse-grained delegadas através de organizational boundaries. Esse padrão é para autoridade fine-grained dentro da sua própria aplicação. Eles podem coexistir.
Você pode serializar capabilities?
Se você serializar um branded type, perde o brand na deserialization. Se precisar passar capabilities entre processes, assine-os com uma key que o receiver confia, ou use um capability server que valida e os re-mints.