R&D new Microservice to authorize based on user ability#474
R&D new Microservice to authorize based on user ability#474dipesh-rumsan wants to merge 5 commits intodevfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new shared-auth microservice to handle user authorization based on CASL abilities. The microservice provides centralized authorization logic that can be consumed by other services via Redis message patterns, implementing role-based and permission-based access control with caching support.
Changes:
- Added new shared-auth microservice with ability checking and user abilities retrieval functionality
- Integrated SDK exports for shared-auth job events to enable cross-service communication
- Updated build scripts and package.json to include the new microservice in the monorepo structure
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Added bcrypt dependencies (with version issues) and build/serve scripts for shared-auth microservice; added packageManager field for Corepack |
| libs/sdk/src/shared-auth/shared-auth.event.ts | Defined job event constants for shared-auth service (CHECK_ABILITY, GET_USER_ABILITIES) |
| libs/sdk/src/shared-auth/index.ts | Exported SharedAuthJobs for use in other services |
| libs/sdk/src/index.ts | Added shared-auth exports to main SDK entry point |
| apps/shared-auth/webpack.config.js | Standard NX webpack configuration for Node.js microservice |
| apps/shared-auth/tsconfig.spec.json | TypeScript configuration for test files |
| apps/shared-auth/tsconfig.json | Root TypeScript configuration with project references |
| apps/shared-auth/tsconfig.app.json | TypeScript configuration for application source code |
| apps/shared-auth/src/types.ts | Defined UserContext and CaslRule interfaces for authorization |
| apps/shared-auth/src/main.ts | Microservice bootstrap with Redis transport configuration |
| apps/shared-auth/src/auth/auth.helpers.ts | Helper functions for loading user roles/permissions and building JWTs |
| apps/shared-auth/src/app/app.module.ts | Root module importing ConfigModule, PrismaModule, and AbilityModule |
| apps/shared-auth/src/ability/ability.service.ts | Core authorization logic with caching, ability checking, and permission retrieval |
| apps/shared-auth/src/ability/ability.module.ts | Module configuration for ability feature |
| apps/shared-auth/src/ability/ability.controller.ts | Message pattern handlers for authorization requests |
| apps/shared-auth/project.json | NX project configuration for build and serve targets |
| apps/shared-auth/modify-package.js | Script to modify generated package.json with Prisma dependencies |
| apps/shared-auth/jest.config.ts | Jest test configuration (with incorrect naming) |
| apps/shared-auth/.eslintrc.json | ESLint configuration extending base rules |
| apps/rahat/src/projects/project.service.ts | Temporary change to always send user context in RPC calls |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| console.log('user obj is: ', permissions); | ||
|
|
||
| const ability = this.buildAbility(permissions); | ||
|
|
||
| const allowed = ability.can(action, subject); | ||
| console.log( | ||
| `user allowed ${allowed} for action ${action} subject ${subject}` | ||
| ); | ||
|
|
||
| const rule = ability.relevantRuleFor(action, subject); | ||
| console.log( | ||
| `user rule ${allowed} for action ${action} subject ${subject}` |
There was a problem hiding this comment.
Debug console.log statements should be removed or replaced with proper logger calls before merging to production. This code contains three console.log statements that will clutter production logs. Replace these with this.logger.log() or this.logger.debug() calls, or remove them entirely.
| console.log('user obj is: ', permissions); | |
| const ability = this.buildAbility(permissions); | |
| const allowed = ability.can(action, subject); | |
| console.log( | |
| `user allowed ${allowed} for action ${action} subject ${subject}` | |
| ); | |
| const rule = ability.relevantRuleFor(action, subject); | |
| console.log( | |
| `user rule ${allowed} for action ${action} subject ${subject}` | |
| this.logger.debug(`User permissions: ${JSON.stringify(permissions)}`); | |
| const ability = this.buildAbility(permissions); | |
| const allowed = ability.can(action, subject); | |
| this.logger.debug( | |
| `User allowed: ${allowed} for action "${action}" on subject "${subject}"` | |
| ); | |
| const rule = ability.relevantRuleFor(action, subject); | |
| this.logger.debug( | |
| `User rule for action "${action}" on subject "${subject}": ${JSON.stringify(rule)}` |
| const { userId, action, subject } = payload; | ||
|
|
||
| const cacheKey = `ability:${userId}:${action}:${subject}`; |
There was a problem hiding this comment.
The cache key does not include the conditions parameter, which means ability checks with different conditions for the same userId/action/subject will incorrectly return the same cached result. This could lead to incorrect authorization decisions when conditional permissions are involved. The cache key should incorporate a hash or stringified version of the conditions when they are present.
| const { userId, action, subject } = payload; | |
| const cacheKey = `ability:${userId}:${action}:${subject}`; | |
| const { userId, action, subject, conditions } = payload; | |
| const conditionsKey = conditions ? JSON.stringify(conditions) : 'no-conditions'; | |
| const cacheKey = `ability:${userId}:${action}:${subject}:${conditionsKey}`; |
| .send(cmd, { | ||
| ...payload, | ||
| ...(requiresUser && { user }), | ||
| ...(true && { user }), // temp solution to send user context for actions that require authentication, will refactor later |
There was a problem hiding this comment.
The temporary solution comment indicates this code change is incomplete. The condition ...(true && { user }) always evaluates to true, which means the user context is always sent regardless of whether the action requires authentication. This bypasses the requiresUser check on line 165 that was presumably meant to control this behavior. This should either be changed to ...(requiresUser && { user }) to use the actual check, or the requiresUser logic should be removed entirely if it's no longer needed.
| ...(true && { user }), // temp solution to send user context for actions that require authentication, will refactor later | |
| ...(requiresUser && { user }), // send user context only for actions that require authentication |
| import { Controller } from '@nestjs/common'; | ||
| import { MessagePattern, Payload } from '@nestjs/microservices'; | ||
| import { | ||
| SharedAuthJobs | ||
| } from '@rahataid/sdk'; | ||
| import { AbilityService } from './ability.service'; | ||
|
|
||
| @Controller() | ||
| export class AbilityController { | ||
| constructor(private readonly abilityService: AbilityService) { } | ||
|
|
||
| @MessagePattern({ cmd: SharedAuthJobs.CHECK_ABILITY }) | ||
| checkAbility( | ||
| @Payload() | ||
| payload: { | ||
| userId: number; | ||
| action: string; | ||
| subject: string; | ||
| conditions?: any; | ||
| }, | ||
| ) { | ||
| return this.abilityService.checkAbility(payload); | ||
| } | ||
|
|
||
| @MessagePattern({ cmd: SharedAuthJobs.GET_USER_ABILITIES }) | ||
| getUserAbilities(@Payload() payload: { userId: number }) { | ||
| return this.abilityService.getUserAbilities(payload); | ||
| } | ||
| } |
There was a problem hiding this comment.
The AbilityController lacks test coverage. Based on the codebase convention (e.g., apps/beneficiary/src/beneficiary/beneficiary.controller.spec.ts), controllers should have accompanying .spec.ts files with unit tests. The controller handles message patterns for authorization checks and should be tested to ensure proper request/response handling and error scenarios.
| "amqp-connection-manager": "^4.1.14", | ||
| "amqplib": "^0.10.5", | ||
| "axios": "^1.6.7", | ||
| "bcrypt": "^6.0.0", |
There was a problem hiding this comment.
The bcrypt package is added to dependencies but is not used anywhere in the shared-auth microservice code. This creates an unused dependency that increases the application bundle size and maintenance burden. Consider removing this dependency unless it will be used in upcoming commits.
| "bcrypt": "^6.0.0", |
| this.redis = new Redis({ | ||
| host: this.configService.get<string>('REDIS_HOST', 'localhost'), | ||
| port: this.configService.get<number>('REDIS_PORT', 6379), | ||
| password: this.configService.get<string>('REDIS_PASSWORD'), | ||
| }); |
There was a problem hiding this comment.
The Redis client is instantiated in the constructor without proper error handling. If the Redis connection fails (e.g., due to incorrect credentials or network issues), the microservice will throw an unhandled error and potentially crash. Consider adding error event listeners (redis.on('error', ...)) and implementing a connection retry strategy, or using a Redis connection pool/manager pattern for better resilience.
| @Injectable() | ||
| export class AbilityService { | ||
| private readonly logger = new Logger(AbilityService.name); | ||
| private readonly redis: Redis; | ||
| private readonly cacheTtl: number = 300; | ||
|
|
||
| constructor( | ||
| private readonly prisma: PrismaService, | ||
| private readonly configService: ConfigService | ||
| ) { | ||
| this.redis = new Redis({ | ||
| host: this.configService.get<string>('REDIS_HOST', 'localhost'), | ||
| port: this.configService.get<number>('REDIS_PORT', 6379), | ||
| password: this.configService.get<string>('REDIS_PASSWORD'), | ||
| }); | ||
| } | ||
|
|
||
| /** Check if a user can perform an action on a subject. */ | ||
| async checkAbility(payload: { | ||
| userId: number; | ||
| action: string; | ||
| subject: string; | ||
| conditions?: any; | ||
| }): Promise<{ allowed: boolean; reason?: string }> { | ||
| try { | ||
| const { userId, action, subject } = payload; | ||
|
|
||
| const cacheKey = `ability:${userId}:${action}:${subject}`; | ||
| const cached = await this.redis.get(cacheKey); | ||
|
|
||
| if (cached) { | ||
| this.logger.log(`Getting data from cache using key ${cacheKey}`); | ||
| return JSON.parse(cached); | ||
| } | ||
|
|
||
| // Build ability from user's roles and permissions | ||
| const { permissions } = await loadUserRolesAndPermissions( | ||
| this.prisma, | ||
| userId | ||
| ); | ||
| console.log('user obj is: ', permissions); | ||
|
|
||
| const ability = this.buildAbility(permissions); | ||
|
|
||
| const allowed = ability.can(action, subject); | ||
| console.log( | ||
| `user allowed ${allowed} for action ${action} subject ${subject}` | ||
| ); | ||
|
|
||
| const rule = ability.relevantRuleFor(action, subject); | ||
| console.log( | ||
| `user rule ${allowed} for action ${action} subject ${subject}` | ||
| ); | ||
|
|
||
| const reason = rule?.reason; | ||
| const result = { allowed, ...(reason ? { reason } : {}) }; | ||
|
|
||
| // Cache the result | ||
| await this.redis.set( | ||
| cacheKey, | ||
| JSON.stringify(result), | ||
| 'EX', | ||
| this.cacheTtl | ||
| ); | ||
|
|
||
| return result; | ||
| } catch (error) { | ||
| this.logger.error('checkAbility failed', error); | ||
| return { allowed: false, reason: 'Ability check failed' }; | ||
| } | ||
| } | ||
|
|
||
| /** Return all CASL rules for a user. */ | ||
| async getUserAbilities(payload: { | ||
| userId: number; | ||
| }): Promise<{ rules: CaslRule[] }> { | ||
| try { | ||
| const { permissions } = await loadUserRolesAndPermissions( | ||
| this.prisma, | ||
| payload.userId | ||
| ); | ||
| return { rules: permissions }; | ||
| } catch (error) { | ||
| this.logger.error('getUserAbilities failed', error); | ||
| return { rules: [] }; | ||
| } | ||
| } | ||
|
|
||
| private buildAbility(permissions: CaslRule[]): AppAbility { | ||
| const builder = new AbilityBuilder<AppAbility>(createMongoAbility); | ||
|
|
||
| for (const perm of permissions) { | ||
| if (perm.inverted) { | ||
| builder.cannot(perm.action, perm.subject); | ||
| } else { | ||
| builder.can(perm.action, perm.subject); | ||
| } | ||
| } | ||
|
|
||
| return builder.build(); | ||
| } | ||
| } |
There was a problem hiding this comment.
The Redis client connection is never closed when the service is destroyed. This can lead to connection leaks, especially during testing or application shutdowns. Implement the OnModuleDestroy interface and add a cleanup method to properly disconnect the Redis client: async onModuleDestroy() { await this.redis.quit(); }
| @Injectable() | ||
| export class AbilityService { | ||
| private readonly logger = new Logger(AbilityService.name); | ||
| private readonly redis: Redis; | ||
| private readonly cacheTtl: number = 300; | ||
|
|
||
| constructor( | ||
| private readonly prisma: PrismaService, | ||
| private readonly configService: ConfigService | ||
| ) { | ||
| this.redis = new Redis({ | ||
| host: this.configService.get<string>('REDIS_HOST', 'localhost'), | ||
| port: this.configService.get<number>('REDIS_PORT', 6379), | ||
| password: this.configService.get<string>('REDIS_PASSWORD'), | ||
| }); | ||
| } | ||
|
|
||
| /** Check if a user can perform an action on a subject. */ | ||
| async checkAbility(payload: { | ||
| userId: number; | ||
| action: string; | ||
| subject: string; | ||
| conditions?: any; | ||
| }): Promise<{ allowed: boolean; reason?: string }> { | ||
| try { | ||
| const { userId, action, subject } = payload; | ||
|
|
||
| const cacheKey = `ability:${userId}:${action}:${subject}`; | ||
| const cached = await this.redis.get(cacheKey); | ||
|
|
||
| if (cached) { | ||
| this.logger.log(`Getting data from cache using key ${cacheKey}`); | ||
| return JSON.parse(cached); | ||
| } | ||
|
|
||
| // Build ability from user's roles and permissions | ||
| const { permissions } = await loadUserRolesAndPermissions( | ||
| this.prisma, | ||
| userId | ||
| ); | ||
| console.log('user obj is: ', permissions); | ||
|
|
||
| const ability = this.buildAbility(permissions); | ||
|
|
||
| const allowed = ability.can(action, subject); | ||
| console.log( | ||
| `user allowed ${allowed} for action ${action} subject ${subject}` | ||
| ); | ||
|
|
||
| const rule = ability.relevantRuleFor(action, subject); | ||
| console.log( | ||
| `user rule ${allowed} for action ${action} subject ${subject}` | ||
| ); | ||
|
|
||
| const reason = rule?.reason; | ||
| const result = { allowed, ...(reason ? { reason } : {}) }; | ||
|
|
||
| // Cache the result | ||
| await this.redis.set( | ||
| cacheKey, | ||
| JSON.stringify(result), | ||
| 'EX', | ||
| this.cacheTtl | ||
| ); | ||
|
|
||
| return result; | ||
| } catch (error) { | ||
| this.logger.error('checkAbility failed', error); | ||
| return { allowed: false, reason: 'Ability check failed' }; | ||
| } | ||
| } | ||
|
|
||
| /** Return all CASL rules for a user. */ | ||
| async getUserAbilities(payload: { | ||
| userId: number; | ||
| }): Promise<{ rules: CaslRule[] }> { | ||
| try { | ||
| const { permissions } = await loadUserRolesAndPermissions( | ||
| this.prisma, | ||
| payload.userId | ||
| ); | ||
| return { rules: permissions }; | ||
| } catch (error) { | ||
| this.logger.error('getUserAbilities failed', error); | ||
| return { rules: [] }; | ||
| } | ||
| } | ||
|
|
||
| private buildAbility(permissions: CaslRule[]): AppAbility { | ||
| const builder = new AbilityBuilder<AppAbility>(createMongoAbility); | ||
|
|
||
| for (const perm of permissions) { | ||
| if (perm.inverted) { | ||
| builder.cannot(perm.action, perm.subject); | ||
| } else { | ||
| builder.can(perm.action, perm.subject); | ||
| } | ||
| } | ||
|
|
||
| return builder.build(); | ||
| } | ||
| } |
There was a problem hiding this comment.
The AbilityService lacks test coverage. Based on the codebase convention (e.g., apps/beneficiary/src/beneficiary/beneficiary.service.spec.ts), services should have accompanying .spec.ts files with unit tests. The service contains critical authorization logic that should be thoroughly tested, including scenarios for permission checking, caching behavior, role expiry, and error handling.
| private buildAbility(permissions: CaslRule[]): AppAbility { | ||
| const builder = new AbilityBuilder<AppAbility>(createMongoAbility); | ||
|
|
||
| for (const perm of permissions) { | ||
| if (perm.inverted) { | ||
| builder.cannot(perm.action, perm.subject); | ||
| } else { | ||
| builder.can(perm.action, perm.subject); | ||
| } | ||
| } | ||
|
|
||
| return builder.build(); |
There was a problem hiding this comment.
The buildAbility method ignores the conditions field from CaslRule permissions. The CaslRule type includes an optional conditions field for fine-grained access control, but the buildAbility method only uses action and subject when building abilities. This means any conditional permissions defined in the database will be ignored, potentially allowing broader access than intended. You should pass conditions to builder.can() and builder.cannot() methods when they exist.
| import { AppModule } from './app/app.module'; | ||
|
|
||
| async function bootstrap() { | ||
| const PORT: number = +process.env.PORT_AUTH || 8080; |
There was a problem hiding this comment.
The PORT_AUTH environment variable is used but not documented in .env.example. This will cause confusion for developers setting up the application, as they won't know this environment variable needs to be configured. Add PORT_AUTH to .env.example with an appropriate default value (e.g., PORT_AUTH=5502 to follow the pattern of PORT=5500 and PORT_BEN=5501).
| const PORT: number = +process.env.PORT_AUTH || 8080; | |
| const PORT: number = +process.env.PORT || 8080; |
No description provided.