@flusys/nestjs-iam 6.2.2 → 6.4.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
  });
@@ -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,6 +573,152 @@ 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 ()=>{
@@ -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
  }
@@ -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
  });
@@ -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,6 +569,152 @@ 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 ()=>{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-iam",
3
- "version": "6.2.2",
3
+ "version": "6.4.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.2",
94
- "@flusys/nestjs-shared": "6.2.2"
93
+ "@flusys/nestjs-core": "6.4.0",
94
+ "@flusys/nestjs-shared": "6.4.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;