Skip to content

Authz

Contract

@modularityjs/authz provides AuthzService — a concrete dispatcher that iterates all registered authorization checkers.

typescript
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.

typescript
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:

typescript
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.

typescript
import { AuthzModule } from '@modularityjs/authz';
import { AuthzPolicyModule } from '@modularityjs/authz-policy';

const modules = [AuthzModule, AuthzPolicyModule];

Define a policy class:

typescript
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:

typescript
@Module({
  imports: [AuthzPolicyModule],
  providers: [PostPolicy],
  pools: [
    { pool: AuthzPoliciesPool, key: 'post-policy', useClass: PostPolicy },
  ],
})
class PostModule {}

Check with context:

typescript
// 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:

typescript
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.

typescript
import { RequirePermission } from '@modularityjs/http-authz';

@Controller('/api/admin')
@RequirePermission('admin.access')
class AdminController {
  @Get('/users')
  listUsers() { ... }

  @Post('/users')
  @RequirePermission('users.create')
  createUser() { ... }
}

Setup

typescript
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:

  1. If the route has required permissions but no authenticated identity is present, the adapter throws AuthenticationException, which the framework filter renders as 401 { code: 'AUTHENTICATION_REQUIRED', message: 'Authentication required' }
  2. Each required permission is checked via AuthzService.can(identity, permission), delegated through the permission checker bound by HttpAuthzModule in afterLoad()
  3. If any permission check fails, the adapter throws AuthorizationException, rendered as 403 { 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:

typescript
import {
  getMethodPermissionMetadata,
  getPermissionMetadata,
} from '@modularityjs/http-authz';

Programmatic Usage

Inject AuthzService directly for permission checks:

typescript
@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 checkers
  • AuthzService — concrete dispatcher that iterates checkers
  • AuthzChecker — interface that drivers implement

This allows multiple authorization strategies to coexist. The preferences pattern (last-wins) is NOT used — all registered checkers are active.