@cloud-copilot/iam-lens 0.1.74 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cjs/principalCan/permission.d.ts +50 -6
  2. package/dist/cjs/principalCan/permission.d.ts.map +1 -1
  3. package/dist/cjs/principalCan/permission.js +449 -353
  4. package/dist/cjs/principalCan/permission.js.map +1 -1
  5. package/dist/cjs/principalCan/permissionSet.d.ts.map +1 -1
  6. package/dist/cjs/principalCan/permissionSet.js +29 -11
  7. package/dist/cjs/principalCan/permissionSet.js.map +1 -1
  8. package/dist/cjs/principalCan/principalCan.d.ts.map +1 -1
  9. package/dist/cjs/principalCan/principalCan.js +87 -8
  10. package/dist/cjs/principalCan/principalCan.js.map +1 -1
  11. package/dist/cjs/principalCan/resources/resourceTypes/s3Buckets.d.ts +4 -0
  12. package/dist/cjs/principalCan/resources/resourceTypes/s3Buckets.d.ts.map +1 -1
  13. package/dist/cjs/principalCan/resources/resourceTypes/s3Buckets.js +59 -0
  14. package/dist/cjs/principalCan/resources/resourceTypes/s3Buckets.js.map +1 -1
  15. package/dist/cjs/principalCan/resources/statements.d.ts.map +1 -1
  16. package/dist/cjs/principalCan/resources/statements.js +3 -0
  17. package/dist/cjs/principalCan/resources/statements.js.map +1 -1
  18. package/dist/esm/principalCan/permission.d.ts +50 -6
  19. package/dist/esm/principalCan/permission.d.ts.map +1 -1
  20. package/dist/esm/principalCan/permission.js +446 -353
  21. package/dist/esm/principalCan/permission.js.map +1 -1
  22. package/dist/esm/principalCan/permissionSet.d.ts.map +1 -1
  23. package/dist/esm/principalCan/permissionSet.js +29 -11
  24. package/dist/esm/principalCan/permissionSet.js.map +1 -1
  25. package/dist/esm/principalCan/principalCan.d.ts.map +1 -1
  26. package/dist/esm/principalCan/principalCan.js +89 -10
  27. package/dist/esm/principalCan/principalCan.js.map +1 -1
  28. package/dist/esm/principalCan/resources/resourceTypes/s3Buckets.d.ts +4 -0
  29. package/dist/esm/principalCan/resources/resourceTypes/s3Buckets.d.ts.map +1 -1
  30. package/dist/esm/principalCan/resources/resourceTypes/s3Buckets.js +58 -0
  31. package/dist/esm/principalCan/resources/resourceTypes/s3Buckets.js.map +1 -1
  32. package/dist/esm/principalCan/resources/statements.d.ts.map +1 -1
  33. package/dist/esm/principalCan/resources/statements.js +3 -0
  34. package/dist/esm/principalCan/resources/statements.js.map +1 -1
  35. package/package.json +1 -1
@@ -173,7 +173,7 @@ export class Permission {
173
173
  // 3. Attempt to combine conditions
174
174
  const condsA = this.conditions || {};
175
175
  const condsB = other.conditions || {};
176
- const mergedConds = mergeConditions(condsA, condsB);
176
+ const mergedConds = unionConditions(condsA, condsB);
177
177
  if (mergedConds === null) {
178
178
  return [this, other];
179
179
  }
@@ -379,7 +379,13 @@ export class Permission {
379
379
  }
380
380
  /**
381
381
  * Subtract a Deny permission from this Allow permission.
382
- * Returns an array of resulting Allow permissions (may be empty if fully denied).
382
+ *
383
+ * Returns the resulting permissions, this can be:
384
+ * - An empty array if the Allow is fully denied by the Deny
385
+ * - A modified Allow permission or multiple Allow permissions
386
+ * - It could also return the original Allow and Deny permission if subtraction cannot be expressed purely in Allow statements
387
+ *
388
+ * @param other the Deny permission to subtract
383
389
  */
384
390
  subtract(other) {
385
391
  // Only subtract Deny from Allow for the same service/action
@@ -390,50 +396,9 @@ export class Permission {
390
396
  // No subtraction applies
391
397
  return [this];
392
398
  }
393
- // Early exit: identical conditions and deny covers allow resources => fully denied
394
399
  const allowCondsNorm = normalizeConditionKeys(this.conditions || {});
395
400
  const denyCondsNorm = normalizeConditionKeys(other.conditions || {});
396
- if (JSON.stringify(allowCondsNorm) === JSON.stringify(denyCondsNorm)) {
397
- // If both have resource[] and deny resources include all allow resources
398
- if (this.resource && other.resource) {
399
- if (this.resource.every((a) => other.resource.some((d) => wildcardToRegex(d).test(a)))) {
400
- return [];
401
- }
402
- }
403
- // If both have notResource[] and deny.notResource excludes superset of allow.notResource
404
- if (this.notResource && other.notResource) {
405
- // Deny excludes everything allow excludes or more, so allow has no effective resources
406
- if (this.notResource.every((n) => other.notResource.includes(n))) {
407
- return [];
408
- }
409
- }
410
- }
411
- // When Deny has multiple condition OPERATORS, we need to create separate Allow permissions
412
- // for each inverted condition, since IAM can't express NOT(A AND B AND C) in one block.
413
- // However, multiple keys under the same operator can be inverted together.
414
- const denyConditions = other.conditions || {};
415
- const hasMultipleConditionOperators = Object.keys(denyConditions).length > 1;
416
- if (hasMultipleConditionOperators) {
417
- // Special case: If deny resource is a proper subset of allow resource, keep both statements separate
418
- // because it's impossible to merge them cleanly
419
- if (this.resource && other.resource) {
420
- const isDenySubsetOfAllow = other.resource.every((denyRes) => this.resource.some((allowRes) => allowRes !== denyRes && wildcardToRegex(allowRes).test(denyRes)));
421
- if (isDenySubsetOfAllow) {
422
- // Keep both the original allow and deny statements
423
- return [this, other];
424
- }
425
- }
426
- return this.subtractWithMultipleConditions(other);
427
- }
428
- // Handle simpler cases with single/no conditions using the existing logic
429
- // 1. Invert Deny conditions
430
- const inverted = invertConditions(denyConditions);
431
- // 2. Merge conditions: original Allow ∧ inverted Deny
432
- const allowConds = normalizeConditionKeys(this.conditions || {});
433
- const mergedConds = mergeComplementaryConditions(mergeConditions(allowConds, inverted) || {
434
- ...allowConds,
435
- ...inverted
436
- });
401
+ const conditionsMatch = JSON.stringify(allowCondsNorm) === JSON.stringify(denyCondsNorm);
437
402
  const allowResource = this.resource;
438
403
  const allowNotResource = this.notResource;
439
404
  const denyResource = other.resource;
@@ -441,350 +406,303 @@ export class Permission {
441
406
  const eff = this.effect;
442
407
  const svc = this.service;
443
408
  const act = this.action;
444
- const conds = Object.keys(mergedConds).length ? mergedConds : undefined;
445
409
  // Case: Allow.resource & Deny.resource
446
410
  if (allowResource !== undefined && denyResource !== undefined) {
447
- // If Deny has no conditions, subtract resources normally
448
- if (!other.conditions || Object.keys(other.conditions).length === 0) {
449
- const remaining = allowResource.filter((a) => !denyResource.some((d) => wildcardToRegex(d).test(a)));
450
- // we cannot express the subtraction in a single statement → keep both.
451
- const denyIsSubset = denyResource.every((d) => allowResource.some((a) => wildcardToRegex(a).test(d)));
452
- if (denyIsSubset && remaining.length === allowResource.length) {
453
- return [this, other];
411
+ const overlappingResources = allowResource.some((a) => {
412
+ return denyResource.some((d) => {
413
+ return wildcardToRegex(d).test(a) || wildcardToRegex(a).test(d);
414
+ });
415
+ });
416
+ // If the resources in the allow and deny do not overlap, return the allow as is
417
+ if (!overlappingResources) {
418
+ return [this];
419
+ }
420
+ // Categories for allows, a single allow could be more than one, because the deny could have multiple
421
+ // Without Conditions:
422
+ //1. Exactly the same as a deny - remove the allow
423
+ //2. A subset of a deny - remove the allow
424
+ //3. A superset of a deny - keep the allow and the deny
425
+ //4. No overlap with any deny - keep the allow as is
426
+ //
427
+ // With Conditions:
428
+ //1. Exactly the same as a deny - invert the conditions and keep the allow
429
+ //2. A subset of a deny - invert the conditions and keep the allow
430
+ //3. A superset of a deny - keep the allow and the deny
431
+ //4. No overlap with any deny - keep the allow as is
432
+ const allowMatches = [];
433
+ const allowSubsets = [];
434
+ const allowSupersets = [];
435
+ const allowNoOverlap = [];
436
+ const denySubsets = [];
437
+ for (const allowedResource of allowResource) {
438
+ let isMatch = false;
439
+ let isSubset = false;
440
+ let isSuperset = false;
441
+ for (const deniedResource of denyResource) {
442
+ if (deniedResource === allowedResource) {
443
+ isMatch = true;
444
+ break;
445
+ }
446
+ if (wildcardToRegex(deniedResource).test(allowedResource)) {
447
+ isSubset = true;
448
+ break;
449
+ }
450
+ if (wildcardToRegex(allowedResource).test(deniedResource)) {
451
+ isSuperset = true;
452
+ denySubsets.push(deniedResource);
453
+ }
454
+ }
455
+ if (isMatch) {
456
+ allowMatches.push(allowedResource);
454
457
  }
455
- if (remaining.length === 0)
456
- return [];
457
- return [new Permission(eff, svc, act, remaining, undefined, conds)];
458
+ else if (isSubset) {
459
+ allowSubsets.push(allowedResource);
460
+ }
461
+ else if (isSuperset) {
462
+ allowSupersets.push(allowedResource);
463
+ }
464
+ else {
465
+ allowNoOverlap.push(allowedResource);
466
+ }
467
+ }
468
+ const permissionsToReturn = [];
469
+ if (allowNoOverlap.length > 0) {
470
+ permissionsToReturn.push(new Permission(eff, svc, act, allowNoOverlap, undefined, this.conditions));
471
+ }
472
+ if (allowSupersets.length > 0) {
473
+ permissionsToReturn.push(new Permission(eff, svc, act, allowSupersets, undefined, this.conditions));
474
+ }
475
+ if (allowMatches.length > 0 || allowSubsets.length > 0) {
476
+ // If conditions are identical, these are fully dropped from the Allow. If not, they need to be kept with inverted conditions
477
+ if (!conditionsMatch) {
478
+ const newAllow = new Permission(eff, svc, act, [...allowMatches, ...allowSubsets], undefined, this.conditions);
479
+ permissionsToReturn.push(...applyDenyConditionsToAllow(newAllow, other));
480
+ }
481
+ }
482
+ if (denySubsets.length > 0) {
483
+ permissionsToReturn.push(new Permission('Deny', svc, act, denySubsets, undefined, other.conditions));
458
484
  }
459
- // Deny is conditional: do not remove resources, let condition inversion handle exclusion
460
- return [new Permission(eff, svc, act, allowResource, undefined, conds)];
485
+ return permissionsToReturn;
461
486
  }
462
487
  // Case: Allow.resource & Deny.notResource
463
- // Deny.notResource means: deny everything EXCEPT notResource patterns
464
- // So deny applies to resources NOT matching notResource patterns
488
+ // =======================================================================
489
+ // SEMANTICS:
490
+ // Deny.notResource means: "deny everything EXCEPT these patterns"
491
+ // So the deny APPLIES to resources that do NOT match denyNotResource patterns
492
+ // And the deny does NOT apply to resources that DO match denyNotResource patterns
493
+ // =======================================================================
465
494
  if (allowResource !== undefined && denyNotResource !== undefined) {
466
- // Check if allow resource patterns include/cover the deny notResource patterns
467
- // E.g., allow resource '*' includes deny notResource 'prod-*'
468
- // But we need to exclude the case where they're exactly the same
469
- const allowIncludesDenyNotResource = denyNotResource.some((dnr) => allowResource.some((ar) => ar !== dnr && wildcardToRegex(ar).test(dnr)));
470
- // Check if conditions are identical
471
- const allowCondsNorm = normalizeConditionKeys(this.conditions || {});
472
- const denyCondsNorm = normalizeConditionKeys(other.conditions || {});
473
- const conditionsMatch = JSON.stringify(allowCondsNorm) === JSON.stringify(denyCondsNorm);
474
- // If allow includes deny notResource and deny has different conditions, keep both statements
475
- // This is because the relationship is too complex to merge cleanly
476
- if (allowIncludesDenyNotResource &&
477
- !conditionsMatch &&
478
- other.conditions &&
479
- Object.keys(other.conditions).length > 0) {
480
- return [this, other];
481
- }
482
- // Split allow resources into two categories:
483
- // 1. Resources that match deny.notResource (deny doesn't apply - excluded from deny)
484
- // 2. Resources that don't match deny.notResource (deny applies)
485
- const resourcesExcludedFromDeny = [];
486
- const resourcesAffectedByDeny = [];
487
- for (const ar of allowResource) {
488
- const matchesDenyNotResource = denyNotResource.some((dnr) => wildcardToRegex(dnr).test(ar));
489
- if (matchesDenyNotResource) {
490
- resourcesExcludedFromDeny.push(ar);
495
+ // STEP 1: Categorize each allow resource based on relationship to denyNotResource patterns
496
+ //
497
+ // Categories:
498
+ // ExcludedFromDeny - Matches a denyNotResource pattern (deny doesn't apply to these)
499
+ // AffectedByDeny - Does NOT match any denyNotResource (deny applies to these)
500
+ // Superset - Covers (is broader than) a denyNotResource pattern
501
+ //
502
+ // Also track which denyNotResource patterns are covered by superset allow resources
503
+ const excludedFromDeny = [];
504
+ const affectedByDeny = [];
505
+ const supersets = [];
506
+ const coveredDenyNotResourcePatterns = [];
507
+ for (const allowedResource of allowResource) {
508
+ let isExcluded = false;
509
+ let isSuperset = false;
510
+ for (const deniedNotResource of denyNotResource) {
511
+ // Check if allowResource exactly matches or is covered by denyNotResource pattern
512
+ if (allowedResource === deniedNotResource ||
513
+ wildcardToRegex(deniedNotResource).test(allowedResource)) {
514
+ isExcluded = true;
515
+ break;
516
+ }
517
+ // Check if allowResource covers (is broader than) denyNotResource pattern
518
+ if (wildcardToRegex(allowedResource).test(deniedNotResource)) {
519
+ isSuperset = true;
520
+ if (!coveredDenyNotResourcePatterns.includes(deniedNotResource)) {
521
+ coveredDenyNotResourcePatterns.push(deniedNotResource);
522
+ }
523
+ }
524
+ }
525
+ if (isExcluded) {
526
+ excludedFromDeny.push(allowedResource);
527
+ }
528
+ else if (isSuperset) {
529
+ supersets.push(allowedResource);
491
530
  }
492
531
  else {
493
- resourcesAffectedByDeny.push(ar);
532
+ affectedByDeny.push(allowedResource);
494
533
  }
495
534
  }
496
- // If Deny has NO conditions
497
- if (!other.conditions || Object.keys(other.conditions).length === 0) {
498
- // Resources affected by deny are fully denied (removed)
499
- // Only resources excluded from deny survive
500
- // If allow.resource patterns are broader than deny.notResource,
501
- // narrow down to the deny.notResource patterns themselves
502
- if (resourcesExcludedFromDeny.length === 0 && resourcesAffectedByDeny.length > 0) {
503
- // Check if any allow resource is a superset of deny.notResource patterns
504
- const narrowedResources = denyNotResource.filter((dnr) => allowResource.some((ar) => wildcardToRegex(ar).test(dnr)));
505
- if (narrowedResources.length > 0) {
506
- return [new Permission(eff, svc, act, narrowedResources, undefined, conds)];
507
- }
508
- return [];
509
- }
510
- if (resourcesExcludedFromDeny.length === 0)
511
- return [];
512
- return [new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, conds)];
535
+ // STEP 2: Early exit - if all allow resources are excluded from deny, return unchanged
536
+ if (excludedFromDeny.length === allowResource.length) {
537
+ return [this];
513
538
  }
514
- // Deny has conditions - need to handle based on condition relationship
515
- // (allowCondsNorm, denyCondsNorm, and conditionsMatch already declared above)
516
- // Check if deny conditions are fully contained within allow conditions
517
- // This means every deny condition key-value is present in allow conditions
518
- const denyIsSubsetOfAllow = Object.entries(denyCondsNorm).every(([op, keyMap]) => {
519
- if (!allowCondsNorm[op])
520
- return false;
521
- return Object.entries(keyMap).every(([key, denyVals]) => {
522
- const allowVals = allowCondsNorm[op]?.[key];
523
- if (!allowVals)
524
- return false;
525
- // For StringEquals-like operators, all deny values must be in allow values
526
- return denyVals.every((dv) => allowVals.includes(dv));
527
- });
528
- });
529
- // Check if allow conditions are fully contained within deny conditions
530
- const allowIsSubsetOfDeny = Object.entries(allowCondsNorm).every(([op, keyMap]) => {
531
- if (!denyCondsNorm[op])
532
- return false;
533
- return Object.entries(keyMap).every(([key, allowVals]) => {
534
- const denyVals = denyCondsNorm[op]?.[key];
535
- if (!denyVals)
536
- return false;
537
- return allowVals.every((av) => denyVals.includes(av));
538
- });
539
- });
540
- // Case 1: Identical conditions
541
- if (conditionsMatch) {
542
- // Resources affected by deny are fully denied
543
- // Narrow to resources excluded from deny (or notResource patterns if broader)
544
- if (resourcesExcludedFromDeny.length > 0) {
545
- return [
546
- new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions)
547
- ];
548
- }
549
- // If all resources affected by deny, narrow to notResource patterns if possible
550
- const narrowedResources = denyNotResource.filter((dnr) => allowResource.some((ar) => wildcardToRegex(ar).test(dnr)));
551
- if (narrowedResources.length > 0) {
552
- return [new Permission(eff, svc, act, narrowedResources, undefined, this.conditions)];
553
- }
554
- return [];
539
+ const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
540
+ // STEP 3: Build output permissions by category
541
+ const permissionsToReturn = [];
542
+ // ExcludedFromDeny: Keep as-is with original conditions (deny doesn't touch these)
543
+ if (excludedFromDeny.length > 0) {
544
+ permissionsToReturn.push(new Permission(eff, svc, act, excludedFromDeny, undefined, this.conditions));
555
545
  }
556
- // Case 2: Deny conditions are superset of allow (or allow has no conditions)
557
- // This means all allow cases would be denied
558
- if (Object.keys(allowCondsNorm).length === 0 ||
559
- (allowIsSubsetOfDeny && !denyIsSubsetOfAllow)) {
560
- // All allow cases are covered by deny
561
- if (resourcesAffectedByDeny.length === 0) {
562
- // Deny doesn't apply, keep original allow
563
- return [new Permission(eff, svc, act, allowResource, undefined, this.conditions)];
564
- }
565
- // If allow has no conditions, only resourcesExcludedFromDeny can survive
566
- // For resources affected by deny with inverted conditions
567
- if (Object.keys(allowCondsNorm).length === 0) {
568
- const results = [];
569
- if (resourcesExcludedFromDeny.length > 0) {
570
- results.push(new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions));
571
- }
572
- if (resourcesAffectedByDeny.length > 0) {
573
- // Can only survive with inverted deny conditions
574
- results.push(new Permission(eff, svc, act, resourcesAffectedByDeny, undefined, conds));
575
- }
576
- return results.length > 0 ? results : [];
546
+ // Superset: Allow resource is broader than denyNotResource patterns
547
+ // - The covered denyNotResource patterns are excluded from deny (allow unconditionally)
548
+ // - The superset allow resources are affected by deny (apply inverted conditions)
549
+ if (supersets.length > 0) {
550
+ // First: Allow the covered patterns unconditionally (they're excluded from deny)
551
+ if (coveredDenyNotResourcePatterns.length > 0) {
552
+ permissionsToReturn.push(new Permission(eff, svc, act, coveredDenyNotResourcePatterns, undefined, this.conditions));
577
553
  }
578
- // If allow has conditions and deny is superset, it's a full deny for affected resources
579
- if (resourcesExcludedFromDeny.length > 0 && resourcesAffectedByDeny.length > 0) {
580
- // Only resources excluded from deny survive
581
- return [
582
- new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions)
583
- ];
554
+ // Second: Apply inverted deny conditions to the superset resources
555
+ if (denyHasConditions && !conditionsMatch) {
556
+ const supersetAllow = new Permission(eff, svc, act, supersets, undefined, this.conditions);
557
+ permissionsToReturn.push(...applyDenyConditionsToAllow(supersetAllow, other));
584
558
  }
585
- if (resourcesExcludedFromDeny.length > 0) {
586
- return [
587
- new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions)
588
- ];
559
+ // If no conditions or conditions match, the superset is fully denied (nothing to add)
560
+ }
561
+ // AffectedByDeny: These resources are hit by the deny
562
+ if (affectedByDeny.length > 0) {
563
+ // If there are no conditions on deny - these are fully denied (drop them)
564
+ // If the conditions match - these are fully denied (drop them)
565
+ if (denyHasConditions && !conditionsMatch) {
566
+ // Different conditions - keep with inverted deny conditions
567
+ const newAllow = new Permission(eff, svc, act, affectedByDeny, undefined, this.conditions);
568
+ permissionsToReturn.push(...applyDenyConditionsToAllow(newAllow, other));
589
569
  }
590
- // All resources affected by deny and deny conditions cover all allow conditions
591
- return [];
592
570
  }
593
- // Case 3: Allow conditions are superset of deny (some allow conditions would be denied)
594
- if (denyIsSubsetOfAllow && !allowIsSubsetOfDeny) {
595
- // Check if deny covers all of allow's matching condition values
596
- // If allow requires A AND B, but deny only requires A, and deny's A values cover all of allow's A values,
597
- // then it's a full deny (because any case matching A will be denied, regardless of B)
598
- let denyCoversAllMatchingValues = true;
599
- for (const [op, keyMap] of Object.entries(denyCondsNorm)) {
600
- for (const [key, denyVals] of Object.entries(keyMap)) {
601
- const allowVals = allowCondsNorm[op]?.[key];
602
- if (allowVals) {
603
- // Check if all allow values for this key are covered by deny values
604
- const allCovered = allowVals.every((av) => denyVals.includes(av));
605
- if (!allCovered) {
606
- denyCoversAllMatchingValues = false;
607
- break;
608
- }
609
- }
610
- }
611
- if (!denyCoversAllMatchingValues)
571
+ return permissionsToReturn;
572
+ }
573
+ // Scenario 3: Allow.notResource & Deny.resource
574
+ if (allowNotResource !== undefined && denyResource !== undefined) {
575
+ // STEP 1: Categorize relationships and track which denyResources are already covered
576
+ const coveredDenyResources = new Set(); // denyResources already excluded by allowNotResource
577
+ const uncoveredDenyResources = []; // denyResources that affect allowed resources
578
+ const subsetReplacements = [];
579
+ // For each denyResource, check if it's covered by any allowNotResource
580
+ for (const denyPattern of denyResource) {
581
+ let isCovered = false;
582
+ for (const allowPattern of allowNotResource) {
583
+ // ExactMatch or Superset - denyResource is already excluded
584
+ if (allowPattern === denyPattern || wildcardToRegex(allowPattern).test(denyPattern)) {
585
+ isCovered = true;
586
+ coveredDenyResources.add(denyPattern);
612
587
  break;
613
- }
614
- if (denyCoversAllMatchingValues) {
615
- // Deny covers all matching values, so it's a full deny for affected resources
616
- if (resourcesExcludedFromDeny.length > 0) {
617
- return [
618
- new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions)
619
- ];
620
588
  }
621
- return [];
622
589
  }
623
- // The deny condition is a subset of allow conditions
624
- // This means some values of allow conditions match the deny
625
- const results = [];
626
- // Resources excluded from deny always survive with original conditions
627
- if (resourcesExcludedFromDeny.length > 0) {
628
- results.push(new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions));
590
+ if (!isCovered) {
591
+ uncoveredDenyResources.push(denyPattern);
629
592
  }
630
- // Resources affected by deny: subtract the matching condition values
631
- if (resourcesAffectedByDeny.length > 0) {
632
- const remainingConds = {};
633
- let hasRemainingConditions = false;
634
- for (const [op, keyMap] of Object.entries(allowCondsNorm)) {
635
- for (const [key, allowVals] of Object.entries(keyMap)) {
636
- const denyVals = denyCondsNorm[op]?.[key];
637
- if (denyVals) {
638
- // Subtract deny values from allow values
639
- const remaining = allowVals.filter((av) => !denyVals.includes(av));
640
- if (remaining.length > 0) {
641
- if (!remainingConds[op])
642
- remainingConds[op] = {};
643
- remainingConds[op][key] = remaining;
644
- hasRemainingConditions = true;
645
- }
646
- }
647
- else {
648
- // Keep as is
649
- if (!remainingConds[op])
650
- remainingConds[op] = {};
651
- remainingConds[op][key] = allowVals;
652
- hasRemainingConditions = true;
653
- }
654
- }
655
- }
656
- if (hasRemainingConditions) {
657
- results.push(new Permission(eff, svc, act, resourcesAffectedByDeny, undefined, remainingConds));
593
+ }
594
+ // Check for Subset patterns (denyResource covers allowNotResource)
595
+ for (const allowPattern of allowNotResource) {
596
+ for (const denyPattern of denyResource) {
597
+ if (wildcardToRegex(denyPattern).test(allowPattern) && allowPattern !== denyPattern) {
598
+ subsetReplacements.push({ allowPattern, denyPattern });
658
599
  }
659
600
  }
660
- return results;
661
601
  }
662
- // Case 4: Different/non-overlapping conditions
663
- // For resources affected by deny, add inverted deny conditions to allow conditions
664
- const results = [];
665
- if (resourcesExcludedFromDeny.length > 0) {
666
- // Deny doesn't apply to these resources
667
- results.push(new Permission(eff, svc, act, resourcesExcludedFromDeny, undefined, this.conditions));
602
+ // Filter out subset deny patterns from uncoveredDenyResources to get true NoOverlap patterns
603
+ const subsetDenyPatternsSet = new Set(subsetReplacements.map((s) => s.denyPattern));
604
+ const noOverlapDenyResources = uncoveredDenyResources.filter((dr) => !subsetDenyPatternsSet.has(dr));
605
+ // STEP 2: If all denyResources are covered, deny has no effect
606
+ if (noOverlapDenyResources.length === 0 && subsetReplacements.length === 0) {
607
+ return [this];
668
608
  }
669
- if (resourcesAffectedByDeny.length > 0) {
670
- // Add inverted deny conditions to allow conditions
671
- results.push(new Permission(eff, svc, act, resourcesAffectedByDeny, undefined, conds));
609
+ // STEP 3: Build output permissions
610
+ const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
611
+ // Build the expanded notResource (original + noOverlap deny resources + subset replacements)
612
+ const subsetAllowPatterns = new Set(subsetReplacements.map((s) => s.allowPattern));
613
+ const keptPatterns = allowNotResource.filter((p) => !subsetAllowPatterns.has(p));
614
+ const subsetDenyPatterns = Array.from(new Set(subsetReplacements.map((s) => s.denyPattern)));
615
+ const expandedNotResource = Array.from(new Set([...keptPatterns, ...subsetDenyPatterns, ...noOverlapDenyResources]));
616
+ // Same conditions or no deny conditions: simply expand notResource
617
+ if (conditionsMatch || !denyHasConditions) {
618
+ return [new Permission(eff, svc, act, undefined, expandedNotResource, this.conditions)];
672
619
  }
673
- return results.length > 0 ? results : [];
674
- }
675
- // Case: Allow.notResource & Deny.resource
676
- if (allowNotResource !== undefined && denyResource !== undefined) {
677
- // If Deny is conditional, let conditions handle; keep original notResource
678
- if (other.conditions && Object.keys(other.conditions).length > 0) {
679
- return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
620
+ // Different conditions: handle Subset and NoOverlap cases separately
621
+ const permissionsToReturn = [];
622
+ const hasSubsetReplacements = subsetReplacements.length > 0;
623
+ const hasNoOverlapAdditions = noOverlapDenyResources.length > 0;
624
+ // Part 1: Original allowNotResource with inverted deny conditions
625
+ // (when deny condition is NOT met, original allow applies)
626
+ const originalAllow = new Permission(eff, svc, act, undefined, allowNotResource, this.conditions);
627
+ permissionsToReturn.push(...applyDenyConditionsToAllow(originalAllow, other));
628
+ // Part 2a: For SUBSET replacements - expanded notResource WITH deny conditions
629
+ // (replacing smaller exclusion with larger one, only valid when condition is met)
630
+ if (hasSubsetReplacements) {
631
+ // Build notResource with just the subset replacements (not the noOverlap additions)
632
+ const subsetExpandedNotResource = Array.from(new Set([...keptPatterns, ...subsetDenyPatterns]));
633
+ const subsetConditions = intersectConditions(this.conditions || {}, other.conditions || {});
634
+ permissionsToReturn.push(new Permission(eff, svc, act, undefined, subsetExpandedNotResource, subsetConditions || other.conditions));
680
635
  }
681
- // Check if every Deny resource is already excluded by allowNotResource
682
- const denyCovered = denyResource.every((dr) => allowNotResource.some((anr) => wildcardToRegex(anr).test(dr)));
683
- if (denyCovered) {
684
- // Deny adds no new exclusions; keep original
685
- return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
636
+ // Part 2b: For NO OVERLAP additions - expanded notResource WITHOUT conditions
637
+ // (adding new exclusion is always safe, no condition needed)
638
+ if (hasNoOverlapAdditions) {
639
+ permissionsToReturn.push(new Permission(eff, svc, act, undefined, expandedNotResource, this.conditions));
686
640
  }
687
- // Otherwise union the exclusions
688
- const newNot = Array.from(new Set([...allowNotResource, ...denyResource]));
689
- return [new Permission(eff, svc, act, undefined, newNot, conds)];
641
+ return permissionsToReturn;
690
642
  }
691
- // Case: Allow.notResource & Deny.notResource --> newNot = ANR \ DNR
643
+ // ═══════════════════════════════════════════════════════════════════════════
644
+ // SCENARIO 4: Allow.NotResource & Deny.NotResource
645
+ // ═══════════════════════════════════════════════════════════════════════════
646
+ //
647
+ // Semantics:
648
+ // Allow.notResource = [A]: allow ALL resources EXCEPT those matching A
649
+ // Deny.notResource = [D]: deny ALL resources EXCEPT those matching D
650
+ //
651
+ // The deny blocks everything except D (the "safe zone").
652
+ // The allow permits everything except A.
653
+ //
654
+ // Surviving resources = (allowed) ∩ (not denied)
655
+ // = (NOT in A) ∩ (in D)
656
+ // = resources in D that are not covered by A
657
+ //
658
+ // Result: resource: [D patterns not covered by A]
659
+ //
660
+ // ═══════════════════════════════════════════════════════════════════════════
692
661
  if (allowNotResource !== undefined && denyNotResource !== undefined) {
693
- // If Deny has conditions, skip list-based subtraction and rely on conditions only
694
- if (other.conditions && Object.keys(other.conditions).length > 0) {
695
- return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
696
- }
697
- const remainingNot = allowNotResource.filter((n) => !denyNotResource.some((dnr) => wildcardToRegex(dnr).test(n)));
698
- if (remainingNot.length === 0)
699
- return [];
700
- return [new Permission(eff, svc, act, undefined, remainingNot, conds)];
701
- }
702
- // This should never happen
703
- throw new Error('Permission.subtract: This should never happen—invalid state.');
704
- }
705
- /**
706
- * Handle subtraction when the Deny permission has multiple conditions.
707
- * Creates separate Allow permissions for each inverted condition.
708
- */
709
- subtractWithMultipleConditions(other) {
710
- const allowResource = this.resource;
711
- const allowNotResource = this.notResource;
712
- const denyResource = other.resource;
713
- const denyNotResource = other.notResource;
714
- const eff = this.effect;
715
- const svc = this.service;
716
- const act = this.action;
717
- // Determine resulting resource/notResource based on Allow vs Deny patterns
718
- let resultResource;
719
- let resultNotResource;
720
- // Case: Allow.resource & Deny.notResource --> The Deny excludes everything EXCEPT notResource
721
- // So the intersection is the items from Deny.notResource that match Allow.resource patterns
722
- if (allowResource !== undefined && denyNotResource !== undefined) {
723
- resultResource = denyNotResource.filter((dnr) => allowResource.some((ar) => wildcardToRegex(ar).test(dnr)));
724
- if (resultResource.length === 0)
662
+ const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
663
+ // Helper: Check if pattern A covers pattern B (A is superset of or equal to B)
664
+ const patternCovers = (a, b) => {
665
+ return a === b || wildcardToRegex(a).test(b);
666
+ };
667
+ // Find D patterns that survive (not covered by any A pattern)
668
+ // These are the resources that are both allowed AND protected from deny
669
+ const survivingResources = denyNotResource.filter((dPattern) => !allowNotResource.some((aPattern) => patternCovers(aPattern, dPattern)));
670
+ // If nothing survives, return empty
671
+ if (survivingResources.length === 0) {
725
672
  return [];
726
- }
727
- // Case: Allow.notResource & Deny.resource --> resultNotResource = ANR ∪ DR
728
- else if (allowNotResource !== undefined && denyResource !== undefined) {
729
- // Check if every Deny resource is already excluded by allowNotResource
730
- const denyCovered = denyResource.every((dr) => allowNotResource.some((anr) => wildcardToRegex(anr).test(dr)));
731
- if (denyCovered) {
732
- resultNotResource = allowNotResource;
733
673
  }
734
- else {
735
- resultNotResource = Array.from(new Set([...allowNotResource, ...denyResource]));
674
+ // Handle conditions
675
+ if (!denyHasConditions || conditionsMatch) {
676
+ // No deny conditions or same conditions: apply directly
677
+ return [new Permission(eff, svc, act, survivingResources, undefined, this.conditions)];
736
678
  }
679
+ // Different conditions: split into two parts
680
+ const permissionsToReturn = [];
681
+ // Part 1: When deny condition is NOT met → original allow applies
682
+ const originalAllow = new Permission(eff, svc, act, undefined, allowNotResource, this.conditions);
683
+ permissionsToReturn.push(...applyDenyConditionsToAllow(originalAllow, other));
684
+ // Part 2: When deny condition IS met → surviving resources with deny's condition
685
+ const denyConditionCount = Object.values(other.conditions || {}).reduce((sum, keyMap) => sum + Object.keys(keyMap).length, 0);
686
+ const part2Conditions = denyConditionCount === 1 ? other.conditions : undefined;
687
+ permissionsToReturn.push(new Permission(eff, svc, act, survivingResources, undefined, part2Conditions));
688
+ return permissionsToReturn;
737
689
  }
738
- // Case: Allow.resource & Deny.resource --> keep allow resource, conditions handle exclusion
739
- else if (allowResource !== undefined && denyResource !== undefined) {
740
- resultResource = allowResource;
741
- }
742
- // Case: Allow.notResource & Deny.notResource --> keep allow notResource, conditions handle exclusion
743
- else if (allowNotResource !== undefined && denyNotResource !== undefined) {
744
- resultNotResource = allowNotResource;
745
- }
746
- // Create separate Allow permissions for each condition key-value pair in Deny
747
- const results = [];
748
- const allowConds = normalizeConditionKeys(this.conditions || {});
749
- for (const [operator, keyMap] of Object.entries(other.conditions || {})) {
750
- for (const [contextKey, values] of Object.entries(keyMap)) {
751
- // Invert this specific condition
752
- const singleCondition = { [operator]: { [contextKey]: values } };
753
- const invertedCondition = invertConditions(singleCondition);
754
- // Merge with the original Allow conditions
755
- const mergedConds = { ...allowConds };
756
- for (const [invertedOp, invertedKeyMap] of Object.entries(invertedCondition)) {
757
- if (mergedConds[invertedOp]) {
758
- // Merge keys under the same operator
759
- for (const [invertedKey, invertedVals] of Object.entries(invertedKeyMap)) {
760
- if (mergedConds[invertedOp][invertedKey]) {
761
- // Union the values
762
- mergedConds[invertedOp][invertedKey] = Array.from(new Set([...mergedConds[invertedOp][invertedKey], ...invertedVals]));
763
- }
764
- else {
765
- mergedConds[invertedOp][invertedKey] = Array.from(invertedVals);
766
- }
767
- }
768
- }
769
- else {
770
- mergedConds[invertedOp] = { ...invertedKeyMap };
771
- }
772
- }
773
- // Apply complementary condition logic
774
- const finalMergedConds = mergeComplementaryConditions(mergedConds);
775
- const finalConds = Object.keys(finalMergedConds).length ? finalMergedConds : undefined;
776
- // Create a new Allow permission with this specific inverted condition
777
- results.push(new Permission(eff, svc, act, resultResource, resultNotResource, finalConds));
778
- }
779
- }
780
- return results;
690
+ // This should never happen
691
+ throw new Error('Permission.subtract: This should never happen—invalid state.');
781
692
  }
782
693
  }
783
694
  /**
784
- * Attempt to merge two condition‐maps. If they can be expressed as a single IAM condition block,
785
- * return that merged block. Otherwise, return null (indicating no single‐block merger is possible).
695
+ * Attempt to union two sets of permission conditions.
696
+ *
697
+ * If the conditions can be merged into a single block that allows all cases allowed by either,
698
+ * returns the merged conditions. If they cannot be merged cleanly (e.g., differing operators
699
+ * or incompatible numeric boundaries), returns null.
700
+ *
701
+ * @param a First set of conditions
702
+ * @param b Second set of conditions
703
+ * @returns Merged conditions or null if they cannot be merged
786
704
  */
787
- function mergeConditions(a, b) {
705
+ export function unionConditions(a, b) {
788
706
  // 1. If the set of operators in 'a' differs from the set in 'b', return null.
789
707
  a = normalizeConditionKeys(a);
790
708
  b = normalizeConditionKeys(b);
@@ -894,6 +812,142 @@ function mergeConditions(a, b) {
894
812
  }
895
813
  return merged;
896
814
  }
815
+ /**
816
+ * Intersect two sets of permission conditions.
817
+ *
818
+ * Attempt to find the intersection of two sets of IAM condition clauses. This will
819
+ * combine condition operators and context keys, retaining only values that satisfy
820
+ * both sets of conditions. If the intersection is empty or cannot be expressed
821
+ * cleanly, returns null.
822
+ *
823
+ * @param conditionsA First set of conditions
824
+ * @param conditionsB Second set of conditions
825
+ * @returns Intersected conditions or null if intersection is empty or cannot be expressed
826
+ */
827
+ export function intersectConditions(a, b) {
828
+ // Normalize both condition sets to lowercase operators and keys
829
+ const normalizedA = normalizeConditionKeys(a);
830
+ const normalizedB = normalizeConditionKeys(b);
831
+ // Collect all unique operators from both sides
832
+ const allOperators = Array.from(new Set([...Object.keys(normalizedA), ...Object.keys(normalizedB)]));
833
+ const result = {};
834
+ for (const operator of allOperators) {
835
+ const keysA = normalizedA[operator] || {};
836
+ const keysB = normalizedB[operator] || {};
837
+ // Collect all unique context keys for this operator
838
+ const allContextKeys = Array.from(new Set([...Object.keys(keysA), ...Object.keys(keysB)]));
839
+ result[operator] = {};
840
+ for (const contextKey of allContextKeys) {
841
+ const valsA = keysA[contextKey];
842
+ const valsB = keysB[contextKey];
843
+ // If key exists in both sides, apply intersection logic based on operator type
844
+ if (valsA !== undefined && valsB !== undefined) {
845
+ const intersectedValues = intersectValuesForOperator(operator, valsA, valsB);
846
+ if (intersectedValues === null) {
847
+ // Empty intersection means no overlap - return null
848
+ return null;
849
+ }
850
+ result[operator][contextKey] = intersectedValues;
851
+ }
852
+ else {
853
+ // Key only exists in one side - carry it through (both conditions must be satisfied)
854
+ result[operator][contextKey] = valsA !== undefined ? Array.from(valsA) : Array.from(valsB);
855
+ }
856
+ }
857
+ // Remove empty operator objects
858
+ if (Object.keys(result[operator]).length === 0) {
859
+ delete result[operator];
860
+ }
861
+ }
862
+ const merged = mergeComplementaryConditions(result);
863
+ // Check if any values array became empty after merging complementary conditions
864
+ // (e.g., StringEquals: ['a'] merged with StringNotEquals: ['a'] results in StringEquals: [])
865
+ for (const [, keyMap] of Object.entries(merged)) {
866
+ for (const [, values] of Object.entries(keyMap)) {
867
+ if (values.length === 0) {
868
+ return null;
869
+ }
870
+ }
871
+ }
872
+ return merged;
873
+ }
874
+ /**
875
+ * Intersect values for a specific operator type.
876
+ *
877
+ * Returns the intersected values, or null if the intersection is empty
878
+ * (meaning the conditions are mutually exclusive).
879
+ */
880
+ function intersectValuesForOperator(operator, valsA, valsB) {
881
+ const baseOp = conditionBaseOperator(operator);
882
+ switch (baseOp) {
883
+ // String/ARN equality operators: intersection of allowed values
884
+ case 'stringequals':
885
+ case 'stringlike':
886
+ case 'arnequals':
887
+ case 'arnlike': {
888
+ const common = valsA.filter((v) => valsB.includes(v));
889
+ return common.length > 0 ? common : null;
890
+ }
891
+ // String/ARN negation operators: union of exclusions (more restrictive)
892
+ case 'stringnotequals':
893
+ case 'stringnotlike':
894
+ case 'arnnotequals':
895
+ case 'arnnotlike': {
896
+ return Array.from(new Set([...valsA, ...valsB]));
897
+ }
898
+ // Numeric less-than operators: take the minimum (more restrictive)
899
+ case 'numericlessthan':
900
+ case 'numericlessthanequals': {
901
+ const numA = Number(valsA[0]);
902
+ const numB = Number(valsB[0]);
903
+ if (isNaN(numA) || isNaN(numB)) {
904
+ return null;
905
+ }
906
+ return [String(Math.min(numA, numB))];
907
+ }
908
+ // Numeric greater-than operators: take the maximum (more restrictive)
909
+ case 'numericgreaterthan':
910
+ case 'numericgreaterthanequals': {
911
+ const numA = Number(valsA[0]);
912
+ const numB = Number(valsB[0]);
913
+ if (isNaN(numA) || isNaN(numB)) {
914
+ return null;
915
+ }
916
+ return [String(Math.max(numA, numB))];
917
+ }
918
+ // Boolean operators: values must match exactly
919
+ case 'bool':
920
+ case 'null': {
921
+ if (valsA[0]?.toLowerCase() !== valsB[0]?.toLowerCase()) {
922
+ return null;
923
+ }
924
+ return [valsA[0]];
925
+ }
926
+ // IP address operators: intersection of CIDR blocks
927
+ case 'ipaddress':
928
+ case 'notipaddress': {
929
+ const common = valsA.filter((cidr) => valsB.includes(cidr));
930
+ return common.length > 0 ? common : null;
931
+ }
932
+ // Date less-than operators: take the earlier date (more restrictive)
933
+ case 'datelessthan':
934
+ case 'datelessthanequals': {
935
+ const dateA = valsA[0];
936
+ const dateB = valsB[0];
937
+ return [dateA < dateB ? dateA : dateB];
938
+ }
939
+ // Date greater-than operators: take the later date (more restrictive)
940
+ case 'dategreaterthan':
941
+ case 'dategreaterthanequals': {
942
+ const dateA = valsA[0];
943
+ const dateB = valsB[0];
944
+ return [dateA > dateB ? dateA : dateB];
945
+ }
946
+ // Unknown operator - cannot handle
947
+ default:
948
+ return null;
949
+ }
950
+ }
897
951
  /**
898
952
  * Checks if an IAM condition operator ends with "IfExists".
899
953
  *
@@ -1016,10 +1070,10 @@ function mergeComplementaryConditions(c) {
1016
1070
  datelessthanequals: 'dategreaterthan',
1017
1071
  dategreaterthan: 'datelessthanequals',
1018
1072
  ipaddress: 'notipaddress',
1019
- notipaddress: 'ipaddress',
1020
- bool: 'bool'
1073
+ notipaddress: 'ipaddress'
1074
+ // bool: 'bool'
1021
1075
  };
1022
- const out = JSON.parse(JSON.stringify(c));
1076
+ const out = structuredClone(c);
1023
1077
  for (const [base, comp] of Object.entries(complement)) {
1024
1078
  if (out[base] && out[comp]) {
1025
1079
  for (const key of Object.keys(out[base])) {
@@ -1034,4 +1088,43 @@ function mergeComplementaryConditions(c) {
1034
1088
  }
1035
1089
  return out;
1036
1090
  }
1091
+ /**
1092
+ * Apply Deny conditions to an Allow permission.
1093
+ *
1094
+ * A Deny permission with conditions (whether multiple operators or multiple keys under one
1095
+ * operator) acts as an AND, meaning the Allow needs to escape ANY one of them (OR when inverted).
1096
+ * Each condition key-value pair is inverted and creates a separate Allow permission.
1097
+ *
1098
+ * It is possible for any given condition to fully deny the Allow, in which case
1099
+ * that condition will produce no resulting Allow permission. The result is an array
1100
+ * of Allow permissions that apply after each Deny condition is applied.
1101
+ *
1102
+ * This may result in multiple Allow permission or an empty array if all are denied.
1103
+ *
1104
+ * @param allow the Allow permission
1105
+ * @param deny the Deny permission
1106
+ * @returns an array of resulting Allow permissions after applying Deny conditions
1107
+ */
1108
+ export function applyDenyConditionsToAllow(allow, deny) {
1109
+ // If Deny has no conditions, it fully denies the Allow
1110
+ if (!deny.conditions || Object.keys(deny.conditions).length === 0) {
1111
+ return [allow];
1112
+ }
1113
+ const results = [];
1114
+ // Each Deny condition key-value pair creates a separate inverted condition for Allow
1115
+ // (multiple keys under the same operator are each inverted separately)
1116
+ for (const [operator, keyMap] of Object.entries(deny.conditions || {})) {
1117
+ for (const [contextKey, values] of Object.entries(keyMap)) {
1118
+ // Invert this specific condition
1119
+ const singleCondition = { [operator]: { [contextKey]: values } };
1120
+ const invertedCondition = invertConditions(singleCondition);
1121
+ // Merge with the original Allow conditions
1122
+ const mergedConditions = intersectConditions(allow.conditions || {}, invertedCondition);
1123
+ if (mergedConditions !== null) {
1124
+ results.push(new Permission(allow.effect, allow.service, allow.action, allow.resource, allow.notResource, mergedConditions));
1125
+ }
1126
+ }
1127
+ }
1128
+ return results;
1129
+ }
1037
1130
  //# sourceMappingURL=permission.js.map