Skip to content

Commit

Permalink
feat(iam): customize IAM role creation behavior (#22856)
Browse files Browse the repository at this point in the history
Introduces a new static method `Role.customizeRoles` which allows customization of role creation behavior. By default this will prevent all IAM roles and policies from being created (they will not be synthesized in the template) and will generate a policy report (both human and machine readable versions) in the
cloud assembly (i.e. cdk.out) with information on the roles that need to be created and their associated IAM policies.

If `customizeRoles.preventSynthesis=true` then this will
- prevent IAM roles (AWS::IAM::Role) from being created
- prevent managed policies (AWS::IAM::ManagedPolicy) from being created
- prevent IAM policies (AWS::IAM::Policy) from being created
- Managed policies that are created and _not_ attached to a role will not be synthesized _and_ will not be added to the report.

Errors are added using `Annotations.addError` so that the app will still synthesize to the `cdk.out` directory and the policy report will be generated.

The policy report can be generated _without_ preventing resource synthesis by setting `customizeRoles.preventSynthesis=false`.

I added an integration test
`aws-lambda/test/integ.lambda-customize-roles.ts` that will only synthesize the snapshot. You can view the generated reports in the snapshot.

refactored:
- Moved the `Import` class that was created under the `fromRoleArn` to a separate private class (imported-role.ts). The implementation is the same (just moved).

This also adds support for the role created as part of custom resources in `core`.

closes #22749, closes #22862


----

### 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*
  • Loading branch information
corymhall authored Nov 11, 2022
1 parent cf78a72 commit b25e526
Show file tree
Hide file tree
Showing 25 changed files with 2,305 additions and 463 deletions.
121 changes: 121 additions & 0 deletions packages/@aws-cdk/aws-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,127 @@ const role = iam.Role.fromRoleArn(this, 'Role', 'arn:aws:iam::123456789012:role/
});
```

### Customizing role creation

It is best practice to allow CDK to manage IAM roles and permissions. You can prevent CDK from
creating roles by using the `customizeRoles` method for special cases. One such case is using CDK in
an environment where role creation is not allowed or needs to be managed through a process outside
of the CDK application.

An example of how to opt in to this behavior is below:

```ts
declare const stack: Stack;
iam.Role.customizeRoles(stack);
```

CDK will not create any IAM roles or policies with the `stack` scope. `cdk synth` will fail and
it will generate a policy report to the cloud assembly (i.e. cdk.out). The `iam-policy-report.txt`
report will contain a list of IAM roles and associated permissions that would have been created.
This report can be used to create the roles with the appropriate permissions outside of
the CDK application.

Once the missing roles have been created, their names can be added to the `usePrecreatedRoles`
property, like shown below:

```ts
declare const app: App;
const stack = new Stack(app, 'MyStack');
iam.Role.customizeRoles(stack, {
usePrecreatedRoles: {
'MyStack/MyRole': 'my-precreated-role-name',
},
});

new iam.Role(stack, 'MyRole', {
assumedBy: new iam.ServicePrincipal('sns.amazonaws.com'),
});
```

If any IAM policies reference deploy time values (i.e. ARN of a resource that hasn't been created
yet) you will have to modify the generated report to be more generic. For example, given the
following CDK code:

```ts
declare const app: App;
const stack = new Stack(app, 'MyStack');
iam.Role.customizeRoles(stack);

const fn = new lambda.Function(stack, 'MyLambda', {
code: new lambda.InlineCode('foo'),
handler: 'index.handler',
runtime: lambda.Runtime.NODEJS_14_X,
});

const bucket = new s3.Bucket(stack, 'Bucket');
bucket.grantRead(fn);
```

The following report will be generated.

```txt
<missing role> (MyStack/MyLambda/ServiceRole)
AssumeRole Policy:
[
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
]
Managed Policy ARNs:
[
"arn:(PARTITION):iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
Managed Policies Statements:
NONE
Identity Policy Statements:
[
{
"Action": [
"s3:GetObject*",
"s3:GetBucket*",
"s3:List*"
],
"Effect": "Allow",
"Resource": [
"(MyStack/Bucket/Resource.Arn)",
"(MyStack/Bucket/Resource.Arn)/*"
]
}
]
```

You would then need to create the role with the inline & managed policies in the report and then
come back and update the `customizeRoles` with the role name.

```ts
declare const app: App;
const stack = new Stack(app, 'MyStack');
iam.Role.customizeRoles(stack, {
usePrecreatedRoles: {
'MyStack/MyLambda/ServiceRole': 'my-role-name',
}
});
```

#### Generating a permissions report

It is also possible to generate the report _without_ preventing the role/policy creation.

```ts
declare const stack: Stack;
iam.Role.customizeRoles(stack, {
preventSynthesis: false,
});
```

## Configuring an ExternalId

If you need to create Roles that will be assumed by third parties, it is generally a good idea to [require an `ExternalId`
Expand Down
53 changes: 35 additions & 18 deletions packages/@aws-cdk/aws-iam/lib/managed-policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ArnFormat, Resource, Stack, Arn, Aws } from '@aws-cdk/core';
import { getCustomizeRolesConfig, PolicySynthesizer } from '@aws-cdk/core/lib/helpers-internal';
import { Construct } from 'constructs';
import { IGroup } from './group';
import { CfnManagedPolicy } from './iam.generated';
Expand Down Expand Up @@ -204,6 +205,7 @@ export class ManagedPolicy extends Resource implements IManagedPolicy {
private readonly roles = new Array<IRole>();
private readonly users = new Array<IUser>();
private readonly groups = new Array<IGroup>();
private readonly _precreatedPolicy?: IManagedPolicy;

constructor(scope: Construct, id: string, props: ManagedPolicyProps = {}) {
super(scope, id, {
Expand All @@ -217,15 +219,33 @@ export class ManagedPolicy extends Resource implements IManagedPolicy {
this.document = props.document;
}

const resource = new CfnManagedPolicy(this, 'Resource', {
policyDocument: this.document,
managedPolicyName: this.physicalName,
description: this.description,
path: this.path,
roles: undefinedIfEmpty(() => this.roles.map(r => r.roleName)),
users: undefinedIfEmpty(() => this.users.map(u => u.userName)),
groups: undefinedIfEmpty(() => this.groups.map(g => g.groupName)),
});
const config = getCustomizeRolesConfig(this);
const _precreatedPolicy = ManagedPolicy.fromManagedPolicyName(this, 'Imported'+id, id);
this.managedPolicyName = id;
this.managedPolicyArn = _precreatedPolicy.managedPolicyArn;
if (config.enabled) {
this._precreatedPolicy = _precreatedPolicy;
}
if (!config.preventSynthesis) {
const resource = new CfnManagedPolicy(this, 'Resource', {
policyDocument: this.document,
managedPolicyName: this.physicalName,
description: this.description,
path: this.path,
roles: undefinedIfEmpty(() => this.roles.map(r => r.roleName)),
users: undefinedIfEmpty(() => this.users.map(u => u.userName)),
groups: undefinedIfEmpty(() => this.groups.map(g => g.groupName)),
});

// arn:aws:iam::123456789012:policy/teststack-CreateTestDBPolicy-16M23YE3CS700
this.managedPolicyName = this.getResourceNameAttribute(Stack.of(this).splitArn(resource.ref, ArnFormat.SLASH_RESOURCE_NAME).resourceName!);
this.managedPolicyArn = this.getResourceArnAttribute(resource.ref, {
region: '', // IAM is global in each partition
service: 'iam',
resource: 'policy',
resourceName: this.physicalName,
});
}

if (props.users) {
props.users.forEach(u => this.attachToUser(u));
Expand All @@ -243,15 +263,6 @@ export class ManagedPolicy extends Resource implements IManagedPolicy {
props.statements.forEach(p => this.addStatements(p));
}

// arn:aws:iam::123456789012:policy/teststack-CreateTestDBPolicy-16M23YE3CS700
this.managedPolicyName = this.getResourceNameAttribute(Stack.of(this).splitArn(resource.ref, ArnFormat.SLASH_RESOURCE_NAME).resourceName!);
this.managedPolicyArn = this.getResourceArnAttribute(resource.ref, {
region: '', // IAM is global in each partition
service: 'iam',
resource: 'policy',
resourceName: this.physicalName,
});

this.node.addValidation({ validate: () => this.validateManagedPolicy() });
}

Expand Down Expand Up @@ -296,6 +307,12 @@ export class ManagedPolicy extends Resource implements IManagedPolicy {

result.push(...this.document.validateForIdentityPolicy());

if (result.length === 0 && this._precreatedPolicy) {
PolicySynthesizer.getOrCreate(this).addManagedPolicy(this.node.path, {
policyStatements: this.document.toJSON()?.Statement,
roles: this.roles.map(role => role.node.path),
});
}
return result;
}
}
88 changes: 88 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/private/imported-role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Resource, Token, TokenComparison, Annotations } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { Grant } from '../grant';
import { IManagedPolicy } from '../managed-policy';
import { Policy } from '../policy';
import { PolicyStatement } from '../policy-statement';
import { IComparablePrincipal, IPrincipal, ArnPrincipal, AddToPrincipalPolicyResult, PrincipalPolicyFragment } from '../principals';
import { IRole, FromRoleArnOptions } from '../role';
import { AttachedPolicies } from '../util';

export interface ImportedRoleProps extends FromRoleArnOptions {
readonly roleArn: string;
readonly roleName: string;
readonly account?: string;
}

export class ImportedRole extends Resource implements IRole, IComparablePrincipal {
public readonly grantPrincipal: IPrincipal = this;
public readonly principalAccount?: string;
public readonly assumeRoleAction: string = 'sts:AssumeRole';
public readonly policyFragment: PrincipalPolicyFragment;
public readonly roleArn: string;
public readonly roleName: string;
private readonly attachedPolicies = new AttachedPolicies();
private readonly defaultPolicyName?: string;
private defaultPolicy?: Policy;

constructor(scope: Construct, id: string, props: ImportedRoleProps) {
super(scope, id, {
account: props.account,
});

this.roleArn = props.roleArn;
this.roleName = props.roleName;
this.policyFragment = new ArnPrincipal(this.roleArn).policyFragment;
this.defaultPolicyName = props.defaultPolicyName;
this.principalAccount = props.account;
}

public addToPolicy(statement: PolicyStatement): boolean {
return this.addToPrincipalPolicy(statement).statementAdded;
}

public addToPrincipalPolicy(statement: PolicyStatement): AddToPrincipalPolicyResult {
if (!this.defaultPolicy) {
this.defaultPolicy = new Policy(this, this.defaultPolicyName ?? 'Policy');
this.attachInlinePolicy(this.defaultPolicy);
}
this.defaultPolicy.addStatements(statement);
return { statementAdded: true, policyDependable: this.defaultPolicy };
}

public attachInlinePolicy(policy: Policy): void {
const thisAndPolicyAccountComparison = Token.compareStrings(this.env.account, policy.env.account);
const equalOrAnyUnresolved = thisAndPolicyAccountComparison === TokenComparison.SAME ||
thisAndPolicyAccountComparison === TokenComparison.BOTH_UNRESOLVED ||
thisAndPolicyAccountComparison === TokenComparison.ONE_UNRESOLVED;
if (equalOrAnyUnresolved) {
this.attachedPolicies.attach(policy);
policy.attachToRole(this);
}
}

public addManagedPolicy(policy: IManagedPolicy): void {
Annotations.of(this).addWarning(`Not adding managed policy: ${policy.managedPolicyArn} to imported role: ${this.roleName}`);
}

public grantPassRole(identity: IPrincipal): Grant {
return this.grant(identity, 'iam:PassRole');
}

public grantAssumeRole(identity: IPrincipal): Grant {
return this.grant(identity, 'sts:AssumeRole');
}

public grant(grantee: IPrincipal, ...actions: string[]): Grant {
return Grant.addToPrincipal({
grantee,
actions,
resourceArns: [this.roleArn],
scope: this,
});
}

public dedupeString(): string | undefined {
return `ImportedRole:${this.roleArn}`;
}
}
Loading

0 comments on commit b25e526

Please sign in to comment.