@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.
- package/cjs/config/message-keys.js +3 -1
- package/cjs/controllers/my-permission.controller.js +54 -0
- package/cjs/controllers/my-permission.controller.spec.js +73 -1
- package/cjs/dtos/permission.dto.js +0 -7
- package/cjs/services/permission-cache.service.js +2 -2
- package/cjs/services/permission-cache.service.spec.js +32 -13
- package/cjs/services/permission.service.js +98 -0
- package/cjs/services/permission.service.spec.js +146 -1
- package/config/message-keys.d.ts +2 -0
- package/controllers/my-permission.controller.d.ts +3 -1
- package/dtos/permission.dto.d.ts +0 -1
- package/fesm/config/message-keys.js +3 -1
- package/fesm/controllers/my-permission.controller.js +55 -1
- package/fesm/controllers/my-permission.controller.spec.js +73 -1
- package/fesm/dtos/permission.dto.js +0 -7
- package/fesm/services/permission-cache.service.js +2 -2
- package/fesm/services/permission-cache.service.spec.js +32 -13
- package/fesm/services/permission.service.js +98 -0
- package/fesm/services/permission.service.spec.js +146 -1
- package/package.json +3 -3
- package/services/permission.service.d.ts +2 -0
|
@@ -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
|
-
|
|
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].
|
|
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
|
package/config/message-keys.d.ts
CHANGED
|
@@ -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
|
}
|
package/dtos/permission.dto.d.ts
CHANGED
|
@@ -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
|
-
|
|
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].
|
|
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.
|
|
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.
|
|
94
|
-
"@flusys/nestjs-shared": "6.
|
|
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;
|