@entitle/fastify
v0.2.0
Published
Fastify HTTP adapter for the Entitle policy engine
Readme
@entitle/fastify
Fastify adapter for Entitle, a strongly-typed feature entitlements and policy engine for TypeScript.
It is a Fastify plugin that registers the engine's operations as routes.
These routes can change who is entitled to what.
/assign,/revokeand/overridegrant any policy, or any limit, to any subject.authorizeis a required option on every Entitle adapter and there is no way to mount one without it:0.0.1served these routes with no authentication, which was an anonymous privilege-escalation API rather than a missing feature, and0.1.0made the hook required in order to close it.exposedefaults to'read-only', so the four mutating routes are not registered at all until you passexpose: 'all'.Read Security in
@entitle/core's README before you deploy this. It is the same text asnode_modules/@entitle/core/README.md, which this package installs alongside itself.
Install
pnpm add @entitle/fastify @entitle/core fastifyUsage
import Fastify from 'fastify'
import { entitleFastifyPlugin } from '@entitle/fastify'
const fastify = Fastify()
await fastify.register(entitleFastifyPlugin(engine), {
prefix: '/entitle',
// Omit `expose` for a read-only mount: check / explain / resolve only.
// `'all'` additionally mounts assign / revoke / override / override/remove.
expose: 'all',
authorize: async ({ access, request }) => {
const session = await getSession(request.headers.cookie)
if (!session) return { ok: false, code: 'UNAUTHENTICATED' }
if (access === 'write' && !session.user.isAdmin) {
return { ok: false, code: 'FORBIDDEN' }
}
return { ok: true, actorId: session.user.id }
},
})
await fastify.listen({ port: 3000 })The engine goes to entitleFastifyPlugin, which is generic in it, and the hook
goes to register -- the same split as createExpressRouter(engine, options).
A provider-backed engine mounts with no alias and no type assertion.
Options
entitleFastifyPlugin takes the engine. These are the register options.
| Option | Required | Default | Meaning |
| ----------- | -------- | ------------- | ----------------------------------------------------- |
| authorize | yes | -- | Decides whether one request may perform one operation |
| expose | no | 'read-only' | Which routes exist: the three reads, or all seven |
| prefix | no | -- | Fastify's own route prefix for this plugin |
authorize
Called once per request, before the operation runs, with:
| Field | Type | Meaning |
| ----------- | -------------------- | --------------------------------------------------- |
| operation | AdminOperationName | Canonical operation name, e.g. 'assign_policy' |
| access | 'read' \| 'write' | Whether the operation observes or changes state |
| input | unknown | The parsed request body |
| request | FastifyRequest | For headers, cookies, and whatever a decorator added |
Return { ok: true, actorId } to allow -- actorId is what the write is
attributed to. Return { ok: false, code: 'UNAUTHENTICATED' } for a 401 or
{ ok: false, code: 'FORBIDDEN' } for a 403; a bare { ok: false } is a 403. A
hook that throws or rejects denies the request: it fails closed, and the
handler is never reached.
A missing hook throws a TypeError when the plugin runs -- for Fastify that is
ready()/listen(), before the first request is served. The app does not boot.
expose
Defaults to 'read-only', which registers check, explain and resolve. The
four mutating routes are not registered at all until expose: 'all', so until
then they 404 rather than 403 -- they genuinely do not exist on that mount. This
is the option most likely to surprise you on an upgrade, because a missing
authorize is a compile error while a missing expose is a silent 404.
Routes
All four Entitle adapters serve the same POST routes at the same paths:
| Route | Access | Description |
| ----------------------- | ------- | ------------------------------------ |
| POST /check | read | Check a single feature for a subject |
| POST /explain | read | Full evaluation trace for a feature |
| POST /resolve | read | Resolve all features for a subject |
| POST /assign | write | Assign a policy to a subject |
| POST /revoke | write | Revoke a policy from a subject |
| POST /override | write | Set a per-subject feature override |
| POST /override/remove | write | Remove a per-subject override |
Every body names the subject by subjectId and subjectKind, removals
included.
Responses
Every success is the same body on all four adapters, with data always
present and null for the four operations that return nothing:
{
"ok": true,
"data": null
}Before 0.1.1 this adapter sent data: undefined, which Fastify's serializer
drops, so the key was absent -- and a client written against @entitle/hono or
@entitle/next and moved here broke on a data === null check. The contract, the idempotent removals, and the three failures the envelope
does not cover are What a success response contains in
@entitle/core's README.
Errors
Every failure is the same body on all four adapters:
{
"ok": false,
"error": {
"code": "STORE_ERROR",
"message": "Failed to assign policy",
"correlationId": "0b9c1f8e-6f2a-4b51-9a2e-2d3f8f0f7c11"
}
}code decides the status; message is a constant chosen by the code path;
correlationId is present exactly when something was withheld, and matches the
logger.error line that carries the cause in full. A driver's error fields, a
store's SQL or file path, a feature's schema and an internal exception never
reach a response. The full contract is What an error response may contain
in @entitle/core's README.
Serving the API open, on purpose
For a sidecar bound to loopback, a mesh that authorizes at the edge, or a test
harness, pass UNSAFE_allowUnauthenticated as authorize rather than writing
your own always-allow hook. It warns once per process, and the name is
deliberately greppable. The deployment
assumptions that have to hold first are under UNSAFE_allowUnauthenticated
in @entitle/core's README.
Testing
@entitle/testing exports createAuthorizeStub, which is the hook plus a record
of what the adapter asked it -- so a test can assert that a refused request never
reached the operation.
Links
@entitle/core-- the engine, and the guidance the sections above point at: what the routes are and which of them write,expose, serving a subject its own grants,explainas information disclosure, actor attribution,UNSAFE_allowUnauthenticatedand the preconditions it needs, and what an error response may contain. Also atnode_modules/@entitle/core/README.md.- Reporting a vulnerability -- privately, to https://github.com/fponticelli,
and not in a public issue.
0.0.1is deprecated and unsupported: it served these routes with no authentication.
License
MIT
