@flusys/nestjs-iam 6.1.0 → 6.1.1

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.
Files changed (39) hide show
  1. package/cjs/controllers/company-action-permission.controller.js +1 -1
  2. package/cjs/controllers/company-action-permission.controller.spec.js +1 -1
  3. package/cjs/controllers/user-action-permission.controller.js +1 -1
  4. package/cjs/controllers/user-action-permission.controller.spec.js +1 -1
  5. package/cjs/dtos/permission.dto.js +7 -0
  6. package/cjs/entities/permission-base.entity.js +1 -10
  7. package/cjs/entities/permission-with-company.entity.js +0 -3
  8. package/cjs/entities/user-iam-permission.entity.js +0 -3
  9. package/cjs/modules/iam.module.js +2 -2
  10. package/cjs/services/action.service.js +4 -4
  11. package/cjs/services/action.service.spec.js +11 -11
  12. package/cjs/services/permission-cache.service.js +363 -52
  13. package/cjs/services/permission-cache.service.spec.js +462 -15
  14. package/cjs/services/permission.service.js +92 -396
  15. package/cjs/services/permission.service.spec.js +35 -310
  16. package/cjs/services/role.service.js +1 -1
  17. package/cjs/services/role.service.spec.js +4 -7
  18. package/dtos/permission.dto.d.ts +1 -0
  19. package/entities/permission-base.entity.d.ts +0 -1
  20. package/fesm/controllers/company-action-permission.controller.js +1 -1
  21. package/fesm/controllers/company-action-permission.controller.spec.js +1 -1
  22. package/fesm/controllers/user-action-permission.controller.js +1 -1
  23. package/fesm/controllers/user-action-permission.controller.spec.js +1 -1
  24. package/fesm/dtos/permission.dto.js +7 -0
  25. package/fesm/entities/permission-base.entity.js +1 -10
  26. package/fesm/entities/permission-with-company.entity.js +0 -3
  27. package/fesm/entities/user-iam-permission.entity.js +0 -3
  28. package/fesm/modules/iam.module.js +4 -4
  29. package/fesm/services/action.service.js +4 -4
  30. package/fesm/services/action.service.spec.js +11 -11
  31. package/fesm/services/permission-cache.service.js +365 -51
  32. package/fesm/services/permission-cache.service.spec.js +462 -15
  33. package/fesm/services/permission.service.js +92 -396
  34. package/fesm/services/permission.service.spec.js +35 -310
  35. package/fesm/services/role.service.js +1 -1
  36. package/fesm/services/role.service.spec.js +4 -7
  37. package/package.json +3 -3
  38. package/services/permission-cache.service.d.ts +20 -3
  39. package/services/permission.service.d.ts +2 -16
@@ -75,7 +75,7 @@ export class ActionService extends ApiService {
75
75
  }
76
76
  }) : Promise.resolve([])
77
77
  ]);
78
- const affectedUserIds = new Set(userActionRows.map((row)=>row.userId).filter((id)=>!!id));
78
+ const affectedUserIds = new Set(userActionRows.map((row)=>row.sourceId));
79
79
  const affectedCompanyIds = [
80
80
  ...new Set(companyActionRows.map((row)=>row.sourceId).filter((id)=>!!id))
81
81
  ];
@@ -90,7 +90,7 @@ export class ActionService extends ApiService {
90
90
  targetId: In(affectedRoleIds)
91
91
  }
92
92
  });
93
- userRoleRows.forEach((row)=>row.userId && affectedUserIds.add(row.userId));
93
+ userRoleRows.forEach((row)=>affectedUserIds.add(row.sourceId));
94
94
  }
95
95
  await permissionRepo.delete({
96
96
  permissionType: IamPermissionType.ROLE_ACTION,
@@ -112,7 +112,7 @@ export class ActionService extends ApiService {
112
112
  affectedUserIds.size > 0 ? this.permissionCacheService.invalidateUsers([
113
113
  ...affectedUserIds
114
114
  ]) : Promise.resolve(),
115
- ...affectedCompanyIds.map((companyId)=>this.permissionService.invalidateCompanyMembersCache(companyId))
115
+ ...affectedCompanyIds.map((companyId)=>this.permissionCacheService.invalidateCompanyMembersCache(companyId))
116
116
  ]);
117
117
  }
118
118
  // Query Customization
@@ -184,7 +184,7 @@ export class ActionService extends ApiService {
184
184
  let whereClause = {};
185
185
  const targetCompanyId = companyId || user.companyId;
186
186
  if (this.iamConfigService.isCompanyFeatureEnabled() && targetCompanyId) {
187
- const companyActionIds = await this.permissionService.getCompanyActionIds(targetCompanyId);
187
+ const companyActionIds = await this.permissionService.getCompanyActions(targetCompanyId, true);
188
188
  if (companyActionIds.length === 0) {
189
189
  return [];
190
190
  }
@@ -42,11 +42,11 @@ describe('ActionService', ()=>{
42
42
  isCompanyFeatureEnabled: jest.fn().mockReturnValue(false)
43
43
  };
44
44
  mockPermissionService = {
45
- getCompanyActionIds: jest.fn(),
46
- invalidateCompanyMembersCache: jest.fn()
45
+ getCompanyActions: jest.fn()
47
46
  };
48
47
  mockPermissionCacheService = {
49
- invalidateUsers: jest.fn()
48
+ invalidateUsers: jest.fn(),
49
+ invalidateCompanyMembersCache: jest.fn()
50
50
  };
51
51
  const module = await Test.createTestingModule({
52
52
  providers: [
@@ -85,20 +85,20 @@ describe('ActionService', ()=>{
85
85
  buildAction()
86
86
  ]);
87
87
  const result = await service.getActionsForPermission(buildMockUser());
88
- expect(mockPermissionService.getCompanyActionIds).not.toHaveBeenCalled();
88
+ expect(mockPermissionService.getCompanyActions).not.toHaveBeenCalled();
89
89
  expect(result).toHaveLength(1);
90
90
  expect(result[0].code).toBe('products.read');
91
91
  });
92
92
  it('scopes actions to the company whitelist when the company feature is enabled', async ()=>{
93
93
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
94
- mockPermissionService.getCompanyActionIds.mockResolvedValue([
94
+ mockPermissionService.getCompanyActions.mockResolvedValue([
95
95
  'action-uuid-1'
96
96
  ]);
97
97
  mockRepo.find.mockResolvedValue([
98
98
  buildAction()
99
99
  ]);
100
100
  await service.getActionsForPermission(buildMockUser(), 'company-uuid-1');
101
- expect(mockPermissionService.getCompanyActionIds).toHaveBeenCalledWith('company-uuid-1');
101
+ expect(mockPermissionService.getCompanyActions).toHaveBeenCalledWith('company-uuid-1', true);
102
102
  expect(mockRepo.find).toHaveBeenCalledWith(expect.objectContaining({
103
103
  where: {
104
104
  id: expect.anything()
@@ -107,7 +107,7 @@ describe('ActionService', ()=>{
107
107
  });
108
108
  it('short-circuits without querying when the company has no whitelisted actions', async ()=>{
109
109
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
110
- mockPermissionService.getCompanyActionIds.mockResolvedValue([]);
110
+ mockPermissionService.getCompanyActions.mockResolvedValue([]);
111
111
  const result = await service.getActionsForPermission(buildMockUser(), 'company-uuid-1');
112
112
  expect(result).toEqual([]);
113
113
  expect(mockRepo.find).not.toHaveBeenCalled();
@@ -247,12 +247,12 @@ describe('ActionService', ()=>{
247
247
  ]) // role-action rows referencing the deleted action
248
248
  .mockResolvedValueOnce([
249
249
  {
250
- userId: 'user-1'
250
+ sourceId: 'user-1'
251
251
  }
252
252
  ]) // direct user-action rows
253
253
  .mockResolvedValueOnce([
254
254
  {
255
- userId: 'user-2'
255
+ sourceId: 'user-2'
256
256
  }
257
257
  ]); // users holding role-1 (cascaded lookup)
258
258
  const queryRunner = buildQueryRunner(permissionRepo);
@@ -273,7 +273,7 @@ describe('ActionService', ()=>{
273
273
  'user-1',
274
274
  'user-2'
275
275
  ]));
276
- expect(mockPermissionService.invalidateCompanyMembersCache).not.toHaveBeenCalled();
276
+ expect(mockPermissionCacheService.invalidateCompanyMembersCache).not.toHaveBeenCalled();
277
277
  });
278
278
  it('also queries and invalidates company whitelist entries when the company feature is enabled', async ()=>{
279
279
  mockIamConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
@@ -292,7 +292,7 @@ describe('ActionService', ()=>{
292
292
  ],
293
293
  type: 'delete'
294
294
  }, buildMockUser(), queryRunner);
295
- expect(mockPermissionService.invalidateCompanyMembersCache).toHaveBeenCalledWith('company-1');
295
+ expect(mockPermissionCacheService.invalidateCompanyMembersCache).toHaveBeenCalledWith('company-1');
296
296
  });
297
297
  it('does not invalidate any cache when nothing is affected', async ()=>{
298
298
  const permissionRepo = createMockRepository();
@@ -26,12 +26,37 @@ function _ts_param(paramIndex, decorator) {
26
26
  };
27
27
  }
28
28
  import { envConfig } from '@flusys/nestjs-core/config';
29
- import { buildPermissionCacheKey, HybridCache, LogAction, PERMISSION_GUARD_CONFIG, PERMISSIONS_CACHE_PREFIX, PermissionCacheKeyOptions, PermissionGuardConfig } from '@flusys/nestjs-shared';
30
- import { Inject, Injectable, Optional } from '@nestjs/common';
29
+ import { buildPermissionCacheKey, HybridCache, LogAction, PERMISSION_GUARD_CONFIG, PermissionCacheKeyOptions, PermissionGuardConfig } from '@flusys/nestjs-shared';
30
+ import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
31
+ import { In, IsNull } from 'typeorm';
32
+ import { Action } from '../entities/action.entity';
33
+ import { UserIamPermissionWithCompany } from '../entities/permission-with-company.entity';
34
+ import { RoleWithCompany } from '../entities/role-with-company.entity';
35
+ import { Role } from '../entities/role.entity';
36
+ import { UserIamPermission } from '../entities/user-iam-permission.entity';
37
+ import { ActionType } from '../enums/action-type.enum';
38
+ import { IamEntityType } from '../enums/iam-entity-type.enum';
39
+ import { IamPermissionType } from '../enums/iam-permission-type.enum';
40
+ import { IAMPermissionMode } from '../enums/permission-type.enum';
41
+ import { IAMConfigService } from './iam-config.service';
42
+ import { IAMDataSourceService } from './iam-datasource.service';
31
43
  export { PermissionCacheKeyOptions } from '@flusys/nestjs-shared';
32
- export const MY_PERMISSIONS_CACHE_PREFIX = PERMISSIONS_CACHE_PREFIX;
33
44
  const NO_COMPANY_SCOPE = 'none';
34
45
  export class PermissionCacheService {
46
+ // Repository Getters
47
+ async getPermissionRepository() {
48
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
49
+ const entity = enableCompanyFeature ? UserIamPermissionWithCompany : UserIamPermission;
50
+ return this.dataSourceProvider.getRepository(entity);
51
+ }
52
+ async getActionRepository() {
53
+ return this.dataSourceProvider.getRepository(Action);
54
+ }
55
+ async getRoleRepository() {
56
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
57
+ const entity = enableCompanyFeature ? RoleWithCompany : Role;
58
+ return this.dataSourceProvider.getRepository(entity);
59
+ }
35
60
  // Cache Key Generation
36
61
  parseDurationMs(duration) {
37
62
  const unitMs = {
@@ -78,69 +103,305 @@ export class PermissionCacheService {
78
103
  }
79
104
  async trackKey(userId, key, companyId) {
80
105
  try {
81
- const trackedKeysKey = this.buildTrackedKeysKey(userId, companyId);
82
- const keys = await this.cacheManager.get(trackedKeysKey) || [];
83
- if (!keys.includes(key)) {
84
- keys.push(key);
85
- await this.cacheManager.set(trackedKeysKey, keys, this.TTL);
106
+ let scopeKey = key;
107
+ if (companyId) {
108
+ scopeKey = this.buildTrackedKeysKey(userId, companyId);
109
+ const keys = await this.cacheManager.get(scopeKey) || [];
110
+ if (!keys.includes(key)) {
111
+ keys.push(key);
112
+ await this.cacheManager.set(scopeKey, keys, this.TTL);
113
+ }
86
114
  }
87
115
  const scopesKey = this.buildTrackedScopesKey(userId);
88
116
  const scopes = await this.cacheManager.get(scopesKey) || [];
89
- const scope = this.scopeToken(companyId);
90
- if (!scopes.includes(scope)) {
91
- scopes.push(scope);
117
+ if (!scopes.includes(scopeKey)) {
118
+ scopes.push(scopeKey);
92
119
  await this.cacheManager.set(scopesKey, scopes, this.TTL);
93
120
  }
94
121
  } catch {
95
122
  // tracking is best-effort; a missed entry only widens invalidation, never narrows it
96
123
  }
97
124
  }
98
- // Cache Invalidation
99
- async invalidateUser(userId, companyId, branchIds) {
100
- if (companyId) {
101
- await this.invalidateScope(userId, companyId, branchIds);
102
- return;
125
+ // My Permissions (cache-first read, DB-backed rebuild)
126
+ /** Get user's effective permissions (cache-first approach) */ async getMyPermissionsResponse(userId, branchId, companyId, parentCodes) {
127
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
128
+ const cacheOptions = {
129
+ userId,
130
+ companyId: enableCompanyFeature ? companyId : null,
131
+ branchId: enableCompanyFeature ? branchId : null
132
+ };
133
+ const cachedData = await this.getMyPermissions(cacheOptions);
134
+ if (cachedData) {
135
+ return this.buildResponseFromCache(cachedData, parentCodes);
103
136
  }
104
- // No company scope given: clear every company/branch scope tracked for this user
105
- const scopesKey = this.buildTrackedScopesKey(userId);
106
- let scopes = [];
107
- try {
108
- scopes = await this.cacheManager.get(scopesKey) || [];
109
- } catch {
110
- scopes = [];
137
+ const freshData = await this.fetchAndCachePermissions(userId, branchId, companyId);
138
+ return this.buildResponseFromCache(freshData, parentCodes);
139
+ }
140
+ /** Build response from cached data, applying optional parentCodes filter */ async buildResponseFromCache(cachedData, parentCodes) {
141
+ let frontendActions = cachedData.frontendActions;
142
+ if (parentCodes?.length) {
143
+ const parentIds = await this.getParentIdsByCodesWithCache(parentCodes);
144
+ if (parentIds.size > 0) {
145
+ frontendActions = frontendActions.filter((a)=>a.parentId && parentIds.has(a.parentId));
146
+ } else {
147
+ frontendActions = [];
148
+ }
111
149
  }
112
- await Promise.all(scopes.map((scope)=>this.invalidateScope(userId, scope === NO_COMPANY_SCOPE ? null : scope, branchIds)));
113
- await this.cacheManager.del(scopesKey);
150
+ return {
151
+ frontendActions: frontendActions.map((a)=>({
152
+ id: a.id,
153
+ code: a.code,
154
+ name: a.name,
155
+ description: a.description
156
+ })),
157
+ cachedEndpoints: cachedData.backendCodes.length
158
+ };
114
159
  }
115
- async invalidateScope(userId, companyId, branchIds) {
116
- const keysToDelete = new Set();
117
- const trackedKeysKey = this.buildTrackedKeysKey(userId, companyId);
118
- let trackedKeys = [];
119
- try {
120
- trackedKeys = await this.cacheManager.get(trackedKeysKey) || [];
121
- } catch {
122
- trackedKeys = [];
160
+ /** Resolve action IDs for the given codes */ async getParentIdsByCodesWithCache(codes) {
161
+ const actionRepo = await this.getActionRepository();
162
+ const actions = await actionRepo.find({
163
+ where: {
164
+ code: In(codes)
165
+ },
166
+ select: [
167
+ 'id',
168
+ 'code'
169
+ ]
170
+ });
171
+ return new Set(actions.map((action)=>action.id));
172
+ }
173
+ /** Fetch permissions from DB and cache them (empty permissions are also cached) */ async fetchAndCachePermissions(userId, branchId, companyId) {
174
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
175
+ const cacheOptions = {
176
+ userId,
177
+ companyId: enableCompanyFeature ? companyId : null,
178
+ branchId
179
+ };
180
+ const emptyData = {
181
+ frontendActions: [],
182
+ backendCodes: []
183
+ };
184
+ const allActionIds = await this.collectAllActionIds(userId, branchId, companyId);
185
+ if (allActionIds.size === 0) {
186
+ await this.setMyPermissions(cacheOptions, emptyData);
187
+ return emptyData;
188
+ }
189
+ return this.buildAndCachePermissionData(allActionIds, cacheOptions);
190
+ }
191
+ /** Collect all action IDs based on permission mode (RBAC, DIRECT, or FULL) */ async collectAllActionIds(userId, branchId, companyId) {
192
+ const permissionMode = this.iamConfigService.getPermissionMode();
193
+ const allActionIds = new Set();
194
+ if (permissionMode === IAMPermissionMode.RBAC || permissionMode === IAMPermissionMode.FULL) {
195
+ const userRoleIds = await this.getUserRoleIds(userId, branchId, companyId);
196
+ if (userRoleIds.length > 0) {
197
+ const roleActionIds = await this.getRoleActionIds(userRoleIds);
198
+ roleActionIds.forEach((id)=>allActionIds.add(id));
199
+ }
200
+ }
201
+ if (permissionMode === IAMPermissionMode.DIRECT || permissionMode === IAMPermissionMode.FULL) {
202
+ const userActionIds = await this.getUserActionIds(userId, branchId, companyId);
203
+ userActionIds.forEach((id)=>allActionIds.add(id));
204
+ }
205
+ return allActionIds;
206
+ }
207
+ /** Fetch actions from DB, separate by type, build cache data, and cache it */ async buildAndCachePermissionData(actionIds, cacheOptions) {
208
+ const actionRepo = await this.getActionRepository();
209
+ const actions = await actionRepo.find({
210
+ where: {
211
+ id: In(Array.from(actionIds)),
212
+ isActive: true
213
+ }
214
+ });
215
+ const backendActions = actions.filter((a)=>a.actionType === ActionType.BACKEND || a.actionType === ActionType.BOTH);
216
+ const frontendActions = actions.filter((a)=>a.actionType === ActionType.FRONTEND || a.actionType === ActionType.BOTH);
217
+ const backendCodes = backendActions.map((a)=>a.code).filter((c)=>!!c);
218
+ const cacheData = {
219
+ frontendActions: frontendActions.map((a)=>({
220
+ id: a.id,
221
+ code: a.code ?? '',
222
+ name: a.name,
223
+ description: a.description,
224
+ parentId: a.parentId
225
+ })),
226
+ backendCodes
227
+ };
228
+ await this.setMyPermissions(cacheOptions, cacheData);
229
+ return cacheData;
230
+ }
231
+ /** Get role IDs assigned to a user (merges company-wide + branch-specific roles) */ async getUserRoleIds(userId, branchId, companyId) {
232
+ const permissionRepo = await this.getPermissionRepository();
233
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
234
+ if (!enableCompanyFeature) {
235
+ const permissions = await permissionRepo.find({
236
+ where: {
237
+ permissionType: IamPermissionType.USER_ROLE,
238
+ sourceType: IamEntityType.USER,
239
+ sourceId: userId
240
+ }
241
+ });
242
+ return permissions.filter((p)=>p.isValid()).map((p)=>p.targetId);
123
243
  }
124
- if (!branchIds?.length) {
125
- trackedKeys.forEach((key)=>keysToDelete.add(key));
126
- keysToDelete.add(trackedKeysKey);
244
+ const roleIds = new Set();
245
+ const baseWhere = {
246
+ permissionType: IamPermissionType.USER_ROLE,
247
+ sourceType: IamEntityType.USER,
248
+ sourceId: userId
249
+ };
250
+ if (companyId) {
251
+ baseWhere.companyId = companyId;
252
+ }
253
+ if (branchId) {
254
+ // Get company-wide + branch-specific roles
255
+ const companyWidePermissions = await permissionRepo.find({
256
+ where: {
257
+ ...baseWhere,
258
+ branchId: IsNull()
259
+ }
260
+ });
261
+ companyWidePermissions.filter((p)=>p.isValid()).forEach((p)=>roleIds.add(p.targetId));
262
+ const branchPermissions = await permissionRepo.find({
263
+ where: {
264
+ ...baseWhere,
265
+ branchId
266
+ }
267
+ });
268
+ branchPermissions.filter((p)=>p.isValid()).forEach((p)=>roleIds.add(p.targetId));
127
269
  } else {
128
- // Branch scope given: only invalidate those branches, keep the rest tracked
129
- branchIds.forEach((branchId)=>keysToDelete.add(this.buildCacheKey({
130
- userId,
131
- companyId,
270
+ // No branch context: get all roles for this company
271
+ const allPermissions = await permissionRepo.find({
272
+ where: baseWhere
273
+ });
274
+ allPermissions.filter((p)=>p.isValid()).forEach((p)=>roleIds.add(p.targetId));
275
+ }
276
+ return Array.from(roleIds);
277
+ }
278
+ async getRoleActionIds(roleIds) {
279
+ const permissionRepo = await this.getPermissionRepository();
280
+ const permissions = await permissionRepo.find({
281
+ where: {
282
+ permissionType: IamPermissionType.ROLE_ACTION,
283
+ sourceType: IamEntityType.ROLE,
284
+ sourceId: In(roleIds)
285
+ }
286
+ });
287
+ return permissions.filter((p)=>p.isValid()).map((p)=>p.targetId);
288
+ }
289
+ /** Get action IDs directly assigned to a user (merges company-wide + branch-specific actions) */ async getUserActionIds(userId, branchId, companyId) {
290
+ const permissionRepo = await this.getPermissionRepository();
291
+ const enableCompanyFeature = this.iamConfigService.isCompanyFeatureEnabled();
292
+ if (!enableCompanyFeature) {
293
+ const permissions = await permissionRepo.find({
294
+ where: {
295
+ permissionType: IamPermissionType.USER_ACTION,
296
+ sourceType: IamEntityType.USER,
297
+ sourceId: userId
298
+ }
299
+ });
300
+ return permissions.filter((p)=>p.isValid()).map((p)=>p.targetId);
301
+ }
302
+ const actionIds = new Set();
303
+ const baseWhere = {
304
+ permissionType: IamPermissionType.USER_ACTION,
305
+ sourceType: IamEntityType.USER,
306
+ sourceId: userId
307
+ };
308
+ if (companyId) {
309
+ baseWhere.companyId = companyId;
310
+ }
311
+ if (branchId) {
312
+ // Get company-wide + branch-specific actions
313
+ const companyWidePermissions = await permissionRepo.find({
314
+ where: {
315
+ ...baseWhere,
316
+ branchId: IsNull()
317
+ }
318
+ });
319
+ companyWidePermissions.filter((p)=>p.isValid()).forEach((p)=>actionIds.add(p.targetId));
320
+ const branchPermissions = await permissionRepo.find({
321
+ where: {
322
+ ...baseWhere,
132
323
  branchId
133
- })));
134
- const remainingKeys = trackedKeys.filter((key)=>!keysToDelete.has(key));
135
- if (remainingKeys.length) {
136
- await this.cacheManager.set(trackedKeysKey, remainingKeys, this.TTL);
324
+ }
325
+ });
326
+ branchPermissions.filter((p)=>p.isValid()).forEach((p)=>actionIds.add(p.targetId));
327
+ } else {
328
+ // No branch context: get all actions for this company
329
+ const allPermissions = await permissionRepo.find({
330
+ where: baseWhere
331
+ });
332
+ allPermissions.filter((p)=>p.isValid()).forEach((p)=>actionIds.add(p.targetId));
333
+ }
334
+ return Array.from(actionIds);
335
+ }
336
+ // Cache Invalidation
337
+ async invalidateUser(userId, companyId, branchIds) {
338
+ const keysToDelete = new Set();
339
+ /** Drop a scope entry from the tracked-scopes index, or queue the whole index for deletion once nothing remains */ const untrackScope = async (scopeKey)=>{
340
+ const scopesKeysKey = this.buildTrackedScopesKey(userId);
341
+ let scopesKeys = [];
342
+ try {
343
+ scopesKeys = await this.cacheManager.get(scopesKeysKey) || [];
344
+ } catch {
345
+ scopesKeys = [];
346
+ }
347
+ if (scopeKey) {
348
+ const remainingScopes = scopesKeys.filter((scope)=>scope !== scopeKey);
349
+ if (remainingScopes.length > 0) {
350
+ await this.cacheManager.set(scopesKeysKey, remainingScopes, this.TTL);
351
+ } else {
352
+ keysToDelete.add(scopesKeysKey);
353
+ }
137
354
  } else {
355
+ for (const scope of scopesKeys){
356
+ if (scope.startsWith(this.TRACKED_KEYS_PREFIX)) {
357
+ let nestedKeys = [];
358
+ try {
359
+ nestedKeys = await this.cacheManager.get(scope) || [];
360
+ } catch {
361
+ nestedKeys = [];
362
+ }
363
+ nestedKeys.forEach((key)=>keysToDelete.add(key));
364
+ }
365
+ keysToDelete.add(scope);
366
+ }
367
+ keysToDelete.add(scopesKeysKey);
368
+ }
369
+ };
370
+ if (companyId) {
371
+ const trackedKeysKey = this.buildTrackedKeysKey(userId, companyId);
372
+ let trackedKeys = [];
373
+ try {
374
+ trackedKeys = await this.cacheManager.get(trackedKeysKey) || [];
375
+ } catch {
376
+ trackedKeys = [];
377
+ }
378
+ if (!branchIds?.length) {
379
+ trackedKeys.forEach((key)=>keysToDelete.add(key));
138
380
  keysToDelete.add(trackedKeysKey);
381
+ await untrackScope(trackedKeysKey);
382
+ } else {
383
+ // Branch scope given: only invalidate those branches, keep the rest tracked
384
+ branchIds.forEach((branchId)=>keysToDelete.add(this.buildCacheKey({
385
+ userId,
386
+ companyId,
387
+ branchId
388
+ })));
389
+ const remainingKeys = trackedKeys.filter((key)=>!keysToDelete.has(key));
390
+ if (remainingKeys.length) {
391
+ await this.cacheManager.set(trackedKeysKey, remainingKeys, this.TTL);
392
+ } else {
393
+ keysToDelete.add(trackedKeysKey);
394
+ await untrackScope(trackedKeysKey);
395
+ }
139
396
  }
397
+ } else {
398
+ await untrackScope();
140
399
  }
141
- await Promise.all([
142
- ...keysToDelete
143
- ].map((key)=>this.cacheManager.del(key)));
400
+ await Promise.all(Array.from(keysToDelete).map(async (key)=>{
401
+ try {
402
+ await this.cacheManager.del(key);
403
+ } catch {}
404
+ }));
144
405
  }
145
406
  async invalidateUsers(userIds, companyId, branchIds) {
146
407
  if (userIds.length === 0) {
@@ -149,13 +410,60 @@ export class PermissionCacheService {
149
410
  const results = await Promise.allSettled(userIds.map((userId)=>this.invalidateUser(userId, companyId, branchIds)));
150
411
  return results.filter((r)=>r.status === 'fulfilled').length;
151
412
  }
152
- constructor(cacheManager, guardConfig){
413
+ /** Invalidate permission cache for all users holding a given role */ async invalidateRoleMembersCache(roleId) {
414
+ const permissionRepo = await this.getPermissionRepository();
415
+ const roleRepo = await this.getRoleRepository();
416
+ const userRoles = await permissionRepo.find({
417
+ where: {
418
+ permissionType: IamPermissionType.USER_ROLE,
419
+ sourceType: IamEntityType.USER,
420
+ targetType: IamEntityType.ROLE,
421
+ targetId: roleId
422
+ }
423
+ });
424
+ const userIds = [
425
+ ...new Set(userRoles.map((ur)=>ur.sourceId))
426
+ ];
427
+ if (userIds.length === 0) {
428
+ return 0;
429
+ }
430
+ const role = await roleRepo.findOne({
431
+ where: {
432
+ id: roleId
433
+ }
434
+ });
435
+ const companyId = role?.companyId || null;
436
+ return this.invalidateUsers(userIds, companyId);
437
+ }
438
+ /** Invalidate permission cache for all users in a company */ async invalidateCompanyMembersCache(companyId) {
439
+ if (!this.iamConfigService.isCompanyFeatureEnabled()) {
440
+ return 0;
441
+ }
442
+ const permissionRepo = await this.getPermissionRepository();
443
+ const userPermissions = await permissionRepo.createQueryBuilder('p').select('DISTINCT p.source_id', 'userId').where('p.company_id = :companyId', {
444
+ companyId
445
+ }).andWhere('p.source_type = :sourceType', {
446
+ sourceType: IamEntityType.USER
447
+ }).getRawMany();
448
+ const userIds = [
449
+ ...new Set(userPermissions.map((p)=>p.userId).filter(Boolean))
450
+ ];
451
+ if (userIds.length === 0) {
452
+ return 0;
453
+ }
454
+ return this.invalidateUsers(userIds, companyId);
455
+ }
456
+ constructor(cacheManager, iamConfigService, dataSourceProvider, guardConfig){
153
457
  _define_property(this, "cacheManager", void 0);
458
+ _define_property(this, "iamConfigService", void 0);
459
+ _define_property(this, "dataSourceProvider", void 0);
154
460
  _define_property(this, "guardConfig", void 0);
155
461
  _define_property(this, "TRACKED_KEYS_PREFIX", void 0);
156
462
  _define_property(this, "TRACKED_SCOPES_PREFIX", void 0);
157
463
  _define_property(this, "TTL", void 0);
158
464
  this.cacheManager = cacheManager;
465
+ this.iamConfigService = iamConfigService;
466
+ this.dataSourceProvider = dataSourceProvider;
159
467
  this.guardConfig = guardConfig;
160
468
  this.TRACKED_KEYS_PREFIX = 'permission-tracked-keys';
161
469
  this.TRACKED_SCOPES_PREFIX = 'permission-tracked-scopes';
@@ -201,13 +509,19 @@ _ts_decorate([
201
509
  _ts_metadata("design:returntype", Promise)
202
510
  ], PermissionCacheService.prototype, "invalidateUsers", null);
203
511
  PermissionCacheService = _ts_decorate([
204
- Injectable(),
512
+ Injectable({
513
+ scope: Scope.REQUEST
514
+ }),
205
515
  _ts_param(0, Inject('CACHE_INSTANCE')),
206
- _ts_param(1, Optional()),
207
- _ts_param(1, Inject(PERMISSION_GUARD_CONFIG)),
516
+ _ts_param(1, Inject(IAMConfigService)),
517
+ _ts_param(2, Inject(IAMDataSourceService)),
518
+ _ts_param(3, Optional()),
519
+ _ts_param(3, Inject(PERMISSION_GUARD_CONFIG)),
208
520
  _ts_metadata("design:type", Function),
209
521
  _ts_metadata("design:paramtypes", [
210
522
  typeof HybridCache === "undefined" ? Object : HybridCache,
523
+ typeof IAMConfigService === "undefined" ? Object : IAMConfigService,
524
+ typeof IAMDataSourceService === "undefined" ? Object : IAMDataSourceService,
211
525
  typeof PermissionGuardConfig === "undefined" ? Object : PermissionGuardConfig
212
526
  ])
213
527
  ], PermissionCacheService);