npm.io
2.0.0 • Published 3d ago

@shield-acl/core

Licence
MIT
Version
2.0.0
Deps
0
Size
187 kB
Vulns
0
Weekly
0

@shield-acl/core

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 grant vive num escopo (app:crm, org:acme/*, *).

Como o algoritmo funciona por dentro: docs/ACL-ALGORITHM.md e docs/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 via ctx.relations.
  • PolicySource (PBAC / OPA / Cedar): consultado no caminho assíncrono.

Testes

pnpm test
pnpm test:coverage

Licença

MIT Anderson D. Rosa

Keywords