• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

restorecommerce / access-control-srv / 18995183650

01 Nov 2025 10:08AM UTC coverage: 69.737% (-29.8%) from 99.584%
18995183650

push

github

Arun-KumarH
fix: up RC deps

766 of 1161 branches covered (65.98%)

Branch coverage included in aggregate %.

1248 of 1727 relevant lines covered (72.26%)

54.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

83.78
/src/core/accessController.ts
1
import * as _ from 'lodash-es';
2
import {
3
  PolicySetWithCombinables, PolicyWithCombinables, AccessControlOperation,
4
  CombiningAlgorithm, AccessControlConfiguration, EffectEvaluation, ContextWithSubResolved
5
} from './interfaces.js';
6
import { Request, Response, Response_Decision, ReverseQuery } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/access_control.js';
7
import { Rule, RuleRQ, ContextQuery, Effect, Target } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/rule.js';
8
import { Policy, PolicyRQ } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/policy.js';
9
import { PolicySetRQ } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/policy_set.js';
10
import { Attribute } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/attribute.js';
11
import {
12
  UserServiceClient
13
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/user.js';
14

15
import { ResourceAdapter } from './resource_adapters/adapter.js';
16
import { GraphQLAdapter } from './resource_adapters/gql.js';
17
import * as errors from './errors.js';
18
import { checkHierarchicalScope } from './hierarchicalScope.js';
19
import { Logger } from 'winston';
20
import { createClient, RedisClientType } from 'redis';
21
import { Topic } from '@restorecommerce/kafka-client';
22
import { verifyACLList } from './verifyACL.js';
23
import { conditionMatches } from './utils.js';
24

25
export type Awaiter = {
26
  resolve: (state: boolean) => void,
27
  reject: (reason: any) => void,
28
  timeoutId: NodeJS.Timeout,
29
};
30

31
export class AccessController {
32
  policySets: Map<string, PolicySetWithCombinables>;
33
  combiningAlgorithms: Map<string, any>;
34
  urns: Map<string, string>;
35
  resourceAdapter: ResourceAdapter;
36
  redisClient: RedisClientType<any, any>;
37
  userTopic: Topic;
38
  waiting: Record<string, Awaiter[]>;
39
  cfg: any;
40
  userService: UserServiceClient;
41

42
  constructor(
43
    private logger: Logger,
11✔
44
    opts: AccessControlConfiguration,
45
    userTopic: Topic,
46
    cfg: any,
47
    userService: UserServiceClient
48
  ) {
49
    this.policySets = new Map<string, PolicySetWithCombinables>();
11✔
50
    this.combiningAlgorithms = new Map<string, any>();
11✔
51

52
    logger.info('Parsing combining algorithms from access control configuration...');
11✔
53
    //  parsing URNs and mapping them to functions
54
    const combiningAlgorithms: CombiningAlgorithm[] = opts?.combiningAlgorithms ?? [];
11!
55
    for (const ca of combiningAlgorithms) {
11✔
56
      const urn = ca.urn;
33✔
57
      const method = ca.method;
33✔
58

59
      if ((this as any)[method]) {
33!
60
        this.combiningAlgorithms.set(urn, (this as any)[method]);
33✔
61
      } else {
62
        logger.error('Unable to setup access controller: an invalid combining algorithm was found!');
×
63
        throw new errors.InvalidCombiningAlgorithm(urn);
×
64
      }
65
    }
66

67
    this.urns = new Map<string, string>();
11✔
68
    for (const urn in opts.urns || {}) {
11!
69
      this.urns.set(urn, opts.urns[urn]);
154✔
70
    }
71
    this.cfg = cfg;
11✔
72
    const redisConfig = this.cfg.get('redis');
11✔
73
    redisConfig.database = this.cfg.get('redis:db-indexes:db-subject');
11✔
74
    this.redisClient = createClient(redisConfig);
11✔
75
    this.redisClient.on('error', (err) => logger.error('Redis Client Error', { code: err.code, message: err.message, stack: err.stack }));
11✔
76
    this.redisClient.connect().then(data => logger.info('Redis client for subject cache connection successful')).catch(err => {
11✔
77
      logger.error('Error creating redis client instance', { code: err.code, message: err.message, stack: err.stack });
×
78
    });
79
    this.userTopic = userTopic;
11✔
80
    this.waiting = {};
11✔
81
    this.userService = userService;
11✔
82
  }
83

84
  clearPolicies(): void {
85
    this.policySets.clear();
15✔
86
  }
87

88
  /**
89
   * Method invoked for access control logic.
90
   *
91
   * @param request
92
   */
93
  async isAllowed(request: Request): Promise<Response> {
94

95
    this.logger.silly('Received an access request');
105✔
96
    if (!request.target) {
105!
97
      this.logger.silly('Access request had no target. Skipping request.');
×
98
      return {
×
99
        decision: Response_Decision.DENY,
100
        evaluation_cacheable: false, // typing for evaluation_cachecable expected so adding default false
101
        obligations: [],
102
        operation_status: {
103
          code: 400,
104
          message: 'Access request had no target. Skipping request'
105
        }
106
      };
107
    }
108

109
    let effect: EffectEvaluation;
110
    const obligations: Attribute[] = [];
105✔
111
    let context = (request as any).context as ContextWithSubResolved;
105✔
112
    if (!context) {
105✔
113
      (context as any) = {};
1✔
114
    }
115
    if (context?.subject?.token) {
105✔
116
      const subject = await this.userService.findByToken({ token: context.subject.token });
20✔
117
      if (subject?.payload) {
20!
118
        context.subject.id = subject.payload.id;
20✔
119
        (context.subject as any).tokens = subject.payload.tokens;
20✔
120
        context.subject.role_associations = subject.payload.role_associations;
20✔
121
      }
122
    }
123

124
    // check if context subject_id contains HR scope if not make request 'createHierarchicalScopes'
125
    if (context?.subject?.token &&
105✔
126
      _.isEmpty(context.subject.hierarchical_scopes)) {
127
      context = await this.createHRScope(context);
20✔
128
    }
129

130
    for (const [, value] of this.policySets) {
105✔
131
      const policySet: PolicySetWithCombinables = value;
119✔
132
      const policyEffects: EffectEvaluation[] = [];
119✔
133

134
      // policyEffect needed to evalute if the properties should be PERMIT / DENY
135
      let policyEffect: Effect;
136
      if (
119✔
137
        !policySet.target
133✔
138
        || await this.targetMatches(policySet.target, request, 'isAllowed', obligations)
139
      ) {
140
        let exactMatch = false;
112✔
141
        for (const [, policyValue] of policySet.combinables) {
112✔
142
          const policy: Policy = policyValue;
167✔
143
          if (policy.effect) {
167✔
144
            policyEffect = policy.effect;
1✔
145
          }
166!
146
          else if (policy.combining_algorithm) {
147
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
166✔
148
            if (method === 'permitOverrides') {
166!
149
              policyEffect = Effect.PERMIT;
×
150
            } else if (method === 'denyOverrides') {
166!
151
              policyEffect = Effect.DENY;
×
152
            }
153
          }
154

155
          if (
167✔
156
            policy.target
238✔
157
            && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect)
158
          ) {
159
            exactMatch = true;
44✔
160
            break;
44✔
161
          }
162
        }
163

164
        // if there are multiple entities in the request.target.resources
165
        // and if exactMatch is true, then check again with the resourcesAttributeMatch providing one entity each time
166
        // to ensure there is an exact policy entity match for each of the requested entity
167
        const entityURN = this.urns.get('entity');
112✔
168
        if (exactMatch && request?.target?.resources?.filter(att => att?.id === entityURN)?.length > 1) {
160✔
169
          exactMatch = this.checkMultipleEntitiesMatch(value, request, obligations);
13✔
170
        }
171

172
        for (const [, policyValue] of policySet.combinables) {
112✔
173
          const policy: PolicyWithCombinables = policyValue;
219✔
174
          if (!policy) {
219!
175
            this.logger.debug('Policy Object not set');
×
176
            continue;
×
177
          }
178
          const ruleEffects: EffectEvaluation[] = [];
219✔
179
          if (
219✔
180
            !policy.target
550✔
181
            || (
182
              exactMatch
183
              && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect)
184
            )
185
            // regex match
186
            || (
187
              !exactMatch
188
              && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect, true)
189
            )
190
          ) {
191
            const rules: Map<string, Rule> = policy.combinables;
144✔
192
            this.logger.verbose(`Checking policy ${policy.name}`);
144✔
193
            let policySubjectMatch: boolean;
194
            // Subject set on Policy validate HR scope matching
195
            if (policy?.target?.subjects?.length > 0) {
144✔
196
              this.logger.verbose(`Checking Policy subject HR Scope match for ${policy.name}`);
7✔
197
              policySubjectMatch = await checkHierarchicalScope(policy.target, request, this.urns, this, this.logger);
7✔
198
            } else {
199
              policySubjectMatch = true;
137✔
200
            }
201
            // only apply a policy effect if there are no rules
202
            // combine rules otherwise
203
            if (rules.size == 0 && !!policy.effect) {
144✔
204
              policyEffects.push({ effect: policy.effect, evaluation_cacheable: policy.evaluation_cacheable });
1✔
205
            }
206
            else {
207
              let evaluationCacheableRule = true;
143✔
208
              for (const [, rule] of policy.combinables) {
143✔
209
                if (!rule) {
400!
210
                  this.logger.debug('Rule Object not set');
×
211
                  continue;
×
212
                }
213
                let evaluation_cacheable = rule.evaluation_cacheable;
400✔
214
                if (!evaluation_cacheable) {
400!
215
                  evaluationCacheableRule = false;
400✔
216
                }
217
                // if rule has not target it should be always applied inside the policy scope
218
                this.logger.verbose(`Checking rule target and request target for ${rule.name}`);
400✔
219
                let matches = !rule.target || await this.targetMatches(rule.target, request, 'isAllowed', obligations, rule.effect);
400✔
220

221
                // check for regex if there is no direct match
222
                if (!matches) {
324✔
223
                  matches = await this.targetMatches(rule.target, request, 'isAllowed', obligations, rule.effect, true);
215✔
224
                }
225

226
                if (matches) {
400✔
227
                  this.logger.verbose(`Checking rule HR Scope for ${rule.name}`);
185✔
228
                  if (matches && rule.target) {
185✔
229
                    matches = await checkHierarchicalScope(rule.target, request, this.urns, this, this.logger);
109✔
230
                  }
231

232
                  try {
185✔
233
                    if (matches && rule.condition?.length) {
185✔
234
                      // context query is only checked when a rule exists
235
                      let context: any;
236
                      if (
3!
237
                        this.resourceAdapter
5✔
238
                        && (
239
                          rule.context_query?.filters?.length
240
                          || rule.context_query?.query?.length
241
                        )
242
                      ) {
243
                        context = await this.pullContextResources(rule.context_query, request);
×
244

245
                        if (_.isNil(context)) {
×
246
                          this.logger.debug('Context query response is empty!');
×
247
                          return {  // deny by default
×
248
                            decision: Response_Decision.DENY,
249
                            obligations,
250
                            evaluation_cacheable,
251
                            operation_status: {
252
                              code: 200,
253
                              message: 'success'
254
                            }
255
                          };
256
                        }
257
                      }
258

259
                      request.context = context ?? request.context;
3✔
260
                      this.logger.debug('Validating rule condition', { name: rule.name, condition: rule.condition });
3✔
261
                      matches = conditionMatches(rule.condition, request);
3✔
262
                      this.logger.debug('condition validation response', { matches });
3✔
263
                    }
264
                  } catch (err: any) {
265
                    this.logger.error('Caught an exception while applying rule condition to request', { code: err.code, message: err.message, stack: err.stack });
×
266
                    return {  // if an exception is caught deny by default
×
267
                      decision: Response_Decision.DENY,
268
                      obligations,
269
                      evaluation_cacheable,
270
                      operation_status: {
271
                        code: err.code ? err.code : 500,
×
272
                        message: err.message
273
                      }
274
                    };
275
                  }
276

277
                  // check if request has an ACL property set, if so verify it with the current rule target
278
                  if (matches && rule?.target) {
185✔
279
                    matches = await verifyACLList(rule.target, request, this.urns, this, this.logger);
99✔
280
                  }
281

282
                  if (matches && policySubjectMatch) {
185✔
283
                    if (!evaluationCacheableRule) {
169!
284
                      evaluation_cacheable = evaluationCacheableRule;
169✔
285
                    }
286
                    ruleEffects.push({ effect: rule.effect, evaluation_cacheable });
169✔
287
                  }
288
                }
289
              }
290

291
              if (ruleEffects?.length > 0) {
143✔
292
                policyEffects.push(this.decide(policy.combining_algorithm, ruleEffects));
111✔
293
              }
294
            }
295
          }
296
        }
297

298
        if (policyEffects?.length > 0) {
112✔
299
          effect = this.decide(policySet.combining_algorithm, policyEffects);
98✔
300
        }
301
      }
302
    }
303

304
    if (!effect) {
105✔
305
      this.logger.silly('Access response is INDETERMINATE');
7✔
306
      return {
7✔
307
        decision: Response_Decision.INDETERMINATE,
308
        obligations,
309
        evaluation_cacheable: undefined,
310
        operation_status: {
311
          code: 200,
312
          message: 'success'
313
        }
314
      };
315
    }
316

317
    const decision = Response_Decision[effect.effect] || Response_Decision.INDETERMINATE;
98!
318

319
    this.logger.silly('Access response is', decision);
105✔
320
    return {
105✔
321
      decision,
322
      obligations,
323
      evaluation_cacheable: effect.evaluation_cacheable,
324
      operation_status: {
325
        code: 200,
326
        message: 'success'
327
      }
328
    };
329
  }
330

331
  async whatIsAllowed(request: Request): Promise<ReverseQuery> {
332
    const policySets: PolicySetRQ[] = [];
24✔
333
    let context = (request as any).context as ContextWithSubResolved;
24✔
334
    if (context?.subject?.token) {
24!
335
      const subject = await this.userService.findByToken({ token: context.subject.token });
×
336
      if (subject?.payload) {
×
337
        context.subject.id = subject.payload.id;
×
338
        (context.subject as any).tokens = subject.payload.tokens;
×
339
        context.subject.role_associations = subject.payload.role_associations;
×
340
      }
341
    }
342
    // check if context subject_id contains HR scope if not make request 'createHierarchicalScopes'
343
    if (context?.subject?.token &&
24!
344
      _.isEmpty(context.subject.hierarchical_scopes)) {
345
      context = await this.createHRScope(context);
×
346
    }
347
    const obligations: Attribute[] = [];
24✔
348
    for (const [, value] of this.policySets) {
24✔
349
      let pSet: PolicySetRQ;
350
      if (
24!
351
        _.isEmpty(value.target)
24!
352
        || await this.targetMatches(value.target, request, 'whatIsAllowed', obligations)
353
      ) {
354
        pSet = _.merge({}, { combining_algorithm: value.combining_algorithm }, _.pick(value, ['id', 'target', 'effect'])) as any;
24✔
355
        pSet.policies = [];
24✔
356

357
        let exactMatch = false;
24✔
358
        let policyEffect: Effect;
359
        for (const [, policy] of value.combinables) {
24✔
360
          if (policy.effect) {
37!
361
            policyEffect = policy.effect;
×
362
          } else if (policy?.combining_algorithm) {
37!
363
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
37✔
364
            if (method === 'permitOverrides') {
37!
365
              policyEffect = Effect.PERMIT;
×
366
            } else if (method === 'denyOverrides') {
37!
367
              policyEffect = Effect.DENY;
×
368
            }
369
          }
370
          if (!!policy.target && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect)) {
37✔
371
            exactMatch = true;
10✔
372
            break;
10✔
373
          }
374
        }
375

376
        // if there are multiple entities in the request.target.resources
377
        // and if exactMatch is true, then check again with the resourcesAttributeMatch providing one entity each time
378
        // to ensure there is an exact policy entity match for each of the requested entity
379
        const entityURN = this.urns.get('entity');
24✔
380
        if (exactMatch && request?.target?.resources?.filter(att => att?.id === entityURN)?.length > 1) {
61✔
381
          exactMatch = this.checkMultipleEntitiesMatch(value, request, obligations);
10✔
382
        }
383

384
        for (const [, policy] of value.combinables) {
24✔
385
          let policyRQ: PolicyRQ;
386
          if (!policy) {
40!
387
            this.logger.debug('Policy Object not set');
×
388
            continue;
×
389
          }
390
          if (_.isEmpty(policy.target)
40✔
391
            || (exactMatch && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect))
392
            || (!exactMatch && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect, true))) {
393
            policyRQ = _.merge({}, { combining_algorithm: policy.combining_algorithm }, _.pick(policy, ['id', 'target', 'effect', 'evaluation_cacheable'])) as any;
34✔
394
            policyRQ.rules = [];
34✔
395

396
            policyRQ.has_rules = (!!policy.combinables && policy.combinables.size > 0);
34✔
397

398
            for (const [, rule] of policy.combinables) {
34✔
399
              if (!rule) {
116!
400
                this.logger.debug('Rule Object not set');
×
401
                continue;
×
402
              }
403
              let ruleRQ: RuleRQ;
404
              this.logger.debug(`WhatIsAllowed Checking rule target and request target for ${rule.name}`);
116✔
405
              let matches = _.isEmpty(rule.target) || await this.targetMatches(rule.target, request, 'whatIsAllowed', obligations, rule.effect);
116✔
406
              // check for regex if there is no direct match
407
              if (!matches) {
94✔
408
                matches = await this.targetMatches(rule.target, request, 'whatIsAllowed', obligations, rule.effect, true);
54✔
409
              }
410

411
              if (_.isEmpty(rule.target) || matches) {
116✔
412
                ruleRQ = _.merge({}, { context_query: rule.context_query }, _.pick(rule, ['id', 'target', 'effect', 'condition', 'evaluation_cacheable']));
62✔
413
                policyRQ.rules.push(ruleRQ);
62✔
414
              }
415
            }
416
            if (!!policyRQ.effect || (!policyRQ.effect && !_.isEmpty(policyRQ.rules))) {
34!
417
              pSet.policies.push(policyRQ);
34✔
418
            }
419
          }
420
        }
421
        if (!_.isEmpty(pSet.policies)) {
24!
422
          policySets.push(pSet);
24✔
423
        }
424
      }
425
    }
426
    return {
24✔
427
      policy_sets: policySets, obligations, operation_status: {
428
        code: 200,
429
        message: 'success'
430
      }
431
    };
432
  }
433

434
  private checkMultipleEntitiesMatch(policySet: PolicySetWithCombinables, request: Request, obligation: Attribute[]): boolean {
435
    let multipleEntitiesMatch = false;
23✔
436
    let exactMatch = true;
23✔
437
    // iterate and find for each of the exact mathing resource attribute
438
    const entityURN = this.urns.get('entity');
23✔
439
    for (const requestAttributeObj of request?.target?.resources || []) {
23!
440
      if (requestAttributeObj.id === entityURN) {
59✔
441
        multipleEntitiesMatch = false;
29✔
442
        for (const [, policyValue] of policySet.combinables) {
29✔
443
          const policy: Policy = policyValue;
58✔
444
          let policyEffect: Effect;
445
          if (policy.effect) {
58!
446
            policyEffect = policy.effect;
×
447
          } else if (policy.combining_algorithm) {
58!
448
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
58✔
449
            if (method === 'permitOverrides') {
58!
450
              policyEffect = Effect.PERMIT;
×
451
            } else if (method === 'denyOverrides') {
58!
452
              policyEffect = Effect.DENY;
×
453
            }
454
          }
455
          if (policy?.target?.resources?.length > 0) {
58✔
456
            if (this.resourceAttributesMatch(policy?.target?.resources, [requestAttributeObj], 'isAllowed', obligation, policyEffect)) {
41✔
457
              multipleEntitiesMatch = true;
12✔
458
            }
459
          }
460
        }
461
        if (!multipleEntitiesMatch) {
29✔
462
          exactMatch = false; // reset exact match even if one of the multiple entities exact match is not found
17✔
463
          break;
17✔
464
        }
465
      }
466
    }
467
    return exactMatch;
23✔
468
  }
469

470
  private resourceAttributesMatch(ruleAttributes: Attribute[],
471
    requestAttributes: Attribute[], operation: AccessControlOperation,
472
    maskPropertyList: Attribute[], effect: Effect, regexMatch?: boolean): boolean {
473
    const entityURN = this.urns.get('entity');
466✔
474
    const propertyURN = this.urns.get('property');
466✔
475
    const maskedPropertyURN = this.urns.get('maskedProperty');
466✔
476
    const operationURN = this.urns.get('operation');
466✔
477
    let entityMatch = false;
466✔
478
    let propertyMatch = false;
466✔
479
    let rulePropertiesExist = false;
466✔
480
    let requestPropertiesExist = false;
466✔
481
    let operationMatch = false;
466✔
482
    let requestEntityURN = '';
466✔
483
    let skipDenyRule = true;
466✔
484
    let rulePropertyValue = '';
466✔
485
    // if there are no resources defined in rule or policy, return as resources match
486
    if (_.isEmpty(ruleAttributes)) {
466✔
487
      return true;
16✔
488
    }
489
    if (!maskPropertyList) {
450!
490
      maskPropertyList = [];
×
491
    }
492
    for (const reqAttr of requestAttributes || []) {
450!
493
      if (reqAttr.id === propertyURN) {
1,575✔
494
        requestPropertiesExist = true;
390✔
495
      }
496
    }
497
    for (const requestAttribute of requestAttributes || []) {
450!
498
      propertyMatch = false;
1,545✔
499
      for (const ruleAttribute of ruleAttributes || []) {
1,545!
500
        if (ruleAttribute.id === propertyURN) {
2,172✔
501
          rulePropertiesExist = true;
609✔
502
          rulePropertyValue = ruleAttribute.value;
609✔
503
        }
504
        // direct match for attribute values
505
        if (!regexMatch) {
2,172✔
506
          if (requestAttribute?.id === entityURN && ruleAttribute?.id === entityURN
1,781✔
507
            && requestAttribute?.value === ruleAttribute?.value) {
508
            // entity match
509
            entityMatch = true;
262✔
510
            requestEntityURN = requestAttribute.value;
262✔
511
          } else if (requestAttribute?.id === operationURN && ruleAttribute?.id === operationURN
1,519✔
512
            && requestAttribute?.value === ruleAttribute?.value) {
513
            operationMatch = true;
5✔
514
          } else if (entityMatch && requestAttribute?.id === propertyURN &&
1,514✔
515
            ruleAttribute?.id === propertyURN) {
516
            // check if requestEntityURNs entityName is part in the ruleAttribute property
517
            // if so check the rule attribute value and request attribute value, if not set property match for
518
            // this request property as true as it does not belong to this rule (since for multiple entities request
519
            // its possible that there could be properties from other entities)
520
            const entityName = requestEntityURN?.substring(requestEntityURN?.lastIndexOf(':') + 1);
180✔
521
            if (requestAttribute?.value?.indexOf(entityName) > -1) {
180✔
522
              // if match for request attribute is not found in rule attribute, Deny for isAllowed
523
              // and add properties to maskPropertyList for WhatIsAllowed
524
              if (ruleAttribute?.value === requestAttribute?.value) {
134✔
525
                propertyMatch = true;
51✔
526
              }
527
            } else if (effect === Effect.PERMIT) { // set propertyMatch to true only when rule is Permit and request does not belong to this rule
46✔
528
              propertyMatch = true; // the requested entity property does not belong to this rule
36✔
529
            }
530
          }
531
        } else if (regexMatch) {
391!
532
          // regex match for attribute values
533
          if (requestAttribute?.id === entityURN && ruleAttribute?.id === entityURN) {
391✔
534
            // rule entity, get ruleNS and entityRegexValue for rule
535
            const value = ruleAttribute?.value;
93✔
536
            const pattern = value?.substring(value?.lastIndexOf(':') + 1);
93✔
537
            const nsEntityArray = pattern?.split('.');
93✔
538
            // firstElement could be either entity or namespace
539
            const nsOrEntity = nsEntityArray[0];
93✔
540
            const entityRegexValue = nsEntityArray[nsEntityArray?.length - 1];
93✔
541
            let reqNS, ruleNS;
542
            if (nsOrEntity?.toUpperCase() != entityRegexValue?.toUpperCase()) {
93!
543
              // rule name space is present
544
              ruleNS = nsOrEntity.toUpperCase();
×
545
            }
546

547
            // request entity, get reqNS and requestEntityValue for request
548
            const reqValue = requestAttribute?.value;
93✔
549
            requestEntityURN = reqValue;
93✔
550
            const reqAttributeNS = reqValue?.substring(0, reqValue?.lastIndexOf(':'));
93✔
551
            const ruleAttributeNS = value?.substring(0, value?.lastIndexOf(':'));
93✔
552
            // verify namespace before entity name
553
            if (reqAttributeNS != ruleAttributeNS) {
93!
554
              entityMatch = false;
×
555
            }
556
            const reqPattern = reqValue?.substring(reqValue?.lastIndexOf(':') + 1);
93✔
557
            const reqNSEntityArray = reqPattern?.split('.');
93✔
558
            // firstElement could be either entity or namespace
559
            const reqNSOrEntity = reqNSEntityArray[0];
93✔
560
            const requestEntityValue = reqNSEntityArray[reqNSEntityArray?.length - 1];
93✔
561
            if (reqNSOrEntity?.toUpperCase() != requestEntityValue?.toUpperCase()) {
93✔
562
              // request name space is present
563
              reqNS = reqNSOrEntity.toUpperCase();
3✔
564
            }
565

566
            if ((reqNS && ruleNS && (reqNS === ruleNS)) || (!reqNS && !ruleNS)) {
93!
567
              const reExp = new RegExp(entityRegexValue);
90✔
568
              if (requestEntityValue.match(reExp)) {
90✔
569
                entityMatch = true;
34✔
570
              }
571
            }
572
          } else if (entityMatch && requestAttribute?.id === propertyURN && ruleAttribute?.id === propertyURN) {
298✔
573
            // check for matching URN property value
574
            const rulePropertyValue = ruleAttribute?.value?.substring(ruleAttribute?.value?.lastIndexOf('#') + 1);
37✔
575
            const requestPropertyValue = requestAttribute?.value?.substring(requestAttribute?.value?.lastIndexOf('#') + 1);
37✔
576
            if (rulePropertyValue === requestPropertyValue) {
37✔
577
              propertyMatch = true;
8✔
578
            }
579
          }
580
        }
581
      }
582

583
      if (operation === 'isAllowed' && effect === Effect.DENY && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,545✔
584
        && entityMatch && rulePropertiesExist && propertyMatch) {
585
        skipDenyRule = false; // Deny effect rule to be skipped only if the propertyMatch and effect is DENY
3✔
586
      }
587

588
      // if no match is found for the request attribute property in rule ==> this implies this is
589
      // an additional property in request which should be denied or masked
590
      if (operation === 'isAllowed' && effect === Effect.PERMIT && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,545✔
591
        && entityMatch && rulePropertiesExist && !propertyMatch) {
592
        return false;
20✔
593
      }
594

595
      // for whatIsAllowed if decision is PERMIT and propertyMatch to false it implies
596
      // subject has requested additional properties requestAttribute.value add it to the maksPropertyList
597
      if (operation === 'whatIsAllowed' && effect === Effect.PERMIT && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,525✔
598
        && entityMatch && rulePropertiesExist && !propertyMatch) {
599
        if (!requestPropertiesExist) {
9✔
600
          return false; // since its not possible to evaluate what properties subject would read
6✔
601
        }
602
        // since there can be multiple rules for same entity below check is to find if maskPropertyList already
603
        // contains the entityValue from previous matching rule
604
        const maskPropExists = maskPropertyList?.find((maskObj) => maskObj?.value === requestEntityURN);
3✔
605
        // for masking if no request properties are specified
606
        let maskProperty;
607
        if (requestPropertiesExist && requestAttribute?.value) {
9!
608
          maskProperty = requestAttribute?.value;
3✔
609
        } else if (!requestPropertiesExist) {
×
610
          maskProperty = rulePropertyValue;
×
611
        }
612
        if (maskProperty?.indexOf('#') <= -1) { // validate maskPropertyURN value
3!
613
          continue;
×
614
        }
615
        if (!maskPropExists) {
3!
616
          maskPropertyList.push({ id: entityURN, value: requestEntityURN, attributes: [{ id: maskedPropertyURN, value: maskProperty, attributes: [] }] });
3✔
617
        } else {
618
          maskPropExists.attributes.push({ id: maskedPropertyURN, value: maskProperty, attributes: [] });
×
619
        }
620
      }
621

622
      // for whatIsAllowed if decision is deny and propertyMatch to true it implies
623
      // subject does not have access to the requestAttribute.value add it to the maksPropertyList
624
      // last condition (propertyMatch || !requestPropertiesExist) -> is to match Deny rule when user does not provide any req props
625
      if (operation === 'whatIsAllowed' && effect === Effect.DENY && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,519✔
626
        && entityMatch && rulePropertiesExist && (propertyMatch || !requestPropertiesExist)) {
627
        // since there can be multiple rules for same entity below check is to find if maskPropertyList already
628
        // contains the entityValue from previous matching rule
629
        const maskPropExists = maskPropertyList?.find((maskObj) => maskObj.value === requestEntityURN);
11✔
630
        let maskProperty;
631
        // for masking if no request properties are specified
632
        if (requestPropertiesExist && requestAttribute?.value) {
11✔
633
          maskProperty = requestAttribute.value;
3✔
634
        } else if (!requestPropertiesExist) {
8!
635
          maskProperty = rulePropertyValue;
8✔
636
        }
637
        if (maskProperty?.indexOf('#') <= -1) { // validate maskPropertyURN value
11!
638
          continue;
×
639
        }
640
        if (!maskPropExists) {
11✔
641
          maskPropertyList.push({ id: entityURN, value: requestEntityURN, attributes: [{ id: maskedPropertyURN, value: maskProperty, attributes: [] }] });
6✔
642
        } else {
643
          maskPropExists.attributes.push({ id: maskedPropertyURN, value: maskProperty, attributes: [] });
5✔
644
        }
645
      }
646
    }
647

648
    // skip deny rule property is effective only if ruleProps exist and requestProps exist
649
    if (skipDenyRule && rulePropertiesExist && requestPropertiesExist && effect === Effect.DENY &&
424✔
650
      operation === 'isAllowed' && !propertyMatch) {
651
      return false;
8✔
652
    }
653

654
    // if there is no entity or no operation match return false
655
    if (!entityMatch && !operationMatch) {
416✔
656
      return false;
149✔
657
    }
658
    return true;
267✔
659
  }
660

661
  /**
662
 * Check if a request's target matches a rule, policy or policy set's target.
663
 * @param targetA
664
 * @param targetB
665
 */
666
  private async targetMatches(ruleTarget: Target, request: Request,
667
    operation: AccessControlOperation = 'isAllowed', maskPropertyList: Attribute[],
930✔
668
    effect: Effect = Effect.PERMIT, regexMatch?: boolean): Promise<boolean> {
930✔
669
    const requestTarget = request.target;
930✔
670
    const subMatch = await this.checkSubjectMatches(ruleTarget.subjects, requestTarget.subjects, request);
930✔
671
    const match = subMatch && this.attributesMatch(ruleTarget.actions, requestTarget.actions);
930✔
672
    if (!match) {
930✔
673
      return false;
505✔
674
    }
675
    return this.resourceAttributesMatch(ruleTarget.resources,
425✔
676
      requestTarget.resources, operation, maskPropertyList, effect, regexMatch);
677
  }
678

679
  /**
680
   * Check if the attributes of a action or resources from a rule, policy
681
   * or policy set match the attributes from a request.
682
   *
683
   * @param ruleAttributes
684
   * @param requestAttributes
685
   */
686
  private attributesMatch(ruleAttributes: Attribute[], requestAttributes: Attribute[]): boolean {
687
    for (const attribute of ruleAttributes || []) {
776✔
688
      const id = attribute?.id;
522✔
689
      const value = attribute?.value;
522✔
690
      const match = !!requestAttributes?.find((requestAttribute) => {
522✔
691
        // return requestAttribute.id == id && requestAttribute.value == value;
692
        if (requestAttribute?.id == id && requestAttribute?.value == value) {
693✔
693
          return true;
208✔
694
        } else {
695
          return false;
485✔
696
        }
697
      });
698

699
      if (!match) {
522✔
700
        return false;
314✔
701
      }
702
    }
703
    return true;
462✔
704
  }
705

706
  async getRedisKey(key: string): Promise<any> {
707
    if (!key) {
22!
708
      this.logger.info('Key not defined');
×
709
      return;
×
710
    }
711
    const redisResponse = await this.redisClient.get(key);
22✔
712
    if (!redisResponse) {
22✔
713
      this.logger.info('Key does not exist', { key });
2✔
714
      return;
2✔
715
    }
716
    if (redisResponse) {
20!
717
      this.logger.debug('Found key in cache: ' + key);
20✔
718
      return JSON.parse(redisResponse);
20✔
719
    }
720
  }
721

722
  async evictHRScopes(subID: string): Promise<void> {
723
    const key = `cache:${subID}:*`;
×
724
    const matchingKeys = await this.redisClient.keys(key);
×
725
    if (matchingKeys?.length) {
×
726
      await this.redisClient.del(matchingKeys);
×
727
      this.logger.debug('Evicted Subject cache: ' + key);
×
728
    }
729
    return;
×
730
  }
731

732
  async setRedisKey(key: string, value: any): Promise<any> {
733
    if (!key || !value) {
4!
734
      this.logger.info(`Either key or value for redis set is not defined key: ${key} value: ${value}`);
×
735
      return;
×
736
    }
737
    return await this.redisClient.set(key, value);
4✔
738
  }
739

740
  async createHRScope(context: ContextWithSubResolved): Promise<ContextWithSubResolved> {
741
    if (context && !context.subject) {
20!
742
      context.subject = {};
×
743
    }
744
    const token = context.subject.token;
20✔
745
    const subjectID = context.subject.id;
20✔
746
    const subjectTokens = context.subject.tokens;
20✔
747
    const tokenFound = _.find(subjectTokens ?? [], { token });
20!
748
    let redisHRScopesKey;
749
    if (tokenFound?.interactive) {
20!
750
      redisHRScopesKey = `cache:${subjectID}:hrScopes`;
×
751
    }
20!
752
    else if (tokenFound && !tokenFound.interactive) {
40✔
753
      redisHRScopesKey = `cache:${subjectID}:${token}:hrScopes`;
20✔
754
    }
755
    else {
756
      return context;
×
757
    }
758
    const timeout = this.cfg.get('authorization:hrReqTimeout') ?? 300000;
20✔
759
    const keyExist = await this.redisClient.exists(redisHRScopesKey);
20✔
760

761
    if (!keyExist) {
20✔
762
      const date = new Date().toISOString();
2✔
763
      const tokenDate = token + ':' + date;
2✔
764
      await this.userTopic.emit('hierarchicalScopesRequest', { token: tokenDate });
2✔
765
      this.waiting[tokenDate] = [];
2✔
766
      try {
2✔
767
        await new Promise((resolve, reject) => {
2✔
768
          const timeoutId = setTimeout(async () => {
2✔
769
            reject({ message: 'hr scope read timed out', tokenDate });
×
770
          }, timeout);
771
          this.waiting[tokenDate].push({ resolve, reject, timeoutId });
2✔
772
        });
773
        const subjectHRScopes = await this.getRedisKey(redisHRScopesKey);
2✔
774
        Object.assign(context.subject, { hierarchical_scopes: subjectHRScopes });
2✔
775
      } catch (err) {
776
        // unhandled promise rejection for timeout
777
        this.logger.error(`Error creating Hierarchical scope for subject ${tokenDate}`);
×
778
      }
779
    } else {
780
      try {
18✔
781
        const subjectHRScopes = await this.getRedisKey(redisHRScopesKey);
18✔
782
        Object.assign(context.subject, { hierarchical_scopes: subjectHRScopes });
18✔
783
      } catch (err) {
784
        this.logger.info(`Subject or HR Scope not persisted in redis in acs`);
×
785
      }
786
    }
787
    return context;
20✔
788
  }
789

790
  /**
791
   * Check if the Rule's Subject Role matches with atleast
792
   * one of the user role associations role value
793
   *
794
   * @param ruleAttributes
795
   * @param requestSubAttributes
796
   * @param request
797
   */
798
  private async checkSubjectMatches(ruleSubAttributes: Attribute[],
799
    requestSubAttributes: Attribute[], request: Request): Promise<boolean> {
800
    const context = (request as any)?.context as ContextWithSubResolved;
930✔
801
    // Just check the Role value matches here in subject
802
    const roleURN = this.urns.get('role');
930✔
803
    let ruleRole: string;
804
    if (!ruleSubAttributes || ruleSubAttributes.length === 0) {
930✔
805
      return true;
232✔
806
    }
807
    ruleSubAttributes?.forEach((subjectObject) => {
698✔
808
      if (subjectObject?.id === roleURN) {
1,152✔
809
        ruleRole = subjectObject?.value;
527✔
810
      }
811
    });
812

813
    // must be a rule subject targetted to specific user
814
    if (!ruleRole && this.attributesMatch(ruleSubAttributes, requestSubAttributes)) {
930✔
815
      this.logger.debug('Rule subject targetted to specific user', ruleSubAttributes);
37✔
816
      return true;
37✔
817
    }
818

819
    if (!ruleRole) {
661✔
820
      this.logger.warn(`Subject does not match with rule attributes`, ruleSubAttributes);
134✔
821
      return false;
134✔
822
    }
823
    if (!context?.subject?.role_associations) {
527✔
824
      this.logger.warn('Subject role associations missing', ruleSubAttributes);
8✔
825
      return false;
8✔
826
    }
827
    return context?.subject?.role_associations?.some((roleObj) => roleObj?.role === ruleRole);
519✔
828
  }
829

830
  /**
831
   * A list of rules or policies provides a list of Effects.
832
   * This method is invoked to evaluate the final effect
833
   * according to a combining algorithm
834
   * @param combiningAlgorithm
835
   * @param effects
836
   */
837
  private decide(combiningAlgorithm: string, effects: EffectEvaluation[]): EffectEvaluation {
838
    if (this.combiningAlgorithms.has(combiningAlgorithm)) {
209!
839
      return this.combiningAlgorithms.get(combiningAlgorithm).apply(this, [effects]);
209✔
840
    }
841

842
    throw new errors.InvalidCombiningAlgorithm(combiningAlgorithm);
×
843
  }
844

845
  // Combining algorithms
846

847
  /**
848
  * Always DENY if DENY exists;
849
  * @param effects
850
  */
851
  protected denyOverrides(effects: EffectEvaluation[]): EffectEvaluation {
852
    let effect, evaluation_cacheable;
853
    for (const effectObj of effects || []) {
55!
854
      if (effectObj.effect === Effect.DENY) {
70✔
855
        effect = effectObj.effect;
26✔
856
        evaluation_cacheable = effectObj.evaluation_cacheable;
26✔
857
        break;
26✔
858
      } else {
859
        effect = effectObj.effect;
44✔
860
        evaluation_cacheable = effectObj.evaluation_cacheable;
44✔
861
      }
862
    }
863
    return {
55✔
864
      effect,
865
      evaluation_cacheable
866
    };
867
  }
868

869
  /**
870
   * Always PERMIT if PERMIT exists;
871
   * @param effects
872
   */
873
  protected permitOverrides(effects: EffectEvaluation[]): EffectEvaluation {
874
    let effect, evaluation_cacheable;
875
    for (const effectObj of effects || []) {
153!
876
      if (effectObj?.effect === Effect.PERMIT) {
156✔
877
        effect = effectObj.effect;
90✔
878
        evaluation_cacheable = effectObj.evaluation_cacheable;
90✔
879
        break;
90✔
880
      } else {
881
        effect = effectObj?.effect;
66✔
882
        evaluation_cacheable = effectObj.evaluation_cacheable;
66✔
883
      }
884
    }
885
    return {
153✔
886
      effect,
887
      evaluation_cacheable
888
    };
889
  }
890

891
  /**
892
   * Apply first effect which matches PERMIT or DENY.
893
   * Note that in a future implementation Effect may be extended to further values.
894
   * @param effects
895
   */
896
  protected firstApplicable(effects: EffectEvaluation[]): EffectEvaluation {
897
    return effects[0];
1✔
898
  }
899

900
  // in-memory resource handlers
901

902
  updatePolicySet(policySet: PolicySetWithCombinables): void {
903
    this.policySets.set(policySet.id, policySet);
24✔
904
  }
905

906
  removePolicySet(policySetID: string): void {
907
    this.policySets.delete(policySetID);
1✔
908
  }
909

910
  updatePolicy(policySetID: string, policy: PolicyWithCombinables): void {
911
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
25✔
912
    if (!_.isNil(policySet)) {
25!
913
      policySet.combinables.set(policy.id, policy);
25✔
914
    }
915
  }
916

917
  removePolicy(policySetID: string, policyID: string): void {
918
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
1✔
919
    if (!_.isNil(policySet)) {
1!
920
      policySet.combinables.delete(policyID);
1✔
921
    }
922
  }
923

924
  updateRule(policySetID: string, policyID: string, rule: Rule): void {
925
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
74✔
926
    if (!_.isNil(policySet)) {
74!
927
      const policy: PolicyWithCombinables = policySet.combinables.get(policyID);
74✔
928
      if (!_.isNil(policy)) {
74!
929
        policy.combinables.set(rule.id, rule);
74✔
930
      }
931
    }
932
  }
933

934
  removeRule(policySetID: string, policyID: string, ruleID: string): void {
935
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
3✔
936
    if (!_.isNil(policySet)) {
3!
937
      const policy: PolicyWithCombinables = policySet.combinables.get(policyID);
3✔
938
      if (!_.isNil(policy)) {
3!
939
        policy.combinables.delete(ruleID);
3✔
940
      }
941
    }
942
  }
943

944
  /**
945
   * Creates an adapter within the supported resource adapters.
946
   * @param adapterConfig
947
   */
948
  createResourceAdapter(adapterConfig: any): void {
949

950
    if (!_.isNil(adapterConfig.graphql)) {
5!
951
      const opts = adapterConfig.graphql;
5✔
952
      this.resourceAdapter = new GraphQLAdapter(opts.url, this.logger, opts.clientOpts);
5✔
953
    } else {
954
      throw new errors.UnsupportedResourceAdapter(adapterConfig);
×
955
    }
956
  }
957

958
  /**
959
   * Invokes adapter to pull necessary resources
960
   * and appends them to the request's context under the property `_queryResult`.
961
   * @param contextQuery A ContextQuery object.
962
   * @param context The request's context.
963
   */
964
  async pullContextResources(contextQuery: ContextQuery, request: Request): Promise<any> {
965
    const result = await this.resourceAdapter.query(contextQuery, request);
×
966

967
    return _.merge({}, context, {
×
968
      _queryResult: result
969
    });
970
  }
971
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc