@westayltd/authz
v0.3.3
Published
Business-agnostic NestJS authorization library: RequirePermission decorator + AuthzGuard backed by Auth Service /rbac/me.
Readme
@westayltd/authz
Business-agnostic NestJS authorization library. It enforces permissions on
routes by calling Auth Service's frozen GET /rbac/apps/:appKey/me contract, caching
grants as two keys:
rbac:me:{app}:{principalType}:{principalId}→{ roleKey, status }rbac:perms:{app}:{roleKey}→permissions[]
and failing closed if Auth Service is unreachable.
authServiceBaseUrl must already include the Auth API prefix
(e.g. https://host/auth-service/api/v1). The client appends rbac/me only.
This package contains zero business permission keys, role names, or app
names. Every host application owns its own permissions.ts / rbac.manifest.ts
and imports that manifest into Auth Service separately — this library never
sees business permission strings except as opaque values passed to
@RequirePermission(...).
Install
Until published, consume via a local path or git dependency:
{
"dependencies": {
"@westayltd/authz": "file:../westay-authz"
}
}Wiring
import { Module } from '@nestjs/common';
import { AuthzModule } from '@westayltd/authz';
import { RedisAuthzCacheAdapter } from '@westayltd/authz';
@Module({
imports: [
AuthzModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
appKey: 'catalogue',
authServiceBaseUrl: config.get('AUTH_SERVICE_URL'),
cacheTtlSeconds: 300,
cacheAdapter: new RedisAuthzCacheAdapter(redisClient),
}),
}),
],
})
export class AppModule {}AuthzModule exports AuthzGuard, PermissionResolver, and AuthzClient
for use in any module that imports it.
Protecting an endpoint
AuthzGuard must run after the host app's own JWT guard — it expects
request.user (populated by the JWT guard) and the original Authorization
header to forward to Auth Service.
import { Controller, Post, UseGuards } from '@nestjs/common';
import { StaffJwtGuard } from '../auth/staff-jwt.guard';
import { AuthzGuard, RequirePermission } from '@westayltd/authz';
import { CataloguePermissions } from '../authz/permissions';
@Controller('properties')
@UseGuards(StaffJwtGuard, AuthzGuard)
export class PropertiesController {
@Post()
@RequirePermission(CataloguePermissions.PROPERTY_CREATE)
create() {
/* ... */
}
}A route with no @RequirePermission stays JWT-only — AuthzGuard allows it
without contacting Auth Service.
When a required permission is granted, the guard attaches the resolved
grant on the request so the handler/service can apply own vs all (or
similar) checks without calling /me again:
import { RequestAuthz } from '@westayltd/authz';
const authz: RequestAuthz | undefined = request.authz;
const hasAllScope = authz?.has('example.resource.all') ?? false;request.authz is only set on a granted @RequirePermission check. JWT-only
routes and deny outcomes do not populate it.
Behavior
- Both cache keys hit → authorize locally, no HTTP call.
- Either key miss →
GET /rbac/apps/:appKey/me, cache assignment + role perms on success only. - Cached
status: inactive→403without calling Auth Service. - Deny outcomes (401/403/503) are never cached as grants, so grants/revocations from Auth Service take effect immediately after Auth invalidates Redis.
- Auth Service unreachable after one retry →
503 Service Unavailable(fail closed — never silently allow). - Missing permission →
403 Forbidden. - Missing/invalid principal or session →
401 Unauthorized.
The consumer Redis instance must be the same host and db Authzilla writes to (default db 0). A mismatched db means invalidation never reaches this app.
Cache adapters
NoopAuthzCacheAdapter(default if none supplied): always a cache miss — useful for local dev/tests without Redis. The module logs a warning at boot if you don't pass acacheAdapter, since every authorized request will hit Auth Service.RedisAuthzCacheAdapter: pass an existingioredisclient instance you already manage (connection lifecycle, retries, TLS, etc. stay the host app's responsibility — this library never opens its own Redis connection):
import Redis from 'ioredis';
import { RedisAuthzCacheAdapter } from '@westayltd/authz';
const redisClient = new Redis(process.env.REDIS_URL);
AuthzModule.forRootAsync({
useFactory: () => ({
appKey: 'catalogue',
authServiceBaseUrl: process.env.AUTH_SERVICE_URL,
cacheTtlSeconds: 300,
cacheAdapter: new RedisAuthzCacheAdapter(redisClient),
}),
});If Redis itself errors (connection drop, timeout, etc.), RedisAuthzCacheAdapter
logs a warning and returns as if it were a cache miss/no-op — it never
throws. This means a Redis outage degrades to "every check calls Auth
Service" rather than breaking authorization. If Auth Service is also
unreachable at that point, the guard still fails closed (see Outage
behavior below) — a cache-layer outage never turns into an "allow".
Implement AuthzCacheAdapter yourself for any other backing store.
Outage behavior (fail closed)
V1 only supports failMode: 'closed' — AuthzModule.forRoot/forRootAsync
throw at boot if any other value is configured. Concretely:
| Situation | Outcome |
| --- | --- |
| Both cache keys hit | Allow/deny decided locally, no network call. |
| Cache miss, Auth Service reachable | Calls /rbac/apps/:appKey/me, both keys cached on success only. |
| Cache miss, Auth Service down/unreachable (after 1 retry) | 503 Service Unavailable — deny, never allow. |
| Redis down, Auth Service reachable | Cache adapter no-ops; falls through to calling Auth Service on every request (slower, but correct). |
| Redis down and Auth Service down | 503 — deny. |
Configuration options (AuthzModuleOptions)
| Option | Required | Description |
| --- | --- | --- |
| appKey | yes | This app's key as registered in Auth Service RBAC. |
| authServiceBaseUrl | yes | Auth API prefix, e.g. https://host/auth-service/api/v1. |
| cacheTtlSeconds | yes | TTL for cached grants. |
| cacheAdapter | no | Defaults to NoopAuthzCacheAdapter. |
| failMode | no | V1 only supports 'closed'. |
| principalIdPath | no | Dot path on request for principal id. Default user.id. |
| principalTypePath | no | Dot path on request for principal type. Default user.type, falls back to 'staff'. |
| requestTimeoutMs | no | Abort timeout per /rbac/apps/:appKey/me call. Default 3000. |
Non-goals
- No business permission constants, roles, or manifests (owned by each app).
- No policy engine / ABAC.
- No event bus based invalidation (Auth Service invalidates cache directly via the service layer).
