@cloud-copilot/iam-lens 0.1.11 → 0.1.13
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.
- package/dist/cjs/canWhat/canWhat.d.ts +10 -0
- package/dist/cjs/canWhat/canWhat.d.ts.map +1 -0
- package/dist/cjs/canWhat/canWhat.js +61 -0
- package/dist/cjs/canWhat/canWhat.js.map +1 -0
- package/dist/cjs/canWhat/permission.d.ts +54 -0
- package/dist/cjs/canWhat/permission.d.ts.map +1 -0
- package/dist/cjs/canWhat/permission.js +749 -0
- package/dist/cjs/canWhat/permission.js.map +1 -0
- package/dist/cjs/canWhat/permissionSet.d.ts +59 -0
- package/dist/cjs/canWhat/permissionSet.d.ts.map +1 -0
- package/dist/cjs/canWhat/permissionSet.js +296 -0
- package/dist/cjs/canWhat/permissionSet.js.map +1 -0
- package/dist/cjs/cli.js +28 -0
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/collect/client.js +1 -1
- package/dist/cjs/collect/client.js.map +1 -1
- package/dist/esm/canWhat/canWhat.d.ts +10 -0
- package/dist/esm/canWhat/canWhat.d.ts.map +1 -0
- package/dist/esm/canWhat/canWhat.js +58 -0
- package/dist/esm/canWhat/canWhat.js.map +1 -0
- package/dist/esm/canWhat/permission.d.ts +54 -0
- package/dist/esm/canWhat/permission.d.ts.map +1 -0
- package/dist/esm/canWhat/permission.js +737 -0
- package/dist/esm/canWhat/permission.js.map +1 -0
- package/dist/esm/canWhat/permissionSet.d.ts +59 -0
- package/dist/esm/canWhat/permissionSet.d.ts.map +1 -0
- package/dist/esm/canWhat/permissionSet.js +288 -0
- package/dist/esm/canWhat/permissionSet.js.map +1 -0
- package/dist/esm/cli.js +28 -0
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/collect/client.js +1 -1
- package/dist/esm/collect/client.js.map +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Permission = void 0;
|
|
4
|
+
exports.normalizeConditionKeys = normalizeConditionKeys;
|
|
5
|
+
exports.invertConditions = invertConditions;
|
|
6
|
+
/**
|
|
7
|
+
* Convert an AWS wildcard ARN pattern (e.g. "arn:aws:s3:::bucket/*") into a RegExp.
|
|
8
|
+
*/
|
|
9
|
+
function wildcardToRegex(pattern) {
|
|
10
|
+
const parts = pattern.split('*').map((s) => s.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&'));
|
|
11
|
+
return new RegExp('^' + parts.join('.*') + '$');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* An immutable representation of a single permission for a specific action.
|
|
15
|
+
*
|
|
16
|
+
* This will eventually have methods like "merge with another permission",
|
|
17
|
+
* "check if overlaps with another permission", "subtract a deny permission",
|
|
18
|
+
* etc and those will all return a new Permission instance.
|
|
19
|
+
*/
|
|
20
|
+
class Permission {
|
|
21
|
+
effect;
|
|
22
|
+
service;
|
|
23
|
+
action;
|
|
24
|
+
resource;
|
|
25
|
+
notResource;
|
|
26
|
+
conditions;
|
|
27
|
+
constructor(effect, service, action, resource, notResource, conditions) {
|
|
28
|
+
this.effect = effect;
|
|
29
|
+
this.service = service;
|
|
30
|
+
this.action = action;
|
|
31
|
+
this.resource = resource;
|
|
32
|
+
this.notResource = notResource;
|
|
33
|
+
this.conditions = conditions;
|
|
34
|
+
if (resource !== undefined && notResource !== undefined) {
|
|
35
|
+
throw new Error('Permission must have a resource or notResource, not both.');
|
|
36
|
+
}
|
|
37
|
+
else if (resource === undefined && notResource === undefined) {
|
|
38
|
+
throw new Error('Permission must have a resource or notResource, one must be defined.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if this Permission completely includes the other Permission.
|
|
43
|
+
* Only supports merging of "Allow" permissions (same effect, service, action).
|
|
44
|
+
*/
|
|
45
|
+
includes(other) {
|
|
46
|
+
// 1. Effects, service, and action must match
|
|
47
|
+
if (this.effect !== other.effect ||
|
|
48
|
+
this.service !== other.service ||
|
|
49
|
+
this.action !== other.action) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// 2. Conditions: every condition in this must be implied by the other permission’s conditions
|
|
53
|
+
// That is, for each operator and context key in this.conditions, other.conditions must have it,
|
|
54
|
+
// and the values must satisfy inclusion logic per operator.
|
|
55
|
+
const condsA = normalizeConditionKeys(this.conditions || {});
|
|
56
|
+
const condsB = normalizeConditionKeys(other.conditions || {});
|
|
57
|
+
for (const op of Object.keys(condsA)) {
|
|
58
|
+
if (!(op in condsB))
|
|
59
|
+
return false;
|
|
60
|
+
const keysA = Object.keys(condsA[op]);
|
|
61
|
+
const keysB = Object.keys(condsB[op]);
|
|
62
|
+
// Every key in A must appear in B
|
|
63
|
+
for (const key of keysA) {
|
|
64
|
+
if (!keysB.includes(key))
|
|
65
|
+
return false;
|
|
66
|
+
const valsA = condsA[op][key];
|
|
67
|
+
const valsB = condsB[op][key];
|
|
68
|
+
const baseOp = conditionBaseOperator(op);
|
|
69
|
+
switch (baseOp) {
|
|
70
|
+
case 'stringequals':
|
|
71
|
+
case 'stringlike':
|
|
72
|
+
case 'arnequals':
|
|
73
|
+
case 'arnlike':
|
|
74
|
+
// other must be at least as restrictive: B_vals ⊆ A_vals
|
|
75
|
+
if (!valsB.every((v) => valsA.includes(v)))
|
|
76
|
+
return false;
|
|
77
|
+
break;
|
|
78
|
+
case 'stringnotequals':
|
|
79
|
+
case 'stringnotlike':
|
|
80
|
+
case 'arnnotequals':
|
|
81
|
+
case 'arnnotlike':
|
|
82
|
+
// other must exclude at least what A excludes: A_vals ⊆ B_vals
|
|
83
|
+
if (!valsA.every((v) => valsB.includes(v)))
|
|
84
|
+
return false;
|
|
85
|
+
break;
|
|
86
|
+
case 'numericlessthan':
|
|
87
|
+
case 'numericlessthanequals':
|
|
88
|
+
// other boundary <= this boundary
|
|
89
|
+
const numA = Number(valsA[0]);
|
|
90
|
+
const numB = Number(valsB[0]);
|
|
91
|
+
if (isNaN(numA) || isNaN(numB))
|
|
92
|
+
return false;
|
|
93
|
+
if (numB > numA)
|
|
94
|
+
return false;
|
|
95
|
+
break;
|
|
96
|
+
case 'numericgreaterthan':
|
|
97
|
+
case 'numericgreaterthanequals':
|
|
98
|
+
// other boundary >= this boundary
|
|
99
|
+
const ngA = Number(valsA[0]);
|
|
100
|
+
const ngB = Number(valsB[0]);
|
|
101
|
+
if (isNaN(ngA) || isNaN(ngB))
|
|
102
|
+
return false;
|
|
103
|
+
if (ngB < ngA)
|
|
104
|
+
return false;
|
|
105
|
+
break;
|
|
106
|
+
case 'bool':
|
|
107
|
+
// other must have the same boolean value
|
|
108
|
+
if (valsA[0] !== valsB[0])
|
|
109
|
+
return false;
|
|
110
|
+
break;
|
|
111
|
+
case 'ipaddress':
|
|
112
|
+
case 'notipaddress':
|
|
113
|
+
// every CIDR in B must be contained in some CIDR in A
|
|
114
|
+
for (const cidrB of valsB) {
|
|
115
|
+
if (!valsA.some((cidrA) => cidrA === cidrB)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'datelessthan':
|
|
121
|
+
case 'datelessthanequals':
|
|
122
|
+
// other date <= this date lexically (ISO)
|
|
123
|
+
const dA = valsA[0];
|
|
124
|
+
const dB = valsB[0];
|
|
125
|
+
if (dB > dA)
|
|
126
|
+
return false;
|
|
127
|
+
break;
|
|
128
|
+
case 'dategreaterthan':
|
|
129
|
+
case 'dategreaterthanequals':
|
|
130
|
+
// other date >= this date
|
|
131
|
+
const dgA = valsA[0];
|
|
132
|
+
const dgB = valsB[0];
|
|
133
|
+
if (dgB < dgA)
|
|
134
|
+
return false;
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// 3. Resources / NotResources
|
|
142
|
+
const thisResource = this.resource;
|
|
143
|
+
const thisNotResource = this.notResource;
|
|
144
|
+
const otherResource = other.resource;
|
|
145
|
+
const otherNotResource = other.notResource;
|
|
146
|
+
// 3a. If both have resource[]
|
|
147
|
+
if (thisResource !== undefined && otherResource !== undefined) {
|
|
148
|
+
return otherResource.every((r2) => thisResource.some((r1) => wildcardToRegex(r1).test(r2)));
|
|
149
|
+
}
|
|
150
|
+
// 3b. Both have notResource[]
|
|
151
|
+
if (thisNotResource !== undefined && otherNotResource !== undefined) {
|
|
152
|
+
return thisNotResource.every((n1) => otherNotResource.some((n2) => wildcardToRegex(n1).test(n2)));
|
|
153
|
+
}
|
|
154
|
+
// 3c. A.resource & B.notResource -> B allows almost all, A allows only R1 -> true iff every N2 is matched by some R1
|
|
155
|
+
if (thisResource !== undefined && otherNotResource !== undefined) {
|
|
156
|
+
return otherNotResource.every((n2) => thisResource.some((r1) => wildcardToRegex(r1).test(n2)));
|
|
157
|
+
}
|
|
158
|
+
// 3d. A.notResource & B.resource -> every r2 ∉ N1
|
|
159
|
+
if (thisNotResource !== undefined && otherResource !== undefined) {
|
|
160
|
+
return otherResource.every((r2) => !thisNotResource.some((n1) => wildcardToRegex(n1).test(r2)));
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Returns the union of this Permission with another.
|
|
166
|
+
* If one includes the other, return the including Permission.
|
|
167
|
+
* Otherwise, attempt to merge conditions and resource/notResource.
|
|
168
|
+
* If merge yields a single Permission, return it; else return both.
|
|
169
|
+
*/
|
|
170
|
+
union(other) {
|
|
171
|
+
// 1. Ensure same effect, service, and action
|
|
172
|
+
if (this.effect !== other.effect ||
|
|
173
|
+
this.service !== other.service ||
|
|
174
|
+
this.action !== other.action) {
|
|
175
|
+
return [this, other];
|
|
176
|
+
}
|
|
177
|
+
// 2. If one includes the other, return the including one
|
|
178
|
+
if (this.includes(other)) {
|
|
179
|
+
return [this];
|
|
180
|
+
}
|
|
181
|
+
if (other.includes(this)) {
|
|
182
|
+
return [other];
|
|
183
|
+
}
|
|
184
|
+
// 3. Attempt to combine conditions
|
|
185
|
+
const condsA = this.conditions || {};
|
|
186
|
+
const condsB = other.conditions || {};
|
|
187
|
+
const mergedConds = mergeConditions(condsA, condsB);
|
|
188
|
+
if (mergedConds === null) {
|
|
189
|
+
return [this, other];
|
|
190
|
+
}
|
|
191
|
+
// 4. Combine resource/notResource (constructor enforces exclusivity)
|
|
192
|
+
const thisResource = this.resource;
|
|
193
|
+
const thisNotResource = this.notResource;
|
|
194
|
+
const otherResource = other.resource;
|
|
195
|
+
const otherNotResource = other.notResource;
|
|
196
|
+
const eff = this.effect;
|
|
197
|
+
const svc = this.service;
|
|
198
|
+
const act = this.action;
|
|
199
|
+
const conds = Object.keys(mergedConds).length > 0 ? mergedConds : undefined;
|
|
200
|
+
// Both have resource[]
|
|
201
|
+
if (thisResource !== undefined && otherResource !== undefined) {
|
|
202
|
+
const union = Array.from(new Set([...thisResource, ...otherResource]));
|
|
203
|
+
return [new Permission(eff, svc, act, union, undefined, conds)];
|
|
204
|
+
}
|
|
205
|
+
// Both have notResource[]
|
|
206
|
+
if (thisNotResource !== undefined && otherNotResource !== undefined) {
|
|
207
|
+
// Intersection of both notResource arrays
|
|
208
|
+
const intersection = thisNotResource.filter((n) => otherNotResource.includes(n));
|
|
209
|
+
return [new Permission(eff, svc, act, undefined, intersection, conds)];
|
|
210
|
+
}
|
|
211
|
+
// One has resource, other has notResource
|
|
212
|
+
if (thisResource !== undefined && otherNotResource !== undefined) {
|
|
213
|
+
return [
|
|
214
|
+
new Permission(eff, svc, act, thisResource, undefined, conds),
|
|
215
|
+
new Permission(eff, svc, act, undefined, otherNotResource, conds)
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
if (otherResource !== undefined && thisNotResource !== undefined) {
|
|
219
|
+
return [
|
|
220
|
+
new Permission(eff, svc, act, otherResource, undefined, conds),
|
|
221
|
+
new Permission(eff, svc, act, undefined, thisNotResource, conds)
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
// Otherwise cannot combine, return both
|
|
225
|
+
return [this, other];
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Returns the intersection of this Permission with another.
|
|
229
|
+
* Always returns exactly one Permission. If there is no overlap,
|
|
230
|
+
* returns undefined.
|
|
231
|
+
*/
|
|
232
|
+
intersection(other) {
|
|
233
|
+
// 1. Must match effect, service, and action
|
|
234
|
+
if (this.effect !== other.effect ||
|
|
235
|
+
this.service !== other.service ||
|
|
236
|
+
this.action !== other.action) {
|
|
237
|
+
// No overlap at all—return a "zero-resource" permission
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
if (this.resource != undefined && other.resource != undefined) {
|
|
241
|
+
// 2. If one includes the other, return the narrower one unless both are NotResource
|
|
242
|
+
if (this.includes(other)) {
|
|
243
|
+
return other;
|
|
244
|
+
}
|
|
245
|
+
if (other.includes(this)) {
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 3. Attempt to intersect/merge conditions
|
|
250
|
+
const a = normalizeConditionKeys(this.conditions || {});
|
|
251
|
+
const b = normalizeConditionKeys(other.conditions || {});
|
|
252
|
+
const allOps = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
|
|
253
|
+
const intersectedConds = {};
|
|
254
|
+
for (const op of allOps) {
|
|
255
|
+
const condA = a[op] || {};
|
|
256
|
+
const condB = b[op] || {};
|
|
257
|
+
const allKeys = Array.from(new Set([...Object.keys(condA), ...Object.keys(condB)]));
|
|
258
|
+
intersectedConds[op] = {};
|
|
259
|
+
for (const key of allKeys) {
|
|
260
|
+
const valsA = condA[key] || [];
|
|
261
|
+
const valsB = condB[key] || [];
|
|
262
|
+
// If key appears in both, intersect or combine based on operator
|
|
263
|
+
if (key in condA && key in condB) {
|
|
264
|
+
switch (conditionBaseOperator(op)) {
|
|
265
|
+
case 'stringequals':
|
|
266
|
+
case 'stringlike':
|
|
267
|
+
case 'arnequals':
|
|
268
|
+
case 'arnlike': {
|
|
269
|
+
// Intersection of string lists
|
|
270
|
+
const common = valsA.filter((v) => valsB.includes(v));
|
|
271
|
+
if (common.length === 0) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
intersectedConds[op][key] = common;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'stringnotequals':
|
|
278
|
+
case 'stringnotlike':
|
|
279
|
+
case 'arnnotequals':
|
|
280
|
+
case 'arnnotlike': {
|
|
281
|
+
// Union of exclusions
|
|
282
|
+
intersectedConds[op][key] = Array.from(new Set([...valsA, ...valsB]));
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case 'numericlessthan':
|
|
286
|
+
case 'numericlessthanequals': {
|
|
287
|
+
const numA = Number(valsA[0]);
|
|
288
|
+
const numB = Number(valsB[0]);
|
|
289
|
+
if (isNaN(numA) || isNaN(numB)) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const boundary = Math.min(numA, numB);
|
|
293
|
+
intersectedConds[op][key] = [String(boundary)];
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
case 'numericgreaterthan':
|
|
297
|
+
case 'numericgreaterthanequals': {
|
|
298
|
+
const ngA = Number(valsA[0]);
|
|
299
|
+
const ngB = Number(valsB[0]);
|
|
300
|
+
if (isNaN(ngA) || isNaN(ngB)) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const boundary = Math.max(ngA, ngB);
|
|
304
|
+
intersectedConds[op][key] = [String(boundary)];
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case 'bool': {
|
|
308
|
+
if (valsA[0] !== valsB[0]) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
intersectedConds[op][key] = [valsA[0]];
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case 'ipaddress':
|
|
315
|
+
case 'notipaddress': {
|
|
316
|
+
const common = valsA.filter((cidr) => valsB.includes(cidr));
|
|
317
|
+
if (common.length === 0) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
intersectedConds[op][key] = common;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case 'datelessthan':
|
|
324
|
+
case 'datelessthanequals': {
|
|
325
|
+
const dA = valsA[0];
|
|
326
|
+
const dB = valsB[0];
|
|
327
|
+
intersectedConds[op][key] = [dA < dB ? dA : dB];
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case 'dategreaterthan':
|
|
331
|
+
case 'dategreaterthanequals': {
|
|
332
|
+
const dgA = valsA[0];
|
|
333
|
+
const dgB = valsB[0];
|
|
334
|
+
intersectedConds[op][key] = [dgA > dgB ? dgA : dgB];
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
default:
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// Key only in one side: carry it through
|
|
343
|
+
intersectedConds[op][key] = key in condA ? Array.from(valsA) : Array.from(valsB);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// 4. Combine resource/notResource:
|
|
348
|
+
const thisResource = this.resource;
|
|
349
|
+
const thisNotResource = this.notResource;
|
|
350
|
+
const otherResource = other.resource;
|
|
351
|
+
const otherNotResource = other.notResource;
|
|
352
|
+
const eff = this.effect;
|
|
353
|
+
const svc = this.service;
|
|
354
|
+
const act = this.action;
|
|
355
|
+
const conds = Object.keys(intersectedConds).length > 0 ? intersectedConds : undefined;
|
|
356
|
+
// Both have resource[] => intersect patterns
|
|
357
|
+
if (thisResource !== undefined && otherResource !== undefined) {
|
|
358
|
+
// Keep any R1 that matches something in R2, and any R2 that matches something in R1
|
|
359
|
+
const part1 = thisResource.filter((r1) => otherResource.some((r2) => wildcardToRegex(r1).test(r2)));
|
|
360
|
+
const part2 = otherResource.filter((r2) => thisResource.some((r1) => wildcardToRegex(r2).test(r1)));
|
|
361
|
+
const intersectR = Array.from(new Set([...part1, ...part2]));
|
|
362
|
+
if (intersectR.length === 0) {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
return new Permission(eff, svc, act, intersectR, undefined, conds);
|
|
366
|
+
}
|
|
367
|
+
// Both have notResource[] => union of exclusions (more restrictive), but remove subsumed patterns
|
|
368
|
+
if (thisNotResource !== undefined && otherNotResource !== undefined) {
|
|
369
|
+
// Compute union of both exclusion lists
|
|
370
|
+
const combined = Array.from(new Set([...thisNotResource, ...otherNotResource]));
|
|
371
|
+
// Remove any pattern that is subsumed by a more general pattern
|
|
372
|
+
const filtered = combined.filter((pat) => !combined.some((otherPat) => otherPat !== pat && wildcardToRegex(otherPat).test(pat)));
|
|
373
|
+
return new Permission(eff, svc, act, undefined, filtered, conds);
|
|
374
|
+
}
|
|
375
|
+
// One has resource, other has notResource
|
|
376
|
+
const resource = thisResource || otherResource;
|
|
377
|
+
const notResource = thisNotResource || otherNotResource;
|
|
378
|
+
if (resource !== undefined || notResource !== undefined) {
|
|
379
|
+
const filtered = resource.filter((r1) => !notResource.some((n2) => wildcardToRegex(n2).test(r1)));
|
|
380
|
+
if (filtered.length === 0) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
return new Permission(eff, svc, act, filtered, undefined, conds);
|
|
384
|
+
}
|
|
385
|
+
// This should never happen
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Subtract a Deny permission from this Allow permission.
|
|
390
|
+
* Returns an array of resulting Allow permissions (may be empty if fully denied).
|
|
391
|
+
*/
|
|
392
|
+
subtract(other) {
|
|
393
|
+
// Only subtract Deny from Allow for the same service/action
|
|
394
|
+
if (this.effect !== 'Allow' ||
|
|
395
|
+
other.effect !== 'Deny' ||
|
|
396
|
+
this.service !== other.service ||
|
|
397
|
+
this.action !== other.action) {
|
|
398
|
+
// No subtraction applies
|
|
399
|
+
return [this];
|
|
400
|
+
}
|
|
401
|
+
// Early exit: identical conditions and deny covers allow resources => fully denied
|
|
402
|
+
const allowCondsNorm = normalizeConditionKeys(this.conditions || {});
|
|
403
|
+
const denyCondsNorm = normalizeConditionKeys(other.conditions || {});
|
|
404
|
+
if (JSON.stringify(allowCondsNorm) === JSON.stringify(denyCondsNorm)) {
|
|
405
|
+
// If both have resource[] and deny resources include all allow resources
|
|
406
|
+
if (this.resource && other.resource) {
|
|
407
|
+
if (this.resource.every((a) => other.resource.some((d) => wildcardToRegex(d).test(a)))) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// If both have notResource[] and deny.notResource excludes superset of allow.notResource
|
|
412
|
+
if (this.notResource && other.notResource) {
|
|
413
|
+
// Deny excludes everything allow excludes or more, so allow has no effective resources
|
|
414
|
+
if (this.notResource.every((n) => other.notResource.includes(n))) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// 1. Invert Deny conditions
|
|
420
|
+
const inverted = invertConditions(other.conditions || {});
|
|
421
|
+
// 2. Merge conditions: original Allow ∧ inverted Deny
|
|
422
|
+
const allowConds = normalizeConditionKeys(this.conditions || {});
|
|
423
|
+
const mergedConds = mergeComplementaryConditions(mergeConditions(allowConds, inverted) || {
|
|
424
|
+
...allowConds,
|
|
425
|
+
...inverted
|
|
426
|
+
});
|
|
427
|
+
const allowResource = this.resource;
|
|
428
|
+
const allowNotResource = this.notResource;
|
|
429
|
+
const denyResource = other.resource;
|
|
430
|
+
const denyNotResource = other.notResource;
|
|
431
|
+
const eff = this.effect;
|
|
432
|
+
const svc = this.service;
|
|
433
|
+
const act = this.action;
|
|
434
|
+
const conds = Object.keys(mergedConds).length ? mergedConds : undefined;
|
|
435
|
+
// Case: Allow.resource & Deny.resource
|
|
436
|
+
if (allowResource !== undefined && denyResource !== undefined) {
|
|
437
|
+
// If Deny has no conditions, subtract resources normally
|
|
438
|
+
if (!other.conditions || Object.keys(other.conditions).length === 0) {
|
|
439
|
+
const remaining = allowResource.filter((a) => !denyResource.some((d) => wildcardToRegex(d).test(a)));
|
|
440
|
+
// we cannot express the subtraction in a single statement → keep both.
|
|
441
|
+
const denyIsSubset = denyResource.every((d) => allowResource.some((a) => wildcardToRegex(a).test(d)));
|
|
442
|
+
if (denyIsSubset && remaining.length === allowResource.length) {
|
|
443
|
+
return [this, other];
|
|
444
|
+
}
|
|
445
|
+
if (remaining.length === 0)
|
|
446
|
+
return [];
|
|
447
|
+
return [new Permission(eff, svc, act, remaining, undefined, conds)];
|
|
448
|
+
}
|
|
449
|
+
// Deny is conditional: do not remove resources, let condition inversion handle exclusion
|
|
450
|
+
return [new Permission(eff, svc, act, allowResource, undefined, conds)];
|
|
451
|
+
}
|
|
452
|
+
// Case: Allow.resource & Deny.notResource --> remaining = A ∩ DNR
|
|
453
|
+
if (allowResource !== undefined && denyNotResource !== undefined) {
|
|
454
|
+
// If Deny has conditions, skip list-based subtraction and rely on conditions only
|
|
455
|
+
if (other.conditions && Object.keys(other.conditions).length > 0) {
|
|
456
|
+
return [new Permission(eff, svc, act, allowResource, undefined, conds)];
|
|
457
|
+
}
|
|
458
|
+
const remaining = allowResource.filter((a) => denyNotResource.some((dnr) => wildcardToRegex(dnr).test(a)));
|
|
459
|
+
if (remaining.length === 0)
|
|
460
|
+
return [];
|
|
461
|
+
return [new Permission(eff, svc, act, remaining, undefined, conds)];
|
|
462
|
+
}
|
|
463
|
+
// Case: Allow.notResource & Deny.resource
|
|
464
|
+
if (allowNotResource !== undefined && denyResource !== undefined) {
|
|
465
|
+
// If Deny is conditional, let conditions handle; keep original notResource
|
|
466
|
+
if (other.conditions && Object.keys(other.conditions).length > 0) {
|
|
467
|
+
return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
|
|
468
|
+
}
|
|
469
|
+
// Check if every Deny resource is already excluded by allowNotResource
|
|
470
|
+
const denyCovered = denyResource.every((dr) => allowNotResource.some((anr) => wildcardToRegex(anr).test(dr)));
|
|
471
|
+
if (denyCovered) {
|
|
472
|
+
// Deny adds no new exclusions; keep original
|
|
473
|
+
return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
|
|
474
|
+
}
|
|
475
|
+
// Otherwise union the exclusions
|
|
476
|
+
const newNot = Array.from(new Set([...allowNotResource, ...denyResource]));
|
|
477
|
+
return [new Permission(eff, svc, act, undefined, newNot, conds)];
|
|
478
|
+
}
|
|
479
|
+
// Case: Allow.notResource & Deny.notResource --> newNot = ANR \ DNR
|
|
480
|
+
if (allowNotResource !== undefined && denyNotResource !== undefined) {
|
|
481
|
+
// If Deny has conditions, skip list-based subtraction and rely on conditions only
|
|
482
|
+
if (other.conditions && Object.keys(other.conditions).length > 0) {
|
|
483
|
+
return [new Permission(eff, svc, act, undefined, allowNotResource, conds)];
|
|
484
|
+
}
|
|
485
|
+
const remainingNot = allowNotResource.filter((n) => !denyNotResource.some((dnr) => wildcardToRegex(dnr).test(n)));
|
|
486
|
+
if (remainingNot.length === 0)
|
|
487
|
+
return [];
|
|
488
|
+
return [new Permission(eff, svc, act, undefined, remainingNot, conds)];
|
|
489
|
+
}
|
|
490
|
+
// This should never happen
|
|
491
|
+
throw new Error('Permission.subtract: This should never happen—invalid state.');
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
exports.Permission = Permission;
|
|
495
|
+
/**
|
|
496
|
+
* Attempt to merge two condition‐maps. If they can be expressed as a single IAM condition block,
|
|
497
|
+
* return that merged block. Otherwise, return null (indicating no single‐block merger is possible).
|
|
498
|
+
*/
|
|
499
|
+
function mergeConditions(a, b) {
|
|
500
|
+
// 1. If the set of operators in 'a' differs from the set in 'b', return null.
|
|
501
|
+
a = normalizeConditionKeys(a);
|
|
502
|
+
b = normalizeConditionKeys(b);
|
|
503
|
+
const opsA = Object.keys(a).sort();
|
|
504
|
+
const opsB = Object.keys(b).sort();
|
|
505
|
+
if (JSON.stringify(opsA) !== JSON.stringify(opsB)) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
const merged = {};
|
|
509
|
+
// 2. For each operator op that appears in both:
|
|
510
|
+
for (const op of opsA) {
|
|
511
|
+
const keysA = Object.keys(a[op]).sort();
|
|
512
|
+
const keysB = Object.keys(b[op]).sort();
|
|
513
|
+
// If the set of context‐keys under this operator differs, we can't merge as one block
|
|
514
|
+
if (JSON.stringify(keysA) !== JSON.stringify(keysB)) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
// Now we know op and its context keys align. Build the merged set for this operator:
|
|
518
|
+
merged[op] = {};
|
|
519
|
+
for (const key of keysA) {
|
|
520
|
+
const valsA = a[op][key];
|
|
521
|
+
const valsB = b[op][key];
|
|
522
|
+
// How we combine depends on operator semantics:
|
|
523
|
+
switch (conditionBaseOperator(op)) {
|
|
524
|
+
case 'stringequals':
|
|
525
|
+
case 'stringlike':
|
|
526
|
+
case 'stringnotequals':
|
|
527
|
+
case 'stringnotlike':
|
|
528
|
+
case 'arnequals':
|
|
529
|
+
case 'arnlike':
|
|
530
|
+
case 'arnnotequals':
|
|
531
|
+
case 'arnnotlike':
|
|
532
|
+
// String‐based operators: just union the value arrays
|
|
533
|
+
merged[op][key] = Array.from(new Set([...valsA, ...valsB]));
|
|
534
|
+
break;
|
|
535
|
+
case 'numericlessthan':
|
|
536
|
+
case 'numericlessthanequals':
|
|
537
|
+
case 'numericgreaterthan':
|
|
538
|
+
case 'numericgreaterthanequals':
|
|
539
|
+
case 'numericequals':
|
|
540
|
+
case 'numericnotequals':
|
|
541
|
+
// Numeric operators: pick the “widest” comparison that still covers both sets
|
|
542
|
+
// For simplicity, convert all valsA/valsB to numbers; find the min or max
|
|
543
|
+
const numsA = valsA.map((v) => Number(v));
|
|
544
|
+
const numsB = valsB.map((v) => Number(v));
|
|
545
|
+
if (numsA.some(isNaN) || numsB.some(isNaN)) {
|
|
546
|
+
// Malformed number—cannot merge
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
if (op === 'numericlessthan' || op === 'numericlessthanequals') {
|
|
550
|
+
// We want the largest boundary
|
|
551
|
+
const candidate = Math.max(...numsA, ...numsB);
|
|
552
|
+
merged[op][key] = [String(candidate)];
|
|
553
|
+
}
|
|
554
|
+
else if (op === 'numericgreaterthan' || op === 'numericgreaterthanequals') {
|
|
555
|
+
// We want the smallest boundary
|
|
556
|
+
const candidate = Math.min(...numsA, ...numsB);
|
|
557
|
+
merged[op][key] = [String(candidate)];
|
|
558
|
+
}
|
|
559
|
+
else if (op === 'numericequals' || op === 'numericnotequals') {
|
|
560
|
+
// Union the sets of allowed/not‐allowed numbers
|
|
561
|
+
merged[op][key] = Array.from(new Set([...valsA.map(String), ...valsB.map(String)]));
|
|
562
|
+
}
|
|
563
|
+
break;
|
|
564
|
+
case 'datelessthan':
|
|
565
|
+
case 'datelessthanequals':
|
|
566
|
+
case 'dategreaterthan':
|
|
567
|
+
case 'dategreaterthanequals':
|
|
568
|
+
// Similar idea: choose the “widest” date limit
|
|
569
|
+
// Assume ISO‐8601 strings so lex‐compare works
|
|
570
|
+
if (op === 'datelessthan' || op === 'datelessthanequals') {
|
|
571
|
+
// pick the LARGEST date (latest) because “< latest” covers “< earlier”
|
|
572
|
+
const candidate = [...valsA, ...valsB].sort().reverse()[0];
|
|
573
|
+
merged[op][key] = [candidate];
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// "DateGreaterThan"/"DateGreaterThanEquals": pick the EARLIEST date
|
|
577
|
+
const candidate = [...valsA, ...valsB].sort()[0];
|
|
578
|
+
merged[op][key] = [candidate];
|
|
579
|
+
}
|
|
580
|
+
break;
|
|
581
|
+
case 'bool':
|
|
582
|
+
// Typically valsA and valsB are ["true"] or ["false"].
|
|
583
|
+
// If either contains "true", then the union is ["true","false"]? No—
|
|
584
|
+
// Bool doesn't make sense with an array. In IAM, Bool only works with a single value.
|
|
585
|
+
// If values differ (one says ["true"], the other says ["false"]), you cannot
|
|
586
|
+
// express (Bool==true OR Bool==false) as a single Bool. You’d need two separate
|
|
587
|
+
// statements. So bail out.
|
|
588
|
+
if (valsA[0] === valsB[0]) {
|
|
589
|
+
merged[op][key] = [valsA[0]];
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
break;
|
|
595
|
+
case 'ipaddress':
|
|
596
|
+
case 'notipaddress':
|
|
597
|
+
// You can pass multiple CIDR blocks under a single IpAddress. So union them.
|
|
598
|
+
merged[op][key] = Array.from(new Set([...valsA, ...valsB]));
|
|
599
|
+
break;
|
|
600
|
+
// Any other operators (e.g., “ArnNotLike” etc.) behave similarly to their base type
|
|
601
|
+
default:
|
|
602
|
+
// If we don’t explicitly handle the operator, reject merging
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return merged;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Checks if an IAM condition operator ends with "IfExists".
|
|
611
|
+
*
|
|
612
|
+
* @param op the IAM condition operator, e.g., "StringEqualsIfExists"
|
|
613
|
+
* @returns true if the operator ends with "IfExists", false otherwise.
|
|
614
|
+
*/
|
|
615
|
+
function isIfExists(op) {
|
|
616
|
+
// Check if the operator ends with "IfExists"
|
|
617
|
+
return op.toLowerCase().endsWith('ifexists');
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Get the set operator from an IAM condition operator such as "ForAllValues" or "ForAnyValue".
|
|
621
|
+
*
|
|
622
|
+
* @param op the IAM condition operator, e.g., "ForAllValues:StringEquals"
|
|
623
|
+
* @returns the set operator, e.g., "forallvalues" or "foranyvalue", or undefined if no set operator is present.
|
|
624
|
+
*/
|
|
625
|
+
function conditionSetOperator(op) {
|
|
626
|
+
return op.includes(':') ? op.split(':')[0].toLowerCase() : undefined;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Gets the base operator name from an IAM condition operator. Removes any set operator prefix or
|
|
630
|
+
* "IfExists" suffix.
|
|
631
|
+
*
|
|
632
|
+
* @param op the IAM condition operator, e.g., "ForAllValues:StringEqualsIfExists"
|
|
633
|
+
* @returns the base operator name, e.g., "stringequals" or "arnequals".
|
|
634
|
+
*/
|
|
635
|
+
function conditionBaseOperator(op) {
|
|
636
|
+
// Return the base operator name for IAM condition operators
|
|
637
|
+
return op
|
|
638
|
+
.split(':')
|
|
639
|
+
.at(-1)
|
|
640
|
+
.toLowerCase()
|
|
641
|
+
.replace(/ifexists$/, '');
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Returns a new PermissionConditions object with all operator and context keys lowercased.
|
|
645
|
+
*/
|
|
646
|
+
function normalizeConditionKeys(conds) {
|
|
647
|
+
const result = {};
|
|
648
|
+
for (const [op, keyMap] of Object.entries(conds)) {
|
|
649
|
+
const lowerOp = op.toLowerCase();
|
|
650
|
+
result[lowerOp] = {};
|
|
651
|
+
for (const [contextKey, values] of Object.entries(keyMap)) {
|
|
652
|
+
const lowerContextKey = contextKey.toLowerCase();
|
|
653
|
+
result[lowerOp][lowerContextKey] = Array.from(values);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
const invertOperatorMap = {
|
|
659
|
+
stringequals: 'StringNotEquals',
|
|
660
|
+
stringlike: 'StringNotLike',
|
|
661
|
+
arnequals: 'ArnNotEquals',
|
|
662
|
+
arnlike: 'ArnNotLike',
|
|
663
|
+
stringnotequals: 'StringEquals',
|
|
664
|
+
stringnotlike: 'StringLike',
|
|
665
|
+
arnnotequals: 'ArnEquals',
|
|
666
|
+
arnnotlike: 'ArnLike',
|
|
667
|
+
numericlessthan: 'NumericGreaterThanEquals',
|
|
668
|
+
numericlessthanequals: 'NumericGreaterThan',
|
|
669
|
+
numericgreaterthan: 'NumericLessThanEquals',
|
|
670
|
+
numericgreaterthanequals: 'NumericLessThan',
|
|
671
|
+
numericequals: 'NumericNotEquals',
|
|
672
|
+
numericnotequals: 'NumericEquals',
|
|
673
|
+
datelessthan: 'DateGreaterThanEquals',
|
|
674
|
+
datelessthanequals: 'DateGreaterThan',
|
|
675
|
+
dategreaterthan: 'DateLessThanEquals',
|
|
676
|
+
dategreaterthanequals: 'DateLessThan',
|
|
677
|
+
bool: 'Bool',
|
|
678
|
+
ipaddress: 'NotIpAddress',
|
|
679
|
+
notipaddress: 'IpAddress'
|
|
680
|
+
};
|
|
681
|
+
const invertedSetOperatorMap = {
|
|
682
|
+
forallvalues: 'ForAnyValue',
|
|
683
|
+
foranyvalue: 'ForAllValues'
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* Invert a set of IAM condition clauses for Deny → allow inversion.
|
|
687
|
+
* Preserves ForAllValues:/ForAnyValue: prefixes and IfExists suffixes.
|
|
688
|
+
*
|
|
689
|
+
* @param conds the condition clauses to invert
|
|
690
|
+
* @return a new set of inverted conditions
|
|
691
|
+
*/
|
|
692
|
+
function invertConditions(conds) {
|
|
693
|
+
const normalized = normalizeConditionKeys(conds);
|
|
694
|
+
const inverted = {};
|
|
695
|
+
for (const [op, keyMap] of Object.entries(normalized)) {
|
|
696
|
+
const setOperator = conditionSetOperator(op) || undefined;
|
|
697
|
+
const setOperatorPrefix = setOperator ? invertedSetOperatorMap[setOperator] + ':' : '';
|
|
698
|
+
const hasIfExists = isIfExists(op);
|
|
699
|
+
const coreOp = conditionBaseOperator(op);
|
|
700
|
+
const invertedCore = invertOperatorMap[coreOp] || coreOp;
|
|
701
|
+
const invertedOp = `${setOperatorPrefix}${invertedCore}${hasIfExists ? 'IfExists' : ''}`.toLowerCase();
|
|
702
|
+
inverted[invertedOp] = {};
|
|
703
|
+
for (const [key, vals] of Object.entries(keyMap)) {
|
|
704
|
+
if (coreOp === 'bool' || coreOp === 'null') {
|
|
705
|
+
inverted[invertedOp][key] = vals.map((v) => (v.toLowerCase() === 'true' ? 'false' : 'true'));
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
inverted[invertedOp][key] = Array.from(vals);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return inverted;
|
|
713
|
+
}
|
|
714
|
+
function mergeComplementaryConditions(c) {
|
|
715
|
+
const complement = {
|
|
716
|
+
stringequals: 'stringnotequals',
|
|
717
|
+
stringlike: 'stringnotlike',
|
|
718
|
+
arnequals: 'arnnotequals',
|
|
719
|
+
arnlike: 'arnnotlike',
|
|
720
|
+
numericequals: 'numericnotequals',
|
|
721
|
+
numericnotequals: 'numericequals',
|
|
722
|
+
numericlessthan: 'numericgreaterthanequals',
|
|
723
|
+
numericgreaterthanequals: 'numericlessthan',
|
|
724
|
+
numericlessthanequals: 'numericgreaterthan',
|
|
725
|
+
numericgreaterthan: 'numericlessthanequals',
|
|
726
|
+
datelessthan: 'dategreaterthanequals',
|
|
727
|
+
dategreaterthanequals: 'datelessthan',
|
|
728
|
+
datelessthanequals: 'dategreaterthan',
|
|
729
|
+
dategreaterthan: 'datelessthanequals',
|
|
730
|
+
ipaddress: 'notipaddress',
|
|
731
|
+
notipaddress: 'ipaddress',
|
|
732
|
+
bool: 'bool'
|
|
733
|
+
};
|
|
734
|
+
const out = JSON.parse(JSON.stringify(c));
|
|
735
|
+
for (const [base, comp] of Object.entries(complement)) {
|
|
736
|
+
if (out[base] && out[comp]) {
|
|
737
|
+
for (const key of Object.keys(out[base])) {
|
|
738
|
+
if (key in out[comp]) {
|
|
739
|
+
out[base][key] = out[base][key].filter((v) => !out[comp][key].includes(v));
|
|
740
|
+
delete out[comp][key];
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (out[comp] && Object.keys(out[comp]).length === 0)
|
|
744
|
+
delete out[comp];
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
//# sourceMappingURL=permission.js.map
|