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

restorecommerce / access-control-srv / 8662724573

12 Apr 2024 01:18PM UTC coverage: 76.596% (-0.8%) from 77.425%
8662724573

Pull #322

github

Arun-KumarH
fix: fix subject with HR scope matching on Policy target
Pull Request #322: Acs make scope optional

496 of 663 branches covered (74.81%)

Branch coverage included in aggregate %.

126 of 127 new or added lines in 2 files covered. (99.21%)

11 existing lines in 1 file now uncovered.

2623 of 3409 relevant lines covered (76.94%)

56.17 hits per line

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

87.4
/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 { HierarchicalScope } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/auth.js';
1✔
12
import {
1✔
13
  UserServiceClient
1✔
14
} from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/user.js';
1✔
15

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

12✔
722
  async createHRScope(context: ContextWithSubResolved): Promise<ContextWithSubResolved> {
12✔
723
    if (context && !context.subject) {
11!
724
      context.subject = {};
×
725
    }
×
726
    const token = context.subject.token;
11✔
727
    const subjectID = context.subject.id;
11✔
728
    const subjectTokens = context.subject.tokens;
11✔
729
    const tokenFound = _.find(subjectTokens ?? [], { token });
11!
730
    let redisHRScopesKey;
11✔
731
    if (tokenFound?.interactive) {
11!
732
      redisHRScopesKey = `cache:${subjectID}:hrScopes`;
×
733
    } else if (tokenFound && !tokenFound.interactive) {
11✔
734
      redisHRScopesKey = `cache:${subjectID}:${token}:hrScopes`;
11✔
735
    }
11✔
736
    let timeout = this.cfg.get('authorization:hrReqTimeout');
11✔
737
    if (!timeout) {
11✔
738
      timeout = 300000;
11✔
739
    }
11✔
740
    let hrScopes: any;
11✔
741
    try {
11✔
742
      hrScopes = await this.getRedisKey(redisHRScopesKey);
11✔
743
    } catch (err) {
11!
744
      this.logger.info(`Subject or HR Scope not persisted in redis in acs`);
×
745
    }
×
746
    let keyExist;
11✔
747
    if (redisHRScopesKey) {
11✔
748
      keyExist = await this.redisClient.exists(redisHRScopesKey);
11✔
749
    }
11✔
750
    if (!keyExist) {
11✔
751
      const date = new Date().toISOString();
1✔
752
      const tokenDate = token + ':' + date;
1✔
753
      await this.userTopic.emit('hierarchicalScopesRequest', { token: tokenDate });
1✔
754
      this.waiting[tokenDate] = [];
1✔
755
      try {
1✔
756
        await new Promise((resolve, reject) => {
1✔
757
          const timeoutId = setTimeout(async () => {
1✔
758
            reject({ message: 'hr scope read timed out', tokenDate });
×
759
          }, timeout);
1✔
760
          this.waiting[tokenDate].push({ resolve, reject, timeoutId });
1✔
761
        });
1✔
762
      } catch (err) {
1!
763
        // unhandled promise rejection for timeout
×
764
        this.logger.error(`Error creating Hierarchical scope for subject ${tokenDate}`);
×
765
      }
×
766
      const subjectHRScopes = await this.getRedisKey(redisHRScopesKey);
1✔
767
      Object.assign(context.subject, { hierarchical_scopes: subjectHRScopes });
1✔
768
    } else {
11✔
769
      Object.assign(context.subject, { hierarchical_scopes: hrScopes });
10✔
770
    }
10✔
771
    return context;
11✔
772
  }
11✔
773

12✔
774
  /**
12✔
775
   * Check if the Rule's Subject Role matches with atleast
12✔
776
   * one of the user role associations role value
12✔
777
   *
12✔
778
   * @param ruleAttributes
12✔
779
   * @param requestSubAttributes
12✔
780
   * @param request
12✔
781
   */
12✔
782
  private async checkSubjectMatches(ruleSubAttributes: Attribute[],
12✔
783
    requestSubAttributes: Attribute[], request: Request): Promise<boolean> {
932✔
784
    // Just check the Role value matches here in subject
932✔
785
    const roleURN = this.urns.get('role');
932✔
786
    let ruleRole: string;
932✔
787
    if (!ruleSubAttributes || ruleSubAttributes.length === 0) {
932✔
788
      return true;
232✔
789
    }
232✔
790
    ruleSubAttributes?.forEach((subjectObject) => {
932✔
791
      if (subjectObject?.id === roleURN) {
1,154✔
792
        ruleRole = subjectObject?.value;
529✔
793
      }
529✔
794
    });
932✔
795

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

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

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

×
826
    throw new errors.InvalidCombiningAlgorithm(combiningAlgorithm);
×
827
  }
×
828

12✔
829
  // Combining algorithms
12✔
830

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

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

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

12✔
884
  // in-memory resource handlers
12✔
885

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

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

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

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

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

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

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

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

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

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