@cloud-copilot/iam-lens
Version:
Visibility in IAM in and across AWS accounts
1,008 lines • 56.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Permission = void 0;
exports.unionConditions = unionConditions;
exports.intersectConditions = intersectConditions;
exports.normalizeConditionKeys = normalizeConditionKeys;
exports.invertConditions = invertConditions;
exports.applyDenyConditionsToAllow = applyDenyConditionsToAllow;
/**
* Convert an AWS wildcard ARN pattern (e.g. "arn:aws:s3:::bucket/*") into a RegExp.
*/
function wildcardToRegex(pattern) {
const parts = pattern.split('*').map((s) => s.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'));
return new RegExp('^' + parts.join('.*') + '$');
}
/**
* An immutable representation of a single permission for a specific action.
*
* This will eventually have methods like "merge with another permission",
* "check if overlaps with another permission", "subtract a deny permission",
* etc and those will all return a new Permission instance.
*/
class Permission {
effect;
service;
action;
resource;
notResource;
conditions;
constructor(effect, service, action, resource, notResource, conditions) {
this.effect = effect;
this.service = service;
this.action = action;
this.resource = resource;
this.notResource = notResource;
this.conditions = conditions;
if (resource !== undefined && notResource !== undefined) {
throw new Error('Permission must have a resource or notResource, not both.');
}
else if (resource === undefined && notResource === undefined) {
throw new Error('Permission must have a resource or notResource, one must be defined.');
}
}
/**
* Returns true if this Permission completely includes the other Permission.
* Only supports merging of "Allow" permissions (same effect, service, action).
*/
includes(other) {
// 1. Effects, service, and action must match
if (this.effect !== other.effect ||
this.service !== other.service ||
this.action !== other.action) {
return false;
}
// 2. Conditions: every condition in this must be implied by the other permission’s conditions
// That is, for each operator and context key in this.conditions, other.conditions must have it,
// and the values must satisfy inclusion logic per operator.
const condsA = normalizeConditionKeys(this.conditions || {});
const condsB = normalizeConditionKeys(other.conditions || {});
for (const op of Object.keys(condsA)) {
if (!(op in condsB))
return false;
const keysA = Object.keys(condsA[op]);
const keysB = Object.keys(condsB[op]);
// Every key in A must appear in B
for (const key of keysA) {
if (!keysB.includes(key))
return false;
const valsA = condsA[op][key];
const valsB = condsB[op][key];
const baseOp = conditionBaseOperator(op);
switch (baseOp) {
case 'stringequals':
case 'stringlike':
case 'arnequals':
case 'arnlike':
// other must be at least as restrictive: B_vals ⊆ A_vals
if (!valsB.every((v) => valsA.includes(v)))
return false;
break;
case 'stringnotequals':
case 'stringnotlike':
case 'arnnotequals':
case 'arnnotlike':
// other must exclude at least what A excludes: A_vals ⊆ B_vals
if (!valsA.every((v) => valsB.includes(v)))
return false;
break;
case 'numericlessthan':
case 'numericlessthanequals':
// other boundary <= this boundary
const numA = Number(valsA[0]);
const numB = Number(valsB[0]);
if (isNaN(numA) || isNaN(numB))
return false;
if (numB > numA)
return false;
break;
case 'numericgreaterthan':
case 'numericgreaterthanequals':
// other boundary >= this boundary
const ngA = Number(valsA[0]);
const ngB = Number(valsB[0]);
if (isNaN(ngA) || isNaN(ngB))
return false;
if (ngB < ngA)
return false;
break;
case 'bool':
// other must have the same boolean value
if (valsA[0] !== valsB[0])
return false;
break;
case 'ipaddress':
case 'notipaddress':
// every CIDR in B must be contained in some CIDR in A
for (const cidrB of valsB) {
if (!valsA.some((cidrA) => cidrA === cidrB)) {
return false;
}
}
break;
case 'datelessthan':
case 'datelessthanequals':
// other date <= this date lexically (ISO)
const dA = valsA[0];
const dB = valsB[0];
if (dB > dA)
return false;
break;
case 'dategreaterthan':
case 'dategreaterthanequals':
// other date >= this date
const dgA = valsA[0];
const dgB = valsB[0];
if (dgB < dgA)
return false;
break;
default:
return false;
}
}
}
// 3. Resources / NotResources
const thisResource = this.resource;
const thisNotResource = this.notResource;
const otherResource = other.resource;
const otherNotResource = other.notResource;
// 3a. If both have resource[]
if (thisResource !== undefined && otherResource !== undefined) {
return otherResource.every((r2) => thisResource.some((r1) => wildcardToRegex(r1).test(r2)));
}
// 3b. Both have notResource[]
if (thisNotResource !== undefined && otherNotResource !== undefined) {
return thisNotResource.every((n1) => otherNotResource.some((n2) => wildcardToRegex(n1).test(n2)));
}
// 3c. A.resource & B.notResource -> B allows almost all, A allows only R1 -> true iff every N2 is matched by some R1
if (thisResource !== undefined && otherNotResource !== undefined) {
return otherNotResource.every((n2) => thisResource.some((r1) => wildcardToRegex(r1).test(n2)));
}
// 3d. A.notResource & B.resource -> every r2 ∉ N1
if (thisNotResource !== undefined && otherResource !== undefined) {
return otherResource.every((r2) => !thisNotResource.some((n1) => wildcardToRegex(n1).test(r2)));
}
return false;
}
/**
* Returns the union of this Permission with another.
* If one includes the other, return the including Permission.
* Otherwise, attempt to merge conditions and resource/notResource.
* If merge yields a single Permission, return it; else return both.
*/
union(other) {
// 1. Ensure same effect, service, and action
if (this.effect !== other.effect ||
this.service !== other.service ||
this.action !== other.action) {
return [this, other];
}
// 2. If one includes the other, return the including one
if (this.includes(other)) {
return [this];
}
if (other.includes(this)) {
return [other];
}
// 3. Attempt to combine conditions
const condsA = this.conditions || {};
const condsB = other.conditions || {};
const mergedConds = unionConditions(condsA, condsB);
if (mergedConds === null) {
return [this, other];
}
// 4. Combine resource/notResource (constructor enforces exclusivity)
const thisResource = this.resource;
const thisNotResource = this.notResource;
const otherResource = other.resource;
const otherNotResource = other.notResource;
const eff = this.effect;
const svc = this.service;
const act = this.action;
const conds = Object.keys(mergedConds).length > 0 ? mergedConds : undefined;
// Both have resource[]
if (thisResource !== undefined && otherResource !== undefined) {
const union = Array.from(new Set([...thisResource, ...otherResource]));
return [new Permission(eff, svc, act, union, undefined, conds)];
}
// Both have notResource[]
if (thisNotResource !== undefined && otherNotResource !== undefined) {
// Intersection of both notResource arrays
const intersection = thisNotResource.filter((n) => otherNotResource.includes(n));
return [new Permission(eff, svc, act, undefined, intersection, conds)];
}
// One has resource, other has notResource
if (thisResource !== undefined && otherNotResource !== undefined) {
return [
new Permission(eff, svc, act, thisResource, undefined, conds),
new Permission(eff, svc, act, undefined, otherNotResource, conds)
];
}
if (otherResource !== undefined && thisNotResource !== undefined) {
return [
new Permission(eff, svc, act, otherResource, undefined, conds),
new Permission(eff, svc, act, undefined, thisNotResource, conds)
];
}
// Otherwise cannot combine, return both
return [this, other];
}
/**
* Returns the intersection of this Permission with another.
* Always returns exactly one Permission. If there is no overlap,
* returns undefined.
*
* @param other The other Permission to intersect with.
* @returns A new Permission representing the intersection of other and this, or undefined if there is no intersection.
*/
intersection(other) {
// 1. Must match effect, service, and action
if (this.effect !== other.effect ||
this.service !== other.service ||
this.action !== other.action) {
// No overlap at all—return a "zero-resource" permission
return undefined;
}
if (this.resource != undefined && other.resource != undefined) {
// 2. If one includes the other, return the narrower one unless both are NotResource
if (this.includes(other)) {
return other;
}
if (other.includes(this)) {
return this;
}
}
// 3. Attempt to intersect/merge conditions
const a = normalizeConditionKeys(this.conditions || {});
const b = normalizeConditionKeys(other.conditions || {});
const allOps = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
const intersectedConds = {};
for (const op of allOps) {
const condA = a[op] || {};
const condB = b[op] || {};
const allKeys = Array.from(new Set([...Object.keys(condA), ...Object.keys(condB)]));
intersectedConds[op] = {};
for (const key of allKeys) {
const valsA = condA[key] || [];
const valsB = condB[key] || [];
// If key appears in both, intersect or combine based on operator
if (key in condA && key in condB) {
switch (conditionBaseOperator(op)) {
case 'stringequals':
case 'stringlike':
case 'arnequals':
case 'arnlike': {
// Intersection of string lists
const common = valsA.filter((v) => valsB.includes(v));
if (common.length === 0) {
return undefined;
}
intersectedConds[op][key] = common;
break;
}
case 'stringnotequals':
case 'stringnotlike':
case 'arnnotequals':
case 'arnnotlike': {
// Union of exclusions
intersectedConds[op][key] = Array.from(new Set([...valsA, ...valsB]));
break;
}
case 'numericlessthan':
case 'numericlessthanequals': {
const numA = Number(valsA[0]);
const numB = Number(valsB[0]);
if (isNaN(numA) || isNaN(numB)) {
return undefined;
}
const boundary = Math.min(numA, numB);
intersectedConds[op][key] = [String(boundary)];
break;
}
case 'numericgreaterthan':
case 'numericgreaterthanequals': {
const ngA = Number(valsA[0]);
const ngB = Number(valsB[0]);
if (isNaN(ngA) || isNaN(ngB)) {
return undefined;
}
const boundary = Math.max(ngA, ngB);
intersectedConds[op][key] = [String(boundary)];
break;
}
case 'bool': {
if (valsA[0] !== valsB[0]) {
return undefined;
}
intersectedConds[op][key] = [valsA[0]];
break;
}
case 'ipaddress':
case 'notipaddress': {
const common = valsA.filter((cidr) => valsB.includes(cidr));
if (common.length === 0) {
return undefined;
}
intersectedConds[op][key] = common;
break;
}
case 'datelessthan':
case 'datelessthanequals': {
const dA = valsA[0];
const dB = valsB[0];
intersectedConds[op][key] = [dA < dB ? dA : dB];
break;
}
case 'dategreaterthan':
case 'dategreaterthanequals': {
const dgA = valsA[0];
const dgB = valsB[0];
intersectedConds[op][key] = [dgA > dgB ? dgA : dgB];
break;
}
default:
return undefined;
}
}
else {
// Key only in one side: carry it through
intersectedConds[op][key] = key in condA ? Array.from(valsA) : Array.from(valsB);
}
}
}
// 4. Combine resource/notResource:
const thisResource = this.resource;
const thisNotResource = this.notResource;
const otherResource = other.resource;
const otherNotResource = other.notResource;
const eff = this.effect;
const svc = this.service;
const act = this.action;
const conds = Object.keys(intersectedConds).length > 0 ? intersectedConds : undefined;
// Both have resource[] => intersect patterns
if (thisResource !== undefined && otherResource !== undefined) {
// Keep any R1 that matches something in R2, and any R2 that matches something in R1
const part1 = thisResource.filter((r1) => otherResource.some((r2) => wildcardToRegex(r2).test(r1)));
const part2 = otherResource.filter((r2) => thisResource.some((r1) => wildcardToRegex(r1).test(r2)));
const intersectR = Array.from(new Set([...part1, ...part2]));
if (intersectR.length === 0) {
return undefined;
}
return new Permission(eff, svc, act, intersectR, undefined, conds);
}
// Both have notResource[] => union of exclusions (more restrictive), but remove subsumed patterns
if (thisNotResource !== undefined && otherNotResource !== undefined) {
// Compute union of both exclusion lists
const combined = Array.from(new Set([...thisNotResource, ...otherNotResource]));
// Remove any pattern that is subsumed by a more general pattern
const filtered = combined.filter((pat) => !combined.some((otherPat) => otherPat !== pat && wildcardToRegex(otherPat).test(pat)));
return new Permission(eff, svc, act, undefined, filtered, conds);
}
// One has resource, other has notResource
const resource = thisResource || otherResource;
const notResource = thisNotResource || otherNotResource;
if (resource !== undefined || notResource !== undefined) {
const filtered = resource.filter((r1) => !notResource.some((n2) => wildcardToRegex(n2).test(r1)));
if (filtered.length === 0) {
return undefined;
}
return new Permission(eff, svc, act, filtered, undefined, conds);
}
// This should never happen
return undefined;
}
/**
* Subtract a Deny permission from this Allow permission.
*
* Returns the resulting permissions, this can be:
* - An empty array if the Allow is fully denied by the Deny
* - A modified Allow permission or multiple Allow permissions
* - It could also return the original Allow and Deny permission if subtraction cannot be expressed purely in Allow statements
*
* @param other the Deny permission to subtract
*/
subtract(other) {
// Only subtract Deny from Allow for the same service/action
if (this.effect !== 'Allow' ||
other.effect !== 'Deny' ||
this.service !== other.service ||
this.action !== other.action) {
// No subtraction applies
return [this];
}
const allowCondsNorm = normalizeConditionKeys(this.conditions || {});
const denyCondsNorm = normalizeConditionKeys(other.conditions || {});
const conditionsMatch = JSON.stringify(allowCondsNorm) === JSON.stringify(denyCondsNorm);
const allowResource = this.resource;
const allowNotResource = this.notResource;
const denyResource = other.resource;
const denyNotResource = other.notResource;
const eff = this.effect;
const svc = this.service;
const act = this.action;
// Case: Allow.resource & Deny.resource
if (allowResource !== undefined && denyResource !== undefined) {
const overlappingResources = allowResource.some((a) => {
return denyResource.some((d) => {
return wildcardToRegex(d).test(a) || wildcardToRegex(a).test(d);
});
});
// If the resources in the allow and deny do not overlap, return the allow as is
if (!overlappingResources) {
return [this];
}
// Categories for allows, a single allow could be more than one, because the deny could have multiple
// Without Conditions:
//1. Exactly the same as a deny - remove the allow
//2. A subset of a deny - remove the allow
//3. A superset of a deny - keep the allow and the deny
//4. No overlap with any deny - keep the allow as is
//
// With Conditions:
//1. Exactly the same as a deny - invert the conditions and keep the allow
//2. A subset of a deny - invert the conditions and keep the allow
//3. A superset of a deny - keep the allow and the deny
//4. No overlap with any deny - keep the allow as is
const allowMatches = [];
const allowSubsets = [];
const allowSupersets = [];
const allowNoOverlap = [];
const denySubsets = [];
for (const allowedResource of allowResource) {
let isMatch = false;
let isSubset = false;
let isSuperset = false;
for (const deniedResource of denyResource) {
if (deniedResource === allowedResource) {
isMatch = true;
break;
}
if (wildcardToRegex(deniedResource).test(allowedResource)) {
isSubset = true;
break;
}
if (wildcardToRegex(allowedResource).test(deniedResource)) {
isSuperset = true;
denySubsets.push(deniedResource);
}
}
if (isMatch) {
allowMatches.push(allowedResource);
}
else if (isSubset) {
allowSubsets.push(allowedResource);
}
else if (isSuperset) {
allowSupersets.push(allowedResource);
}
else {
allowNoOverlap.push(allowedResource);
}
}
const permissionsToReturn = [];
if (allowNoOverlap.length > 0) {
permissionsToReturn.push(new Permission(eff, svc, act, allowNoOverlap, undefined, this.conditions));
}
if (allowSupersets.length > 0) {
permissionsToReturn.push(new Permission(eff, svc, act, allowSupersets, undefined, this.conditions));
}
if (allowMatches.length > 0 || allowSubsets.length > 0) {
// If conditions are identical, these are fully dropped from the Allow. If not, they need to be kept with inverted conditions
if (!conditionsMatch) {
const newAllow = new Permission(eff, svc, act, [...allowMatches, ...allowSubsets], undefined, this.conditions);
permissionsToReturn.push(...applyDenyConditionsToAllow(newAllow, other));
}
}
if (denySubsets.length > 0) {
permissionsToReturn.push(new Permission('Deny', svc, act, denySubsets, undefined, other.conditions));
}
return permissionsToReturn;
}
// Case: Allow.resource & Deny.notResource
// =======================================================================
// SEMANTICS:
// Deny.notResource means: "deny everything EXCEPT these patterns"
// So the deny APPLIES to resources that do NOT match denyNotResource patterns
// And the deny does NOT apply to resources that DO match denyNotResource patterns
// =======================================================================
if (allowResource !== undefined && denyNotResource !== undefined) {
// STEP 1: Categorize each allow resource based on relationship to denyNotResource patterns
//
// Categories:
// ExcludedFromDeny - Matches a denyNotResource pattern (deny doesn't apply to these)
// AffectedByDeny - Does NOT match any denyNotResource (deny applies to these)
// Superset - Covers (is broader than) a denyNotResource pattern
//
// Also track which denyNotResource patterns are covered by superset allow resources
const excludedFromDeny = [];
const affectedByDeny = [];
const supersets = [];
const coveredDenyNotResourcePatterns = [];
for (const allowedResource of allowResource) {
let isExcluded = false;
let isSuperset = false;
for (const deniedNotResource of denyNotResource) {
// Check if allowResource exactly matches or is covered by denyNotResource pattern
if (allowedResource === deniedNotResource ||
wildcardToRegex(deniedNotResource).test(allowedResource)) {
isExcluded = true;
break;
}
// Check if allowResource covers (is broader than) denyNotResource pattern
if (wildcardToRegex(allowedResource).test(deniedNotResource)) {
isSuperset = true;
if (!coveredDenyNotResourcePatterns.includes(deniedNotResource)) {
coveredDenyNotResourcePatterns.push(deniedNotResource);
}
}
}
if (isExcluded) {
excludedFromDeny.push(allowedResource);
}
else if (isSuperset) {
supersets.push(allowedResource);
}
else {
affectedByDeny.push(allowedResource);
}
}
// STEP 2: Early exit - if all allow resources are excluded from deny, return unchanged
if (excludedFromDeny.length === allowResource.length) {
return [this];
}
const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
// STEP 3: Build output permissions by category
const permissionsToReturn = [];
// ExcludedFromDeny: Keep as-is with original conditions (deny doesn't touch these)
if (excludedFromDeny.length > 0) {
permissionsToReturn.push(new Permission(eff, svc, act, excludedFromDeny, undefined, this.conditions));
}
// Superset: Allow resource is broader than denyNotResource patterns
// - The covered denyNotResource patterns are excluded from deny (allow unconditionally)
// - The superset allow resources are affected by deny (apply inverted conditions)
if (supersets.length > 0) {
// First: Allow the covered patterns unconditionally (they're excluded from deny)
if (coveredDenyNotResourcePatterns.length > 0) {
permissionsToReturn.push(new Permission(eff, svc, act, coveredDenyNotResourcePatterns, undefined, this.conditions));
}
// Second: Apply inverted deny conditions to the superset resources
if (denyHasConditions && !conditionsMatch) {
const supersetAllow = new Permission(eff, svc, act, supersets, undefined, this.conditions);
permissionsToReturn.push(...applyDenyConditionsToAllow(supersetAllow, other));
}
// If no conditions or conditions match, the superset is fully denied (nothing to add)
}
// AffectedByDeny: These resources are hit by the deny
if (affectedByDeny.length > 0) {
// If there are no conditions on deny - these are fully denied (drop them)
// If the conditions match - these are fully denied (drop them)
if (denyHasConditions && !conditionsMatch) {
// Different conditions - keep with inverted deny conditions
const newAllow = new Permission(eff, svc, act, affectedByDeny, undefined, this.conditions);
permissionsToReturn.push(...applyDenyConditionsToAllow(newAllow, other));
}
}
return permissionsToReturn;
}
// Scenario 3: Allow.notResource & Deny.resource
if (allowNotResource !== undefined && denyResource !== undefined) {
// STEP 1: Categorize relationships and track which denyResources are already covered
const coveredDenyResources = new Set(); // denyResources already excluded by allowNotResource
const uncoveredDenyResources = []; // denyResources that affect allowed resources
const subsetReplacements = [];
// For each denyResource, check if it's covered by any allowNotResource
for (const denyPattern of denyResource) {
let isCovered = false;
for (const allowPattern of allowNotResource) {
// ExactMatch or Superset - denyResource is already excluded
if (allowPattern === denyPattern || wildcardToRegex(allowPattern).test(denyPattern)) {
isCovered = true;
coveredDenyResources.add(denyPattern);
break;
}
}
if (!isCovered) {
uncoveredDenyResources.push(denyPattern);
}
}
// Check for Subset patterns (denyResource covers allowNotResource)
for (const allowPattern of allowNotResource) {
for (const denyPattern of denyResource) {
if (wildcardToRegex(denyPattern).test(allowPattern) && allowPattern !== denyPattern) {
subsetReplacements.push({ allowPattern, denyPattern });
}
}
}
// Filter out subset deny patterns from uncoveredDenyResources to get true NoOverlap patterns
const subsetDenyPatternsSet = new Set(subsetReplacements.map((s) => s.denyPattern));
const noOverlapDenyResources = uncoveredDenyResources.filter((dr) => !subsetDenyPatternsSet.has(dr));
// STEP 2: If all denyResources are covered, deny has no effect
if (noOverlapDenyResources.length === 0 && subsetReplacements.length === 0) {
return [this];
}
// STEP 3: Build output permissions
const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
// Build the expanded notResource (original + noOverlap deny resources + subset replacements)
const subsetAllowPatterns = new Set(subsetReplacements.map((s) => s.allowPattern));
const keptPatterns = allowNotResource.filter((p) => !subsetAllowPatterns.has(p));
const subsetDenyPatterns = Array.from(new Set(subsetReplacements.map((s) => s.denyPattern)));
const expandedNotResource = Array.from(new Set([...keptPatterns, ...subsetDenyPatterns, ...noOverlapDenyResources]));
// Same conditions or no deny conditions: simply expand notResource
if (conditionsMatch || !denyHasConditions) {
return [new Permission(eff, svc, act, undefined, expandedNotResource, this.conditions)];
}
// Different conditions: handle Subset and NoOverlap cases separately
const permissionsToReturn = [];
const hasSubsetReplacements = subsetReplacements.length > 0;
const hasNoOverlapAdditions = noOverlapDenyResources.length > 0;
// Part 1: Original allowNotResource with inverted deny conditions
// (when deny condition is NOT met, original allow applies)
const originalAllow = new Permission(eff, svc, act, undefined, allowNotResource, this.conditions);
permissionsToReturn.push(...applyDenyConditionsToAllow(originalAllow, other));
// Part 2a: For SUBSET replacements - expanded notResource WITH deny conditions
// (replacing smaller exclusion with larger one, only valid when condition is met)
if (hasSubsetReplacements) {
// Build notResource with just the subset replacements (not the noOverlap additions)
const subsetExpandedNotResource = Array.from(new Set([...keptPatterns, ...subsetDenyPatterns]));
const subsetConditions = intersectConditions(this.conditions || {}, other.conditions || {});
permissionsToReturn.push(new Permission(eff, svc, act, undefined, subsetExpandedNotResource, subsetConditions || other.conditions));
}
// Part 2b: For NO OVERLAP additions - expanded notResource WITHOUT conditions
// (adding new exclusion is always safe, no condition needed)
if (hasNoOverlapAdditions) {
permissionsToReturn.push(new Permission(eff, svc, act, undefined, expandedNotResource, this.conditions));
}
return permissionsToReturn;
}
// ═══════════════════════════════════════════════════════════════════════════
// SCENARIO 4: Allow.NotResource & Deny.NotResource
// ═══════════════════════════════════════════════════════════════════════════
//
// Semantics:
// Allow.notResource = [A]: allow ALL resources EXCEPT those matching A
// Deny.notResource = [D]: deny ALL resources EXCEPT those matching D
//
// The deny blocks everything except D (the "safe zone").
// The allow permits everything except A.
//
// Surviving resources = (allowed) ∩ (not denied)
// = (NOT in A) ∩ (in D)
// = resources in D that are not covered by A
//
// Result: resource: [D patterns not covered by A]
//
// ═══════════════════════════════════════════════════════════════════════════
if (allowNotResource !== undefined && denyNotResource !== undefined) {
const denyHasConditions = other.conditions && Object.keys(other.conditions).length > 0;
// Helper: Check if pattern A covers pattern B (A is superset of or equal to B)
const patternCovers = (a, b) => {
return a === b || wildcardToRegex(a).test(b);
};
// Find D patterns that survive (not covered by any A pattern)
// These are the resources that are both allowed AND protected from deny
const survivingResources = denyNotResource.filter((dPattern) => !allowNotResource.some((aPattern) => patternCovers(aPattern, dPattern)));
// If nothing survives, return empty
if (survivingResources.length === 0) {
return [];
}
// Handle conditions
if (!denyHasConditions || conditionsMatch) {
// No deny conditions or same conditions: apply directly
return [new Permission(eff, svc, act, survivingResources, undefined, this.conditions)];
}
// Different conditions: split into two parts
const permissionsToReturn = [];
// Part 1: When deny condition is NOT met → original allow applies
const originalAllow = new Permission(eff, svc, act, undefined, allowNotResource, this.conditions);
permissionsToReturn.push(...applyDenyConditionsToAllow(originalAllow, other));
// Part 2: When deny condition IS met → surviving resources with deny's condition
const denyConditionCount = Object.values(other.conditions || {}).reduce((sum, keyMap) => sum + Object.keys(keyMap).length, 0);
const part2Conditions = denyConditionCount === 1 ? other.conditions : undefined;
permissionsToReturn.push(new Permission(eff, svc, act, survivingResources, undefined, part2Conditions));
return permissionsToReturn;
}
// This should never happen
throw new Error('Permission.subtract: This should never happen—invalid state.');
}
}
exports.Permission = Permission;
/**
* Attempt to union two sets of permission conditions.
*
* If the conditions can be merged into a single block that allows all cases allowed by either,
* returns the merged conditions. If they cannot be merged cleanly (e.g., differing operators
* or incompatible numeric boundaries), returns null.
*
* @param a First set of conditions
* @param b Second set of conditions
* @returns Merged conditions or null if they cannot be merged
*/
function unionConditions(a, b) {
// 1. If the set of operators in 'a' differs from the set in 'b', return null.
a = normalizeConditionKeys(a);
b = normalizeConditionKeys(b);
const opsA = Object.keys(a).sort();
const opsB = Object.keys(b).sort();
if (JSON.stringify(opsA) !== JSON.stringify(opsB)) {
return null;
}
const merged = {};
// 2. For each operator op that appears in both:
for (const op of opsA) {
const keysA = Object.keys(a[op]).sort();
const keysB = Object.keys(b[op]).sort();
// If the set of context‐keys under this operator differs, we can't merge as one block
if (JSON.stringify(keysA) !== JSON.stringify(keysB)) {
return null;
}
// Now we know op and its context keys align. Build the merged set for this operator:
merged[op] = {};
for (const key of keysA) {
const valsA = a[op][key];
const valsB = b[op][key];
// How we combine depends on operator semantics:
switch (conditionBaseOperator(op)) {
case 'stringequals':
case 'stringlike':
case 'stringnotequals':
case 'stringnotlike':
case 'arnequals':
case 'arnlike':
case 'arnnotequals':
case 'arnnotlike':
// String‐based operators: just union the value arrays
merged[op][key] = Array.from(new Set([...valsA, ...valsB]));
break;
case 'numericlessthan':
case 'numericlessthanequals':
case 'numericgreaterthan':
case 'numericgreaterthanequals':
case 'numericequals':
case 'numericnotequals':
// Numeric operators: pick the “widest” comparison that still covers both sets
// For simplicity, convert all valsA/valsB to numbers; find the min or max
const numsA = valsA.map((v) => Number(v));
const numsB = valsB.map((v) => Number(v));
if (numsA.some(isNaN) || numsB.some(isNaN)) {
// Malformed number—cannot merge
return null;
}
if (op === 'numericlessthan' || op === 'numericlessthanequals') {
// We want the largest boundary
const candidate = Math.max(...numsA, ...numsB);
merged[op][key] = [String(candidate)];
}
else if (op === 'numericgreaterthan' || op === 'numericgreaterthanequals') {
// We want the smallest boundary
const candidate = Math.min(...numsA, ...numsB);
merged[op][key] = [String(candidate)];
}
else if (op === 'numericequals' || op === 'numericnotequals') {
// Union the sets of allowed/not‐allowed numbers
merged[op][key] = Array.from(new Set([...valsA.map(String), ...valsB.map(String)]));
}
break;
case 'datelessthan':
case 'datelessthanequals':
case 'dategreaterthan':
case 'dategreaterthanequals':
// Similar idea: choose the “widest” date limit
// Assume ISO‐8601 strings so lex‐compare works
if (op === 'datelessthan' || op === 'datelessthanequals') {
// pick the LARGEST date (latest) because “< latest” covers “< earlier”
const candidate = [...valsA, ...valsB].sort().reverse()[0];
merged[op][key] = [candidate];
}
else {
// "DateGreaterThan"/"DateGreaterThanEquals": pick the EARLIEST date
const candidate = [...valsA, ...valsB].sort()[0];
merged[op][key] = [candidate];
}
break;
case 'bool':
// Typically valsA and valsB are ["true"] or ["false"].
// If either contains "true", then the union is ["true","false"]? No—
// Bool doesn't make sense with an array. In IAM, Bool only works with a single value.
// If values differ (one says ["true"], the other says ["false"]), you cannot
// express (Bool==true OR Bool==false) as a single Bool. You’d need two separate
// statements. So bail out.
if (valsA[0] === valsB[0]) {
merged[op][key] = [valsA[0]];
}
else {
return null;
}
break;
case 'ipaddress':
case 'notipaddress':
// You can pass multiple CIDR blocks under a single IpAddress. So union them.
merged[op][key] = Array.from(new Set([...valsA, ...valsB]));
break;
// Any other operators (e.g., “ArnNotLike” etc.) behave similarly to their base type
default:
// If we don’t explicitly handle the operator, reject merging
return null;
}
}
}
return merged;
}
/**
* Intersect two sets of permission conditions.
*
* Attempt to find the intersection of two sets of IAM condition clauses. This will
* combine condition operators and context keys, retaining only values that satisfy
* both sets of conditions. If the intersection is empty or cannot be expressed
* cleanly, returns null.
*
* @param conditionsA First set of conditions
* @param conditionsB Second set of conditions
* @returns Intersected conditions or null if intersection is empty or cannot be expressed
*/
function intersectConditions(a, b) {
// Normalize both condition sets to lowercase operators and keys
const normalizedA = normalizeConditionKeys(a);
const normalizedB = normalizeConditionKeys(b);
// Collect all unique operators from both sides
const allOperators = Array.from(new Set([...Object.keys(normalizedA), ...Object.keys(normalizedB)]));
const result = {};
for (const operator of allOperators) {
const keysA = normalizedA[operator] || {};
const keysB = normalizedB[operator] || {};
// Collect all unique context keys for this operator
const allContextKeys = Array.from(new Set([...Object.keys(keysA), ...Object.keys(keysB)]));
result[operator] = {};
for (const contextKey of allContextKeys) {
const valsA = keysA[contextKey];
const valsB = keysB[contextKey];
// If key exists in both sides, apply intersection logic based on operator type
if (valsA !== undefined && valsB !== undefined) {
const intersectedValues = intersectValuesForOperator(operator, valsA, valsB);
if (intersectedValues === null) {
// Empty intersection means no overlap - return null
return null;
}
result[operator][contextKey] = intersectedValues;
}
else {
// Key only exists in one side - carry it through (both conditions must be satisfied)
result[operator][contextKey] = valsA !== undefined ? Array.from(valsA) : Array.from(valsB);
}
}
// Remove empty operator objects
if (Object.keys(result[operator]).length === 0) {
delete result[operator];
}
}
const merged = mergeComplementaryConditions(result);
// Check if any values array became empty after merging complementary conditions
// (e.g., StringEquals: ['a'] merged with StringNotEquals: ['a'] results in StringEquals: [])
for (const [, keyMap] of Object.entries(merged)) {
for (const [, values] of Object.entries(keyMap)) {
if (values.length === 0) {
return null;
}
}
}
return merged;
}
/**
* Intersect values for a specific operator type.
*
* Returns the intersected values, or null if the intersection is empty
* (meaning the conditions are mutually exclusive).
*/
function intersectValuesForOperator(operator, valsA, valsB) {
const baseOp = conditionBaseOperator(operator);
switch (baseOp) {
// String/ARN equality operators: intersection of allowed values
case 'stringequals':
case 'stringlike':
case 'arnequals':
case 'arnlike': {
const common = valsA.filter((v) => valsB.includes(v));
return common.length > 0 ? common : null;
}
// String/ARN negation operators: union of exclusions (more restrictive)
case 'stringnotequals':
case 'stringnotlike':
case 'arnnotequals':
case 'arnnotlike': {
return Array.from(new Set([...valsA, ...valsB]));
}
// Numeric less-than operators: take the minimum (more restrictive)
case 'numericlessthan':
case 'numericlessthanequals': {
const numA = Number(valsA[0]);
const numB = Number(valsB[0]);
if (isNaN(numA) || isNaN(numB)) {
return null;
}
return [String(Math.min(numA, numB))];
}
// Numeric greater-than operators: take the maximum (more restrictive)
case 'numericgreaterthan':
case 'numericgreaterthanequals': {
const numA = Number(valsA[0]);
const numB = Number(valsB[0]);
if (isNaN(numA) || isNaN(numB)) {
return null;
}
return [String(Math.max(numA, numB))];
}
// Boolean operators: values must match exactly
case 'bool':
case 'null': {
if (valsA[0]?.toLowerCase() !== valsB[0]?.toLowerCase()) {
return null;
}
return [valsA[0]];
}
// IP address operators: intersection of CIDR blocks
case 'ipaddress':
case 'notipaddress': {
const common = valsA.filter((cidr) => valsB.includes(cidr));
return common.length > 0 ? common : null;
}
// Date less-than operators: take the earlier date (more restrictive)
case 'datelessthan':
case 'datelessthanequals': {
const dateA = valsA[0];
const dateB = valsB[0];
return [dateA < dateB ? dateA : dateB];
}
// Date greater-than operators: take the later date (more restrictive)
case 'dategreaterthan':
case 'dategreaterthanequals': {
const dateA = valsA[0];
const dateB = valsB[0];
return [dateA > dateB ? dateA : dateB];
}
// Unknown operator - cannot handle
default:
return null;
}
}
/**
* Checks if an IAM condition operator ends with "IfExists".
*
* @param op the IAM condition operator, e.g., "StringEqualsIfExists"
* @returns true if the operator ends with "IfExists", false otherwise.
*/
function isIfExists(op) {
// Check if the operator ends with "IfExists"
return op.toLowerCase().endsWith('ifexists');
}
/**
* Get the set operator from an IAM condition operator such as "ForAllValues" or "ForAnyValue".
*
* @param op the IAM condition operator, e.g., "ForAllValues:StringEquals"
* @returns the set operator, e.g., "forallvalues" or "foranyvalue", or undefined if no set operator is present.
*/
function conditionSetOperator(op) {
return op.includes(':') ? op.split(':')[0].toLowerCase() : undefined;
}
/**
* Gets the base operator name from an IAM condition operator. Removes any set operator prefix or
* "IfExists" suffix.
*
* @param op the IAM condition operator, e.g., "ForAllValues:StringEqualsIfExists"
* @returns the base operator name, e.g., "stringequals" or "arnequals".
*/
function conditionBaseOperator(op) {
// Return the base operator name for IAM condition operators
return op
.split(':')
.at(-1)
.toLowerCase()
.replace(/ifexists$/, '');
}
/**
* Returns a new PermissionConditions object with all operator and context keys lowercased.
*/
function normalizeConditionKeys(conds) {
const result = {};
for (const [op, keyMap] of Object.entries(conds)) {
const lowerOp = op.toLowerCase();
result[lowerOp] = {};
for (const [context