@flusys/nestjs-iam 6.4.1 → 7.0.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/event-actions.js +36 -0
- package/cjs/config/index.js +1 -0
- package/cjs/config/message-keys.js +10 -1
- package/cjs/modules/iam.module.js +4 -2
- package/cjs/services/action.service.js +43 -0
- package/cjs/services/action.service.spec.js +37 -0
- package/cjs/services/permission.service.js +28 -4
- package/cjs/services/permission.service.spec.js +81 -0
- package/cjs/services/role.service.js +44 -0
- package/cjs/services/role.service.spec.js +35 -0
- package/config/event-actions.d.ts +12 -0
- package/config/index.d.ts +1 -0
- package/config/message-keys.d.ts +6 -0
- package/fesm/config/event-actions.js +15 -0
- package/fesm/config/index.js +1 -0
- package/fesm/config/message-keys.js +7 -1
- package/fesm/modules/iam.module.js +5 -3
- package/fesm/services/action.service.js +46 -3
- package/fesm/services/action.service.spec.js +37 -0
- package/fesm/services/permission.service.js +29 -5
- package/fesm/services/permission.service.spec.js +81 -0
- package/fesm/services/role.service.js +46 -2
- package/fesm/services/role.service.spec.js +35 -0
- package/interfaces/iam-module-options.interface.d.ts +2 -0
- package/package.json +3 -3
- package/services/action.service.d.ts +1 -0
- package/services/permission.service.d.ts +1 -0
- package/services/role.service.d.ts +1 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain event actions published by the IAM module, alongside the CRUD
|
|
3
|
+
* lifecycle actions every ApiService emits.
|
|
4
|
+
*/ "use strict";
|
|
5
|
+
Object.defineProperty(exports, "__esModule", {
|
|
6
|
+
value: true
|
|
7
|
+
});
|
|
8
|
+
function _export(target, all) {
|
|
9
|
+
for(var name in all)Object.defineProperty(target, name, {
|
|
10
|
+
enumerable: true,
|
|
11
|
+
get: Object.getOwnPropertyDescriptor(all, name).get
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
_export(exports, {
|
|
15
|
+
get IAM_EVENT_ACTIONS () {
|
|
16
|
+
return IAM_EVENT_ACTIONS;
|
|
17
|
+
},
|
|
18
|
+
get IAM_EVENT_ENTITIES () {
|
|
19
|
+
return IAM_EVENT_ENTITIES;
|
|
20
|
+
},
|
|
21
|
+
get IAM_EVENT_MODULE () {
|
|
22
|
+
return IAM_EVENT_MODULE;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
const IAM_EVENT_ACTIONS = {
|
|
26
|
+
PERMISSIONS_ASSIGNED: 'permissions-assigned'
|
|
27
|
+
};
|
|
28
|
+
const IAM_EVENT_ENTITIES = {
|
|
29
|
+
ACTION: 'action',
|
|
30
|
+
ROLE: 'role',
|
|
31
|
+
USER_ACTION: 'user_action',
|
|
32
|
+
ROLE_ACTION: 'role_action',
|
|
33
|
+
COMPANY_ACTION: 'company_action',
|
|
34
|
+
USER_ROLE: 'user_role'
|
|
35
|
+
};
|
|
36
|
+
const IAM_EVENT_MODULE = 'iam';
|
package/cjs/config/index.js
CHANGED
|
@@ -25,6 +25,9 @@ _export(exports, {
|
|
|
25
25
|
get PERMISSION_OPERATION_MESSAGES () {
|
|
26
26
|
return PERMISSION_OPERATION_MESSAGES;
|
|
27
27
|
},
|
|
28
|
+
get ROLE_MESSAGES () {
|
|
29
|
+
return ROLE_MESSAGES;
|
|
30
|
+
},
|
|
28
31
|
get ROLE_PERMISSION_MESSAGES () {
|
|
29
32
|
return ROLE_PERMISSION_MESSAGES;
|
|
30
33
|
},
|
|
@@ -33,7 +36,13 @@ _export(exports, {
|
|
|
33
36
|
}
|
|
34
37
|
});
|
|
35
38
|
const ACTION_MESSAGES = {
|
|
36
|
-
GET_ALL_SUCCESS: 'action.get.all.success'
|
|
39
|
+
GET_ALL_SUCCESS: 'action.get.all.success',
|
|
40
|
+
READONLY_UPDATE_FORBIDDEN: 'action.readonly.update.forbidden',
|
|
41
|
+
READONLY_DELETE_FORBIDDEN: 'action.readonly.delete.forbidden'
|
|
42
|
+
};
|
|
43
|
+
const ROLE_MESSAGES = {
|
|
44
|
+
READONLY_UPDATE_FORBIDDEN: 'role.readonly.update.forbidden',
|
|
45
|
+
READONLY_DELETE_FORBIDDEN: 'role.readonly.delete.forbidden'
|
|
37
46
|
};
|
|
38
47
|
const PERMISSION_OPERATION_MESSAGES = {
|
|
39
48
|
PROCESS_SUCCESS: 'permission.process.success',
|
|
@@ -101,7 +101,8 @@ let IAMModule = class IAMModule {
|
|
|
101
101
|
_iamconfigservice.IAMConfigService,
|
|
102
102
|
_iamdatasourceservice.IAMDataSourceService,
|
|
103
103
|
...this.getServices(permissionMode),
|
|
104
|
-
this.getPermissionGuardConfigProvider(enableCompanyFeature)
|
|
104
|
+
this.getPermissionGuardConfigProvider(enableCompanyFeature),
|
|
105
|
+
(0, _modules.createModuleEventsProvider)('iam', _iamconstants.IAM_MODULE_OPTIONS)
|
|
105
106
|
];
|
|
106
107
|
const module = {
|
|
107
108
|
module: IAMModule,
|
|
@@ -128,7 +129,8 @@ let IAMModule = class IAMModule {
|
|
|
128
129
|
_iamconfigservice.IAMConfigService,
|
|
129
130
|
_iamdatasourceservice.IAMDataSourceService,
|
|
130
131
|
...this.getServices(permissionMode),
|
|
131
|
-
this.getPermissionGuardConfigProvider(enableCompanyFeature)
|
|
132
|
+
this.getPermissionGuardConfigProvider(enableCompanyFeature),
|
|
133
|
+
(0, _modules.createModuleEventsProvider)('iam', _iamconstants.IAM_MODULE_OPTIONS)
|
|
132
134
|
];
|
|
133
135
|
const module = {
|
|
134
136
|
module: IAMModule,
|
|
@@ -50,6 +50,30 @@ function _ts_param(paramIndex, decorator) {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
let ActionService = class ActionService extends _classes.ApiService {
|
|
53
|
+
async beforeUpdateOperation(dto, _user, _queryRunner) {
|
|
54
|
+
const dtos = Array.isArray(dto) ? dto : [
|
|
55
|
+
dto
|
|
56
|
+
];
|
|
57
|
+
const ids = dtos.map((d)=>d.id);
|
|
58
|
+
await this.ensureDataSourceRepository();
|
|
59
|
+
const readonlyActions = await this.repository.find({
|
|
60
|
+
where: {
|
|
61
|
+
id: (0, _typeorm.In)(ids),
|
|
62
|
+
readOnly: true,
|
|
63
|
+
deletedAt: (0, _typeorm.IsNull)()
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (readonlyActions.length > 0) {
|
|
67
|
+
const names = readonlyActions.map((a)=>a.name).join(', ');
|
|
68
|
+
throw new _common.ForbiddenException({
|
|
69
|
+
message: `Cannot modify readonly action(s): ${names}`,
|
|
70
|
+
messageKey: _config.ACTION_MESSAGES.READONLY_UPDATE_FORBIDDEN,
|
|
71
|
+
messageVariables: {
|
|
72
|
+
names
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
53
77
|
async beforeDeleteOperation(dto, user, queryRunner) {
|
|
54
78
|
const actionIds = Array.isArray(dto.id) ? dto.id : [
|
|
55
79
|
dto.id
|
|
@@ -57,6 +81,24 @@ let ActionService = class ActionService extends _classes.ApiService {
|
|
|
57
81
|
if (actionIds.length === 0) {
|
|
58
82
|
return;
|
|
59
83
|
}
|
|
84
|
+
await this.ensureDataSourceRepository();
|
|
85
|
+
const readonlyActions = await this.repository.find({
|
|
86
|
+
where: {
|
|
87
|
+
id: (0, _typeorm.In)(actionIds),
|
|
88
|
+
readOnly: true,
|
|
89
|
+
deletedAt: (0, _typeorm.IsNull)()
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
if (readonlyActions.length > 0) {
|
|
93
|
+
const names = readonlyActions.map((a)=>a.name).join(', ');
|
|
94
|
+
throw new _common.ForbiddenException({
|
|
95
|
+
message: `Cannot delete readonly action(s): ${names}`,
|
|
96
|
+
messageKey: _config.ACTION_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
97
|
+
messageVariables: {
|
|
98
|
+
names
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
60
102
|
const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
|
|
61
103
|
const permissionEntity = enableCompanyFeature ? _permissionwithcompanyentity.UserIamPermissionWithCompany : _useriampermissionentity.UserIamPermission;
|
|
62
104
|
const permissionRepo = queryRunner.manager.getRepository(permissionEntity);
|
|
@@ -136,6 +178,7 @@ let ActionService = class ActionService extends _classes.ApiService {
|
|
|
136
178
|
'actionType',
|
|
137
179
|
'permissionLogic',
|
|
138
180
|
'isActive',
|
|
181
|
+
'readOnly',
|
|
139
182
|
'parentId',
|
|
140
183
|
'serial',
|
|
141
184
|
'createdAt'
|
|
@@ -41,6 +41,7 @@ describe('ActionService', ()=>{
|
|
|
41
41
|
let mockPermissionCacheService;
|
|
42
42
|
beforeEach(async ()=>{
|
|
43
43
|
mockRepo = (0, _repositorymock.createMockRepository)();
|
|
44
|
+
mockRepo.find.mockResolvedValue([]);
|
|
44
45
|
const mockDataSourceProvider = (0, _repositorymock.createMockDataSourceProvider)(mockRepo);
|
|
45
46
|
mockIamConfigService = {
|
|
46
47
|
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
@@ -310,5 +311,41 @@ describe('ActionService', ()=>{
|
|
|
310
311
|
}, (0, _loggedusermock.buildMockUser)(), queryRunner);
|
|
311
312
|
expect(mockPermissionCacheService.invalidateUsers).not.toHaveBeenCalled();
|
|
312
313
|
});
|
|
314
|
+
it('rejects deleting a readonly action', async ()=>{
|
|
315
|
+
mockRepo.find.mockResolvedValue([
|
|
316
|
+
buildAction({
|
|
317
|
+
readOnly: true
|
|
318
|
+
})
|
|
319
|
+
]);
|
|
320
|
+
const permissionRepo = (0, _repositorymock.createMockRepository)();
|
|
321
|
+
const queryRunner = buildQueryRunner(permissionRepo);
|
|
322
|
+
await expect(service.beforeDeleteOperation({
|
|
323
|
+
id: 'action-uuid-1',
|
|
324
|
+
type: 'delete'
|
|
325
|
+
}, (0, _loggedusermock.buildMockUser)(), queryRunner)).rejects.toThrow('Cannot delete readonly action');
|
|
326
|
+
expect(permissionRepo.find).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
describe('beforeUpdateOperation', ()=>{
|
|
330
|
+
it('rejects updating a readonly action', async ()=>{
|
|
331
|
+
mockRepo.find.mockResolvedValue([
|
|
332
|
+
buildAction({
|
|
333
|
+
readOnly: true
|
|
334
|
+
})
|
|
335
|
+
]);
|
|
336
|
+
await expect(service.beforeUpdateOperation({
|
|
337
|
+
id: 'action-uuid-1',
|
|
338
|
+
name: 'Renamed'
|
|
339
|
+
}, (0, _loggedusermock.buildMockUser)(), {})).rejects.toThrow('Cannot modify readonly action');
|
|
340
|
+
});
|
|
341
|
+
it('allows updating a non-readonly action', async ()=>{
|
|
342
|
+
// The service filters readOnly: true in the query itself, so a non-readonly
|
|
343
|
+
// action never comes back — simulate that by resolving an empty array.
|
|
344
|
+
mockRepo.find.mockResolvedValue([]);
|
|
345
|
+
await expect(service.beforeUpdateOperation({
|
|
346
|
+
id: 'action-uuid-1',
|
|
347
|
+
name: 'Renamed'
|
|
348
|
+
}, (0, _loggedusermock.buildMockUser)(), {})).resolves.toBeUndefined();
|
|
349
|
+
});
|
|
313
350
|
});
|
|
314
351
|
});
|
|
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "PermissionService", {
|
|
|
9
9
|
}
|
|
10
10
|
});
|
|
11
11
|
const _nestjsshared = require("@flusys/nestjs-shared");
|
|
12
|
+
const _classes = require("@flusys/nestjs-shared/classes");
|
|
12
13
|
const _common = require("@nestjs/common");
|
|
13
14
|
const _typeorm = require("typeorm");
|
|
14
15
|
const _config = require("../config");
|
|
@@ -155,7 +156,9 @@ let PermissionService = class PermissionService {
|
|
|
155
156
|
await this.permissionCacheService.invalidateUser(dto.userId, enableCompanyFeature ? companyId : null, enableCompanyFeature ? [
|
|
156
157
|
branchId
|
|
157
158
|
] : []);
|
|
158
|
-
|
|
159
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
160
|
+
await this.publishAssignmentEvent(_config.IAM_EVENT_ENTITIES.USER_ACTION, dto.userId, result);
|
|
161
|
+
return result;
|
|
159
162
|
}
|
|
160
163
|
async getUserActions(userId, companyId, branchId) {
|
|
161
164
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -269,7 +272,9 @@ let PermissionService = class PermissionService {
|
|
|
269
272
|
removed = result.affected || 0;
|
|
270
273
|
}
|
|
271
274
|
await this.permissionCacheService.invalidateRoleMembersCache(dto.roleId);
|
|
272
|
-
|
|
275
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
276
|
+
await this.publishAssignmentEvent(_config.IAM_EVENT_ENTITIES.ROLE_ACTION, dto.roleId, result);
|
|
277
|
+
return result;
|
|
273
278
|
}
|
|
274
279
|
async getRoleActions(roleId) {
|
|
275
280
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -379,7 +384,9 @@ let PermissionService = class PermissionService {
|
|
|
379
384
|
}
|
|
380
385
|
});
|
|
381
386
|
await this.permissionCacheService.invalidateCompanyMembersCache(dto.companyId);
|
|
382
|
-
|
|
387
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
388
|
+
await this.publishAssignmentEvent(_config.IAM_EVENT_ENTITIES.COMPANY_ACTION, dto.companyId, result);
|
|
389
|
+
return result;
|
|
383
390
|
}
|
|
384
391
|
/** Get all actions assigned to a company (whitelist) */ async getCompanyActions(companyId, isOnlyId = false) {
|
|
385
392
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -486,7 +493,9 @@ let PermissionService = class PermissionService {
|
|
|
486
493
|
await this.permissionCacheService.invalidateUser(dto.userId, enableCompanyFeature ? companyId : null, [
|
|
487
494
|
branchId
|
|
488
495
|
]);
|
|
489
|
-
|
|
496
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
497
|
+
await this.publishAssignmentEvent(_config.IAM_EVENT_ENTITIES.USER_ROLE, dto.userId, result);
|
|
498
|
+
return result;
|
|
490
499
|
}
|
|
491
500
|
/** Get user's roles (branch-scoped, filtered by companyId and branchId if provided) */ async getUserRoles(userId, branchId, companyId) {
|
|
492
501
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -640,6 +649,21 @@ let PermissionService = class PermissionService {
|
|
|
640
649
|
toRemove: items.filter((item)=>item.action === _permissiondto.PermissionAction.REMOVE)
|
|
641
650
|
};
|
|
642
651
|
}
|
|
652
|
+
async publishAssignmentEvent(entity, targetId, result) {
|
|
653
|
+
await (0, _classes.publishDomainEvent)({
|
|
654
|
+
module: _config.IAM_EVENT_MODULE,
|
|
655
|
+
entity,
|
|
656
|
+
action: _config.IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED,
|
|
657
|
+
ids: [
|
|
658
|
+
targetId
|
|
659
|
+
],
|
|
660
|
+
metadata: {
|
|
661
|
+
added: result.added,
|
|
662
|
+
removed: result.removed,
|
|
663
|
+
total: result.total
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
643
667
|
/** Build standard operation result DTO */ buildOperationResult(_totalItems, added, removed) {
|
|
644
668
|
return {
|
|
645
669
|
added,
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
3
3
|
value: true
|
|
4
4
|
});
|
|
5
5
|
const _testing = require("@nestjs/testing");
|
|
6
|
+
const _classes = require("@flusys/nestjs-shared/classes");
|
|
6
7
|
const _repositorymock = require("@test-utils/mocks/repository.mock");
|
|
7
8
|
const _config = require("../config");
|
|
8
9
|
const _permissiondto = require("../dtos/permission.dto");
|
|
@@ -834,4 +835,84 @@ describe('PermissionService', ()=>{
|
|
|
834
835
|
]);
|
|
835
836
|
});
|
|
836
837
|
});
|
|
838
|
+
describe('domain events', ()=>{
|
|
839
|
+
let publish;
|
|
840
|
+
beforeEach(()=>{
|
|
841
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
842
|
+
_classes.EventBusRegistry.reset();
|
|
843
|
+
_classes.EventBusRegistry.setBus({
|
|
844
|
+
publish,
|
|
845
|
+
subscribe: jest.fn(),
|
|
846
|
+
close: jest.fn()
|
|
847
|
+
});
|
|
848
|
+
_classes.EventBusRegistry.configureModule('iam', {
|
|
849
|
+
enabled: true
|
|
850
|
+
});
|
|
851
|
+
mockIamConfigService.isDirectPermissionEnabled.mockReturnValue(true);
|
|
852
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
853
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
854
|
+
{}
|
|
855
|
+
]);
|
|
856
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
857
|
+
});
|
|
858
|
+
afterEach(()=>{
|
|
859
|
+
_classes.EventBusRegistry.reset();
|
|
860
|
+
});
|
|
861
|
+
it('publishes iam.user_action.permissions-assigned with the operation counts', async ()=>{
|
|
862
|
+
await service.assignUserActions({
|
|
863
|
+
userId: 'user-1',
|
|
864
|
+
items: [
|
|
865
|
+
{
|
|
866
|
+
id: 'action-1',
|
|
867
|
+
action: _permissiondto.PermissionAction.ADD
|
|
868
|
+
}
|
|
869
|
+
]
|
|
870
|
+
});
|
|
871
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
872
|
+
name: 'iam.user_action.permissions-assigned',
|
|
873
|
+
payload: expect.objectContaining({
|
|
874
|
+
ids: [
|
|
875
|
+
'user-1'
|
|
876
|
+
],
|
|
877
|
+
metadata: expect.objectContaining({
|
|
878
|
+
total: 1
|
|
879
|
+
})
|
|
880
|
+
})
|
|
881
|
+
}));
|
|
882
|
+
});
|
|
883
|
+
it('publishes iam.user_role.permissions-assigned when roles are assigned', async ()=>{
|
|
884
|
+
mockRoleRepo.find.mockResolvedValue([
|
|
885
|
+
{
|
|
886
|
+
id: 'role-1'
|
|
887
|
+
}
|
|
888
|
+
]);
|
|
889
|
+
await service.assignUserRoles({
|
|
890
|
+
userId: 'user-1',
|
|
891
|
+
items: [
|
|
892
|
+
{
|
|
893
|
+
id: 'role-1',
|
|
894
|
+
action: _permissiondto.PermissionAction.ADD
|
|
895
|
+
}
|
|
896
|
+
]
|
|
897
|
+
});
|
|
898
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
899
|
+
name: 'iam.user_role.permissions-assigned'
|
|
900
|
+
}));
|
|
901
|
+
});
|
|
902
|
+
it('does not publish when the iam module has events disabled', async ()=>{
|
|
903
|
+
_classes.EventBusRegistry.configureModule('iam', {
|
|
904
|
+
enabled: false
|
|
905
|
+
});
|
|
906
|
+
await service.assignUserActions({
|
|
907
|
+
userId: 'user-1',
|
|
908
|
+
items: [
|
|
909
|
+
{
|
|
910
|
+
id: 'action-1',
|
|
911
|
+
action: _permissiondto.PermissionAction.ADD
|
|
912
|
+
}
|
|
913
|
+
]
|
|
914
|
+
});
|
|
915
|
+
expect(publish).not.toHaveBeenCalled();
|
|
916
|
+
});
|
|
917
|
+
});
|
|
837
918
|
});
|
|
@@ -13,6 +13,7 @@ const _modules = require("@flusys/nestjs-shared/modules");
|
|
|
13
13
|
const _utils = require("@flusys/nestjs-shared/utils");
|
|
14
14
|
const _common = require("@nestjs/common");
|
|
15
15
|
const _typeorm = require("typeorm");
|
|
16
|
+
const _config = require("../config");
|
|
16
17
|
const _permissionwithcompanyentity = require("../entities/permission-with-company.entity");
|
|
17
18
|
const _rolewithcompanyentity = require("../entities/role-with-company.entity");
|
|
18
19
|
const _roleentity = require("../entities/role.entity");
|
|
@@ -50,6 +51,30 @@ function _ts_param(paramIndex, decorator) {
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
let RoleService = class RoleService extends _classes.ApiService {
|
|
54
|
+
async beforeUpdateOperation(dto, _user, _queryRunner) {
|
|
55
|
+
const dtos = Array.isArray(dto) ? dto : [
|
|
56
|
+
dto
|
|
57
|
+
];
|
|
58
|
+
const ids = dtos.map((d)=>d.id);
|
|
59
|
+
await this.ensureDataSourceRepository();
|
|
60
|
+
const readonlyRoles = await this.repository.find({
|
|
61
|
+
where: {
|
|
62
|
+
id: (0, _typeorm.In)(ids),
|
|
63
|
+
readOnly: true,
|
|
64
|
+
deletedAt: (0, _typeorm.IsNull)()
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
if (readonlyRoles.length > 0) {
|
|
68
|
+
const names = readonlyRoles.map((r)=>r.name).join(', ');
|
|
69
|
+
throw new _common.ForbiddenException({
|
|
70
|
+
message: `Cannot modify readonly role(s): ${names}`,
|
|
71
|
+
messageKey: _config.ROLE_MESSAGES.READONLY_UPDATE_FORBIDDEN,
|
|
72
|
+
messageVariables: {
|
|
73
|
+
names
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
53
78
|
async beforeDeleteOperation(dto, user, queryRunner) {
|
|
54
79
|
await super.beforeDeleteOperation(dto, user, queryRunner);
|
|
55
80
|
const roleIds = Array.isArray(dto.id) ? dto.id : [
|
|
@@ -58,6 +83,24 @@ let RoleService = class RoleService extends _classes.ApiService {
|
|
|
58
83
|
if (roleIds.length === 0) {
|
|
59
84
|
return;
|
|
60
85
|
}
|
|
86
|
+
await this.ensureDataSourceRepository();
|
|
87
|
+
const readonlyRoles = await this.repository.find({
|
|
88
|
+
where: {
|
|
89
|
+
id: (0, _typeorm.In)(roleIds),
|
|
90
|
+
readOnly: true,
|
|
91
|
+
deletedAt: (0, _typeorm.IsNull)()
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (readonlyRoles.length > 0) {
|
|
95
|
+
const names = readonlyRoles.map((r)=>r.name).join(', ');
|
|
96
|
+
throw new _common.ForbiddenException({
|
|
97
|
+
message: `Cannot delete readonly role(s): ${names}`,
|
|
98
|
+
messageKey: _config.ROLE_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
99
|
+
messageVariables: {
|
|
100
|
+
names
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
61
104
|
const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
|
|
62
105
|
const permissionEntity = enableCompanyFeature ? _permissionwithcompanyentity.UserIamPermissionWithCompany : _useriampermissionentity.UserIamPermission;
|
|
63
106
|
const permissionRepo = queryRunner.manager.getRepository(permissionEntity);
|
|
@@ -111,6 +154,7 @@ let RoleService = class RoleService extends _classes.ApiService {
|
|
|
111
154
|
'name',
|
|
112
155
|
'description',
|
|
113
156
|
'isActive',
|
|
157
|
+
'readOnly',
|
|
114
158
|
'serial',
|
|
115
159
|
'createdAt'
|
|
116
160
|
];
|
|
@@ -36,6 +36,7 @@ describe('RoleService', ()=>{
|
|
|
36
36
|
let mockPermissionCacheService;
|
|
37
37
|
beforeEach(async ()=>{
|
|
38
38
|
mockRepo = (0, _repositorymock.createMockRepository)();
|
|
39
|
+
mockRepo.find.mockResolvedValue([]);
|
|
39
40
|
const mockDataSourceProvider = (0, _repositorymock.createMockDataSourceProvider)(mockRepo);
|
|
40
41
|
mockIamConfigService = {
|
|
41
42
|
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
@@ -305,5 +306,39 @@ describe('RoleService', ()=>{
|
|
|
305
306
|
'user-1'
|
|
306
307
|
]);
|
|
307
308
|
});
|
|
309
|
+
it('rejects deleting a readonly role', async ()=>{
|
|
310
|
+
mockRepo.find.mockResolvedValue([
|
|
311
|
+
buildRole({
|
|
312
|
+
readOnly: true
|
|
313
|
+
})
|
|
314
|
+
]);
|
|
315
|
+
const permissionRepo = (0, _repositorymock.createMockRepository)();
|
|
316
|
+
const queryRunner = buildQueryRunner(permissionRepo);
|
|
317
|
+
await expect(service.beforeDeleteOperation({
|
|
318
|
+
id: 'role-uuid-1',
|
|
319
|
+
type: 'delete'
|
|
320
|
+
}, (0, _loggedusermock.buildMockUser)(), queryRunner)).rejects.toThrow('Cannot delete readonly role');
|
|
321
|
+
expect(permissionRepo.find).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
describe('beforeUpdateOperation', ()=>{
|
|
325
|
+
it('rejects updating a readonly role', async ()=>{
|
|
326
|
+
mockRepo.find.mockResolvedValue([
|
|
327
|
+
buildRole({
|
|
328
|
+
readOnly: true
|
|
329
|
+
})
|
|
330
|
+
]);
|
|
331
|
+
await expect(service.beforeUpdateOperation({
|
|
332
|
+
id: 'role-uuid-1',
|
|
333
|
+
name: 'Renamed'
|
|
334
|
+
}, (0, _loggedusermock.buildMockUser)(), {})).rejects.toThrow('Cannot modify readonly role');
|
|
335
|
+
});
|
|
336
|
+
it('allows updating a non-readonly role', async ()=>{
|
|
337
|
+
mockRepo.find.mockResolvedValue([]);
|
|
338
|
+
await expect(service.beforeUpdateOperation({
|
|
339
|
+
id: 'role-uuid-1',
|
|
340
|
+
name: 'Renamed'
|
|
341
|
+
}, (0, _loggedusermock.buildMockUser)(), {})).resolves.toBeUndefined();
|
|
342
|
+
});
|
|
308
343
|
});
|
|
309
344
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const IAM_EVENT_ACTIONS: {
|
|
2
|
+
readonly PERMISSIONS_ASSIGNED: "permissions-assigned";
|
|
3
|
+
};
|
|
4
|
+
export declare const IAM_EVENT_ENTITIES: {
|
|
5
|
+
readonly ACTION: "action";
|
|
6
|
+
readonly ROLE: "role";
|
|
7
|
+
readonly USER_ACTION: "user_action";
|
|
8
|
+
readonly ROLE_ACTION: "role_action";
|
|
9
|
+
readonly COMPANY_ACTION: "company_action";
|
|
10
|
+
readonly USER_ROLE: "user_role";
|
|
11
|
+
};
|
|
12
|
+
export declare const IAM_EVENT_MODULE = "iam";
|
package/config/index.d.ts
CHANGED
package/config/message-keys.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export declare const ACTION_MESSAGES: {
|
|
2
2
|
readonly GET_ALL_SUCCESS: "action.get.all.success";
|
|
3
|
+
readonly READONLY_UPDATE_FORBIDDEN: "action.readonly.update.forbidden";
|
|
4
|
+
readonly READONLY_DELETE_FORBIDDEN: "action.readonly.delete.forbidden";
|
|
5
|
+
};
|
|
6
|
+
export declare const ROLE_MESSAGES: {
|
|
7
|
+
readonly READONLY_UPDATE_FORBIDDEN: "role.readonly.update.forbidden";
|
|
8
|
+
readonly READONLY_DELETE_FORBIDDEN: "role.readonly.delete.forbidden";
|
|
3
9
|
};
|
|
4
10
|
export declare const PERMISSION_OPERATION_MESSAGES: {
|
|
5
11
|
readonly PROCESS_SUCCESS: "permission.process.success";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain event actions published by the IAM module, alongside the CRUD
|
|
3
|
+
* lifecycle actions every ApiService emits.
|
|
4
|
+
*/ export const IAM_EVENT_ACTIONS = {
|
|
5
|
+
PERMISSIONS_ASSIGNED: 'permissions-assigned'
|
|
6
|
+
};
|
|
7
|
+
/** Every entity the IAM module publishes under, ApiService backed or not */ export const IAM_EVENT_ENTITIES = {
|
|
8
|
+
ACTION: 'action',
|
|
9
|
+
ROLE: 'role',
|
|
10
|
+
USER_ACTION: 'user_action',
|
|
11
|
+
ROLE_ACTION: 'role_action',
|
|
12
|
+
COMPANY_ACTION: 'company_action',
|
|
13
|
+
USER_ROLE: 'user_role'
|
|
14
|
+
};
|
|
15
|
+
export const IAM_EVENT_MODULE = 'iam';
|
package/fesm/config/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// ==================== IAM MODULE MESSAGE KEYS ====================
|
|
2
2
|
export const ACTION_MESSAGES = {
|
|
3
|
-
GET_ALL_SUCCESS: 'action.get.all.success'
|
|
3
|
+
GET_ALL_SUCCESS: 'action.get.all.success',
|
|
4
|
+
READONLY_UPDATE_FORBIDDEN: 'action.readonly.update.forbidden',
|
|
5
|
+
READONLY_DELETE_FORBIDDEN: 'action.readonly.delete.forbidden'
|
|
6
|
+
};
|
|
7
|
+
export const ROLE_MESSAGES = {
|
|
8
|
+
READONLY_UPDATE_FORBIDDEN: 'role.readonly.update.forbidden',
|
|
9
|
+
READONLY_DELETE_FORBIDDEN: 'role.readonly.delete.forbidden'
|
|
4
10
|
};
|
|
5
11
|
export const PERMISSION_OPERATION_MESSAGES = {
|
|
6
12
|
PROCESS_SUCCESS: 'permission.process.success',
|
|
@@ -5,7 +5,7 @@ function _ts_decorate(decorators, target, key, desc) {
|
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
}
|
|
7
7
|
import { PERMISSION_GUARD_CONFIG, PERMISSIONS_CACHE_PREFIX } from '@flusys/nestjs-shared';
|
|
8
|
-
import { CacheModule, UtilsModule } from '@flusys/nestjs-shared/modules';
|
|
8
|
+
import { CacheModule, UtilsModule, createModuleEventsProvider } from '@flusys/nestjs-shared/modules';
|
|
9
9
|
import { Module } from '@nestjs/common';
|
|
10
10
|
import { IAM_MODULE_OPTIONS } from '../config/iam.constants';
|
|
11
11
|
import { ActionController, CompanyActionPermissionController, MyPermissionController, RoleController, RolePermissionController, UserActionPermissionController } from '../controllers';
|
|
@@ -91,7 +91,8 @@ export class IAMModule {
|
|
|
91
91
|
IAMConfigService,
|
|
92
92
|
IAMDataSourceService,
|
|
93
93
|
...this.getServices(permissionMode),
|
|
94
|
-
this.getPermissionGuardConfigProvider(enableCompanyFeature)
|
|
94
|
+
this.getPermissionGuardConfigProvider(enableCompanyFeature),
|
|
95
|
+
createModuleEventsProvider('iam', IAM_MODULE_OPTIONS)
|
|
95
96
|
];
|
|
96
97
|
const module = {
|
|
97
98
|
module: IAMModule,
|
|
@@ -118,7 +119,8 @@ export class IAMModule {
|
|
|
118
119
|
IAMConfigService,
|
|
119
120
|
IAMDataSourceService,
|
|
120
121
|
...this.getServices(permissionMode),
|
|
121
|
-
this.getPermissionGuardConfigProvider(enableCompanyFeature)
|
|
122
|
+
this.getPermissionGuardConfigProvider(enableCompanyFeature),
|
|
123
|
+
createModuleEventsProvider('iam', IAM_MODULE_OPTIONS)
|
|
122
124
|
];
|
|
123
125
|
const module = {
|
|
124
126
|
module: IAMModule,
|
|
@@ -27,9 +27,9 @@ function _ts_param(paramIndex, decorator) {
|
|
|
27
27
|
}
|
|
28
28
|
import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
|
|
29
29
|
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
30
|
-
import { BadRequestException, Inject, Injectable, Scope } from '@nestjs/common';
|
|
31
|
-
import { In } from 'typeorm';
|
|
32
|
-
import { PERMISSION_OPERATION_MESSAGES } from '../config';
|
|
30
|
+
import { BadRequestException, ForbiddenException, Inject, Injectable, Scope } from '@nestjs/common';
|
|
31
|
+
import { In, IsNull } from 'typeorm';
|
|
32
|
+
import { ACTION_MESSAGES, PERMISSION_OPERATION_MESSAGES } from '../config';
|
|
33
33
|
import { Action } from '../entities/action.entity';
|
|
34
34
|
import { UserIamPermissionWithCompany } from '../entities/permission-with-company.entity';
|
|
35
35
|
import { UserIamPermission } from '../entities/user-iam-permission.entity';
|
|
@@ -40,6 +40,30 @@ import { IAMDataSourceService } from './iam-datasource.service';
|
|
|
40
40
|
import { PermissionCacheService } from './permission-cache.service';
|
|
41
41
|
import { PermissionService } from './permission.service';
|
|
42
42
|
export class ActionService extends ApiService {
|
|
43
|
+
async beforeUpdateOperation(dto, _user, _queryRunner) {
|
|
44
|
+
const dtos = Array.isArray(dto) ? dto : [
|
|
45
|
+
dto
|
|
46
|
+
];
|
|
47
|
+
const ids = dtos.map((d)=>d.id);
|
|
48
|
+
await this.ensureDataSourceRepository();
|
|
49
|
+
const readonlyActions = await this.repository.find({
|
|
50
|
+
where: {
|
|
51
|
+
id: In(ids),
|
|
52
|
+
readOnly: true,
|
|
53
|
+
deletedAt: IsNull()
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (readonlyActions.length > 0) {
|
|
57
|
+
const names = readonlyActions.map((a)=>a.name).join(', ');
|
|
58
|
+
throw new ForbiddenException({
|
|
59
|
+
message: `Cannot modify readonly action(s): ${names}`,
|
|
60
|
+
messageKey: ACTION_MESSAGES.READONLY_UPDATE_FORBIDDEN,
|
|
61
|
+
messageVariables: {
|
|
62
|
+
names
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
43
67
|
async beforeDeleteOperation(dto, user, queryRunner) {
|
|
44
68
|
const actionIds = Array.isArray(dto.id) ? dto.id : [
|
|
45
69
|
dto.id
|
|
@@ -47,6 +71,24 @@ export class ActionService extends ApiService {
|
|
|
47
71
|
if (actionIds.length === 0) {
|
|
48
72
|
return;
|
|
49
73
|
}
|
|
74
|
+
await this.ensureDataSourceRepository();
|
|
75
|
+
const readonlyActions = await this.repository.find({
|
|
76
|
+
where: {
|
|
77
|
+
id: In(actionIds),
|
|
78
|
+
readOnly: true,
|
|
79
|
+
deletedAt: IsNull()
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (readonlyActions.length > 0) {
|
|
83
|
+
const names = readonlyActions.map((a)=>a.name).join(', ');
|
|
84
|
+
throw new ForbiddenException({
|
|
85
|
+
message: `Cannot delete readonly action(s): ${names}`,
|
|
86
|
+
messageKey: ACTION_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
87
|
+
messageVariables: {
|
|
88
|
+
names
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
50
92
|
const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
|
|
51
93
|
const permissionEntity = enableCompanyFeature ? UserIamPermissionWithCompany : UserIamPermission;
|
|
52
94
|
const permissionRepo = queryRunner.manager.getRepository(permissionEntity);
|
|
@@ -126,6 +168,7 @@ export class ActionService extends ApiService {
|
|
|
126
168
|
'actionType',
|
|
127
169
|
'permissionLogic',
|
|
128
170
|
'isActive',
|
|
171
|
+
'readOnly',
|
|
129
172
|
'parentId',
|
|
130
173
|
'serial',
|
|
131
174
|
'createdAt'
|
|
@@ -37,6 +37,7 @@ describe('ActionService', ()=>{
|
|
|
37
37
|
let mockPermissionCacheService;
|
|
38
38
|
beforeEach(async ()=>{
|
|
39
39
|
mockRepo = createMockRepository();
|
|
40
|
+
mockRepo.find.mockResolvedValue([]);
|
|
40
41
|
const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
|
|
41
42
|
mockIamConfigService = {
|
|
42
43
|
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
@@ -306,5 +307,41 @@ describe('ActionService', ()=>{
|
|
|
306
307
|
}, buildMockUser(), queryRunner);
|
|
307
308
|
expect(mockPermissionCacheService.invalidateUsers).not.toHaveBeenCalled();
|
|
308
309
|
});
|
|
310
|
+
it('rejects deleting a readonly action', async ()=>{
|
|
311
|
+
mockRepo.find.mockResolvedValue([
|
|
312
|
+
buildAction({
|
|
313
|
+
readOnly: true
|
|
314
|
+
})
|
|
315
|
+
]);
|
|
316
|
+
const permissionRepo = createMockRepository();
|
|
317
|
+
const queryRunner = buildQueryRunner(permissionRepo);
|
|
318
|
+
await expect(service.beforeDeleteOperation({
|
|
319
|
+
id: 'action-uuid-1',
|
|
320
|
+
type: 'delete'
|
|
321
|
+
}, buildMockUser(), queryRunner)).rejects.toThrow('Cannot delete readonly action');
|
|
322
|
+
expect(permissionRepo.find).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('beforeUpdateOperation', ()=>{
|
|
326
|
+
it('rejects updating a readonly action', async ()=>{
|
|
327
|
+
mockRepo.find.mockResolvedValue([
|
|
328
|
+
buildAction({
|
|
329
|
+
readOnly: true
|
|
330
|
+
})
|
|
331
|
+
]);
|
|
332
|
+
await expect(service.beforeUpdateOperation({
|
|
333
|
+
id: 'action-uuid-1',
|
|
334
|
+
name: 'Renamed'
|
|
335
|
+
}, buildMockUser(), {})).rejects.toThrow('Cannot modify readonly action');
|
|
336
|
+
});
|
|
337
|
+
it('allows updating a non-readonly action', async ()=>{
|
|
338
|
+
// The service filters readOnly: true in the query itself, so a non-readonly
|
|
339
|
+
// action never comes back — simulate that by resolving an empty array.
|
|
340
|
+
mockRepo.find.mockResolvedValue([]);
|
|
341
|
+
await expect(service.beforeUpdateOperation({
|
|
342
|
+
id: 'action-uuid-1',
|
|
343
|
+
name: 'Renamed'
|
|
344
|
+
}, buildMockUser(), {})).resolves.toBeUndefined();
|
|
345
|
+
});
|
|
309
346
|
});
|
|
310
347
|
});
|
|
@@ -26,9 +26,10 @@ function _ts_param(paramIndex, decorator) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
import { LogAction } from '@flusys/nestjs-shared';
|
|
29
|
+
import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
|
|
29
30
|
import { BadRequestException, ConflictException, Inject, Injectable, Scope } from '@nestjs/common';
|
|
30
31
|
import { In, IsNull } from 'typeorm';
|
|
31
|
-
import { IAM_MODE_MESSAGES, PERMISSION_OPERATION_MESSAGES } from '../config';
|
|
32
|
+
import { IAM_EVENT_ACTIONS, IAM_EVENT_ENTITIES, IAM_EVENT_MODULE, IAM_MODE_MESSAGES, PERMISSION_OPERATION_MESSAGES } from '../config';
|
|
32
33
|
import { AssignCompanyActionsDto, AssignRoleActionsDto, AssignUserActionsDto, AssignUserRolesDto, PermissionAction } from '../dtos/permission.dto';
|
|
33
34
|
import { Action } from '../entities/action.entity';
|
|
34
35
|
import { UserIamPermissionWithCompany } from '../entities/permission-with-company.entity';
|
|
@@ -145,7 +146,9 @@ export class PermissionService {
|
|
|
145
146
|
await this.permissionCacheService.invalidateUser(dto.userId, enableCompanyFeature ? companyId : null, enableCompanyFeature ? [
|
|
146
147
|
branchId
|
|
147
148
|
] : []);
|
|
148
|
-
|
|
149
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
150
|
+
await this.publishAssignmentEvent(IAM_EVENT_ENTITIES.USER_ACTION, dto.userId, result);
|
|
151
|
+
return result;
|
|
149
152
|
}
|
|
150
153
|
async getUserActions(userId, companyId, branchId) {
|
|
151
154
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -259,7 +262,9 @@ export class PermissionService {
|
|
|
259
262
|
removed = result.affected || 0;
|
|
260
263
|
}
|
|
261
264
|
await this.permissionCacheService.invalidateRoleMembersCache(dto.roleId);
|
|
262
|
-
|
|
265
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
266
|
+
await this.publishAssignmentEvent(IAM_EVENT_ENTITIES.ROLE_ACTION, dto.roleId, result);
|
|
267
|
+
return result;
|
|
263
268
|
}
|
|
264
269
|
async getRoleActions(roleId) {
|
|
265
270
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -369,7 +374,9 @@ export class PermissionService {
|
|
|
369
374
|
}
|
|
370
375
|
});
|
|
371
376
|
await this.permissionCacheService.invalidateCompanyMembersCache(dto.companyId);
|
|
372
|
-
|
|
377
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
378
|
+
await this.publishAssignmentEvent(IAM_EVENT_ENTITIES.COMPANY_ACTION, dto.companyId, result);
|
|
379
|
+
return result;
|
|
373
380
|
}
|
|
374
381
|
/** Get all actions assigned to a company (whitelist) */ async getCompanyActions(companyId, isOnlyId = false) {
|
|
375
382
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -476,7 +483,9 @@ export class PermissionService {
|
|
|
476
483
|
await this.permissionCacheService.invalidateUser(dto.userId, enableCompanyFeature ? companyId : null, [
|
|
477
484
|
branchId
|
|
478
485
|
]);
|
|
479
|
-
|
|
486
|
+
const result = this.buildOperationResult(dto.items.length, added, removed);
|
|
487
|
+
await this.publishAssignmentEvent(IAM_EVENT_ENTITIES.USER_ROLE, dto.userId, result);
|
|
488
|
+
return result;
|
|
480
489
|
}
|
|
481
490
|
/** Get user's roles (branch-scoped, filtered by companyId and branchId if provided) */ async getUserRoles(userId, branchId, companyId) {
|
|
482
491
|
const permissionRepo = await this.getPermissionRepository();
|
|
@@ -630,6 +639,21 @@ export class PermissionService {
|
|
|
630
639
|
toRemove: items.filter((item)=>item.action === PermissionAction.REMOVE)
|
|
631
640
|
};
|
|
632
641
|
}
|
|
642
|
+
async publishAssignmentEvent(entity, targetId, result) {
|
|
643
|
+
await publishDomainEvent({
|
|
644
|
+
module: IAM_EVENT_MODULE,
|
|
645
|
+
entity,
|
|
646
|
+
action: IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED,
|
|
647
|
+
ids: [
|
|
648
|
+
targetId
|
|
649
|
+
],
|
|
650
|
+
metadata: {
|
|
651
|
+
added: result.added,
|
|
652
|
+
removed: result.removed,
|
|
653
|
+
total: result.total
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
633
657
|
/** Build standard operation result DTO */ buildOperationResult(_totalItems, added, removed) {
|
|
634
658
|
return {
|
|
635
659
|
added,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Test } from '@nestjs/testing';
|
|
2
|
+
import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
|
|
2
3
|
import { createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
3
4
|
import { IAM_MODE_MESSAGES, PERMISSION_OPERATION_MESSAGES } from '../config';
|
|
4
5
|
import { PermissionAction } from '../dtos/permission.dto';
|
|
@@ -830,4 +831,84 @@ describe('PermissionService', ()=>{
|
|
|
830
831
|
]);
|
|
831
832
|
});
|
|
832
833
|
});
|
|
834
|
+
describe('domain events', ()=>{
|
|
835
|
+
let publish;
|
|
836
|
+
beforeEach(()=>{
|
|
837
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
838
|
+
EventBusRegistry.reset();
|
|
839
|
+
EventBusRegistry.setBus({
|
|
840
|
+
publish,
|
|
841
|
+
subscribe: jest.fn(),
|
|
842
|
+
close: jest.fn()
|
|
843
|
+
});
|
|
844
|
+
EventBusRegistry.configureModule('iam', {
|
|
845
|
+
enabled: true
|
|
846
|
+
});
|
|
847
|
+
mockIamConfigService.isDirectPermissionEnabled.mockReturnValue(true);
|
|
848
|
+
mockPermissionRepo.find.mockResolvedValue([]);
|
|
849
|
+
mockPermissionRepo.save.mockResolvedValue([
|
|
850
|
+
{}
|
|
851
|
+
]);
|
|
852
|
+
mockPermissionRepo.delete.mockResolvedValue(buildDeleteResult(1));
|
|
853
|
+
});
|
|
854
|
+
afterEach(()=>{
|
|
855
|
+
EventBusRegistry.reset();
|
|
856
|
+
});
|
|
857
|
+
it('publishes iam.user_action.permissions-assigned with the operation counts', async ()=>{
|
|
858
|
+
await service.assignUserActions({
|
|
859
|
+
userId: 'user-1',
|
|
860
|
+
items: [
|
|
861
|
+
{
|
|
862
|
+
id: 'action-1',
|
|
863
|
+
action: PermissionAction.ADD
|
|
864
|
+
}
|
|
865
|
+
]
|
|
866
|
+
});
|
|
867
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
868
|
+
name: 'iam.user_action.permissions-assigned',
|
|
869
|
+
payload: expect.objectContaining({
|
|
870
|
+
ids: [
|
|
871
|
+
'user-1'
|
|
872
|
+
],
|
|
873
|
+
metadata: expect.objectContaining({
|
|
874
|
+
total: 1
|
|
875
|
+
})
|
|
876
|
+
})
|
|
877
|
+
}));
|
|
878
|
+
});
|
|
879
|
+
it('publishes iam.user_role.permissions-assigned when roles are assigned', async ()=>{
|
|
880
|
+
mockRoleRepo.find.mockResolvedValue([
|
|
881
|
+
{
|
|
882
|
+
id: 'role-1'
|
|
883
|
+
}
|
|
884
|
+
]);
|
|
885
|
+
await service.assignUserRoles({
|
|
886
|
+
userId: 'user-1',
|
|
887
|
+
items: [
|
|
888
|
+
{
|
|
889
|
+
id: 'role-1',
|
|
890
|
+
action: PermissionAction.ADD
|
|
891
|
+
}
|
|
892
|
+
]
|
|
893
|
+
});
|
|
894
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
895
|
+
name: 'iam.user_role.permissions-assigned'
|
|
896
|
+
}));
|
|
897
|
+
});
|
|
898
|
+
it('does not publish when the iam module has events disabled', async ()=>{
|
|
899
|
+
EventBusRegistry.configureModule('iam', {
|
|
900
|
+
enabled: false
|
|
901
|
+
});
|
|
902
|
+
await service.assignUserActions({
|
|
903
|
+
userId: 'user-1',
|
|
904
|
+
items: [
|
|
905
|
+
{
|
|
906
|
+
id: 'action-1',
|
|
907
|
+
action: PermissionAction.ADD
|
|
908
|
+
}
|
|
909
|
+
]
|
|
910
|
+
});
|
|
911
|
+
expect(publish).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
});
|
|
833
914
|
});
|
|
@@ -28,8 +28,9 @@ function _ts_param(paramIndex, decorator) {
|
|
|
28
28
|
import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
|
|
29
29
|
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
30
30
|
import { applyCompanyFilter } from '@flusys/nestjs-shared/utils';
|
|
31
|
-
import { Inject, Injectable, Scope } from '@nestjs/common';
|
|
32
|
-
import { In } from 'typeorm';
|
|
31
|
+
import { ForbiddenException, Inject, Injectable, Scope } from '@nestjs/common';
|
|
32
|
+
import { In, IsNull } from 'typeorm';
|
|
33
|
+
import { ROLE_MESSAGES } from '../config';
|
|
33
34
|
import { UserIamPermissionWithCompany } from '../entities/permission-with-company.entity';
|
|
34
35
|
import { RoleWithCompany } from '../entities/role-with-company.entity';
|
|
35
36
|
import { Role } from '../entities/role.entity';
|
|
@@ -40,6 +41,30 @@ import { IAMConfigService } from './iam-config.service';
|
|
|
40
41
|
import { IAMDataSourceService } from './iam-datasource.service';
|
|
41
42
|
import { PermissionCacheService } from './permission-cache.service';
|
|
42
43
|
export class RoleService extends ApiService {
|
|
44
|
+
async beforeUpdateOperation(dto, _user, _queryRunner) {
|
|
45
|
+
const dtos = Array.isArray(dto) ? dto : [
|
|
46
|
+
dto
|
|
47
|
+
];
|
|
48
|
+
const ids = dtos.map((d)=>d.id);
|
|
49
|
+
await this.ensureDataSourceRepository();
|
|
50
|
+
const readonlyRoles = await this.repository.find({
|
|
51
|
+
where: {
|
|
52
|
+
id: In(ids),
|
|
53
|
+
readOnly: true,
|
|
54
|
+
deletedAt: IsNull()
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
if (readonlyRoles.length > 0) {
|
|
58
|
+
const names = readonlyRoles.map((r)=>r.name).join(', ');
|
|
59
|
+
throw new ForbiddenException({
|
|
60
|
+
message: `Cannot modify readonly role(s): ${names}`,
|
|
61
|
+
messageKey: ROLE_MESSAGES.READONLY_UPDATE_FORBIDDEN,
|
|
62
|
+
messageVariables: {
|
|
63
|
+
names
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
43
68
|
async beforeDeleteOperation(dto, user, queryRunner) {
|
|
44
69
|
await super.beforeDeleteOperation(dto, user, queryRunner);
|
|
45
70
|
const roleIds = Array.isArray(dto.id) ? dto.id : [
|
|
@@ -48,6 +73,24 @@ export class RoleService extends ApiService {
|
|
|
48
73
|
if (roleIds.length === 0) {
|
|
49
74
|
return;
|
|
50
75
|
}
|
|
76
|
+
await this.ensureDataSourceRepository();
|
|
77
|
+
const readonlyRoles = await this.repository.find({
|
|
78
|
+
where: {
|
|
79
|
+
id: In(roleIds),
|
|
80
|
+
readOnly: true,
|
|
81
|
+
deletedAt: IsNull()
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (readonlyRoles.length > 0) {
|
|
85
|
+
const names = readonlyRoles.map((r)=>r.name).join(', ');
|
|
86
|
+
throw new ForbiddenException({
|
|
87
|
+
message: `Cannot delete readonly role(s): ${names}`,
|
|
88
|
+
messageKey: ROLE_MESSAGES.READONLY_DELETE_FORBIDDEN,
|
|
89
|
+
messageVariables: {
|
|
90
|
+
names
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
51
94
|
const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
|
|
52
95
|
const permissionEntity = enableCompanyFeature ? UserIamPermissionWithCompany : UserIamPermission;
|
|
53
96
|
const permissionRepo = queryRunner.manager.getRepository(permissionEntity);
|
|
@@ -101,6 +144,7 @@ export class RoleService extends ApiService {
|
|
|
101
144
|
'name',
|
|
102
145
|
'description',
|
|
103
146
|
'isActive',
|
|
147
|
+
'readOnly',
|
|
104
148
|
'serial',
|
|
105
149
|
'createdAt'
|
|
106
150
|
];
|
|
@@ -32,6 +32,7 @@ describe('RoleService', ()=>{
|
|
|
32
32
|
let mockPermissionCacheService;
|
|
33
33
|
beforeEach(async ()=>{
|
|
34
34
|
mockRepo = createMockRepository();
|
|
35
|
+
mockRepo.find.mockResolvedValue([]);
|
|
35
36
|
const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
|
|
36
37
|
mockIamConfigService = {
|
|
37
38
|
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
|
|
@@ -301,5 +302,39 @@ describe('RoleService', ()=>{
|
|
|
301
302
|
'user-1'
|
|
302
303
|
]);
|
|
303
304
|
});
|
|
305
|
+
it('rejects deleting a readonly role', async ()=>{
|
|
306
|
+
mockRepo.find.mockResolvedValue([
|
|
307
|
+
buildRole({
|
|
308
|
+
readOnly: true
|
|
309
|
+
})
|
|
310
|
+
]);
|
|
311
|
+
const permissionRepo = createMockRepository();
|
|
312
|
+
const queryRunner = buildQueryRunner(permissionRepo);
|
|
313
|
+
await expect(service.beforeDeleteOperation({
|
|
314
|
+
id: 'role-uuid-1',
|
|
315
|
+
type: 'delete'
|
|
316
|
+
}, buildMockUser(), queryRunner)).rejects.toThrow('Cannot delete readonly role');
|
|
317
|
+
expect(permissionRepo.find).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
describe('beforeUpdateOperation', ()=>{
|
|
321
|
+
it('rejects updating a readonly role', async ()=>{
|
|
322
|
+
mockRepo.find.mockResolvedValue([
|
|
323
|
+
buildRole({
|
|
324
|
+
readOnly: true
|
|
325
|
+
})
|
|
326
|
+
]);
|
|
327
|
+
await expect(service.beforeUpdateOperation({
|
|
328
|
+
id: 'role-uuid-1',
|
|
329
|
+
name: 'Renamed'
|
|
330
|
+
}, buildMockUser(), {})).rejects.toThrow('Cannot modify readonly role');
|
|
331
|
+
});
|
|
332
|
+
it('allows updating a non-readonly role', async ()=>{
|
|
333
|
+
mockRepo.find.mockResolvedValue([]);
|
|
334
|
+
await expect(service.beforeUpdateOperation({
|
|
335
|
+
id: 'role-uuid-1',
|
|
336
|
+
name: 'Renamed'
|
|
337
|
+
}, buildMockUser(), {})).resolves.toBeUndefined();
|
|
338
|
+
});
|
|
304
339
|
});
|
|
305
340
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { IBootstrapAppConfig, IDataSourceServiceOptions, IDynamicModuleConfig, IModuleOptionsFactory } from '@flusys/nestjs-core';
|
|
2
|
+
import { IModuleEventsConfig } from '@flusys/nestjs-shared/interfaces';
|
|
2
3
|
import { ModuleMetadata, Type } from '@nestjs/common';
|
|
3
4
|
export interface IIAMModuleConfig extends IDataSourceServiceOptions {
|
|
5
|
+
events?: IModuleEventsConfig;
|
|
4
6
|
}
|
|
5
7
|
export interface IAMModuleOptions extends IDynamicModuleConfig {
|
|
6
8
|
bootstrapAppConfig?: IBootstrapAppConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flusys/nestjs-iam",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.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": "
|
|
94
|
-
"@flusys/nestjs-shared": "
|
|
93
|
+
"@flusys/nestjs-core": "7.0.0",
|
|
94
|
+
"@flusys/nestjs-shared": "7.0.0"
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -17,6 +17,7 @@ export declare class ActionService extends ApiService<CreateActionDto, UpdateAct
|
|
|
17
17
|
private readonly permissionService;
|
|
18
18
|
private readonly permissionCacheService;
|
|
19
19
|
constructor(cacheManager: HybridCache, utilsService: UtilsService, iamConfigService: IAMConfigService, dataSourceProvider: IAMDataSourceService, permissionService: PermissionService, permissionCacheService: PermissionCacheService);
|
|
20
|
+
protected beforeUpdateOperation(dto: UpdateActionDto | UpdateActionDto[], _user: ILoggedUserInfo | null, _queryRunner: QueryRunner): Promise<void>;
|
|
20
21
|
protected beforeDeleteOperation(dto: DeleteDto, user: ILoggedUserInfo | null, queryRunner: QueryRunner): Promise<void>;
|
|
21
22
|
getSelectQuery(query: SelectQueryBuilder<Action>, _user: ILoggedUserInfo | null, select?: string[]): Promise<{
|
|
22
23
|
query: SelectQueryBuilder<Action>;
|
|
@@ -23,6 +23,7 @@ export declare class PermissionService {
|
|
|
23
23
|
getUserEffectiveActions(userId: string, companyId?: string | null, branchId?: string | null): Promise<UserActionResponseDto[]>;
|
|
24
24
|
getMyPermissions(userId: string, branchId: string | null, companyId?: string | null, parentCodes?: string[]): Promise<MyPermissionsResponseDto>;
|
|
25
25
|
private splitItemsByAction;
|
|
26
|
+
private publishAssignmentEvent;
|
|
26
27
|
private buildOperationResult;
|
|
27
28
|
revokeCompanyPermissions(companyId: string): Promise<void>;
|
|
28
29
|
revokeBranchPermissions(branchId: string, companyId: string): Promise<void>;
|
|
@@ -15,6 +15,7 @@ export declare class RoleService extends ApiService<CreateRoleDto, UpdateRoleDto
|
|
|
15
15
|
private readonly iamConfigService;
|
|
16
16
|
private readonly permissionCacheService;
|
|
17
17
|
constructor(cacheManager: HybridCache, utilsService: UtilsService, iamConfigService: IAMConfigService, dataSourceProvider: IAMDataSourceService, permissionCacheService: PermissionCacheService);
|
|
18
|
+
protected beforeUpdateOperation(dto: UpdateRoleDto | UpdateRoleDto[], _user: ILoggedUserInfo | null, _queryRunner: QueryRunner): Promise<void>;
|
|
18
19
|
protected beforeDeleteOperation(dto: DeleteDto, user: ILoggedUserInfo | null, queryRunner: QueryRunner): Promise<void>;
|
|
19
20
|
protected resolveEntity(): EntityTarget<RoleBase>;
|
|
20
21
|
convertSingleDtoToEntity(dto: CreateRoleDto | UpdateRoleDto, user: ILoggedUserInfo | null): Promise<RoleBase>;
|