@flusys/nestjs-iam 6.2.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,7 +51,9 @@ const COMPANY_ACTION_PERMISSION_MESSAGES = {
51
51
  GET_SUCCESS: 'company.action.permission.get.success'
52
52
  };
53
53
  const MY_PERMISSION_MESSAGES = {
54
- GET_SUCCESS: 'my.permission.get.success'
54
+ GET_SUCCESS: 'my.permission.get.success',
55
+ MY_ROLES_SUCCESS: 'my.permission.roles.success',
56
+ MY_ACTIONS_SUCCESS: 'my.permission.actions.success'
55
57
  };
56
58
  const IAM_MODE_MESSAGES = {
57
59
  DIRECT_MODE_UNAVAILABLE: 'iam.direct.mode.unavailable',
@@ -51,6 +51,24 @@ let MyPermissionController = class MyPermissionController {
51
51
  data
52
52
  };
53
53
  }
54
+ async getMyRoles(user) {
55
+ const data = await this.permissionService.getUserEffectiveRoles(user.id, user.companyId ?? null, user.branchId ?? null);
56
+ return {
57
+ success: true,
58
+ message: 'Roles loaded successfully',
59
+ messageKey: _config.MY_PERMISSION_MESSAGES.MY_ROLES_SUCCESS,
60
+ data
61
+ };
62
+ }
63
+ async getMyActions(user) {
64
+ const data = await this.permissionService.getUserEffectiveActions(user.id, user.companyId ?? null, user.branchId ?? null);
65
+ return {
66
+ success: true,
67
+ message: 'Actions loaded successfully',
68
+ messageKey: _config.MY_PERMISSION_MESSAGES.MY_ACTIONS_SUCCESS,
69
+ data
70
+ };
71
+ }
54
72
  // NOTE: @Inject() required for bundled code - type metadata may be lost during esbuild
55
73
  constructor(permissionService){
56
74
  _define_property(this, "permissionService", void 0);
@@ -80,6 +98,42 @@ _ts_decorate([
80
98
  ]),
81
99
  _ts_metadata("design:returntype", Promise)
82
100
  ], MyPermissionController.prototype, "getMyPermissions", null);
101
+ _ts_decorate([
102
+ (0, _common.Post)('my-roles'),
103
+ (0, _swagger.ApiOperation)({
104
+ summary: 'Get current user roles',
105
+ description: 'Returns effective roles for the authenticated user, merging company-wide (branch-independent) and login-branch-specific role assignments.'
106
+ }),
107
+ (0, _nestjsshared.ApiResponseDto)(_permissiondto.UserRoleResponseDto, true, 'single'),
108
+ (0, _swagger.ApiResponse)({
109
+ status: 401,
110
+ description: 'Unauthorized'
111
+ }),
112
+ _ts_param(0, (0, _nestjsshared.CurrentUser)()),
113
+ _ts_metadata("design:type", Function),
114
+ _ts_metadata("design:paramtypes", [
115
+ typeof _nestjsshared.ILoggedUserInfo === "undefined" ? Object : _nestjsshared.ILoggedUserInfo
116
+ ]),
117
+ _ts_metadata("design:returntype", Promise)
118
+ ], MyPermissionController.prototype, "getMyRoles", null);
119
+ _ts_decorate([
120
+ (0, _common.Post)('my-actions'),
121
+ (0, _swagger.ApiOperation)({
122
+ summary: 'Get current user direct actions',
123
+ description: 'Returns effective direct action assignments for the authenticated user, merging company-wide (branch-independent) and login-branch-specific assignments.'
124
+ }),
125
+ (0, _nestjsshared.ApiResponseDto)(_permissiondto.UserActionResponseDto, true, 'single'),
126
+ (0, _swagger.ApiResponse)({
127
+ status: 401,
128
+ description: 'Unauthorized'
129
+ }),
130
+ _ts_param(0, (0, _nestjsshared.CurrentUser)()),
131
+ _ts_metadata("design:type", Function),
132
+ _ts_metadata("design:paramtypes", [
133
+ typeof _nestjsshared.ILoggedUserInfo === "undefined" ? Object : _nestjsshared.ILoggedUserInfo
134
+ ]),
135
+ _ts_metadata("design:returntype", Promise)
136
+ ], MyPermissionController.prototype, "getMyActions", null);
83
137
  MyPermissionController = _ts_decorate([
84
138
  (0, _swagger.ApiTags)('IAM - My Permissions'),
85
139
  (0, _common.Controller)('iam/permissions'),
@@ -9,7 +9,9 @@ describe('MyPermissionController', ()=>{
9
9
  let mockPermissionService;
10
10
  beforeEach(()=>{
11
11
  mockPermissionService = {
12
- getMyPermissions: jest.fn()
12
+ getMyPermissions: jest.fn(),
13
+ getUserEffectiveRoles: jest.fn(),
14
+ getUserEffectiveActions: jest.fn()
13
15
  };
14
16
  controller = new _mypermissioncontroller.MyPermissionController(mockPermissionService);
15
17
  });
@@ -53,4 +55,74 @@ describe('MyPermissionController', ()=>{
53
55
  expect(mockPermissionService.getMyPermissions).toHaveBeenCalledWith('user-1', null, null, undefined);
54
56
  });
55
57
  });
58
+ describe('getMyRoles', ()=>{
59
+ it('resolves effective roles for the current user, company, and branch', async ()=>{
60
+ const user = (0, _loggedusermock.buildMockUser)({
61
+ id: 'user-1',
62
+ companyId: 'company-1',
63
+ branchId: 'branch-1'
64
+ });
65
+ const roles = [
66
+ {
67
+ id: 'perm-1',
68
+ roleId: 'role-1',
69
+ roleName: 'Manager'
70
+ }
71
+ ];
72
+ mockPermissionService.getUserEffectiveRoles.mockResolvedValue(roles);
73
+ const result = await controller.getMyRoles(user);
74
+ expect(mockPermissionService.getUserEffectiveRoles).toHaveBeenCalledWith('user-1', 'company-1', 'branch-1');
75
+ expect(result).toEqual({
76
+ success: true,
77
+ message: 'Roles loaded successfully',
78
+ messageKey: expect.any(String),
79
+ data: roles
80
+ });
81
+ });
82
+ it('passes null for missing company and branch context', async ()=>{
83
+ const user = (0, _loggedusermock.buildMockUser)({
84
+ id: 'user-1',
85
+ companyId: undefined,
86
+ branchId: undefined
87
+ });
88
+ mockPermissionService.getUserEffectiveRoles.mockResolvedValue([]);
89
+ await controller.getMyRoles(user);
90
+ expect(mockPermissionService.getUserEffectiveRoles).toHaveBeenCalledWith('user-1', null, null);
91
+ });
92
+ });
93
+ describe('getMyActions', ()=>{
94
+ it('resolves effective actions for the current user, company, and branch', async ()=>{
95
+ const user = (0, _loggedusermock.buildMockUser)({
96
+ id: 'user-1',
97
+ companyId: 'company-1',
98
+ branchId: 'branch-1'
99
+ });
100
+ const actions = [
101
+ {
102
+ id: 'perm-1',
103
+ actionId: 'action-1',
104
+ actionCode: 'user.view'
105
+ }
106
+ ];
107
+ mockPermissionService.getUserEffectiveActions.mockResolvedValue(actions);
108
+ const result = await controller.getMyActions(user);
109
+ expect(mockPermissionService.getUserEffectiveActions).toHaveBeenCalledWith('user-1', 'company-1', 'branch-1');
110
+ expect(result).toEqual({
111
+ success: true,
112
+ message: 'Actions loaded successfully',
113
+ messageKey: expect.any(String),
114
+ data: actions
115
+ });
116
+ });
117
+ it('passes null for missing company and branch context', async ()=>{
118
+ const user = (0, _loggedusermock.buildMockUser)({
119
+ id: 'user-1',
120
+ companyId: undefined,
121
+ branchId: undefined
122
+ });
123
+ mockPermissionService.getUserEffectiveActions.mockResolvedValue([]);
124
+ await controller.getMyActions(user);
125
+ expect(mockPermissionService.getUserEffectiveActions).toHaveBeenCalledWith('user-1', null, null);
126
+ });
127
+ });
56
128
  });
@@ -544,18 +544,11 @@ _ts_decorate([
544
544
  ], UserRoleResponseDto.prototype, "createdAt", void 0);
545
545
  let FrontendActionDto = class FrontendActionDto {
546
546
  constructor(){
547
- _define_property(this, "id", void 0);
548
547
  _define_property(this, "code", void 0);
549
548
  _define_property(this, "name", void 0);
550
549
  _define_property(this, "description", void 0);
551
550
  }
552
551
  };
553
- _ts_decorate([
554
- (0, _swagger.ApiProperty)({
555
- description: 'Action ID'
556
- }),
557
- _ts_metadata("design:type", String)
558
- ], FrontendActionDto.prototype, "id", void 0);
559
552
  _ts_decorate([
560
553
  (0, _swagger.ApiProperty)({
561
554
  description: 'Action code'
@@ -166,7 +166,6 @@ let PermissionCacheService = class PermissionCacheService {
166
166
  }
167
167
  return {
168
168
  frontendActions: frontendActions.map((a)=>({
169
- id: a.id,
170
169
  code: a.code,
171
170
  name: a.name,
172
171
  description: a.description
@@ -392,7 +391,8 @@ let PermissionCacheService = class PermissionCacheService {
392
391
  } catch {
393
392
  trackedKeys = [];
394
393
  }
395
- if (!branchIds?.length) {
394
+ const hasCompanyWideScope = branchIds?.some((branchId)=>!branchId);
395
+ if (!branchIds?.length || hasCompanyWideScope) {
396
396
  trackedKeys.forEach((key)=>keysToDelete.add(key));
397
397
  keysToDelete.add(trackedKeysKey);
398
398
  await untrackScope(trackedKeysKey);
@@ -211,8 +211,6 @@ describe('PermissionCacheService', ()=>{
211
211
  expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
212
212
  });
213
213
  it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
214
- // company-scoped key format only differentiates by branchId when the guard config
215
- // enables the company feature — otherwise every branch maps to the same cache key.
216
214
  const guardConfig = {
217
215
  enableCompanyFeature: true
218
216
  };
@@ -229,18 +227,15 @@ describe('PermissionCacheService', ()=>{
229
227
  ];
230
228
  return undefined;
231
229
  });
232
- // companyId given directly invokes invalidateScope (bypasses the tracked-scopes fan-out)
233
230
  await service.invalidateUser('user-1', 'company-1', [
234
231
  'branch-a'
235
232
  ]);
236
- // the specific branch key gets deleted
237
233
  const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
238
234
  expect(deletedKeys).toContain((0, _nestjsshared.buildPermissionCacheKey)({
239
235
  userId: 'user-1',
240
236
  companyId: 'company-1',
241
237
  branchId: 'branch-a'
242
238
  }, guardConfig));
243
- // remaining tracked keys get re-saved rather than the tracked-keys index being wiped
244
239
  expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.arrayContaining([
245
240
  keptKey,
246
241
  'some-other-tracked-key'
@@ -269,6 +264,37 @@ describe('PermissionCacheService', ()=>{
269
264
  expect(deletedKeys).toContain(onlyKey);
270
265
  expect(deletedKeys.some((k)=>String(k).includes('permission-tracked-keys'))).toBe(true);
271
266
  });
267
+ it('wipes every tracked branch key when a null branchId (company-wide grant) is in the scope list', async ()=>{
268
+ const guardConfig = {
269
+ enableCompanyFeature: true
270
+ };
271
+ const service = buildService(guardConfig);
272
+ const branchAKey = (0, _nestjsshared.buildPermissionCacheKey)({
273
+ userId: 'user-1',
274
+ companyId: 'company-1',
275
+ branchId: 'branch-a'
276
+ }, guardConfig);
277
+ const branchBKey = (0, _nestjsshared.buildPermissionCacheKey)({
278
+ userId: 'user-1',
279
+ companyId: 'company-1',
280
+ branchId: 'branch-b'
281
+ }, guardConfig);
282
+ const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
283
+ mockCacheManager.get.mockImplementation(async (key)=>{
284
+ if (key.includes('permission-tracked-keys')) return [
285
+ branchAKey,
286
+ branchBKey
287
+ ];
288
+ return undefined;
289
+ });
290
+ await service.invalidateUser('user-1', 'company-1', [
291
+ null
292
+ ]);
293
+ const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
294
+ expect(deletedKeys).toContain(branchAKey);
295
+ expect(deletedKeys).toContain(branchBKey);
296
+ expect(deletedKeys.some((k)=>String(k).includes(trackedKeysKey))).toBe(true);
297
+ });
272
298
  it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
273
299
  const service = buildService();
274
300
  // scopes list mixes a nested tracked-keys index (company scope) with a raw cache key (untracked scope)
@@ -400,7 +426,7 @@ describe('PermissionCacheService', ()=>{
400
426
  'user'
401
427
  ]);
402
428
  expect(result.frontendActions).toHaveLength(1);
403
- expect(result.frontendActions[0].id).toBe('child-1');
429
+ expect(result.frontendActions[0].code).toBe('user.view');
404
430
  });
405
431
  it('returns an empty frontendActions list when parentCodes resolve to no known parents', async ()=>{
406
432
  const service = buildService();
@@ -563,7 +589,6 @@ describe('PermissionCacheService', ()=>{
563
589
  expect(result.cachedEndpoints).toBe(1);
564
590
  expect(result.frontendActions).toEqual([
565
591
  {
566
- id: 'action-1',
567
592
  code: 'user.view',
568
593
  name: 'View Users',
569
594
  description: null
@@ -606,9 +631,6 @@ describe('PermissionCacheService', ()=>{
606
631
  expect(result).toBe(2);
607
632
  });
608
633
  it('wipes every tracked cache entry per member instead of only branches with an existing permission row', async ()=>{
609
- // Company-wide action changes are merged into every branch a member queries, including
610
- // branches where the member holds no explicit permission row — so invalidation must not
611
- // be narrowed to whatever branch_ids happen to already exist in the permission table.
612
634
  const service = buildService();
613
635
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
614
636
  mockRawMany(mockPermissionRepo, [
@@ -665,9 +687,6 @@ describe('PermissionCacheService', ()=>{
665
687
  expect(result).toBe(1);
666
688
  });
667
689
  it('wipes every tracked cache entry for role members instead of only branches with an existing permission row', async ()=>{
668
- // Role actions are merged into every branch a member queries, including branches
669
- // where the member holds no explicit permission row — so invalidation must not be
670
- // narrowed to whatever branch_ids happen to already exist in the permission table.
671
690
  const service = buildService();
672
691
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
673
692
  mockPermissionRepo.find.mockResolvedValue([
@@ -531,6 +531,104 @@ let PermissionService = class PermissionService {
531
531
  };
532
532
  });
533
533
  }
534
+ // Effective (Merged) Permissions — self-scoped, company-wide + branch-specific
535
+ /** Get user's effective roles: company-wide (branchId null) merged with branch-specific */ async getUserEffectiveRoles(userId, companyId, branchId) {
536
+ const permissionRepo = await this.getPermissionRepository();
537
+ const roleRepo = await this.getRoleRepository();
538
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
539
+ const baseWhere = {
540
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
541
+ sourceType: _iamentitytypeenum.IamEntityType.USER,
542
+ sourceId: userId
543
+ };
544
+ if (enableCompanyFeature && companyId) baseWhere.companyId = companyId;
545
+ const permissions = enableCompanyFeature && branchId ? await permissionRepo.find({
546
+ where: [
547
+ {
548
+ ...baseWhere,
549
+ branchId: (0, _typeorm.IsNull)()
550
+ },
551
+ {
552
+ ...baseWhere,
553
+ branchId
554
+ }
555
+ ]
556
+ }) : await permissionRepo.find({
557
+ where: baseWhere
558
+ });
559
+ const validPermissions = permissions.filter((p)=>p.isValid());
560
+ if (validPermissions.length === 0) {
561
+ return [];
562
+ }
563
+ const roleIds = [
564
+ ...new Set(validPermissions.map((p)=>p.targetId))
565
+ ];
566
+ const roles = await roleRepo.find({
567
+ where: {
568
+ id: (0, _typeorm.In)(roleIds)
569
+ }
570
+ });
571
+ const roleMap = new Map(roles.map((r)=>[
572
+ r.id,
573
+ r
574
+ ]));
575
+ return validPermissions.filter((p)=>roleMap.has(p.targetId)).map((p)=>{
576
+ const role = roleMap.get(p.targetId);
577
+ return {
578
+ id: p.id,
579
+ userId: p.sourceId,
580
+ roleId: role.id,
581
+ roleName: role.name,
582
+ branchId: enableCompanyFeature ? p.branchId ?? null : null,
583
+ createdAt: p.createdAt
584
+ };
585
+ });
586
+ }
587
+ /** Get user's effective actions: company-wide (branchId null) merged with branch-specific */ async getUserEffectiveActions(userId, companyId, branchId) {
588
+ const permissionRepo = await this.getPermissionRepository();
589
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
590
+ const baseWhere = {
591
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ACTION,
592
+ sourceType: _iamentitytypeenum.IamEntityType.USER,
593
+ sourceId: userId
594
+ };
595
+ if (enableCompanyFeature && companyId) baseWhere.companyId = companyId;
596
+ const permissions = enableCompanyFeature && branchId ? await permissionRepo.find({
597
+ where: [
598
+ {
599
+ ...baseWhere,
600
+ branchId: (0, _typeorm.IsNull)()
601
+ },
602
+ {
603
+ ...baseWhere,
604
+ branchId
605
+ }
606
+ ]
607
+ }) : await permissionRepo.find({
608
+ where: baseWhere
609
+ });
610
+ const validPermissions = permissions.filter((p)=>p.isValid());
611
+ if (validPermissions.length === 0) {
612
+ return [];
613
+ }
614
+ const actionIds = [
615
+ ...new Set(validPermissions.map((p)=>p.targetId))
616
+ ];
617
+ const actionMap = await this.getActionsMap(actionIds);
618
+ return validPermissions.filter((p)=>actionMap.has(p.targetId)).map((p)=>{
619
+ const action = actionMap.get(p.targetId);
620
+ return {
621
+ id: p.id,
622
+ userId: p.sourceId,
623
+ actionId: action.id,
624
+ actionCode: action.code ?? '',
625
+ actionName: action.name,
626
+ companyId: ('companyId' in p ? p.companyId : null) ?? null,
627
+ branchId: ('branchId' in p ? p.branchId : null) ?? null,
628
+ createdAt: p.createdAt
629
+ };
630
+ });
631
+ }
534
632
  // My Permissions
535
633
  /** Get user's effective permissions (cache-first approach) */ async getMyPermissions(userId, branchId, companyId, parentCodes) {
536
634
  return this.permissionCacheService.getMyPermissionsResponse(userId, branchId, companyId, parentCodes);
@@ -573,13 +573,158 @@ describe('PermissionService', ()=>{
573
573
  }));
574
574
  });
575
575
  });
576
+ // ==================== Effective (Merged) Permissions ====================
577
+ describe('getUserEffectiveRoles', ()=>{
578
+ it('returns an empty array when the user has no role assignments', async ()=>{
579
+ mockPermissionRepo.find.mockResolvedValue([]);
580
+ expect(await service.getUserEffectiveRoles('user-1')).toEqual([]);
581
+ expect(mockRoleRepo.find).not.toHaveBeenCalled();
582
+ });
583
+ it('queries only by userId when the company feature is disabled, ignoring companyId/branchId', async ()=>{
584
+ mockPermissionRepo.find.mockResolvedValue([]);
585
+ await service.getUserEffectiveRoles('user-1', 'company-1', 'branch-1');
586
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
587
+ where: {
588
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
589
+ sourceType: _iamentitytypeenum.IamEntityType.USER,
590
+ sourceId: 'user-1'
591
+ }
592
+ });
593
+ });
594
+ it('merges company-wide (branchId null) and branch-specific role assignments via an OR query', async ()=>{
595
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
596
+ mockPermissionRepo.find.mockResolvedValue([
597
+ buildPermissionRow({
598
+ id: 'perm-1',
599
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
600
+ targetType: _iamentitytypeenum.IamEntityType.ROLE,
601
+ targetId: 'role-1',
602
+ companyId: 'company-1',
603
+ branchId: null
604
+ }),
605
+ buildPermissionRow({
606
+ id: 'perm-2',
607
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
608
+ targetType: _iamentitytypeenum.IamEntityType.ROLE,
609
+ targetId: 'role-2',
610
+ companyId: 'company-1',
611
+ branchId: 'branch-1'
612
+ })
613
+ ]);
614
+ mockRoleRepo.find.mockResolvedValue([
615
+ {
616
+ id: 'role-1',
617
+ name: 'Company Admin'
618
+ },
619
+ {
620
+ id: 'role-2',
621
+ name: 'Branch Manager'
622
+ }
623
+ ]);
624
+ const result = await service.getUserEffectiveRoles('user-1', 'company-1', 'branch-1');
625
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
626
+ where: [
627
+ expect.objectContaining({
628
+ sourceId: 'user-1',
629
+ companyId: 'company-1',
630
+ branchId: expect.anything()
631
+ }),
632
+ expect.objectContaining({
633
+ sourceId: 'user-1',
634
+ companyId: 'company-1',
635
+ branchId: 'branch-1'
636
+ })
637
+ ]
638
+ });
639
+ expect(result.map((r)=>r.roleId).sort()).toEqual([
640
+ 'role-1',
641
+ 'role-2'
642
+ ]);
643
+ });
644
+ it('excludes expired/not-yet-valid permission rows', async ()=>{
645
+ mockPermissionRepo.find.mockResolvedValue([
646
+ buildPermissionRow({
647
+ permissionType: _iampermissiontypeenum.IamPermissionType.USER_ROLE,
648
+ targetType: _iamentitytypeenum.IamEntityType.ROLE,
649
+ targetId: 'role-1',
650
+ validUntil: new Date('2000-01-01')
651
+ })
652
+ ]);
653
+ const result = await service.getUserEffectiveRoles('user-1');
654
+ expect(result).toEqual([]);
655
+ expect(mockRoleRepo.find).not.toHaveBeenCalled();
656
+ });
657
+ });
658
+ describe('getUserEffectiveActions', ()=>{
659
+ it('returns an empty array when the user has no action assignments', async ()=>{
660
+ mockPermissionRepo.find.mockResolvedValue([]);
661
+ expect(await service.getUserEffectiveActions('user-1')).toEqual([]);
662
+ expect(mockActionRepo.find).not.toHaveBeenCalled();
663
+ });
664
+ it('merges company-wide (branchId null) and branch-specific action assignments via an OR query', async ()=>{
665
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
666
+ mockPermissionRepo.find.mockResolvedValue([
667
+ buildPermissionRow({
668
+ id: 'perm-1',
669
+ targetId: 'action-1',
670
+ companyId: 'company-1',
671
+ branchId: null
672
+ }),
673
+ buildPermissionRow({
674
+ id: 'perm-2',
675
+ targetId: 'action-2',
676
+ companyId: 'company-1',
677
+ branchId: 'branch-1'
678
+ })
679
+ ]);
680
+ mockActionRepo.find.mockResolvedValue([
681
+ buildAction({
682
+ id: 'action-1',
683
+ code: 'user.view'
684
+ }),
685
+ buildAction({
686
+ id: 'action-2',
687
+ code: 'user.create'
688
+ })
689
+ ]);
690
+ const result = await service.getUserEffectiveActions('user-1', 'company-1', 'branch-1');
691
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
692
+ where: [
693
+ expect.objectContaining({
694
+ sourceId: 'user-1',
695
+ companyId: 'company-1',
696
+ branchId: expect.anything()
697
+ }),
698
+ expect.objectContaining({
699
+ sourceId: 'user-1',
700
+ companyId: 'company-1',
701
+ branchId: 'branch-1'
702
+ })
703
+ ]
704
+ });
705
+ expect(result.map((a)=>a.actionId).sort()).toEqual([
706
+ 'action-1',
707
+ 'action-2'
708
+ ]);
709
+ });
710
+ it('excludes expired/not-yet-valid permission rows', async ()=>{
711
+ mockPermissionRepo.find.mockResolvedValue([
712
+ buildPermissionRow({
713
+ targetId: 'action-1',
714
+ validUntil: new Date('2000-01-01')
715
+ })
716
+ ]);
717
+ const result = await service.getUserEffectiveActions('user-1');
718
+ expect(result).toEqual([]);
719
+ expect(mockActionRepo.find).not.toHaveBeenCalled();
720
+ });
721
+ });
576
722
  // ==================== My Permissions / permission-mode branching ====================
577
723
  describe('getMyPermissions', ()=>{
578
724
  it('delegates to the cache service and returns its response', async ()=>{
579
725
  const response = {
580
726
  frontendActions: [
581
727
  {
582
- id: 'action-1',
583
728
  code: 'user.view',
584
729
  name: 'View',
585
730
  description: null
@@ -18,6 +18,8 @@ export declare const COMPANY_ACTION_PERMISSION_MESSAGES: {
18
18
  };
19
19
  export declare const MY_PERMISSION_MESSAGES: {
20
20
  readonly GET_SUCCESS: "my.permission.get.success";
21
+ readonly MY_ROLES_SUCCESS: "my.permission.roles.success";
22
+ readonly MY_ACTIONS_SUCCESS: "my.permission.actions.success";
21
23
  };
22
24
  export declare const IAM_MODE_MESSAGES: {
23
25
  readonly DIRECT_MODE_UNAVAILABLE: "iam.direct.mode.unavailable";
@@ -1,8 +1,10 @@
1
1
  import { ILoggedUserInfo, SingleResponseDto } from '@flusys/nestjs-shared';
2
- import { MyPermissionsQueryDto, MyPermissionsResponseDto } from '../dtos/permission.dto';
2
+ import { MyPermissionsQueryDto, MyPermissionsResponseDto, UserActionResponseDto, UserRoleResponseDto } from '../dtos/permission.dto';
3
3
  import { PermissionService } from '../services/permission.service';
4
4
  export declare class MyPermissionController {
5
5
  private readonly permissionService;
6
6
  constructor(permissionService: PermissionService);
7
7
  getMyPermissions(query: MyPermissionsQueryDto, user: ILoggedUserInfo): Promise<SingleResponseDto<MyPermissionsResponseDto>>;
8
+ getMyRoles(user: ILoggedUserInfo): Promise<SingleResponseDto<UserRoleResponseDto[]>>;
9
+ getMyActions(user: ILoggedUserInfo): Promise<SingleResponseDto<UserActionResponseDto[]>>;
8
10
  }
@@ -77,7 +77,6 @@ export declare class UserRoleResponseDto {
77
77
  createdAt: Date;
78
78
  }
79
79
  export declare class FrontendActionDto {
80
- id: string;
81
80
  code: string;
82
81
  name: string;
83
82
  description: string | null;
@@ -18,7 +18,9 @@ export const COMPANY_ACTION_PERMISSION_MESSAGES = {
18
18
  GET_SUCCESS: 'company.action.permission.get.success'
19
19
  };
20
20
  export const MY_PERMISSION_MESSAGES = {
21
- GET_SUCCESS: 'my.permission.get.success'
21
+ GET_SUCCESS: 'my.permission.get.success',
22
+ MY_ROLES_SUCCESS: 'my.permission.roles.success',
23
+ MY_ACTIONS_SUCCESS: 'my.permission.actions.success'
22
24
  };
23
25
  export const IAM_MODE_MESSAGES = {
24
26
  DIRECT_MODE_UNAVAILABLE: 'iam.direct.mode.unavailable',
@@ -29,7 +29,7 @@ import { ApiResponseDto, CurrentUser, ILoggedUserInfo, JwtAuthGuard } from '@flu
29
29
  import { MY_PERMISSION_MESSAGES } from '../config';
30
30
  import { Body, Controller, Inject, Post, UseGuards } from '@nestjs/common';
31
31
  import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
32
- import { MyPermissionsQueryDto, MyPermissionsResponseDto } from '../dtos/permission.dto';
32
+ import { MyPermissionsQueryDto, MyPermissionsResponseDto, UserActionResponseDto, UserRoleResponseDto } from '../dtos/permission.dto';
33
33
  import { PermissionService } from '../services/permission.service';
34
34
  export class MyPermissionController {
35
35
  async getMyPermissions(query, user) {
@@ -41,6 +41,24 @@ export class MyPermissionController {
41
41
  data
42
42
  };
43
43
  }
44
+ async getMyRoles(user) {
45
+ const data = await this.permissionService.getUserEffectiveRoles(user.id, user.companyId ?? null, user.branchId ?? null);
46
+ return {
47
+ success: true,
48
+ message: 'Roles loaded successfully',
49
+ messageKey: MY_PERMISSION_MESSAGES.MY_ROLES_SUCCESS,
50
+ data
51
+ };
52
+ }
53
+ async getMyActions(user) {
54
+ const data = await this.permissionService.getUserEffectiveActions(user.id, user.companyId ?? null, user.branchId ?? null);
55
+ return {
56
+ success: true,
57
+ message: 'Actions loaded successfully',
58
+ messageKey: MY_PERMISSION_MESSAGES.MY_ACTIONS_SUCCESS,
59
+ data
60
+ };
61
+ }
44
62
  // NOTE: @Inject() required for bundled code - type metadata may be lost during esbuild
45
63
  constructor(permissionService){
46
64
  _define_property(this, "permissionService", void 0);
@@ -70,6 +88,42 @@ _ts_decorate([
70
88
  ]),
71
89
  _ts_metadata("design:returntype", Promise)
72
90
  ], MyPermissionController.prototype, "getMyPermissions", null);
91
+ _ts_decorate([
92
+ Post('my-roles'),
93
+ ApiOperation({
94
+ summary: 'Get current user roles',
95
+ description: 'Returns effective roles for the authenticated user, merging company-wide (branch-independent) and login-branch-specific role assignments.'
96
+ }),
97
+ ApiResponseDto(UserRoleResponseDto, true, 'single'),
98
+ ApiResponse({
99
+ status: 401,
100
+ description: 'Unauthorized'
101
+ }),
102
+ _ts_param(0, CurrentUser()),
103
+ _ts_metadata("design:type", Function),
104
+ _ts_metadata("design:paramtypes", [
105
+ typeof ILoggedUserInfo === "undefined" ? Object : ILoggedUserInfo
106
+ ]),
107
+ _ts_metadata("design:returntype", Promise)
108
+ ], MyPermissionController.prototype, "getMyRoles", null);
109
+ _ts_decorate([
110
+ Post('my-actions'),
111
+ ApiOperation({
112
+ summary: 'Get current user direct actions',
113
+ description: 'Returns effective direct action assignments for the authenticated user, merging company-wide (branch-independent) and login-branch-specific assignments.'
114
+ }),
115
+ ApiResponseDto(UserActionResponseDto, true, 'single'),
116
+ ApiResponse({
117
+ status: 401,
118
+ description: 'Unauthorized'
119
+ }),
120
+ _ts_param(0, CurrentUser()),
121
+ _ts_metadata("design:type", Function),
122
+ _ts_metadata("design:paramtypes", [
123
+ typeof ILoggedUserInfo === "undefined" ? Object : ILoggedUserInfo
124
+ ]),
125
+ _ts_metadata("design:returntype", Promise)
126
+ ], MyPermissionController.prototype, "getMyActions", null);
73
127
  MyPermissionController = _ts_decorate([
74
128
  ApiTags('IAM - My Permissions'),
75
129
  Controller('iam/permissions'),
@@ -5,7 +5,9 @@ describe('MyPermissionController', ()=>{
5
5
  let mockPermissionService;
6
6
  beforeEach(()=>{
7
7
  mockPermissionService = {
8
- getMyPermissions: jest.fn()
8
+ getMyPermissions: jest.fn(),
9
+ getUserEffectiveRoles: jest.fn(),
10
+ getUserEffectiveActions: jest.fn()
9
11
  };
10
12
  controller = new MyPermissionController(mockPermissionService);
11
13
  });
@@ -49,4 +51,74 @@ describe('MyPermissionController', ()=>{
49
51
  expect(mockPermissionService.getMyPermissions).toHaveBeenCalledWith('user-1', null, null, undefined);
50
52
  });
51
53
  });
54
+ describe('getMyRoles', ()=>{
55
+ it('resolves effective roles for the current user, company, and branch', async ()=>{
56
+ const user = buildMockUser({
57
+ id: 'user-1',
58
+ companyId: 'company-1',
59
+ branchId: 'branch-1'
60
+ });
61
+ const roles = [
62
+ {
63
+ id: 'perm-1',
64
+ roleId: 'role-1',
65
+ roleName: 'Manager'
66
+ }
67
+ ];
68
+ mockPermissionService.getUserEffectiveRoles.mockResolvedValue(roles);
69
+ const result = await controller.getMyRoles(user);
70
+ expect(mockPermissionService.getUserEffectiveRoles).toHaveBeenCalledWith('user-1', 'company-1', 'branch-1');
71
+ expect(result).toEqual({
72
+ success: true,
73
+ message: 'Roles loaded successfully',
74
+ messageKey: expect.any(String),
75
+ data: roles
76
+ });
77
+ });
78
+ it('passes null for missing company and branch context', async ()=>{
79
+ const user = buildMockUser({
80
+ id: 'user-1',
81
+ companyId: undefined,
82
+ branchId: undefined
83
+ });
84
+ mockPermissionService.getUserEffectiveRoles.mockResolvedValue([]);
85
+ await controller.getMyRoles(user);
86
+ expect(mockPermissionService.getUserEffectiveRoles).toHaveBeenCalledWith('user-1', null, null);
87
+ });
88
+ });
89
+ describe('getMyActions', ()=>{
90
+ it('resolves effective actions for the current user, company, and branch', async ()=>{
91
+ const user = buildMockUser({
92
+ id: 'user-1',
93
+ companyId: 'company-1',
94
+ branchId: 'branch-1'
95
+ });
96
+ const actions = [
97
+ {
98
+ id: 'perm-1',
99
+ actionId: 'action-1',
100
+ actionCode: 'user.view'
101
+ }
102
+ ];
103
+ mockPermissionService.getUserEffectiveActions.mockResolvedValue(actions);
104
+ const result = await controller.getMyActions(user);
105
+ expect(mockPermissionService.getUserEffectiveActions).toHaveBeenCalledWith('user-1', 'company-1', 'branch-1');
106
+ expect(result).toEqual({
107
+ success: true,
108
+ message: 'Actions loaded successfully',
109
+ messageKey: expect.any(String),
110
+ data: actions
111
+ });
112
+ });
113
+ it('passes null for missing company and branch context', async ()=>{
114
+ const user = buildMockUser({
115
+ id: 'user-1',
116
+ companyId: undefined,
117
+ branchId: undefined
118
+ });
119
+ mockPermissionService.getUserEffectiveActions.mockResolvedValue([]);
120
+ await controller.getMyActions(user);
121
+ expect(mockPermissionService.getUserEffectiveActions).toHaveBeenCalledWith('user-1', null, null);
122
+ });
123
+ });
52
124
  });
@@ -478,18 +478,11 @@ _ts_decorate([
478
478
  ], UserRoleResponseDto.prototype, "createdAt", void 0);
479
479
  export class FrontendActionDto {
480
480
  constructor(){
481
- _define_property(this, "id", void 0);
482
481
  _define_property(this, "code", void 0);
483
482
  _define_property(this, "name", void 0);
484
483
  _define_property(this, "description", void 0);
485
484
  }
486
485
  }
487
- _ts_decorate([
488
- ApiProperty({
489
- description: 'Action ID'
490
- }),
491
- _ts_metadata("design:type", String)
492
- ], FrontendActionDto.prototype, "id", void 0);
493
486
  _ts_decorate([
494
487
  ApiProperty({
495
488
  description: 'Action code'
@@ -149,7 +149,6 @@ export class PermissionCacheService {
149
149
  }
150
150
  return {
151
151
  frontendActions: frontendActions.map((a)=>({
152
- id: a.id,
153
152
  code: a.code,
154
153
  name: a.name,
155
154
  description: a.description
@@ -375,7 +374,8 @@ export class PermissionCacheService {
375
374
  } catch {
376
375
  trackedKeys = [];
377
376
  }
378
- if (!branchIds?.length) {
377
+ const hasCompanyWideScope = branchIds?.some((branchId)=>!branchId);
378
+ if (!branchIds?.length || hasCompanyWideScope) {
379
379
  trackedKeys.forEach((key)=>keysToDelete.add(key));
380
380
  keysToDelete.add(trackedKeysKey);
381
381
  await untrackScope(trackedKeysKey);
@@ -207,8 +207,6 @@ describe('PermissionCacheService', ()=>{
207
207
  expect(mockCacheManager.del).not.toHaveBeenCalledWith(expect.stringContaining('permission-tracked-scopes'));
208
208
  });
209
209
  it('invalidates only the requested branch keys, keeping remaining branches tracked', async ()=>{
210
- // company-scoped key format only differentiates by branchId when the guard config
211
- // enables the company feature — otherwise every branch maps to the same cache key.
212
210
  const guardConfig = {
213
211
  enableCompanyFeature: true
214
212
  };
@@ -225,18 +223,15 @@ describe('PermissionCacheService', ()=>{
225
223
  ];
226
224
  return undefined;
227
225
  });
228
- // companyId given directly invokes invalidateScope (bypasses the tracked-scopes fan-out)
229
226
  await service.invalidateUser('user-1', 'company-1', [
230
227
  'branch-a'
231
228
  ]);
232
- // the specific branch key gets deleted
233
229
  const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
234
230
  expect(deletedKeys).toContain(buildPermissionCacheKey({
235
231
  userId: 'user-1',
236
232
  companyId: 'company-1',
237
233
  branchId: 'branch-a'
238
234
  }, guardConfig));
239
- // remaining tracked keys get re-saved rather than the tracked-keys index being wiped
240
235
  expect(mockCacheManager.set).toHaveBeenCalledWith(expect.stringContaining('permission-tracked-keys'), expect.arrayContaining([
241
236
  keptKey,
242
237
  'some-other-tracked-key'
@@ -265,6 +260,37 @@ describe('PermissionCacheService', ()=>{
265
260
  expect(deletedKeys).toContain(onlyKey);
266
261
  expect(deletedKeys.some((k)=>String(k).includes('permission-tracked-keys'))).toBe(true);
267
262
  });
263
+ it('wipes every tracked branch key when a null branchId (company-wide grant) is in the scope list', async ()=>{
264
+ const guardConfig = {
265
+ enableCompanyFeature: true
266
+ };
267
+ const service = buildService(guardConfig);
268
+ const branchAKey = buildPermissionCacheKey({
269
+ userId: 'user-1',
270
+ companyId: 'company-1',
271
+ branchId: 'branch-a'
272
+ }, guardConfig);
273
+ const branchBKey = buildPermissionCacheKey({
274
+ userId: 'user-1',
275
+ companyId: 'company-1',
276
+ branchId: 'branch-b'
277
+ }, guardConfig);
278
+ const trackedKeysKey = 'permission-tracked-keys:user:user-1:company:company-1';
279
+ mockCacheManager.get.mockImplementation(async (key)=>{
280
+ if (key.includes('permission-tracked-keys')) return [
281
+ branchAKey,
282
+ branchBKey
283
+ ];
284
+ return undefined;
285
+ });
286
+ await service.invalidateUser('user-1', 'company-1', [
287
+ null
288
+ ]);
289
+ const deletedKeys = mockCacheManager.del.mock.calls.map(([key])=>key);
290
+ expect(deletedKeys).toContain(branchAKey);
291
+ expect(deletedKeys).toContain(branchBKey);
292
+ expect(deletedKeys.some((k)=>String(k).includes(trackedKeysKey))).toBe(true);
293
+ });
268
294
  it('without a companyId, invalidates every tracked scope for the user and clears the scopes index', async ()=>{
269
295
  const service = buildService();
270
296
  // scopes list mixes a nested tracked-keys index (company scope) with a raw cache key (untracked scope)
@@ -396,7 +422,7 @@ describe('PermissionCacheService', ()=>{
396
422
  'user'
397
423
  ]);
398
424
  expect(result.frontendActions).toHaveLength(1);
399
- expect(result.frontendActions[0].id).toBe('child-1');
425
+ expect(result.frontendActions[0].code).toBe('user.view');
400
426
  });
401
427
  it('returns an empty frontendActions list when parentCodes resolve to no known parents', async ()=>{
402
428
  const service = buildService();
@@ -559,7 +585,6 @@ describe('PermissionCacheService', ()=>{
559
585
  expect(result.cachedEndpoints).toBe(1);
560
586
  expect(result.frontendActions).toEqual([
561
587
  {
562
- id: 'action-1',
563
588
  code: 'user.view',
564
589
  name: 'View Users',
565
590
  description: null
@@ -602,9 +627,6 @@ describe('PermissionCacheService', ()=>{
602
627
  expect(result).toBe(2);
603
628
  });
604
629
  it('wipes every tracked cache entry per member instead of only branches with an existing permission row', async ()=>{
605
- // Company-wide action changes are merged into every branch a member queries, including
606
- // branches where the member holds no explicit permission row — so invalidation must not
607
- // be narrowed to whatever branch_ids happen to already exist in the permission table.
608
630
  const service = buildService();
609
631
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
610
632
  mockRawMany(mockPermissionRepo, [
@@ -661,9 +683,6 @@ describe('PermissionCacheService', ()=>{
661
683
  expect(result).toBe(1);
662
684
  });
663
685
  it('wipes every tracked cache entry for role members instead of only branches with an existing permission row', async ()=>{
664
- // Role actions are merged into every branch a member queries, including branches
665
- // where the member holds no explicit permission row — so invalidation must not be
666
- // narrowed to whatever branch_ids happen to already exist in the permission table.
667
686
  const service = buildService();
668
687
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
669
688
  mockPermissionRepo.find.mockResolvedValue([
@@ -521,6 +521,104 @@ export class PermissionService {
521
521
  };
522
522
  });
523
523
  }
524
+ // Effective (Merged) Permissions — self-scoped, company-wide + branch-specific
525
+ /** Get user's effective roles: company-wide (branchId null) merged with branch-specific */ async getUserEffectiveRoles(userId, companyId, branchId) {
526
+ const permissionRepo = await this.getPermissionRepository();
527
+ const roleRepo = await this.getRoleRepository();
528
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
529
+ const baseWhere = {
530
+ permissionType: IamPermissionType.USER_ROLE,
531
+ sourceType: IamEntityType.USER,
532
+ sourceId: userId
533
+ };
534
+ if (enableCompanyFeature && companyId) baseWhere.companyId = companyId;
535
+ const permissions = enableCompanyFeature && branchId ? await permissionRepo.find({
536
+ where: [
537
+ {
538
+ ...baseWhere,
539
+ branchId: IsNull()
540
+ },
541
+ {
542
+ ...baseWhere,
543
+ branchId
544
+ }
545
+ ]
546
+ }) : await permissionRepo.find({
547
+ where: baseWhere
548
+ });
549
+ const validPermissions = permissions.filter((p)=>p.isValid());
550
+ if (validPermissions.length === 0) {
551
+ return [];
552
+ }
553
+ const roleIds = [
554
+ ...new Set(validPermissions.map((p)=>p.targetId))
555
+ ];
556
+ const roles = await roleRepo.find({
557
+ where: {
558
+ id: In(roleIds)
559
+ }
560
+ });
561
+ const roleMap = new Map(roles.map((r)=>[
562
+ r.id,
563
+ r
564
+ ]));
565
+ return validPermissions.filter((p)=>roleMap.has(p.targetId)).map((p)=>{
566
+ const role = roleMap.get(p.targetId);
567
+ return {
568
+ id: p.id,
569
+ userId: p.sourceId,
570
+ roleId: role.id,
571
+ roleName: role.name,
572
+ branchId: enableCompanyFeature ? p.branchId ?? null : null,
573
+ createdAt: p.createdAt
574
+ };
575
+ });
576
+ }
577
+ /** Get user's effective actions: company-wide (branchId null) merged with branch-specific */ async getUserEffectiveActions(userId, companyId, branchId) {
578
+ const permissionRepo = await this.getPermissionRepository();
579
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
580
+ const baseWhere = {
581
+ permissionType: IamPermissionType.USER_ACTION,
582
+ sourceType: IamEntityType.USER,
583
+ sourceId: userId
584
+ };
585
+ if (enableCompanyFeature && companyId) baseWhere.companyId = companyId;
586
+ const permissions = enableCompanyFeature && branchId ? await permissionRepo.find({
587
+ where: [
588
+ {
589
+ ...baseWhere,
590
+ branchId: IsNull()
591
+ },
592
+ {
593
+ ...baseWhere,
594
+ branchId
595
+ }
596
+ ]
597
+ }) : await permissionRepo.find({
598
+ where: baseWhere
599
+ });
600
+ const validPermissions = permissions.filter((p)=>p.isValid());
601
+ if (validPermissions.length === 0) {
602
+ return [];
603
+ }
604
+ const actionIds = [
605
+ ...new Set(validPermissions.map((p)=>p.targetId))
606
+ ];
607
+ const actionMap = await this.getActionsMap(actionIds);
608
+ return validPermissions.filter((p)=>actionMap.has(p.targetId)).map((p)=>{
609
+ const action = actionMap.get(p.targetId);
610
+ return {
611
+ id: p.id,
612
+ userId: p.sourceId,
613
+ actionId: action.id,
614
+ actionCode: action.code ?? '',
615
+ actionName: action.name,
616
+ companyId: ('companyId' in p ? p.companyId : null) ?? null,
617
+ branchId: ('branchId' in p ? p.branchId : null) ?? null,
618
+ createdAt: p.createdAt
619
+ };
620
+ });
621
+ }
524
622
  // My Permissions
525
623
  /** Get user's effective permissions (cache-first approach) */ async getMyPermissions(userId, branchId, companyId, parentCodes) {
526
624
  return this.permissionCacheService.getMyPermissionsResponse(userId, branchId, companyId, parentCodes);
@@ -569,13 +569,158 @@ describe('PermissionService', ()=>{
569
569
  }));
570
570
  });
571
571
  });
572
+ // ==================== Effective (Merged) Permissions ====================
573
+ describe('getUserEffectiveRoles', ()=>{
574
+ it('returns an empty array when the user has no role assignments', async ()=>{
575
+ mockPermissionRepo.find.mockResolvedValue([]);
576
+ expect(await service.getUserEffectiveRoles('user-1')).toEqual([]);
577
+ expect(mockRoleRepo.find).not.toHaveBeenCalled();
578
+ });
579
+ it('queries only by userId when the company feature is disabled, ignoring companyId/branchId', async ()=>{
580
+ mockPermissionRepo.find.mockResolvedValue([]);
581
+ await service.getUserEffectiveRoles('user-1', 'company-1', 'branch-1');
582
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
583
+ where: {
584
+ permissionType: IamPermissionType.USER_ROLE,
585
+ sourceType: IamEntityType.USER,
586
+ sourceId: 'user-1'
587
+ }
588
+ });
589
+ });
590
+ it('merges company-wide (branchId null) and branch-specific role assignments via an OR query', async ()=>{
591
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
592
+ mockPermissionRepo.find.mockResolvedValue([
593
+ buildPermissionRow({
594
+ id: 'perm-1',
595
+ permissionType: IamPermissionType.USER_ROLE,
596
+ targetType: IamEntityType.ROLE,
597
+ targetId: 'role-1',
598
+ companyId: 'company-1',
599
+ branchId: null
600
+ }),
601
+ buildPermissionRow({
602
+ id: 'perm-2',
603
+ permissionType: IamPermissionType.USER_ROLE,
604
+ targetType: IamEntityType.ROLE,
605
+ targetId: 'role-2',
606
+ companyId: 'company-1',
607
+ branchId: 'branch-1'
608
+ })
609
+ ]);
610
+ mockRoleRepo.find.mockResolvedValue([
611
+ {
612
+ id: 'role-1',
613
+ name: 'Company Admin'
614
+ },
615
+ {
616
+ id: 'role-2',
617
+ name: 'Branch Manager'
618
+ }
619
+ ]);
620
+ const result = await service.getUserEffectiveRoles('user-1', 'company-1', 'branch-1');
621
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
622
+ where: [
623
+ expect.objectContaining({
624
+ sourceId: 'user-1',
625
+ companyId: 'company-1',
626
+ branchId: expect.anything()
627
+ }),
628
+ expect.objectContaining({
629
+ sourceId: 'user-1',
630
+ companyId: 'company-1',
631
+ branchId: 'branch-1'
632
+ })
633
+ ]
634
+ });
635
+ expect(result.map((r)=>r.roleId).sort()).toEqual([
636
+ 'role-1',
637
+ 'role-2'
638
+ ]);
639
+ });
640
+ it('excludes expired/not-yet-valid permission rows', async ()=>{
641
+ mockPermissionRepo.find.mockResolvedValue([
642
+ buildPermissionRow({
643
+ permissionType: IamPermissionType.USER_ROLE,
644
+ targetType: IamEntityType.ROLE,
645
+ targetId: 'role-1',
646
+ validUntil: new Date('2000-01-01')
647
+ })
648
+ ]);
649
+ const result = await service.getUserEffectiveRoles('user-1');
650
+ expect(result).toEqual([]);
651
+ expect(mockRoleRepo.find).not.toHaveBeenCalled();
652
+ });
653
+ });
654
+ describe('getUserEffectiveActions', ()=>{
655
+ it('returns an empty array when the user has no action assignments', async ()=>{
656
+ mockPermissionRepo.find.mockResolvedValue([]);
657
+ expect(await service.getUserEffectiveActions('user-1')).toEqual([]);
658
+ expect(mockActionRepo.find).not.toHaveBeenCalled();
659
+ });
660
+ it('merges company-wide (branchId null) and branch-specific action assignments via an OR query', async ()=>{
661
+ mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
662
+ mockPermissionRepo.find.mockResolvedValue([
663
+ buildPermissionRow({
664
+ id: 'perm-1',
665
+ targetId: 'action-1',
666
+ companyId: 'company-1',
667
+ branchId: null
668
+ }),
669
+ buildPermissionRow({
670
+ id: 'perm-2',
671
+ targetId: 'action-2',
672
+ companyId: 'company-1',
673
+ branchId: 'branch-1'
674
+ })
675
+ ]);
676
+ mockActionRepo.find.mockResolvedValue([
677
+ buildAction({
678
+ id: 'action-1',
679
+ code: 'user.view'
680
+ }),
681
+ buildAction({
682
+ id: 'action-2',
683
+ code: 'user.create'
684
+ })
685
+ ]);
686
+ const result = await service.getUserEffectiveActions('user-1', 'company-1', 'branch-1');
687
+ expect(mockPermissionRepo.find).toHaveBeenCalledWith({
688
+ where: [
689
+ expect.objectContaining({
690
+ sourceId: 'user-1',
691
+ companyId: 'company-1',
692
+ branchId: expect.anything()
693
+ }),
694
+ expect.objectContaining({
695
+ sourceId: 'user-1',
696
+ companyId: 'company-1',
697
+ branchId: 'branch-1'
698
+ })
699
+ ]
700
+ });
701
+ expect(result.map((a)=>a.actionId).sort()).toEqual([
702
+ 'action-1',
703
+ 'action-2'
704
+ ]);
705
+ });
706
+ it('excludes expired/not-yet-valid permission rows', async ()=>{
707
+ mockPermissionRepo.find.mockResolvedValue([
708
+ buildPermissionRow({
709
+ targetId: 'action-1',
710
+ validUntil: new Date('2000-01-01')
711
+ })
712
+ ]);
713
+ const result = await service.getUserEffectiveActions('user-1');
714
+ expect(result).toEqual([]);
715
+ expect(mockActionRepo.find).not.toHaveBeenCalled();
716
+ });
717
+ });
572
718
  // ==================== My Permissions / permission-mode branching ====================
573
719
  describe('getMyPermissions', ()=>{
574
720
  it('delegates to the cache service and returns its response', async ()=>{
575
721
  const response = {
576
722
  frontendActions: [
577
723
  {
578
- id: 'action-1',
579
724
  code: 'user.view',
580
725
  name: 'View',
581
726
  description: null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-iam",
3
- "version": "6.2.1",
3
+ "version": "6.3.0",
4
4
  "description": "Identity and Access Management (IAM) module for NestJS applications",
5
5
  "main": "cjs/index.js",
6
6
  "module": "fesm/index.js",
@@ -90,7 +90,7 @@
90
90
  "express": "^5.0.0"
91
91
  },
92
92
  "dependencies": {
93
- "@flusys/nestjs-core": "6.2.1",
94
- "@flusys/nestjs-shared": "6.2.1"
93
+ "@flusys/nestjs-core": "6.3.0",
94
+ "@flusys/nestjs-shared": "6.3.0"
95
95
  }
96
96
  }
@@ -19,6 +19,8 @@ export declare class PermissionService {
19
19
  getCompanyActions(companyId: string, isOnlyId?: boolean): Promise<CompanyActionResponseDto[] | string[]>;
20
20
  assignUserRoles(dto: AssignUserRolesDto): Promise<PermissionOperationResultDto>;
21
21
  getUserRoles(userId: string, branchId?: string | undefined, companyId?: string | undefined): Promise<UserRoleResponseDto[]>;
22
+ getUserEffectiveRoles(userId: string, companyId?: string | null, branchId?: string | null): Promise<UserRoleResponseDto[]>;
23
+ getUserEffectiveActions(userId: string, companyId?: string | null, branchId?: string | null): Promise<UserActionResponseDto[]>;
22
24
  getMyPermissions(userId: string, branchId: string | null, companyId?: string | null, parentCodes?: string[]): Promise<MyPermissionsResponseDto>;
23
25
  private splitItemsByAction;
24
26
  private buildOperationResult;