Authz
Contract
@modularityjs/authz provides AuthzService — a concrete dispatcher that iterates all registered authorization checkers.
interface AuthzChecker {
can(
identity: AuthIdentity,
permission: string,
context?: AuthzContext,
): Promise<boolean>;
permissions(identity: AuthIdentity): Promise<string[]>;
}
interface AuthzContext {
readonly resource?: string;
readonly resourceId?: string;
readonly subject?: unknown;
}AuthzService reads checkers from AuthzCheckersPool. Any checker returning true allows the action. Permissions are aggregated from all checkers.
Multiple drivers can be active simultaneously — RBAC handles static role-based checks while policies handle per-resource logic.
Drivers
RBAC (@modularityjs/authz-rbac)
Role-based access control. Maps roles to permissions. Roles are read from AuthIdentity.attributes.
import { AuthzModule } from '@modularityjs/authz';
import { AuthzRbacModule } from '@modularityjs/authz-rbac';
const modules = [
AuthzModule,
AuthzRbacModule.forRoot({
roles: {
admin: ['users.create', 'users.delete', 'users.read'],
editor: ['posts.create', 'posts.update', 'posts.read'],
viewer: ['users.read', 'posts.read'],
},
}),
];Configuration:
AuthzRbacModule.forRoot({
roles: { ... }, // Role-to-permissions map (defaults to {})
identityRolesKey: 'roles', // Key in AuthIdentity.attributes (default)
});Policy (@modularityjs/authz-policy)
Per-resource policy classes for context-dependent authorization. Each policy handles one resource type with can{Action} methods.
import { AuthzModule } from '@modularityjs/authz';
import { AuthzPolicyModule } from '@modularityjs/authz-policy';
const modules = [AuthzModule, AuthzPolicyModule];Define a policy class:
import type { AuthIdentity } from '@modularityjs/auth';
import type { AuthzPolicy } from '@modularityjs/authz-policy';
@Injectable()
class PostPolicy implements AuthzPolicy {
readonly resource = 'posts';
canCreate(identity: AuthIdentity): boolean {
return (
(identity.attributes.roles as string[] | undefined)?.includes('editor') ??
false
);
}
canUpdate(identity: AuthIdentity, post: Post): boolean {
return post.authorId === identity.id;
}
canDelete(identity: AuthIdentity, post: Post): boolean {
return post.authorId === identity.id;
}
}Register in a module via AuthzPoliciesPool:
@Module({
imports: [AuthzPolicyModule],
providers: [PostPolicy],
pools: [
{ pool: AuthzPoliciesPool, key: 'post-policy', useClass: PostPolicy },
],
})
class PostModule {}Check with context:
// Permission string maps to policy method: 'posts.update' → PostPolicy.canUpdate()
await authz.can(identity, 'posts.update', { subject: post });Using Both Together
RBAC and Policy can be active simultaneously. Register both:
const modules = [
AuthzModule,
AuthzRbacModule.forRoot({ roles: { admin: ['users.create'] } }),
AuthzPolicyModule,
];When authz.can() is called, the dispatcher checks all registered checkers. If RBAC allows (static permission) OR a policy allows (resource-level check), the action is permitted.
HTTP Bridge (@modularityjs/http-authz)
HttpAuthzModule connects authorization to the HTTP layer. When loaded, the Fastify adapter reads @RequirePermission() metadata at route registration time and enforces it inside the request pipeline before the handler runs.
import { RequirePermission } from '@modularityjs/http-authz';
@Controller('/api/admin')
@RequirePermission('admin.access')
class AdminController {
@Get('/users')
listUsers() { ... }
@Post('/users')
@RequirePermission('users.create')
createUser() { ... }
}Setup
import { AuthzModule } from '@modularityjs/authz';
import { HttpModule } from '@modularityjs/http';
import { HttpAuthzModule } from '@modularityjs/http-authz';
import { AuthzRbacModule } from '@modularityjs/authz-rbac';
const modules = [
HttpModule.forRoot({ port: 3000 }),
HttpFastifyModule,
AuthzModule,
AuthzRbacModule.forRoot({
roles: { admin: ['admin.access', 'users.create'] },
}),
HttpAuthzModule,
];Enforcement Flow
The adapter reads permissions from both class-level and method-level @RequirePermission() decorators. At request time:
- If the route has required permissions but no authenticated identity is present, the adapter throws
AuthenticationException, which the framework filter renders as401 { code: 'AUTHENTICATION_REQUIRED', message: 'Authentication required' } - Each required permission is checked via
AuthzService.can(identity, permission), delegated through the permission checker bound byHttpAuthzModuleinafterLoad() - If any permission check fails, the adapter throws
AuthorizationException, rendered as403 { code: 'ACCESS_DENIED', message: 'Access denied', context: { permission } }
Class-level and method-level permissions are combined — a route with @RequirePermission('admin.access') on the class and @RequirePermission('users.create') on the method requires both permissions.
Metadata Access
Permission metadata can also be read programmatically:
import {
getMethodPermissionMetadata,
getPermissionMetadata,
} from '@modularityjs/http-authz';Programmatic Usage
Inject AuthzService directly for permission checks:
@Injectable()
class UserController {
constructor(@Inject(AuthzService) private readonly authz: AuthzService) {}
async createUser(identity: AuthIdentity, name: string) {
if (!(await this.authz.can(identity, 'users.create'))) {
throw new AuthorizationException('Access denied', {
permission: 'users.create',
});
}
// ...
}
}Architecture
Authorization uses the pool + dispatcher pattern (same as Logger, Events, Health):
AuthzCheckersPool— pool where drivers register their checkersAuthzService— concrete dispatcher that iterates checkersAuthzChecker— interface that drivers implement
This allows multiple authorization strategies to coexist. The preferences pattern (last-wins) is NOT used — all registered checkers are active.