Skip to content

Commit 3bf737b

Browse files
authored
feat(iam): PolicyStatements can be frozen (#20911)
PolicyStatements now have a `freeze()` method, which prevents further modification. `freeze()` is called just prior to rendering and other statement manipulation. This has two benefits: - Construct authors can `freeze()` statements and be sure that consumer code holding a reference to the statement can no longer mutate it later. - Third-party library authors that generate IAM statements lazily (specifically, `cdk-iam-floyd`) can hook into the `freeze()` method to do their generation. ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent f2b4eff commit 3bf737b

File tree

4 files changed

+146
-13
lines changed

4 files changed

+146
-13
lines changed

packages/@aws-cdk/aws-iam/lib/policy-document.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export class PolicyDocument implements cdk.IResolvable {
7676
}
7777

7878
public resolve(context: cdk.IResolveContext): any {
79+
this.freezeStatements();
7980
this._maybeMergeStatements(context.scope);
8081

8182
// In the previous implementation of 'merge', sorting of actions/resources on
@@ -212,6 +213,7 @@ export class PolicyDocument implements cdk.IResolvable {
212213
const newDocs: PolicyDocument[] = [];
213214

214215
// Maps final statements to original statements
216+
this.freezeStatements();
215217
let statementsToOriginals = new Map(this.statements.map(s => [s, [s]]));
216218

217219
// We always run 'mergeStatements' to minimize the policy before splitting.
@@ -298,4 +300,13 @@ export class PolicyDocument implements cdk.IResolvable {
298300
private shouldMerge(scope: IConstruct) {
299301
return this.minimize ?? cdk.FeatureFlags.of(scope).isEnabled(cxapi.IAM_MINIMIZE_POLICIES) ?? false;
300302
}
303+
304+
/**
305+
* Freeze all statements
306+
*/
307+
private freezeStatements() {
308+
for (const statement of this.statements) {
309+
statement.freeze();
310+
}
311+
}
301312
}

packages/@aws-cdk/aws-iam/lib/policy-statement.ts

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,28 +69,21 @@ export class PolicyStatement {
6969
return ret;
7070
}
7171

72-
/**
73-
* Statement ID for this statement
74-
*/
75-
public sid?: string;
76-
77-
/**
78-
* Whether to allow or deny the actions in this statement
79-
*/
80-
public effect: Effect;
81-
8272
private readonly _action = new Array<string>();
8373
private readonly _notAction = new Array<string>();
8474
private readonly _principal: { [key: string]: any[] } = {};
8575
private readonly _notPrincipal: { [key: string]: any[] } = {};
8676
private readonly _resource = new Array<string>();
8777
private readonly _notResource = new Array<string>();
8878
private readonly _condition: { [key: string]: any } = { };
79+
private _sid?: string;
80+
private _effect: Effect;
8981
private principalConditionsJson?: string;
9082

9183
// Hold on to those principals
9284
private readonly _principals = new Array<IPrincipal>();
9385
private readonly _notPrincipals = new Array<IPrincipal>();
86+
private _frozen = false;
9487

9588
constructor(props: PolicyStatementProps = {}) {
9689
// Validate actions
@@ -101,8 +94,8 @@ export class PolicyStatement {
10194
}
10295
}
10396

104-
this.sid = props.sid;
105-
this.effect = props.effect || Effect.ALLOW;
97+
this._sid = props.sid;
98+
this._effect = props.effect || Effect.ALLOW;
10699

107100
this.addActions(...props.actions || []);
108101
this.addNotActions(...props.notActions || []);
@@ -115,6 +108,36 @@ export class PolicyStatement {
115108
}
116109
}
117110

111+
/**
112+
* Statement ID for this statement
113+
*/
114+
public get sid(): string | undefined {
115+
return this._sid;
116+
}
117+
118+
/**
119+
* Set Statement ID for this statement
120+
*/
121+
public set sid(sid: string | undefined) {
122+
this.assertNotFrozen('sid');
123+
this._sid = sid;
124+
}
125+
126+
/**
127+
* Whether to allow or deny the actions in this statement
128+
*/
129+
public get effect(): Effect {
130+
return this._effect;
131+
}
132+
133+
/**
134+
* Set effect for this statement
135+
*/
136+
public set effect(effect: Effect) {
137+
this.assertNotFrozen('effect');
138+
this._effect = effect;
139+
}
140+
118141
//
119142
// Actions
120143
//
@@ -127,6 +150,7 @@ export class PolicyStatement {
127150
* @param actions actions that will be allowed.
128151
*/
129152
public addActions(...actions: string[]) {
153+
this.assertNotFrozen('addActions');
130154
if (actions.length > 0 && this._notAction.length > 0) {
131155
throw new Error('Cannot add \'Actions\' to policy statement if \'NotActions\' have been added');
132156
}
@@ -142,6 +166,7 @@ export class PolicyStatement {
142166
* @param notActions actions that will be denied. All other actions will be permitted.
143167
*/
144168
public addNotActions(...notActions: string[]) {
169+
this.assertNotFrozen('addNotActions');
145170
if (notActions.length > 0 && this._action.length > 0) {
146171
throw new Error('Cannot add \'NotActions\' to policy statement if \'Actions\' have been added');
147172
}
@@ -167,6 +192,7 @@ export class PolicyStatement {
167192
* @param principals IAM principals that will be added
168193
*/
169194
public addPrincipals(...principals: IPrincipal[]) {
195+
this.assertNotFrozen('addPrincipals');
170196
this._principals.push(...principals);
171197
if (Object.keys(principals).length > 0 && Object.keys(this._notPrincipal).length > 0) {
172198
throw new Error('Cannot add \'Principals\' to policy statement if \'NotPrincipals\' have been added');
@@ -188,6 +214,7 @@ export class PolicyStatement {
188214
* @param notPrincipals IAM principals that will be denied access
189215
*/
190216
public addNotPrincipals(...notPrincipals: IPrincipal[]) {
217+
this.assertNotFrozen('addNotPrincipals');
191218
this._notPrincipals.push(...notPrincipals);
192219
if (Object.keys(notPrincipals).length > 0 && Object.keys(this._principal).length > 0) {
193220
throw new Error('Cannot add \'NotPrincipals\' to policy statement if \'Principals\' have been added');
@@ -280,6 +307,7 @@ export class PolicyStatement {
280307
* @param arns Amazon Resource Names (ARNs) of the resources that this policy statement applies to
281308
*/
282309
public addResources(...arns: string[]) {
310+
this.assertNotFrozen('addResources');
283311
if (arns.length > 0 && this._notResource.length > 0) {
284312
throw new Error('Cannot add \'Resources\' to policy statement if \'NotResources\' have been added');
285313
}
@@ -295,6 +323,7 @@ export class PolicyStatement {
295323
* @param arns Amazon Resource Names (ARNs) of the resources that this policy statement does not apply to
296324
*/
297325
public addNotResources(...arns: string[]) {
326+
this.assertNotFrozen('addNotResources');
298327
if (arns.length > 0 && this._resource.length > 0) {
299328
throw new Error('Cannot add \'NotResources\' to policy statement if \'Resources\' have been added');
300329
}
@@ -344,6 +373,7 @@ export class PolicyStatement {
344373
* ```
345374
*/
346375
public addCondition(key: string, value: Condition) {
376+
this.assertNotFrozen('addCondition');
347377
const existingValue = this._condition[key];
348378
this._condition[key] = existingValue ? { ...existingValue, ...value } : value;
349379
}
@@ -544,6 +574,29 @@ export class PolicyStatement {
544574
return { ...this._condition };
545575
}
546576

577+
/**
578+
* Make the PolicyStatement immutable
579+
*
580+
* After calling this, any of the `addXxx()` methods will throw an exception.
581+
*
582+
* Libraries that lazily generate statement bodies can override this method to
583+
* fill the actual PolicyStatement fields. Be aware that this method may be called
584+
* multiple times.
585+
*/
586+
public freeze(): PolicyStatement {
587+
this._frozen = true;
588+
return this;
589+
}
590+
591+
/**
592+
* Whether the PolicyStatement has been frozen
593+
*
594+
* The statement object is frozen when `freeze()` is called.
595+
*/
596+
public get frozen(): boolean {
597+
return this._frozen;
598+
}
599+
547600
/**
548601
* Estimate the size of this policy statement
549602
*
@@ -577,6 +630,15 @@ export class PolicyStatement {
577630
}
578631
}
579632
}
633+
634+
/**
635+
* Throw an exception when the object is frozen
636+
*/
637+
private assertNotFrozen(method: string) {
638+
if (this._frozen) {
639+
throw new Error(`${method}: freeze() has been called on this PolicyStatement previously, so it can no longer be modified`);
640+
}
641+
}
580642
}
581643

582644
/**

packages/@aws-cdk/aws-iam/test/merge-statements.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,25 @@ test('keep merging even if it requires multiple passes', () => {
471471
]);
472472
});
473473

474+
test('lazily generated statements are merged correctly', () => {
475+
assertMerged([
476+
new LazyStatement((s) => {
477+
s.addActions('service:A');
478+
s.addResources('R1');
479+
}),
480+
new LazyStatement((s) => {
481+
s.addActions('service:B');
482+
s.addResources('R1');
483+
}),
484+
], [
485+
{
486+
Effect: 'Allow',
487+
Action: ['service:A', 'service:B'],
488+
Resource: 'R1',
489+
},
490+
]);
491+
});
492+
474493
function assertNoMerge(statements: iam.PolicyStatement[]) {
475494
const app = new App();
476495
const stack = new Stack(app, 'Stack');
@@ -499,3 +518,17 @@ function assertMerged(statements: iam.PolicyStatement[], expected: any[]) {
499518
function assertMergedC(doMerge: boolean, statements: iam.PolicyStatement[], expected: any[]) {
500519
return doMerge ? assertMerged(statements, expected) : assertNoMerge(statements);
501520
}
521+
522+
/**
523+
* A statement that fills itself only when freeze() is called.
524+
*/
525+
class LazyStatement extends iam.PolicyStatement {
526+
constructor(private readonly modifyMe: (x: iam.PolicyStatement) => void) {
527+
super();
528+
}
529+
530+
public freeze() {
531+
this.modifyMe(this);
532+
return super.freeze();
533+
}
534+
}

packages/@aws-cdk/aws-iam/test/policy-statement.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Stack } from '@aws-cdk/core';
2-
import { AnyPrincipal, Group, PolicyDocument, PolicyStatement } from '../lib';
2+
import { AnyPrincipal, Group, PolicyDocument, PolicyStatement, Effect } from '../lib';
33

44
describe('IAM policy statement', () => {
55

@@ -214,4 +214,31 @@ describe('IAM policy statement', () => {
214214
expect(() => policyStatement.addNotPrincipals(group))
215215
.toThrow(/Cannot use an IAM Group as the 'Principal' or 'NotPrincipal' in an IAM Policy/);
216216
});
217+
218+
219+
test('a frozen policy statement cannot be modified any more', () => {
220+
// GIVEN
221+
const statement = new PolicyStatement({
222+
actions: ['action:a'],
223+
resources: ['*'],
224+
});
225+
statement.freeze();
226+
227+
// WHEN
228+
const modifications = [
229+
() => statement.sid = 'asdf',
230+
() => statement.effect = Effect.DENY,
231+
() => statement.addActions('abc:def'),
232+
() => statement.addNotActions('abc:def'),
233+
() => statement.addResources('*'),
234+
() => statement.addNotResources('*'),
235+
() => statement.addPrincipals(new AnyPrincipal()),
236+
() => statement.addNotPrincipals(new AnyPrincipal()),
237+
() => statement.addCondition('key', 'value'),
238+
];
239+
240+
for (const mod of modifications) {
241+
expect(mod).toThrow(/can no longer be modified/);
242+
}
243+
});
217244
});

0 commit comments

Comments
 (0)