Skip to content

006 - Runtime Permissions Provider Registration

Status

Accepted

Date

2026-03-23

Context

The Bug

After implementing the CASL authorization system, command-handler-level permission checks (AuthorizationService.assertCan()) always failed with CORE.CANNOT_PERFORM_ACTION, even for users with manage:all. Meanwhile, controller-level guard checks (PoliciesGuard) worked correctly for the same user and the same permissions.

Debug output from assertCan revealed the root cause:

rawPermissions []
hasProvider    false
ability.rules  []

The IPermissionsProvider was never injected into AuthorizationService.

Why It Happened

The system has two NestJS modules involved:

  • AuthorizationModule (@Global): provides AuthorizationService and CaslAbilityFactory.
  • IdentityModule: provides IdentityPermissionsProvider under the PERMISSIONS_PROVIDER token and exports it.
AuthorizationModule (@Global)          IdentityModule
├── CaslAbilityFactory                 ├── IdentityPermissionsProvider
├── AuthorizationService               │     (provided as PERMISSIONS_PROVIDER)
│     @Optional()                      ├── exports: [PERMISSIONS_PROVIDER]
│     @Inject(PERMISSIONS_PROVIDER)    └── ...repos, commands, etc.
│     → resolves to undefined ❌
└──

NestJS DI scoping rule: A service's dependencies are resolved from the module where the service is declared, NOT from every module in the app. AuthorizationService is declared in AuthorizationModule. Even though IdentityModule exports PERMISSIONS_PROVIDER, AuthorizationModule does not import IdentityModule — so the token is invisible. The @Optional() decorator silently resolves it to undefined.

Why PoliciesGuard Worked

PoliciesGuard also had @Optional() @Inject(PERMISSIONS_PROVIDER) and it worked. This is because guards are not singletons from their declaring module — NestJS instantiates guards in the context of the controller's module. Since the controllers live in IdentityModule (or modules that import it), the guard could see the PERMISSIONS_PROVIDER token. AuthorizationService, being a true singleton from AuthorizationModule, could not.

Why Importing IdentityModule Was Not an Option

The obvious fix — AuthorizationModule imports IdentityModule — creates a circular dependency: IdentityModule's command handlers inject AuthorizationService (from AuthorizationModule), so AuthorizationModule cannot also import IdentityModule.

Decision

Replace DI-token-based injection with runtime registration, following the same pattern already used for SubjectLoader.

How It Works

  1. AuthorizationService owns a private permissionsProvider field (initially undefined) and exposes two methods:

    typescript
    registerPermissionsProvider(provider: IPermissionsProvider): void
    getPermissionsProvider(): IPermissionsProvider | undefined
  2. IdentityPermissionsProvider implements OnModuleInit and registers itself at startup:

    typescript
    onModuleInit(): void {
      this.authorizationService.registerPermissionsProvider(this);
    }
  3. PoliciesGuard accesses the provider via authorizationService.getPermissionsProvider() instead of direct DI.

  4. The PERMISSIONS_PROVIDER DI token is no longer used for injection. The interface is kept for typing.

Flow After the Fix

App startup
  └── IdentityModule initializes
        └── IdentityPermissionsProvider.onModuleInit()
              └── authorizationService.registerPermissionsProvider(this) ✅

Request arrives
  └── PoliciesGuard.canActivate()
        └── authorizationService.getPermissionsProvider().getUserPermissions() ✅
  └── CommandHandler.execute()
        └── authorizationService.assertCan()
              └── this.permissionsProvider.getUserPermissions() ✅

Both the guard and the service now use the exact same provider instance.

Alternatives Considered

  • AuthorizationModule.forRoot({ permissionsProvider }): Would let CoreModule or AppModule pass the provider class. Rejected because IdentityPermissionsProvider depends on identity repositories — those deps only exist inside IdentityModule, so useClass in a different module would fail to resolve them.

  • forwardRef(() => IdentityModule): Would resolve the circular import. Rejected because circular forwardRef is fragile, hard to debug, and discouraged by NestJS docs for cross-layer dependencies.

  • Make IdentityModule @Global(): Would make PERMISSIONS_PROVIDER visible everywhere. Rejected because it over-exposes identity internals (repos, services, strategies) to every module in the app.

Consequences

Positive

  • Consistent pattern: Runtime registration is already the established pattern for SubjectLoader. Adding registerPermissionsProvider keeps the authorization system internally consistent.
  • No circular deps: IdentityModule depends on AuthorizationModule (one direction only).
  • Single provider instance: Both the guard and the service use the same object — no duplicate instantiation.

Tradeoffs

  • Runtime vs compile-time safety: If IdentityModule is removed or onModuleInit fails silently, the provider will be undefined at runtime. This is the same tradeoff as SubjectLoader and is acceptable given @Optional() was already in use.
  • Ordering dependency: The provider is only available after IdentityModule initializes. This is guaranteed by NestJS module lifecycle (providers init before the app starts listening).

Source Paths

  • apps/api/src/core/authorization/authorization.service.ts
  • apps/api/src/core/authorization/authorization.module.ts
  • apps/api/src/core/authorization/guards/policies.guard.ts
  • apps/api/src/modules/identity/infrastructure/permissions/identity-permissions.provider.ts
  • apps/api/src/modules/identity/identity.module.ts