Skip to content

Commit cc43ea3

Browse files
authored
chore(iam): add a PrecreatedRole class (#22824)
This PR adds a new private class for creating a `PrecreatedRole`. For background see the [precreated-roles RFC](https://github.com/aws/aws-cdk-rfcs/blob/master/text/0063-precreated-roles.md). This PR adds the class and the functionality to generate a report as part of synthesis, but it does not yet expose this publicly. A future PR will use this class as part of the `Role` class. The report that is generated will contain references to resources that have not yet been created. Those values are not easy to read in a report and make it harder to search/replace with a hard coded value once the resource _is_ created. To account for this we replace any references with easier to read values. For example, this: ``` "Resource": { "Fn::Join": [ "", [ "arn:", { "Ref": "AWS::Partition" }, ":iam::", { "Ref": "AWS::AccountId" }, ":role/Role" ] ] } ``` Will become: `"Resource": "arn:(PARTITION):iam::(ACCOUNT):role/Role"` note: labeled as `chore` since this does not add any public facing API. Future PRs will add the public API that will expose the report generation closes #22750 ---- ### 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 4d4e8cc commit cc43ea3

File tree

2 files changed

+530
-0
lines changed

2 files changed

+530
-0
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { Resource, ISynthesisSession, attachCustomSynthesis, Stack, Reference, Tokenization, IResolvable, StringConcat, DefaultTokenResolver } from '@aws-cdk/core';
4+
import { Construct, Dependable, DependencyGroup } from 'constructs';
5+
import { Grant } from '../grant';
6+
import { IManagedPolicy } from '../managed-policy';
7+
import { Policy } from '../policy';
8+
import { PolicyDocument } from '../policy-document';
9+
import { PolicyStatement } from '../policy-statement';
10+
import { AddToPrincipalPolicyResult, IPrincipal, PrincipalPolicyFragment } from '../principals';
11+
import { IRole } from '../role';
12+
13+
const POLICY_SYNTHESIZER_ID = 'PolicySynthesizer';
14+
15+
/**
16+
* Options for a precreated role
17+
*/
18+
export interface PrecreatedRoleProps {
19+
/**
20+
* The base role to use for the precreated role. In most cases this will be
21+
* the `Role` or `IRole` that is being created by a construct. For example,
22+
* users (or constructs) will create an IAM role with `new Role(this, 'MyRole', {...})`.
23+
* That `Role` will be used as the base role for the `PrecreatedRole` meaning it be able
24+
* to access any methods and properties on the base role.
25+
*/
26+
readonly role: IRole;
27+
28+
/**
29+
* The assume role (trust) policy for the precreated role.
30+
*
31+
* @default - no assume role policy
32+
*/
33+
readonly assumeRolePolicy?: PolicyDocument;
34+
35+
/**
36+
* If the role is missing from the precreatedRole context
37+
*
38+
* @default false
39+
*/
40+
readonly missing?: boolean;
41+
}
42+
43+
/**
44+
* An IAM role that has been created outside of CDK and can be
45+
* used in place of a role that CDK _is_ creating.
46+
*
47+
* When any policy is attached to a precreated role the policy will be
48+
* synthesized into a separate report and will _not_ be synthesized in
49+
* the CloudFormation template.
50+
*/
51+
export class PrecreatedRole extends Resource implements IRole {
52+
public readonly assumeRoleAction: string;
53+
public readonly policyFragment: PrincipalPolicyFragment;
54+
public readonly grantPrincipal = this;
55+
public readonly principalAccount?: string;
56+
public readonly roleArn: string;
57+
public readonly roleName: string;
58+
public readonly stack: Stack;
59+
60+
private readonly policySynthesizer: PolicySynthesizer;
61+
private readonly policyStatements: string[] = [];
62+
private readonly managedPolicies: string[] = [];
63+
64+
private readonly role: IRole;
65+
constructor(scope: Construct, id: string, props: PrecreatedRoleProps) {
66+
super(scope, id, {
67+
account: props.role.env.account,
68+
region: props.role.env.region,
69+
});
70+
this.role = props.role;
71+
this.assumeRoleAction = this.role.assumeRoleAction;
72+
this.policyFragment = this.role.policyFragment;
73+
this.principalAccount = this.role.principalAccount;
74+
this.roleArn = this.role.roleArn;
75+
this.roleName = this.role.roleName;
76+
this.stack = this.role.stack;
77+
78+
Dependable.implement(this, {
79+
dependencyRoots: [this.role],
80+
});
81+
82+
// add a single PolicySynthesizer under the `App` scope
83+
this.policySynthesizer = (this.node.root.node.tryFindChild(POLICY_SYNTHESIZER_ID)
84+
?? new PolicySynthesizer(this.node.root)) as PolicySynthesizer;
85+
this.policySynthesizer.addRole(this.node.path, {
86+
roleName: this.roleName,
87+
managedPolicies: this.managedPolicies,
88+
policyStatements: this.policyStatements,
89+
assumeRolePolicy: Stack.of(this).resolve(props.assumeRolePolicy?.toJSON()?.Statement),
90+
missing: props.missing,
91+
});
92+
}
93+
94+
public attachInlinePolicy(policy: Policy): void {
95+
const statements = policy.document.toJSON()?.Statement;
96+
if (statements && Array.isArray(statements)) {
97+
statements.forEach(statement => {
98+
this.policyStatements.push(statement);
99+
});
100+
}
101+
}
102+
103+
public addManagedPolicy(policy: IManagedPolicy): void {
104+
this.managedPolicies.push(policy.managedPolicyArn);
105+
}
106+
107+
public addToPolicy(statement: PolicyStatement): boolean {
108+
this.policyStatements.push(statement.toStatementJson());
109+
return false;
110+
}
111+
112+
public addToPrincipalPolicy(statement: PolicyStatement): AddToPrincipalPolicyResult {
113+
this.addToPolicy(statement);
114+
// If we return `false`, the grants will try to add the statement to the resource
115+
// (if possible).
116+
return { statementAdded: true, policyDependable: new DependencyGroup() };
117+
}
118+
119+
public grant(grantee: IPrincipal, ...actions: string[]): Grant {
120+
return this.role.grant(grantee, ...actions);
121+
}
122+
123+
public grantPassRole(grantee: IPrincipal): Grant {
124+
return this.role.grantPassRole(grantee);
125+
}
126+
127+
public grantAssumeRole(identity: IPrincipal): Grant {
128+
return this.role.grantAssumeRole(identity);
129+
}
130+
}
131+
132+
/**
133+
* Options for generating the role policy report
134+
*/
135+
interface RoleReportOptions {
136+
/**
137+
* The name of the IAM role.
138+
*
139+
* If this is not provided the role will be assumed
140+
* to be missing.
141+
*
142+
* @default 'missing role'
143+
*/
144+
readonly roleName?: string;
145+
146+
/**
147+
* A list of IAM Policy Statements
148+
*/
149+
readonly policyStatements: string[];
150+
151+
/**
152+
* A list of IAM Managed Policy ARNs
153+
*/
154+
readonly managedPolicies: string[];
155+
156+
/**
157+
* The trust policy for the IAM Role.
158+
*
159+
* @default - no trust policy.
160+
*/
161+
readonly assumeRolePolicy?: string;
162+
163+
/**
164+
* Whether or not the role is missing from the list of
165+
* precreated roles.
166+
*
167+
* @default false
168+
*/
169+
readonly missing?: boolean;
170+
}
171+
172+
/**
173+
* A construct that is responsible for generating an IAM policy Report
174+
* for all IAM roles that are created as part of the CDK application.
175+
*
176+
* The report will contain the following information for each IAM Role in the app:
177+
*
178+
* 1. Is the role "missing" (not provided in the customizeRoles.usePrecreatedRoles)?
179+
* 2. The AssumeRole Policy (AKA Trust Policy)
180+
* 3. Any "Identity" policies (i.e. policies attached to the role)
181+
* 4. Any Managed policies
182+
*/
183+
class PolicySynthesizer extends Construct {
184+
private readonly roleReport: { [roleName: string]: RoleReportOptions } = {};
185+
constructor(scope: Construct) {
186+
super(scope, POLICY_SYNTHESIZER_ID);
187+
188+
attachCustomSynthesis(this, {
189+
onSynthesize: (session: ISynthesisSession) => {
190+
const filePath = path.join(session.outdir, 'iam-policy-report.txt');
191+
fs.writeFileSync(filePath, this.createReport());
192+
},
193+
});
194+
}
195+
196+
private createReport(): string {
197+
return Object.entries(this.roleReport).flatMap(([key, value]) => {
198+
return [
199+
`<${value.missing ? 'missing role' : value.roleName}> (${key})`,
200+
'',
201+
'AssumeRole Policy:',
202+
...this.toJsonString(value.assumeRolePolicy),
203+
'',
204+
'Managed Policies:',
205+
...this.toJsonString(value.managedPolicies),
206+
'',
207+
'Identity Policy:',
208+
...this.toJsonString(value.policyStatements),
209+
];
210+
}).join('\n');
211+
}
212+
213+
private toJsonString(value?: any): string[] {
214+
if ((Array.isArray(value) && value.length === 0) || !value) {
215+
return [];
216+
}
217+
218+
return [JSON.stringify({ values: this.resolveReferences(value) }.values, undefined, 2)];
219+
}
220+
221+
/**
222+
* Resolve any references and replace with a more user friendly value. This is the value
223+
* that will appear in the report, so instead of getting something like this (not very useful):
224+
*
225+
* "Resource": {
226+
* "Fn::Join": [
227+
* "",
228+
* [
229+
* "arn:",
230+
* {
231+
* "Ref": "AWS::Partition"
232+
* },
233+
* ":iam::",
234+
* {
235+
* "Ref": "AWS::AccountId"
236+
* },
237+
* ":role/Role"
238+
* ]
239+
* ]
240+
* }
241+
*
242+
* We will instead get:
243+
*
244+
* "Resource": "arn:(PARTITION):iam::(ACCOUNT):role/Role"
245+
*
246+
* Or if referencing a resource attribute
247+
*
248+
* "Resource": {
249+
* "Fn::GetAtt": [
250+
* "SomeResource",
251+
* "Arn"
252+
* ]
253+
* }
254+
*
255+
* Becomes
256+
*
257+
* "(Path/To/SomeResource.Arn)"
258+
*/
259+
private resolveReferences(ref: any): any {
260+
if (Array.isArray(ref)) {
261+
return ref.map(r => this.resolveReferences(r));
262+
} else if (typeof ref === 'object') {
263+
return this.resolveJsonObject(ref);
264+
}
265+
const resolvable = Tokenization.reverseString(ref);
266+
if (resolvable.length === 1 && Reference.isReference(resolvable.firstToken)) {
267+
return `(${resolvable.firstToken.target.node.path}.${resolvable.firstToken.displayName})`;
268+
} else {
269+
const resolvedTokens = resolvable.mapTokens({
270+
mapToken: (r: IResolvable) => {
271+
if (Reference.isReference(r)) {
272+
return `(${r.target.node.path}.${r.displayName})`;
273+
}
274+
const resolved = Tokenization.resolve(r, {
275+
scope: this,
276+
resolver: new DefaultTokenResolver(new StringConcat()),
277+
});
278+
if (typeof resolved === 'object' && resolved.hasOwnProperty('Ref')) {
279+
switch (resolved.Ref) {
280+
case 'AWS::AccountId':
281+
return '(ACCOUNT)';
282+
case 'AWS::Partition':
283+
return '(PARTITION)';
284+
case 'AWS::Region':
285+
return '(REGION)';
286+
default:
287+
return r;
288+
}
289+
}
290+
return r;
291+
},
292+
});
293+
return resolvedTokens.join(new StringConcat());
294+
}
295+
}
296+
297+
private resolveJsonObject(statement: { [key: string]: any }): any {
298+
const newStatement = statement;
299+
for (const [key, value] of Object.entries(statement)) {
300+
newStatement[key] = this.resolveReferences(value);
301+
}
302+
return newStatement;
303+
}
304+
305+
/**
306+
* Add an IAM role to the report
307+
*/
308+
public addRole(rolePath: string, options: RoleReportOptions): void {
309+
if (this.roleReport.hasOwnProperty(rolePath)) {
310+
throw new Error(`IAM Policy Report already has an entry for role: ${rolePath}`);
311+
}
312+
this.roleReport[rolePath] = options;
313+
}
314+
}

0 commit comments

Comments
 (0)