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

restorecommerce / access-control-srv / 9698111598

27 Jun 2024 02:07PM UTC coverage: 74.798% (-1.8%) from 76.633%
9698111598

push

github

Arun-KumarH
fix: Up cfg for importing seed data on startup and updated deps

499 of 669 branches covered (74.59%)

Branch coverage included in aggregate %.

2644 of 3533 relevant lines covered (74.84%)

55.5 hits per line

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

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

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

1✔
25
export class AccessController {
1✔
26
  policySets: Map<string, PolicySetWithCombinables>;
12✔
27
  combiningAlgorithms: Map<string, any>;
12✔
28
  urns: Map<string, string>;
12✔
29
  resourceAdapter: ResourceAdapter;
12✔
30
  redisClient: RedisClientType<any, any>;
12✔
31
  userTopic: Topic;
12✔
32
  waiting: any;
12✔
33
  cfg: any;
12✔
34
  userService: UserServiceClient;
12✔
35

12✔
36
  constructor(
12✔
37
    private logger: Logger,
12✔
38
    opts: AccessControlConfiguration,
12✔
39
    userTopic: Topic,
12✔
40
    cfg: any,
12✔
41
    userService: UserServiceClient
12✔
42
  ) {
12✔
43
    this.policySets = new Map<string, PolicySetWithCombinables>();
12✔
44
    this.combiningAlgorithms = new Map<string, any>();
12✔
45

12✔
46
    logger.info('Parsing combining algorithms from access control configuration...');
12✔
47
    //  parsing URNs and mapping them to functions
12✔
48
    const combiningAlgorithms: CombiningAlgorithm[] = opts?.combiningAlgorithms ?? [];
12!
49
    for (let ca of combiningAlgorithms) {
12✔
50
      const urn = ca.urn;
36✔
51
      const method = ca.method;
36✔
52

36✔
53
      if (this[method]) {
36✔
54
        this.combiningAlgorithms.set(urn, this[method]);
36✔
55
      } else {
36!
56
        logger.error('Unable to setup access controller: an invalid combining algorithm was found!');
×
57
        throw new errors.InvalidCombiningAlgorithm(urn);
×
58
      }
×
59
    }
36✔
60

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

12✔
78
  clearPolicies(): void {
12✔
79
    this.policySets.clear();
×
80
  }
×
81

12✔
82
  /**
12✔
83
   * Method invoked for access control logic.
12✔
84
   *
12✔
85
   * @param request
12✔
86
   */
12✔
87
  async isAllowed(request: Request): Promise<Response> {
12✔
88

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

107✔
103
    let effect: EffectEvaluation;
107✔
104
    let obligations: Attribute[] = [];
107✔
105
    let context = (request as any).context as ContextWithSubResolved;
107✔
106
    if (!context) {
107✔
107
      (context as any) = {};
1✔
108
    }
1✔
109
    if (context?.subject?.token) {
107✔
110
      const subject = await this.userService.findByToken({ token: context.subject.token });
20✔
111
      if (subject?.payload) {
20✔
112
        context.subject.id = subject.payload.id;
20✔
113
        (context.subject as any).tokens = subject.payload.tokens;
20✔
114
        context.subject.role_associations = subject.payload.role_associations;
20✔
115
      }
20✔
116
    }
20✔
117
    for (let [, value] of this.policySets) {
107✔
118
      const policySet: PolicySetWithCombinables = value;
121✔
119
      let policyEffects: EffectEvaluation[] = [];
121✔
120

121✔
121
      // policyEffect needed to evalute if the properties should be PERMIT / DENY
121✔
122
      let policyEffect: Effect;
121✔
123
      if (
121✔
124
        !policySet.target
121✔
125
        || await this.targetMatches(policySet.target, request, 'isAllowed', obligations)
121✔
126
      ) {
121✔
127
        let exactMatch = false;
114✔
128
        for (let [, policyValue] of policySet.combinables) {
114✔
129
          const policy: Policy = policyValue;
169✔
130
          if (policy.effect) {
169✔
131
            policyEffect = policy.effect;
1✔
132
          }
1✔
133
          else if (policy.combining_algorithm) {
168✔
134
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
168✔
135
            if (method === 'permitOverrides') {
168!
136
              policyEffect = Effect.PERMIT;
×
137
            } else if (method === 'denyOverrides') {
168!
138
              policyEffect = Effect.DENY;
×
139
            }
×
140
          }
168✔
141

169✔
142
          if (
169✔
143
            policy.target
169✔
144
            && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect)
169✔
145
          ) {
169✔
146
            exactMatch = true;
44✔
147
            break;
44✔
148
          }
44✔
149
        }
169✔
150

114✔
151
        // if there are multiple entities in the request.target.resources
114✔
152
        // and if exactMatch is true, then check again with the resourcesAttributeMatch providing one entity each time
114✔
153
        // to ensure there is an exact policy entity match for each of the requested entity
114✔
154
        const entityURN = this.urns.get('entity');
114✔
155
        if (exactMatch && request?.target?.resources?.filter(att => att?.id === entityURN)?.length > 1) {
114✔
156
          exactMatch = this.checkMultipleEntitiesMatch(value, request, obligations);
13✔
157
        }
13✔
158

114✔
159
        for (let [, policyValue] of policySet.combinables) {
114✔
160
          const policy: PolicyWithCombinables = policyValue;
221✔
161
          if (!policy) {
221!
162
            this.logger.debug('Policy Object not set');
×
163
            continue;
×
164
          }
×
165
          const ruleEffects: EffectEvaluation[] = [];
221✔
166
          if (
221✔
167
            !policy.target
221✔
168
            || (
221✔
169
              exactMatch
123✔
170
              && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect)
123✔
171
            )
221✔
172
            // regex match
221✔
173
            || (
221✔
174
              !exactMatch
85✔
175
              && await this.targetMatches(policy.target, request, 'isAllowed', obligations, policyEffect, true)
85✔
176
            )
221✔
177
          ) {
221✔
178
            const rules: Map<string, Rule> = policy.combinables;
146✔
179
            this.logger.verbose(`Checking policy ${policy.name}`);
146✔
180
            let policySubjectMatch: boolean;
146✔
181
            // Subject set on Policy validate HR scope matching
146✔
182
            if (policy?.target?.subjects?.length > 0) {
146✔
183
              this.logger.verbose(`Checking Policy subject HR Scope match for ${policy.name}`);
7✔
184
              policySubjectMatch = await checkHierarchicalScope(policy.target, request, this.urns, this, this.logger);
7✔
185
            } else {
146✔
186
              policySubjectMatch = true;
139✔
187
            }
139✔
188
            // only apply a policy effect if there are no rules
146✔
189
            // combine rules otherwise
146✔
190
            if (rules.size == 0 && !!policy.effect) {
146✔
191
              policyEffects.push({ effect: policy.effect, evaluation_cacheable: policy.evaluation_cacheable });
1✔
192
            }
1✔
193
            else {
145✔
194
              let evaluationCacheableRule = true;
145✔
195
              for (let [, rule] of policy.combinables) {
145✔
196
                if (!rule) {
404!
197
                  this.logger.debug('Rule Object not set');
×
198
                  continue;
×
199
                }
×
200
                let evaluation_cacheable = rule.evaluation_cacheable;
404✔
201
                if (!evaluation_cacheable) {
404✔
202
                  evaluationCacheableRule = false;
404✔
203
                }
404✔
204
                // if rule has not target it should be always applied inside the policy scope
404✔
205
                this.logger.verbose(`Checking rule target and request target for ${rule.name}`);
404✔
206
                let matches = !rule.target || await this.targetMatches(rule.target, request, 'isAllowed', obligations, rule.effect);
404✔
207

326✔
208
                // check for regex if there is no direct match
326✔
209
                if (!matches) {
404✔
210
                  matches = await this.targetMatches(rule.target, request, 'isAllowed', obligations, rule.effect, true);
215✔
211
                }
215✔
212

404✔
213
                if (matches) {
404✔
214
                  this.logger.verbose(`Checking rule HR Scope for ${rule.name}`);
189✔
215
                  if (matches && rule.target) {
189✔
216
                    matches = await checkHierarchicalScope(rule.target, request, this.urns, this, this.logger);
111✔
217
                  }
111✔
218

189✔
219
                  try {
189✔
220
                    if (matches && rule.condition?.length) {
189✔
221
                      // context query is only checked when a rule exists
5✔
222
                      let context: any;
5✔
223
                      if (
5✔
224
                        this.resourceAdapter
5✔
225
                        && (
5✔
226
                          rule.context_query?.filters?.length
3✔
227
                          || rule.context_query?.query?.length
3!
228
                        )
5✔
229
                      ) {
5✔
230
                        context = await this.pullContextResources(rule.context_query, request);
2✔
231

2✔
232
                        if (_.isNil(context)) {
2!
233
                          this.logger.debug('Context query response is empty!');
×
234
                          return {  // deny by default
×
235
                            decision: Response_Decision.DENY,
×
236
                            obligations,
×
237
                            evaluation_cacheable,
×
238
                            operation_status: {
×
239
                              code: 200,
×
240
                              message: 'success'
×
241
                            }
×
242
                          };
×
243
                        }
×
244
                      }
2✔
245

5✔
246
                      request.context = context ?? request.context;
5✔
247
                      this.logger.debug('Validating rule condition', { name: rule.name, condition: rule.condition });
5✔
248
                      matches = conditionMatches(rule.condition, request);
5✔
249
                      this.logger.debug('condition validation response', { matches });
5✔
250
                    }
5✔
251
                  } catch (err: any) {
189!
252
                    this.logger.error('Caught an exception while applying rule condition to request', { code: err.code, message: err.message, stack: err.stack });
×
253
                    return {  // if an exception is caught deny by default
×
254
                      decision: Response_Decision.DENY,
×
255
                      obligations,
×
256
                      evaluation_cacheable,
×
257
                      operation_status: {
×
258
                        code: err.code ? err.code : 500,
×
259
                        message: err.message
×
260
                      }
×
261
                    };
×
262
                  }
×
263

189✔
264
                  // check if request has an ACL property set, if so verify it with the current rule target
189✔
265
                  if (matches && rule?.target) {
189✔
266
                    matches = await verifyACLList(rule.target, request, this.urns, this, this.logger);
100✔
267
                  }
100✔
268

189✔
269
                  if (matches && policySubjectMatch) {
189✔
270
                    if (!evaluationCacheableRule) {
172✔
271
                      evaluation_cacheable = evaluationCacheableRule;
172✔
272
                    }
172✔
273
                    ruleEffects.push({ effect: rule.effect, evaluation_cacheable });
172✔
274
                  }
172✔
275
                }
189✔
276
              }
404✔
277

145✔
278
              if (ruleEffects?.length > 0) {
145✔
279
                policyEffects.push(this.decide(policy.combining_algorithm, ruleEffects));
113✔
280
              }
113✔
281
            }
145✔
282
          }
146✔
283
        }
221✔
284

114✔
285
        if (policyEffects?.length > 0) {
114✔
286
          effect = this.decide(policySet.combining_algorithm, policyEffects);
100✔
287
        }
100✔
288
      }
114✔
289
    }
121✔
290

107✔
291
    if (!effect) {
107✔
292
      this.logger.silly('Access response is INDETERMINATE');
7✔
293
      return {
7✔
294
        decision: Response_Decision.INDETERMINATE,
7✔
295
        obligations,
7✔
296
        evaluation_cacheable: undefined,
7✔
297
        operation_status: {
7✔
298
          code: 200,
7✔
299
          message: 'success'
7✔
300
        }
7✔
301
      };
7✔
302
    }
7✔
303

100✔
304
    let decision: Response_Decision;
100✔
305
    decision = Response_Decision[effect.effect] || Response_Decision.INDETERMINATE;
107!
306

107✔
307
    this.logger.silly('Access response is', decision);
107✔
308
    return {
107✔
309
      decision,
107✔
310
      obligations,
107✔
311
      evaluation_cacheable: effect.evaluation_cacheable,
107✔
312
      operation_status: {
107✔
313
        code: 200,
107✔
314
        message: 'success'
107✔
315
      }
107✔
316
    };
107✔
317
  }
107✔
318

12✔
319
  async whatIsAllowed(request: Request): Promise<ReverseQuery> {
12✔
320
    let policySets: PolicySetRQ[] = [];
24✔
321
    let context = (request as any).context as ContextWithSubResolved;
24✔
322
    if (context?.subject?.token) {
24!
323
      const subject = await this.userService.findByToken({ token: context.subject.token });
×
324
      if (subject?.payload) {
×
325
        context.subject.id = subject.payload.id;
×
326
        (context.subject as any).tokens = subject.payload.tokens;
×
327
        context.subject.role_associations = subject.payload.role_associations;
×
328
      }
×
329
    }
×
330
    let obligations: Attribute[] = [];
24✔
331
    for (let [, value] of this.policySets) {
24✔
332
      let pSet: PolicySetRQ;
24✔
333
      if (
24✔
334
        _.isEmpty(value.target)
24✔
335
        || await this.targetMatches(value.target, request, 'whatIsAllowed', obligations)
24!
336
      ) {
24✔
337
        pSet = _.merge({}, { combining_algorithm: value.combining_algorithm }, _.pick(value, ['id', 'target', 'effect'])) as any;
24✔
338
        pSet.policies = [];
24✔
339

24✔
340
        let exactMatch = false;
24✔
341
        let policyEffect: Effect;
24✔
342
        for (let [, policy] of value.combinables) {
24✔
343
          if (policy.effect) {
37!
344
            policyEffect = policy.effect;
×
345
          } else if (policy?.combining_algorithm) {
37✔
346
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
37✔
347
            if (method === 'permitOverrides') {
37!
348
              policyEffect = Effect.PERMIT;
×
349
            } else if (method === 'denyOverrides') {
37!
350
              policyEffect = Effect.DENY;
×
351
            }
×
352
          }
37✔
353
          if (!!policy.target && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect)) {
37✔
354
            exactMatch = true;
10✔
355
            break;
10✔
356
          }
10✔
357
        }
37✔
358

24✔
359
        // if there are multiple entities in the request.target.resources
24✔
360
        // and if exactMatch is true, then check again with the resourcesAttributeMatch providing one entity each time
24✔
361
        // to ensure there is an exact policy entity match for each of the requested entity
24✔
362
        const entityURN = this.urns.get('entity');
24✔
363
        if (exactMatch && request?.target?.resources?.filter(att => att?.id === entityURN)?.length > 1) {
24✔
364
          exactMatch = this.checkMultipleEntitiesMatch(value, request, obligations);
10✔
365
        }
10✔
366

24✔
367
        for (let [, policy] of value.combinables) {
24✔
368
          let policyRQ: PolicyRQ;
40✔
369
          if (!policy) {
40!
370
            this.logger.debug('Policy Object not set');
×
371
            continue;
×
372
          }
×
373
          if (_.isEmpty(policy.target)
40✔
374
            || (exactMatch && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect))
40✔
375
            || (!exactMatch && await this.targetMatches(policy.target, request, 'whatIsAllowed', obligations, policyEffect, true))) {
40✔
376
            policyRQ = _.merge({}, { combining_algorithm: policy.combining_algorithm }, _.pick(policy, ['id', 'target', 'effect', 'evaluation_cacheable'])) as any;
34✔
377
            policyRQ.rules = [];
34✔
378

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

34✔
381
            for (let [, rule] of policy.combinables) {
34✔
382
              if (!rule) {
116!
383
                this.logger.debug('Rule Object not set');
×
384
                continue;
×
385
              }
×
386
              let ruleRQ: RuleRQ;
116✔
387
              this.logger.debug(`WhatIsAllowed Checking rule target and request target for ${rule.name}`);
116✔
388
              let matches = _.isEmpty(rule.target) || await this.targetMatches(rule.target, request, 'whatIsAllowed', obligations, rule.effect);
116✔
389
              // check for regex if there is no direct match
94✔
390
              if (!matches) {
116✔
391
                matches = await this.targetMatches(rule.target, request, 'whatIsAllowed', obligations, rule.effect, true);
54✔
392
              }
54✔
393

116✔
394
              if (_.isEmpty(rule.target) || matches) {
116✔
395
                ruleRQ = _.merge({}, { context_query: rule.context_query }, _.pick(rule, ['id', 'target', 'effect', 'condition', 'evaluation_cacheable']));
62✔
396
                policyRQ.rules.push(ruleRQ);
62✔
397
              }
62✔
398
            }
116✔
399
            if (!!policyRQ.effect || (!policyRQ.effect && !_.isEmpty(policyRQ.rules))) {
34✔
400
              pSet.policies.push(policyRQ);
34✔
401
            }
34✔
402
          }
34✔
403
        }
40✔
404
        if (!_.isEmpty(pSet.policies)) {
24✔
405
          policySets.push(pSet);
24✔
406
        }
24✔
407
      }
24✔
408
    }
24✔
409
    return {
24✔
410
      policy_sets: policySets, obligations, operation_status: {
24✔
411
        code: 200,
24✔
412
        message: 'success'
24✔
413
      }
24✔
414
    };
24✔
415
  }
24✔
416

12✔
417
  private checkMultipleEntitiesMatch(policySet: PolicySetWithCombinables, request: Request, obligation: Attribute[]): boolean {
12✔
418
    let multipleEntitiesMatch = false;
23✔
419
    let exactMatch = true;
23✔
420
    // iterate and find for each of the exact mathing resource attribute
23✔
421
    const entityURN = this.urns.get('entity');
23✔
422
    for (let requestAttributeObj of request?.target?.resources || []) {
23!
423
      if (requestAttributeObj.id === entityURN) {
59✔
424
        multipleEntitiesMatch = false;
29✔
425
        for (let [, policyValue] of policySet.combinables) {
29✔
426
          const policy: Policy = policyValue;
58✔
427
          let policyEffect: Effect;
58✔
428
          if (policy.effect) {
58!
429
            policyEffect = policy.effect;
×
430
          } else if (policy.combining_algorithm) {
58✔
431
            const method = this.combiningAlgorithms.get(policy.combining_algorithm);
58✔
432
            if (method === 'permitOverrides') {
58!
433
              policyEffect = Effect.PERMIT;
×
434
            } else if (method === 'denyOverrides') {
58!
435
              policyEffect = Effect.DENY;
×
436
            }
×
437
          }
58✔
438
          if (policy?.target?.resources?.length > 0) {
58✔
439
            if (this.resourceAttributesMatch(policy?.target?.resources, [requestAttributeObj], 'isAllowed', obligation, policyEffect)) {
41✔
440
              multipleEntitiesMatch = true;
12✔
441
            }
12✔
442
          }
41✔
443
        }
58✔
444
        if (!multipleEntitiesMatch) {
29✔
445
          exactMatch = false; // reset exact match even if one of the multiple entities exact match is not found
17✔
446
          break;
17✔
447
        }
17✔
448
      }
29✔
449
    }
59✔
450
    return exactMatch;
23✔
451
  }
23✔
452

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

93✔
530
            // request entity, get reqNS and requestEntityValue for request
93✔
531
            let reqValue = requestAttribute?.value;
93✔
532
            requestEntityURN = reqValue;
93✔
533
            const reqAttributeNS = reqValue?.substring(0, reqValue?.lastIndexOf(':'));
93✔
534
            const ruleAttributeNS = value?.substring(0, value?.lastIndexOf(':'));
93✔
535
            // verify namespace before entity name
93✔
536
            if (reqAttributeNS != ruleAttributeNS) {
93!
537
              entityMatch = false;
×
538
            }
×
539
            let reqPattern = reqValue?.substring(reqValue?.lastIndexOf(':') + 1);
93✔
540
            let reqNSEntityArray = reqPattern?.split('.');
93✔
541
            // firstElement could be either entity or namespace
93✔
542
            let reqNSOrEntity = reqNSEntityArray[0];
93✔
543
            let requestEntityValue = reqNSEntityArray[reqNSEntityArray?.length - 1];
93✔
544
            if (reqNSOrEntity?.toUpperCase() != requestEntityValue?.toUpperCase()) {
93✔
545
              // request name space is present
3✔
546
              reqNS = reqNSOrEntity.toUpperCase();
3✔
547
            }
3✔
548

93✔
549
            if ((reqNS && ruleNS && (reqNS === ruleNS)) || (!reqNS && !ruleNS)) {
93!
550
              const reExp = new RegExp(entityRegexValue);
90✔
551
              if (requestEntityValue.match(reExp)) {
90✔
552
                entityMatch = true;
34✔
553
              }
34✔
554
            }
90✔
555
          } else if (entityMatch && requestAttribute?.id === propertyURN && ruleAttribute?.id === propertyURN) {
391✔
556
            // check for matching URN property value
37✔
557
            const rulePropertyValue = ruleAttribute?.value?.substring(ruleAttribute?.value?.lastIndexOf('#') + 1);
37✔
558
            const requestPropertyValue = requestAttribute?.value?.substring(requestAttribute?.value?.lastIndexOf('#') + 1);
37✔
559
            if (rulePropertyValue === requestPropertyValue) {
37✔
560
              propertyMatch = true;
8✔
561
            }
8✔
562
          }
37✔
563
        }
391✔
564
      }
2,184✔
565

1,551✔
566
      if (operation === 'isAllowed' && effect === Effect.DENY && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,551✔
567
        && entityMatch && rulePropertiesExist && propertyMatch) {
1,551✔
568
        skipDenyRule = false; // Deny effect rule to be skipped only if the propertyMatch and effect is DENY
3✔
569
      }
3✔
570

1,551✔
571
      // if no match is found for the request attribute property in rule ==> this implies this is
1,551✔
572
      // an additional property in request which should be denied or masked
1,551✔
573
      if (operation === 'isAllowed' && effect === Effect.PERMIT && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,551✔
574
        && entityMatch && rulePropertiesExist && !propertyMatch) {
1,551✔
575
        return false;
20✔
576
      }
20✔
577

1,531✔
578
      // for whatIsAllowed if decision is PERMIT and propertyMatch to false it implies
1,531✔
579
      // subject has requested additional properties requestAttribute.value add it to the maksPropertyList
1,531✔
580
      if (operation === 'whatIsAllowed' && effect === Effect.PERMIT && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,551✔
581
        && entityMatch && rulePropertiesExist && !propertyMatch) {
1,551✔
582
        if (!requestPropertiesExist) {
9✔
583
          return false; // since its not possible to evaluate what properties subject would read
6✔
584
        }
6✔
585
        // since there can be multiple rules for same entity below check is to find if maskPropertyList already
3✔
586
        // contains the entityValue from previous matching rule
3✔
587
        const maskPropExists = maskPropertyList?.find((maskObj) => maskObj?.value === requestEntityURN);
9✔
588
        // for masking if no request properties are specified
9✔
589
        let maskProperty;
9✔
590
        if (requestPropertiesExist && requestAttribute?.value) {
9✔
591
          maskProperty = requestAttribute?.value;
3✔
592
        } else if (!requestPropertiesExist) {
9!
593
          maskProperty = rulePropertyValue;
×
594
        }
×
595
        if (maskProperty?.indexOf('#') <= -1) { // validate maskPropertyURN value
9!
596
          continue;
×
597
        }
×
598
        if (!maskPropExists) {
3✔
599
          maskPropertyList.push({ id: entityURN, value: requestEntityURN, attributes: [{ id: maskedPropertyURN, value: maskProperty, attributes: [] }] });
3✔
600
        } else {
9!
601
          maskPropExists.attributes.push({ id: maskedPropertyURN, value: maskProperty, attributes: [] });
×
602
        }
×
603
      }
9✔
604

1,525✔
605
      // for whatIsAllowed if decision is deny and propertyMatch to true it implies
1,525✔
606
      // subject does not have access to the requestAttribute.value add it to the maksPropertyList
1,525✔
607
      // last condition (propertyMatch || !requestPropertiesExist) -> is to match Deny rule when user does not provide any req props
1,525✔
608
      if (operation === 'whatIsAllowed' && effect === Effect.DENY && (requestAttribute?.id === propertyURN || !requestPropertiesExist)
1,551✔
609
        && entityMatch && rulePropertiesExist && (propertyMatch || !requestPropertiesExist)) {
1,551✔
610
        // since there can be multiple rules for same entity below check is to find if maskPropertyList already
11✔
611
        // contains the entityValue from previous matching rule
11✔
612
        const maskPropExists = maskPropertyList?.find((maskObj) => maskObj.value === requestEntityURN);
11✔
613
        let maskProperty;
11✔
614
        // for masking if no request properties are specified
11✔
615
        if (requestPropertiesExist && requestAttribute?.value) {
11✔
616
          maskProperty = requestAttribute.value;
3✔
617
        } else if (!requestPropertiesExist) {
11✔
618
          maskProperty = rulePropertyValue;
8✔
619
        }
8✔
620
        if (maskProperty?.indexOf('#') <= -1) { // validate maskPropertyURN value
11!
621
          continue;
×
622
        }
×
623
        if (!maskPropExists) {
11✔
624
          maskPropertyList.push({ id: entityURN, value: requestEntityURN, attributes: [{ id: maskedPropertyURN, value: maskProperty, attributes: [] }] });
6✔
625
        } else {
11✔
626
          maskPropExists.attributes.push({ id: maskedPropertyURN, value: maskProperty, attributes: [] });
5✔
627
        }
5✔
628
      }
11✔
629
    }
1,551✔
630

426✔
631
    // skip deny rule property is effective only if ruleProps exist and requestProps exist
426✔
632
    if (skipDenyRule && rulePropertiesExist && requestPropertiesExist && effect === Effect.DENY &&
468✔
633
      operation === 'isAllowed' && !propertyMatch) {
468✔
634
      return false;
8✔
635
    }
8✔
636

418✔
637
    // if there is no entity or no operation match return false
418✔
638
    if (!entityMatch && !operationMatch) {
468✔
639
      return false;
149✔
640
    }
149✔
641
    return true;
269✔
642
  }
269✔
643

12✔
644
  /**
12✔
645
 * Check if a request's target matches a rule, policy or policy set's target.
12✔
646
 * @param targetA
12✔
647
 * @param targetB
12✔
648
 */
12✔
649
  private async targetMatches(ruleTarget: Target, request: Request,
12✔
650
    operation: AccessControlOperation = 'isAllowed', maskPropertyList: Attribute[],
932✔
651
    effect: Effect = Effect.PERMIT, regexMatch?: boolean): Promise<boolean> {
932✔
652
    const requestTarget = request.target;
932✔
653
    const subMatch = await this.checkSubjectMatches(ruleTarget.subjects, requestTarget.subjects, request);
932✔
654
    const match = subMatch && this.attributesMatch(ruleTarget.actions, requestTarget.actions);
932✔
655
    if (!match) {
932✔
656
      return false;
505✔
657
    }
505✔
658
    return this.resourceAttributesMatch(ruleTarget.resources,
427✔
659
      requestTarget.resources, operation, maskPropertyList, effect, regexMatch);
427✔
660
  }
427✔
661

12✔
662
  /**
12✔
663
   * Check if the attributes of a action or resources from a rule, policy
12✔
664
   * or policy set match the attributes from a request.
12✔
665
   *
12✔
666
   * @param ruleAttributes
12✔
667
   * @param requestAttributes
12✔
668
   */
12✔
669
  private attributesMatch(ruleAttributes: Attribute[], requestAttributes: Attribute[]): boolean {
12✔
670
    for (let attribute of ruleAttributes || []) {
778✔
671
      const id = attribute?.id;
524✔
672
      const value = attribute?.value;
524✔
673
      const match = !!requestAttributes?.find((requestAttribute) => {
524✔
674
        // return requestAttribute.id == id && requestAttribute.value == value;
695✔
675
        if (requestAttribute?.id == id && requestAttribute?.value == value) {
695✔
676
          return true;
210✔
677
        } else {
695✔
678
          return false;
485✔
679
        }
485✔
680
      });
524✔
681

524✔
682
      if (!match) {
524✔
683
        return false;
314✔
684
      }
314✔
685
    }
524✔
686
    return true;
464✔
687
  }
464✔
688

12✔
689
  async getRedisKey(key: string): Promise<any> {
12✔
690
    if (!key) {
22!
691
      this.logger.info('Key not defined');
×
692
      return;
×
693
    }
×
694
    const redisResponse = await this.redisClient.get(key);
22✔
695
    if (!redisResponse) {
22✔
696
      this.logger.info('Key does not exist', { key });
2✔
697
      return;
2✔
698
    }
2✔
699
    if (redisResponse) {
20✔
700
      this.logger.debug('Found key in cache: ' + key);
20✔
701
      return JSON.parse(redisResponse);
20✔
702
    }
20✔
703
  }
22✔
704

12✔
705
  async evictHRScopes(subID: string): Promise<void> {
12✔
706
    const key = `cache:${subID}:*`;
×
707
    const matchingKeys = await this.redisClient.keys(key);
×
708
    await this.redisClient.del(matchingKeys);
×
709
    this.logger.debug('Evicted Subject cache: ' + key);
×
710
    return;
×
711
  }
×
712

12✔
713
  async setRedisKey(key: string, value: any): Promise<any> {
12✔
714
    if (!key || !value) {
4!
715
      this.logger.info(`Either key or value for redis set is not defined key: ${key} value: ${value}`);
×
716
      return;
×
717
    }
×
718
    return await this.redisClient.set(key, value);
4✔
719
  }
4✔
720

12✔
721
  async createHRScope(context: ContextWithSubResolved): Promise<ContextWithSubResolved> {
12✔
722
    if (context && !context.subject) {
20!
723
      context.subject = {};
×
724
    }
×
725
    const token = context.subject.token;
20✔
726
    const subjectID = context.subject.id;
20✔
727
    const subjectTokens = context.subject.tokens;
20✔
728
    const tokenFound = _.find(subjectTokens ?? [], { token });
20!
729
    let redisHRScopesKey;
20✔
730
    if (tokenFound?.interactive) {
20!
731
      redisHRScopesKey = `cache:${subjectID}:hrScopes`;
×
732
    }
×
733
    else if (tokenFound && !tokenFound.interactive) {
20✔
734
      redisHRScopesKey = `cache:${subjectID}:${token}:hrScopes`;
20✔
735
    }
20✔
736
    else {
×
737
      return context;
×
738
    }
×
739
    const timeout = this.cfg.get('authorization:hrReqTimeout') ?? 300000;
20✔
740
    const keyExist = await this.redisClient.exists(redisHRScopesKey);
20✔
741

20✔
742
    if (!keyExist) {
20✔
743
      const date = new Date().toISOString();
2✔
744
      const tokenDate = token + ':' + date;
2✔
745
      await this.userTopic.emit('hierarchicalScopesRequest', { token: tokenDate });
2✔
746
      this.waiting[tokenDate] = [];
2✔
747
      try {
2✔
748
        await new Promise((resolve, reject) => {
2✔
749
          const timeoutId = setTimeout(async () => {
2✔
750
            reject({ message: 'hr scope read timed out', tokenDate });
×
751
          }, timeout);
2✔
752
          this.waiting[tokenDate].push({ resolve, reject, timeoutId });
2✔
753
        });
2✔
754
        const subjectHRScopes = await this.getRedisKey(redisHRScopesKey);
2✔
755
        Object.assign(context.subject, { hierarchical_scopes: subjectHRScopes });
2✔
756
      } catch (err) {
2!
757
        // unhandled promise rejection for timeout
×
758
        this.logger.error(`Error creating Hierarchical scope for subject ${tokenDate}`);
×
759
      }
×
760
    } else {
20✔
761
      try {
18✔
762
        const subjectHRScopes = await this.getRedisKey(redisHRScopesKey);
18✔
763
        Object.assign(context.subject, { hierarchical_scopes: subjectHRScopes });
18✔
764
      } catch (err) {
18!
765
        this.logger.info(`Subject or HR Scope not persisted in redis in acs`);
×
766
      }
×
767
    }
18✔
768
    return context;
20✔
769
  }
20✔
770

12✔
771
  /**
12✔
772
   * Check if the Rule's Subject Role matches with atleast
12✔
773
   * one of the user role associations role value
12✔
774
   *
12✔
775
   * @param ruleAttributes
12✔
776
   * @param requestSubAttributes
12✔
777
   * @param request
12✔
778
   */
12✔
779
  private async checkSubjectMatches(ruleSubAttributes: Attribute[],
12✔
780
    requestSubAttributes: Attribute[], request: Request): Promise<boolean> {
932✔
781
    let context = (request as any)?.context as ContextWithSubResolved;
932✔
782
    // check if context subject_id contains HR scope if not make request 'createHierarchicalScopes'
932✔
783
    if (context?.subject?.token &&
932✔
784
      _.isEmpty(context.subject.hierarchical_scopes)) {
932✔
785
      context = await this.createHRScope(context);
20✔
786
    }
20✔
787
    // Just check the Role value matches here in subject
932✔
788
    const roleURN = this.urns.get('role');
932✔
789
    let ruleRole: string;
932✔
790
    if (!ruleSubAttributes || ruleSubAttributes.length === 0) {
932✔
791
      return true;
232✔
792
    }
232✔
793
    ruleSubAttributes?.forEach((subjectObject) => {
932✔
794
      if (subjectObject?.id === roleURN) {
1,154✔
795
        ruleRole = subjectObject?.value;
529✔
796
      }
529✔
797
    });
932✔
798

932✔
799
    // must be a rule subject targetted to specific user
932✔
800
    if (!ruleRole && this.attributesMatch(ruleSubAttributes, requestSubAttributes)) {
932✔
801
      this.logger.debug('Rule subject targetted to specific user', ruleSubAttributes);
37✔
802
      return true;
37✔
803
    }
37✔
804

663✔
805
    if (!ruleRole) {
932✔
806
      this.logger.warn(`Subject does not match with rule attributes`, ruleSubAttributes);
134✔
807
      return false;
134✔
808
    }
134✔
809
    if (!context?.subject?.role_associations) {
932✔
810
      this.logger.warn('Subject role associations missing', ruleSubAttributes);
8✔
811
      return false;
8✔
812
    }
8✔
813
    return context?.subject?.role_associations?.some((roleObj) => roleObj?.role === ruleRole);
932✔
814
  }
932✔
815

12✔
816
  /**
12✔
817
   * A list of rules or policies provides a list of Effects.
12✔
818
   * This method is invoked to evaluate the final effect
12✔
819
   * according to a combining algorithm
12✔
820
   * @param combiningAlgorithm
12✔
821
   * @param effects
12✔
822
   */
12✔
823
  private decide(combiningAlgorithm: string, effects: EffectEvaluation[]): EffectEvaluation {
12✔
824
    if (this.combiningAlgorithms.has(combiningAlgorithm)) {
213✔
825
      return this.combiningAlgorithms.get(combiningAlgorithm).apply(this, [effects]);
213✔
826
    }
213✔
827

×
828
    throw new errors.InvalidCombiningAlgorithm(combiningAlgorithm);
×
829
  }
×
830

12✔
831
  // Combining algorithms
12✔
832

12✔
833
  /**
12✔
834
  * Always DENY if DENY exists;
12✔
835
  * @param effects
12✔
836
  */
12✔
837
  protected denyOverrides(effects: EffectEvaluation[]): EffectEvaluation {
12✔
838
    let effect, evaluation_cacheable;
55✔
839
    for (let effectObj of effects || []) {
55!
840
      if (effectObj.effect === Effect.DENY) {
70✔
841
        effect = effectObj.effect;
26✔
842
        evaluation_cacheable = effectObj.evaluation_cacheable;
26✔
843
        break;
26✔
844
      } else {
70✔
845
        effect = effectObj.effect;
44✔
846
        evaluation_cacheable = effectObj.evaluation_cacheable;
44✔
847
      }
44✔
848
    }
70✔
849
    return {
55✔
850
      effect,
55✔
851
      evaluation_cacheable
55✔
852
    };
55✔
853
  }
55✔
854

12✔
855
  /**
12✔
856
   * Always PERMIT if PERMIT exists;
12✔
857
   * @param effects
12✔
858
   */
12✔
859
  protected permitOverrides(effects: EffectEvaluation[]): EffectEvaluation {
12✔
860
    let effect, evaluation_cacheable;
157✔
861
    for (let effectObj of effects || []) {
157!
862
      if (effectObj?.effect === Effect.PERMIT) {
160✔
863
        effect = effectObj.effect;
92✔
864
        evaluation_cacheable = effectObj.evaluation_cacheable;
92✔
865
        break;
92✔
866
      } else {
160✔
867
        effect = effectObj?.effect;
68✔
868
        evaluation_cacheable = effectObj.evaluation_cacheable;
68✔
869
      }
68✔
870
    }
160✔
871
    return {
157✔
872
      effect,
157✔
873
      evaluation_cacheable
157✔
874
    };
157✔
875
  }
157✔
876

12✔
877
  /**
12✔
878
   * Apply first effect which matches PERMIT or DENY.
12✔
879
   * Note that in a future implementation Effect may be extended to further values.
12✔
880
   * @param effects
12✔
881
   */
12✔
882
  protected firstApplicable(effects: EffectEvaluation[]): EffectEvaluation {
12✔
883
    return effects[0];
1✔
884
  }
1✔
885

12✔
886
  // in-memory resource handlers
12✔
887

12✔
888
  updatePolicySet(policySet: PolicySetWithCombinables): void {
12✔
889
    this.policySets.set(policySet.id, policySet);
25✔
890
  }
25✔
891

12✔
892
  removePolicySet(policySetID: string): void {
12✔
893
    this.policySets.delete(policySetID);
16✔
894
  }
16✔
895

12✔
896
  updatePolicy(policySetID: string, policy: PolicyWithCombinables): void {
12✔
897
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
25✔
898
    if (!_.isNil(policySet)) {
25✔
899
      policySet.combinables.set(policy.id, policy);
25✔
900
    }
25✔
901
  }
25✔
902

12✔
903
  removePolicy(policySetID: string, policyID: string): void {
12✔
904
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
1✔
905
    if (!_.isNil(policySet)) {
1✔
906
      policySet.combinables.delete(policyID);
1✔
907
    }
1✔
908
  }
1✔
909

12✔
910
  updateRule(policySetID: string, policyID: string, rule: Rule): void {
12✔
911
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
74✔
912
    if (!_.isNil(policySet)) {
74✔
913
      const policy: PolicyWithCombinables = policySet.combinables.get(policyID);
74✔
914
      if (!_.isNil(policy)) {
74✔
915
        policy.combinables.set(rule.id, rule);
74✔
916
      }
74✔
917
    }
74✔
918
  }
74✔
919

12✔
920
  removeRule(policySetID: string, policyID: string, ruleID: string): void {
12✔
921
    const policySet: PolicySetWithCombinables = this.policySets.get(policySetID);
3✔
922
    if (!_.isNil(policySet)) {
3✔
923
      const policy: PolicyWithCombinables = policySet.combinables.get(policyID);
3✔
924
      if (!_.isNil(policy)) {
3✔
925
        policy.combinables.delete(ruleID);
3✔
926
      }
3✔
927
    }
3✔
928
  }
3✔
929

12✔
930
  /**
12✔
931
   * Creates an adapter within the supported resource adapters.
12✔
932
   * @param adapterConfig
12✔
933
   */
12✔
934
  createResourceAdapter(adapterConfig: any): void {
12✔
935

6✔
936
    if (!_.isNil(adapterConfig.graphql)) {
6✔
937
      const opts = adapterConfig.graphql;
6✔
938
      this.resourceAdapter = new GraphQLAdapter(opts.url, this.logger, opts.clientOpts);
6✔
939
    } else {
6!
940
      throw new errors.UnsupportedResourceAdapter(adapterConfig);
×
941
    }
×
942
  }
6✔
943

12✔
944
  /**
12✔
945
   * Invokes adapter to pull necessary resources
12✔
946
   * and appends them to the request's context under the property `_queryResult`.
12✔
947
   * @param contextQuery A ContextQuery object.
12✔
948
   * @param context The request's context.
12✔
949
   */
12✔
950
  async pullContextResources(contextQuery: ContextQuery, request: Request): Promise<any> {
12✔
951
    const result = await this.resourceAdapter.query(contextQuery, request);
2✔
952

2✔
953
    return _.merge({}, context, {
2✔
954
      _queryResult: result
2✔
955
    });
2✔
956
  }
2✔
957
}
12✔
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