@shield-acl/core
Motor de autorização RBAC + ABAC + Scope. Zero dependências.
O core é uma biblioteca puramente funcional (sem dependência de framework) que
decide se um usuário pode executar uma ação sobre um recurso, dentro de um
escopo (app).
- RBAC: usuário →
grants(roles/permissões por escopo), com herança de roles. - ABAC: condições dinâmicas sobre atributos de subject/resource/action/environment.
- Scope: cada
grantvive num escopo (app:crm,org:acme/*,*).
Como o algoritmo funciona por dentro:
docs/ACL-ALGORITHM.mdedocs/FUNCIONAMENTO.md. Porquês das decisões:DECISIONS.md.
Instalação
pnpm add @shield-acl/core
Uso básico
import { ACL } from "@shield-acl/core";
const acl = new ACL();
// 1. Catálogo de roles
acl.defineRole({
name: "admin",
permissions: [{ action: "*", resource: "*" }],
});
acl.defineRole({
name: "editor",
permissions: [
{ action: "read", resource: "posts" },
{ action: ["create", "update"], resource: "posts" },
],
});
// 2. Usuário com grants ESCOPADOS
const user = {
id: 1,
grants: [
{ scope: "app:blog", roles: ["editor"] },
{ scope: "app:admin", roles: ["admin"] },
],
};
// 3. Verificar — sempre dentro de um scope
acl.can(user, "app:blog", "read", "posts"); // true
acl.can(user, "app:blog", "delete", "posts"); // false (editor não deleta)
acl.can(user, "app:admin", "delete", "posts"); // true (admin no outro app)
Modelo de dados
interface User {
id: string | number;
grants: Grant[]; // atribuições por escopo (não há "roles globais")
attributes?: Record<string, unknown>; // atributos de subject (ABAC)
}
interface Grant {
scope: Scope; // "app:crm" | "org:acme/*" | "*"
roles?: RoleName[]; // roles concedidas NESTE escopo
permissions?: Permission[]; // permissões diretas NESTE escopo
}
interface Role {
name: RoleName; // "admin" | "crm:admin" | "app:crm-prod:admin"
permissions: Permission[];
inherits?: RoleName[];
}
interface Permission {
action: Action | Action[];
resource?: Resource | Resource[];
conditions?: Condition[]; // ABAC (sync ou async)
deny?: boolean; // negação explícita
priority?: number;
}
type Scope = string; // path hierárquico
Scope — a dimensão multi-app
O scope é um path em string, casado com a mesma engine de wildcard de actions/resources:
"*"; // super-admin de plataforma (qualquer app)
"org:acme/*"; // qualquer app da organização acme
"app:crm-prod"; // só esta instância
const orgAdmin = { id: 3, grants: [{ scope: "org:acme/*", roles: ["admin"] }] };
acl.can(orgAdmin, "org:acme/app:crm", "delete", "x"); // true
acl.can(orgAdmin, "org:outra/app:crm", "delete", "x"); // false
Cascata de roles (opcional)
Com um ScopeResolver, uma role referenciada num grant é resolvida na ordem
instância → tipo → global (mais específico vence, estilo CSS):
const acl = new ACL({
scopeResolver: {
resolve: (s) => (s === "app:crm-prod" ? { id: s, type: "crm" } : undefined),
},
});
acl.defineRole({
name: "admin",
permissions: [{ action: "read", resource: "*" }],
}); // global
acl.defineRole({
name: "crm:admin",
permissions: [{ action: "*", resource: "leads" }],
}); // por tipo
const seller = { id: 4, grants: [{ scope: "app:crm-prod", roles: ["admin"] }] };
// "admin" resolve para "crm:admin" (tipo) neste scope:
acl.can(seller, "app:crm-prod", "update", "leads"); // true
Condições dinâmicas (ABAC)
Uma condição recebe o EvaluationContext e retorna boolean (ou Promise):
interface EvaluationContext {
user: User;
scope: Scope;
action: Action;
resource?: unknown; // a INSTÂNCIA (via options.resource)
environment?: Environment; // { now?, ip?, mfa?, device?, ... }
relations?: RelationResolver; // gancho ReBAC (se configurado)
}
Condições prontas em conditions:
import { conditions } from "@shield-acl/core";
conditions.isOwner("authorId"); // user é dono do recurso
conditions.hasStatus("draft", "review"); // recurso em um dos status
conditions.userHasAttribute("plan", "pro"); // atributo do subject
conditions.createdWithin(60_000); // criado há < 60s
conditions.requireMFA(); // environment.mfa === true
conditions.withinBusinessHours(9, 18); // horário comercial
conditions.ipIn("10.0.0.1"); // IP na allowlist
conditions.and(a, b) / conditions.or(a, b) / conditions.not(a); // combinadores
Passe a instância do recurso e o environment via options:
acl.can(user, "app:A", "update", "posts", {
resource: post, // instância → ctx.resource
environment: { mfa: true }, // → ctx.environment
});
resource(4º parâmetro, string) = tipo para matching.options.resource= instância para as conditions. São camadas diferentes.
Síncrono x assíncrono
can/evaluate são síncronos (ideais para render no React) e lançam se
cruzarem uma condition assíncrona. Para regras async (banco, ReBAC, policySource),
use canAsync/evaluateAsync:
await acl.canAsync(user, "app:A", "edit", "docs", { resource: doc });
forScope — facade single-app
Quem opera dentro de um app fixa o scope uma vez:
const crm = acl.forScope("app:crm-prod");
crm.can(user, "delete", "posts"); // scope embutido
crm.getUserPermissions(user);
crm.getGrantedRoles(user);
API
Construtor
new ACL(config?: ACLConfig)
createACL(config?: ACLConfig) // factory equivalente
interface ACLConfig {
wildcard?: string; // default "*"
caseSensitive?: boolean; // default false (inclui scope)
cache?: boolean; // default true
cacheMaxSize?: number; // default 1000
cacheTTL?: number; // ms, default 300000 (5 min)
defaultDeny?: boolean; // default true
debug?: boolean;
scopeResolver?: ScopeResolver; // habilita cascata por tipo
relationResolver?: RelationResolver; // gancho ReBAC (exposto às conditions)
policySource?: PolicySource; // gancho PBAC (caminho async)
}
Métodos
| Método | Descrição |
|---|---|
defineRole(role) / removeRole(name) / getRole(name) |
catálogo de roles |
setRoles(roles[]) |
substitui o catálogo em lote (1 notificação) |
can(user, scope, action, resource?, options?) |
→ boolean |
evaluate(user, scope, action, resource?, options?) |
→ EvaluationResult |
canAsync / evaluateAsync(...) |
versões assíncronas |
forScope(scope) |
facade com scope fixo |
getUserPermissions(user, scope) |
permissões efetivas no scope |
getGrantedRoles(user, scope) |
roles concedidas no scope |
getRoleHierarchy(name, scope?) |
cadeia de herança |
clearCache() |
limpa o cache (silencioso) |
subscribe(listener) / revision / touch() |
observabilidade (store) |
EvaluationResult:
interface EvaluationResult {
allowed: boolean;
reason: string;
matchedRule?: Permission;
scope: Scope;
}
Observabilidade (store)
O engine notifica assinantes quando o catálogo muda — base da reatividade em tempo real do React:
const off = acl.subscribe(() => console.log("catálogo mudou", acl.revision));
acl.defineRole(/* ... */); // dispara o listener; revision++
acl.setRoles(await api.roles()); // recarrega em lote
off(); // unsubscribe
Helpers
import {
permission,
permissions,
rolePresets,
permissionPatterns,
} from "@shield-acl/core";
permission()
.action("update")
.resource("posts")
.when(conditions.isOwner("authorId"))
.build();
permissions.crud("posts"); // create/read/update/delete
permissions.readonly("posts"); // read/list/view
rolePresets.editor(); // Role pronta
permissionPatterns.workflow("docs", {
draft: ["update"],
approved: ["publish"],
});
Ganchos de extensão
Interfaces declaradas para evolução sem reescrever o core — não implementadas aqui (ver ADR):
RelationResolver(ReBAC / Zanzibar): exposto às conditions viactx.relations.PolicySource(PBAC / OPA / Cedar): consultado no caminho assíncrono.
Testes
pnpm test
pnpm test:coverage
Licença
MIT Anderson D. Rosa