@shield-acl/fastify
Plugin Fastify para o Shield ACL — autorização multi-app declarativa.
Integra o @shield-acl/core ao Fastify: resolve usuário e scope
por requisição, protege rotas com authorize, e oferece guards para use cases
(DDD).
Onde usar ACL em cada camada:
docs/examples/LAYERS-ARCHITECTURE.md. Porquês das decisões:DECISIONS.md.
Instalação
pnpm add @shield-acl/fastify @shield-acl/core fastify
- Fastify 5.x · Node 20+ · TypeScript.
Registro
import Fastify from "fastify"
import { ACL } from "@shield-acl/core"
import shieldACL from "@shield-acl/fastify"
const fastify = Fastify()
const acl = new ACL()
acl.defineRole({ name: "admin", permissions: [{ action: "*", resource: "*" }] })
await fastify.register(shieldACL, {
acl,
// Quem é o usuário (JWT, sessão, etc.) — retorna { id, grants, attributes? }
getUserFromRequest: async (req) => {
const token = req.headers.authorization?.replace("Bearer ", "")
return token ? verify(token) : null
},
// Em qual app estamos — a peça multi-app. Default: "*"
getScopeFromRequest: (req) => `app:${req.headers["x-app-id"]}`,
})
Opções do plugin:
interface ShieldACLOptions {
acl: ACL
getUserFromRequest?: (req) => User | null | Promise<User | null>
getScopeFromRequest?: (req) => Scope | Promise<Scope> // default () => "*"
userProperty?: string // onde guardar o user (default "user")
errorHandler?: (error: UnauthorizedError, req, reply) => void | Promise<void>
debug?: boolean
}
Proteger rotas — authorize
// simples
fastify.get(
"/posts",
{
preHandler: fastify.authorize({ action: "read", resource: "posts" }),
},
handler,
)
// recurso dinâmico + instância + environment para conditions ABAC
fastify.patch(
"/posts/:id",
{
preHandler: fastify.authorize({
action: "update",
resource: "posts",
options: async (req) => ({
resource: await getPost(req.params.id), // instância → ctx.resource
environment: { mfa: req.headers["x-mfa"] === "1" },
}),
}),
},
handler,
)
// override de scope, skip e async
fastify.delete(
"/x",
{
preHandler: fastify.authorize({
action: "delete",
resource: "posts",
scope: (req) => `app:${req.headers["x-app-id"]}`, // default: o da request
skip: (req) => req.headers["x-internal"] === "true",
async: true, // usa evaluateAsync (conditions/policySource async)
}),
},
handler,
)
AuthorizeOptions:
interface AuthorizeOptions {
action: Action
resource?: Resource | ((req) => Resource | undefined)
scope?: Scope | ((req) => Scope | Promise<Scope>) // default: request.aclScope
options?: EvalOptions | ((req) => EvalOptions | Promise<EvalOptions>)
async?: boolean // default false
skip?: (req) => boolean | Promise<boolean>
errorHandler?: (error, req, reply) => void | Promise<void>
}
Verificações programáticas
fastify.get("/me/permissions", async (req) => ({
canCreate: req.can("create", "posts"), // usa o scope da request
canPublish: await req.canAsync("publish", "posts"), // conditions async
detail: req.evaluate("delete", "posts"), // { allowed, reason, scope, ... }
}))
// verificar um usuário arbitrário, num scope explícito
const ok = fastify.can(otherUser, "app:crm", "read", "leads")
Decorators disponíveis:
// instância
fastify.acl: ACL
fastify.authorize(opts): preHandlerHookHandler
fastify.can(user, scope, action, resource?, options?): boolean
fastify.createUseCaseGuard(useCase, opts)
// request
request.user?: User
request.aclScope: Scope
request.can(action, resource?, options?): boolean
request.canAsync(action, resource?, options?): Promise<boolean>
request.evaluate(action, resource?, options?): EvaluationResult
DDD — guards de use case
Mantém o use case puro; a autorização fica no guard (usa evaluateAsync, então
suporta conditions assíncronas naturalmente):
class DeletePostUseCase {
async execute(input: { id: string }, user: User) {
/* ... lógica de negócio ... */
}
}
const guarded = fastify.createUseCaseGuard(new DeletePostUseCase(), {
getPermissions: (input) => ({ action: "delete", resource: "posts" }),
scope: (input, user) => "app:crm",
options: async (input) => ({ resource: await getPost(input.id) }),
})
fastify.delete("/posts/:id", async (req) => {
return guarded({ id: req.params.id }, req.user)
})
UseCaseGuardOptions:
interface UseCaseGuardOptions<TInput> {
getPermissions: (input) => Perm | Perm[] | Promise<Perm | Perm[]>
scope?: Scope | ((input, user) => Scope | Promise<Scope>) // default "*"
options?: EvalOptions | ((input, user) => EvalOptions | Promise<EvalOptions>)
allowAnonymous?: boolean // default false
}
Também há o decorator experimental @RequirePermissions({ ... }) (requer o ACL
acessível via this.acl).
Tratamento de erro
Negações lançam UnauthorizedError (statusCode: 403, com action, resource,
scope, reason). Um errorHandler custom formata a resposta:
await fastify.register(shieldACL, {
acl,
errorHandler: (error, req, reply) => {
req.log.warn({
user: req.user?.id,
action: error.action,
scope: error.scope,
})
reply.status(403).send({ error: "forbidden", message: "Sem permissão" })
},
})
Compatibilidade
- Fastify 5.x · Node 20+ · TypeScript 5+.
Testes
pnpm test
pnpm test:coverage
Licença
MIT Anderson D. Rosa