-
Notifications
You must be signed in to change notification settings - Fork 0
NestJS Integration
@kengela/nestjs wires the PDP into NestJS: a deny-by-default guard, decorators to declare the
required access, an injection token for the PDP, and a dedicated exception for step-up.
@nestjs/common,@nestjs/coreandreflect-metadataare peerDependencies: your application is the one that provides them.
npm add @kengela/nestjs @kengela/authz-core @kengela/adapter-expr-cel| Export | Type | Role |
|---|---|---|
KengelaAuthzGuard |
guard | Deny-by-default; builds an AccessRequest and delegates to the PDP. |
RequirePermission(resourceType, action) |
decorator | Declares the access a route requires. |
PublicRoute() |
decorator | Marks a route as public (deliberate opt-out). |
CurrentPrincipal() |
parameter decorator | Injects the Principal (placed on req.user). |
KENGELA_PDP |
token (symbol) | Injection point for the PolicyDecisionPoint implementation. |
StepUpRequiredException |
exception | Thrown when the PDP returns step_up. |
The guard installs as APP_GUARD (global). The PDP is provided under the KENGELA_PDP token:
the application picks its implementation (here LayeredDecisionPoint).
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { KengelaAuthzGuard, KENGELA_PDP } from '@kengela/nestjs';
import { LayeredDecisionPoint } from '@kengela/authz-core';
import { CelExpressionEngine } from '@kengela/adapter-expr-cel';
import type { PolicyDecisionPoint } from '@kengela/contracts';
@Module({
providers: [
{
provide: KENGELA_PDP,
useFactory: (grants, relations, policies): PolicyDecisionPoint =>
new LayeredDecisionPoint({ grants, relations, policies, expr: new CelExpressionEngine() }),
inject: [/* your AuthorizationRepository, RelationResolver, PolicyStore providers */],
},
{ provide: APP_GUARD, useClass: KengelaAuthzGuard },
],
})
export class AuthzModule {}The Principal must be placed on req.user upstream of the guard (authentication
middleware/guard, e.g. via BetterAuthIdentity or IdentityPort, see
03-authentication.md).
@RequirePermission(resourceType, action) declares the `${resourceType}.${action}` permission.
@CurrentPrincipal() injects the current Principal:
import { Controller, Get, Post } from '@nestjs/common';
import { RequirePermission, PublicRoute, CurrentPrincipal } from '@kengela/nestjs';
import type { Principal } from '@kengela/contracts';
@Controller('orders')
export class OrdersController {
@Get()
@RequirePermission('data.orders', 'read')
list(@CurrentPrincipal() principal: Principal) {
// reached only if the PDP answered allow
}
@Post('refund')
@RequirePermission('data.orders', 'refund')
refund() {
// if a policy returns step_up → StepUpRequiredException (see below)
}
@Get('health')
@PublicRoute()
health() {
return { ok: true };
}
}Two security guarantees, proven by test:
-
Deny-by-default. A route without
@RequirePermissionor@PublicRouteis denied (ForbiddenException('route_not_annotated')). We never expose a route through a forgotten annotation. APrincipalmissing on a protected route →UnauthorizedException('no_principal')(401). -
Handler > class precedence (fail-closed). The handler annotation always takes precedence over the class one. A
@PublicRoute()placed on the controller cannot neutralize a@RequirePermissionplaced on a handler. The exact order:Priority Annotation Effect 1 handler @RequirePermissionevaluate (even if the class is public) 2 handler @PublicRoutepublic 3 class @RequirePermissionevaluate 4 class @PublicRoutepublic 5 nothing deny (unannotated route)
This hardening fixes a classic fail-open where a class-level @PublicRoute made every route public,
including a sensitive handler.
The guard builds an AccessRequest (resource at type level + the principal's tenant), calls
pdp.check(), and maps the effect:
decision.effect |
HTTP result |
|---|---|
allow |
the request passes |
deny |
ForbiddenException(decision.reason) → 403 |
step_up |
StepUpRequiredException(obligations, reason) → enriched 403 |
// Body of the StepUpRequiredException (403):
{
statusCode: 403,
error: 'step_up_required',
reason: 'refund_needs_passkey',
obligations: [{ type: 'require_passkey' }],
}On the client side, step_up_required triggers a challenge (re-run MFA/passkey), not a definitive
failure.
Guard scope. The guard covers RBAC + context conditions (
principal.ctx: risk/geo/mfa = conditional access). ABAC conditions on the attributes of a specific resource (e.g. "same agency") are checked at the service level, by calling the PDP directly with the loaded resource:
@Injectable()
export class OrdersService {
constructor(@Inject(KENGELA_PDP) private readonly pdp: PolicyDecisionPoint) {}
async read(principal: Principal, order: Order) {
const decision = await this.pdp.check({
principal,
action: 'read',
resource: {
type: 'data.orders',
id: order.id,
tenantId: principal.tenantId,
attributes: { agencyId: order.agencyId, ownerId: order.ownerId },
},
});
if (decision.effect !== 'allow') throw new ForbiddenException(decision.reason);
return order;
}
}The guard filters coarsely (can we read some orders?); the service decides finely (can we read this order?).
@kengela/scim-server is framework-agnostic (pure handlers). A NestJS controller just resolves the
tenant, parses the body, calls the handler and returns status/body. See
04-identity-federation.md.
🇬🇧 English
- Home
- Getting started
- Architecture
- Authorization
- Authentication
- Identity federation
- Compliance & PII
- NestJS integration
- Developing an adapter
- Security
Recipes
- NestJS + native + Prisma
- better-auth
- SCIM / Entra
- LDAP / AD
- RBAC / ABAC
- PII / GDPR
- Combo: better-auth + PII
- Combo: SCIM/Entra + authz
- Combo: full stack
🇫🇷 Français
- Accueil
- Prise en main
- Architecture
- Autorisation
- Authentification
- Fédération d'identité
- Conformité & PII
- Intégration NestJS
- Développer un adapter
- Sécurité
Recettes