Appearance
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): providesAuthorizationServiceandCaslAbilityFactory.IdentityModule: providesIdentityPermissionsProviderunder thePERMISSIONS_PROVIDERtoken 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
AuthorizationServiceowns a privatepermissionsProviderfield (initiallyundefined) and exposes two methods:typescriptregisterPermissionsProvider(provider: IPermissionsProvider): void getPermissionsProvider(): IPermissionsProvider | undefinedIdentityPermissionsProviderimplementsOnModuleInitand registers itself at startup:typescriptonModuleInit(): void { this.authorizationService.registerPermissionsProvider(this); }PoliciesGuardaccesses the provider viaauthorizationService.getPermissionsProvider()instead of direct DI.The
PERMISSIONS_PROVIDERDI 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 letCoreModuleorAppModulepass the provider class. Rejected becauseIdentityPermissionsProviderdepends on identity repositories — those deps only exist insideIdentityModule, souseClassin a different module would fail to resolve them.forwardRef(() => IdentityModule): Would resolve the circular import. Rejected because circularforwardRefis fragile, hard to debug, and discouraged by NestJS docs for cross-layer dependencies.Make
IdentityModule@Global(): Would makePERMISSIONS_PROVIDERvisible 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. AddingregisterPermissionsProviderkeeps the authorization system internally consistent. - No circular deps:
IdentityModuledepends onAuthorizationModule(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
IdentityModuleis removed oronModuleInitfails silently, the provider will beundefinedat runtime. This is the same tradeoff asSubjectLoaderand is acceptable given@Optional()was already in use. - Ordering dependency: The provider is only available after
IdentityModuleinitializes. This is guaranteed by NestJS module lifecycle (providers init before the app starts listening).
Related Docs
- Feature docs: CASL Authorization, RBAC and Custom Roles
Source Paths
apps/api/src/core/authorization/authorization.service.tsapps/api/src/core/authorization/authorization.module.tsapps/api/src/core/authorization/guards/policies.guard.tsapps/api/src/modules/identity/infrastructure/permissions/identity-permissions.provider.tsapps/api/src/modules/identity/identity.module.ts