npm.io
2.0.0 • Published 4d ago

@shield-acl/fastify

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

@shield-acl/fastify

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

Keywords