Skip to content

Commit 415eb86

Browse files
authored
feat(iam): Permissions Boundaries (#12777)
Allow configuring Permissions Boundaries for an entire subtree using Aspects, add a sample policy which can be used to reduce future misconfiguration risk for untrusted CodeBuild projects as an example. Addresses one part of aws/aws-cdk-rfcs#5. Fixes #3242. ALSO IN THIS COMMIT: Fix a bug in the `assert` library, where `haveResource()` would *never* match any resource that didn't have a `Properties` block (even if we tested for no property in particular, or the absence of properties). This fix caused two ECS tests to fail, which were asserting the wrong thing anyway (both were asserting `notTo(haveResource(...))` where they actually meant to assert `to(haveResource())`. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 2c8a409 commit 415eb86

File tree

10 files changed

+354
-4
lines changed

10 files changed

+354
-4
lines changed

packages/@aws-cdk/assert/lib/assertions/have-resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion<StackInspector>
6666
for (const logicalId of Object.keys(inspector.value.Resources || {})) {
6767
const resource = inspector.value.Resources[logicalId];
6868
if (resource.Type === this.resourceType) {
69-
const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource;
69+
const propsToCheck = this.part === ResourcePart.Properties ? (resource.Properties ?? {}) : resource;
7070

7171
// Pass inspection object as 2nd argument, initialize failure with default string,
7272
// to maintain backwards compatibility with old predicate API.

packages/@aws-cdk/aws-codebuild/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './cache';
1010
export * from './build-spec';
1111
export * from './file-location';
1212
export * from './linux-gpu-build-image';
13+
export * from './untrusted-code-boundary-policy';
1314

1415
// AWS::CodeBuild CloudFormation Resources:
1516
export * from './codebuild.generated';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as iam from '@aws-cdk/aws-iam';
2+
import { Construct } from 'constructs';
3+
4+
/**
5+
* Construction properties for UntrustedCodeBoundaryPolicy
6+
*/
7+
export interface UntrustedCodeBoundaryPolicyProps {
8+
/**
9+
* The name of the managed policy.
10+
*
11+
* @default - A name is automatically generated.
12+
*/
13+
readonly managedPolicyName?: string;
14+
15+
/**
16+
* Additional statements to add to the default set of statements
17+
*
18+
* @default - No additional statements
19+
*/
20+
readonly additionalStatements?: iam.PolicyStatement[];
21+
}
22+
23+
/**
24+
* Permissions Boundary for a CodeBuild Project running untrusted code
25+
*
26+
* This class is a Policy, intended to be used as a Permissions Boundary
27+
* for a CodeBuild project. It allows most of the actions necessary to run
28+
* the CodeBuild project, but disallows reading from Parameter Store
29+
* and Secrets Manager.
30+
*
31+
* Use this when your CodeBuild project is running untrusted code (for
32+
* example, if you are using one to automatically build Pull Requests
33+
* that anyone can submit), and you want to prevent your future self
34+
* from accidentally exposing Secrets to this build.
35+
*
36+
* (The reason you might want to do this is because otherwise anyone
37+
* who can submit a Pull Request to your project can write a script
38+
* to email those secrets to themselves).
39+
*
40+
* @example
41+
*
42+
* iam.PermissionsBoundary.of(project).apply(new UntrustedCodeBoundaryPolicy(this, 'Boundary'));
43+
*/
44+
export class UntrustedCodeBoundaryPolicy extends iam.ManagedPolicy {
45+
constructor(scope: Construct, id: string, props: UntrustedCodeBoundaryPolicyProps = {}) {
46+
super(scope, id, {
47+
managedPolicyName: props.managedPolicyName,
48+
description: 'Permissions Boundary Policy for CodeBuild Projects running untrusted code',
49+
statements: [
50+
new iam.PolicyStatement({
51+
actions: [
52+
// For logging
53+
'logs:CreateLogGroup',
54+
'logs:CreateLogStream',
55+
'logs:PutLogEvents',
56+
57+
// For test reports
58+
'codebuild:CreateReportGroup',
59+
'codebuild:CreateReport',
60+
'codebuild:UpdateReport',
61+
'codebuild:BatchPutTestCases',
62+
'codebuild:BatchPutCodeCoverages',
63+
64+
// For batch builds
65+
'codebuild:StartBuild',
66+
'codebuild:StopBuild',
67+
'codebuild:RetryBuild',
68+
69+
// For pulling ECR images
70+
'ecr:GetDownloadUrlForLayer',
71+
'ecr:BatchGetImage',
72+
'ecr:BatchCheckLayerAvailability',
73+
74+
// For running in a VPC
75+
'ec2:CreateNetworkInterfacePermission',
76+
'ec2:CreateNetworkInterface',
77+
'ec2:DescribeNetworkInterfaces',
78+
'ec2:DeleteNetworkInterface',
79+
'ec2:DescribeSubnets',
80+
'ec2:DescribeSecurityGroups',
81+
'ec2:DescribeDhcpOptions',
82+
'ec2:DescribeVpcs',
83+
84+
// NOTABLY MISSING:
85+
// - Reading secrets
86+
// - Reading parameterstore
87+
],
88+
resources: ['*'],
89+
}),
90+
...props.additionalStatements ?? [],
91+
],
92+
});
93+
}
94+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect, haveResourceLike, arrayWith } from '@aws-cdk/assert';
2+
import * as iam from '@aws-cdk/aws-iam';
3+
import * as cdk from '@aws-cdk/core';
4+
import { Test } from 'nodeunit';
5+
import * as codebuild from '../lib';
6+
7+
export = {
8+
'can attach permissions boundary to Project'(test: Test) {
9+
// GIVEN
10+
const stack = new cdk.Stack();
11+
12+
// WHEN
13+
const project = new codebuild.Project(stack, 'Project', {
14+
source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }),
15+
});
16+
iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary'));
17+
18+
// THEN
19+
expect(stack).to(haveResourceLike('AWS::IAM::Role', {
20+
PermissionsBoundary: { Ref: 'BoundaryEA298153' },
21+
}));
22+
23+
test.done();
24+
},
25+
26+
'can add additional statements Boundary'(test: Test) {
27+
// GIVEN
28+
const stack = new cdk.Stack();
29+
30+
// WHEN
31+
const project = new codebuild.Project(stack, 'Project', {
32+
source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }),
33+
});
34+
iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary', {
35+
additionalStatements: [
36+
new iam.PolicyStatement({
37+
actions: ['a:a'],
38+
resources: ['b'],
39+
}),
40+
],
41+
}));
42+
43+
// THEN
44+
expect(stack).to(haveResourceLike('AWS::IAM::ManagedPolicy', {
45+
PolicyDocument: {
46+
Statement: arrayWith({
47+
Effect: 'Allow',
48+
Action: 'a:a',
49+
Resource: 'b',
50+
}),
51+
},
52+
}));
53+
54+
test.done();
55+
},
56+
};

packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ export = {
543543
});
544544

545545
// THEN
546-
expect(stack).notTo(haveResource('AWS::ECR::Repository', {}));
546+
expect(stack).to(haveResource('AWS::ECR::Repository', {}));
547547

548548
test.done();
549549
},

packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export = {
126126
test.done();
127127
},
128128

129-
'without a defined log group'(test: Test) {
129+
'without a defined log group: creates one anyway'(test: Test) {
130130
// GIVEN
131131
td.addContainer('Container', {
132132
image,
@@ -136,7 +136,7 @@ export = {
136136
});
137137

138138
// THEN
139-
expect(stack).notTo(haveResource('AWS::Logs::LogGroup', {}));
139+
expect(stack).to(haveResource('AWS::Logs::LogGroup', {}));
140140

141141
test.done();
142142
},

packages/@aws-cdk/aws-iam/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,50 @@ const newPolicy = new Policy(stack, 'MyNewPolicy', {
264264
});
265265
```
266266

267+
## Permissions Boundaries
268+
269+
[Permissions
270+
Boundaries](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html)
271+
can be used as a mechanism to prevent privilege esclation by creating new
272+
`Role`s. Permissions Boundaries are a Managed Policy, attached to Roles or
273+
Users, that represent the *maximum* set of permissions they can have. The
274+
effective set of permissions of a Role (or User) will be the intersection of
275+
the Identity Policy and the Permissions Boundary attached to the Role (or
276+
User). Permissions Boundaries are typically created by account
277+
Administrators, and their use on newly created `Role`s will be enforced by
278+
IAM policies.
279+
280+
It is possible to attach Permissions Boundaries to all Roles created in a construct
281+
tree all at once:
282+
283+
```ts
284+
// This imports an existing policy.
285+
const boundary = iam.ManagedPolicy.fromManagedPolicyArn(this, 'Boundary', 'arn:aws:iam::123456789012:policy/boundary');
286+
287+
// This creates a new boundary
288+
const boundary2 = new iam.ManagedPolicy(this, 'Boundary2', {
289+
statements: [
290+
new iam.PolicyStatement({
291+
effect: iam.Effect.DENY,
292+
actions: ['iam:*'],
293+
resources: ['*'],
294+
}),
295+
],
296+
});
297+
298+
// Directly apply the boundary to a Role you create
299+
iam.PermissionsBoundary.of(role).apply(boundary);
300+
301+
// Apply the boundary to an Role that was implicitly created for you
302+
iam.PermissionsBoundary.of(lambdaFunction).apply(boundary);
303+
304+
// Apply the boundary to all Roles in a stack
305+
iam.PermissionsBoundary.of(stack).apply(boundary);
306+
307+
// Remove a Permissions Boundary that is inherited, for example from the Stack level
308+
iam.PermissionsBoundary.of(customResource).clear();
309+
```
310+
267311
## OpenID Connect Providers
268312

269313
OIDC identity providers are entities in IAM that describe an external identity

packages/@aws-cdk/aws-iam/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './identity-base';
1111
export * from './grant';
1212
export * from './unknown-principal';
1313
export * from './oidc-provider';
14+
export * from './permissions-boundary';
1415

1516
// AWS::IAM CloudFormation Resources:
1617
export * from './iam.generated';
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Node, IConstruct } from 'constructs';
2+
import { CfnRole, CfnUser } from './iam.generated';
3+
import { IManagedPolicy } from './managed-policy';
4+
5+
/**
6+
* Modify the Permissions Boundaries of Users and Roles in a construct tree
7+
*
8+
* @example
9+
*
10+
* const policy = ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess');
11+
* PermissionsBoundary.of(stack).apply(policy);
12+
*/
13+
export class PermissionsBoundary {
14+
/**
15+
* Access the Permissions Boundaries of a construct tree
16+
*/
17+
public static of(scope: IConstruct): PermissionsBoundary {
18+
return new PermissionsBoundary(scope);
19+
}
20+
21+
private constructor(private readonly scope: IConstruct) {
22+
}
23+
24+
/**
25+
* Apply the given policy as Permissions Boundary to all Roles in the scope
26+
*
27+
* Will override any Permissions Boundaries configured previously; in case
28+
* a Permission Boundary is applied in multiple scopes, the Boundary applied
29+
* closest to the Role wins.
30+
*/
31+
public apply(boundaryPolicy: IManagedPolicy) {
32+
Node.of(this.scope).applyAspect({
33+
visit(node: IConstruct) {
34+
if (node instanceof CfnRole || node instanceof CfnUser) {
35+
node.permissionsBoundary = boundaryPolicy.managedPolicyArn;
36+
}
37+
},
38+
});
39+
}
40+
41+
/**
42+
* Remove previously applied Permissions Boundaries
43+
*/
44+
public clear() {
45+
Node.of(this.scope).applyAspect({
46+
visit(node: IConstruct) {
47+
if (node instanceof CfnRole || node instanceof CfnUser) {
48+
node.permissionsBoundary = undefined;
49+
}
50+
},
51+
});
52+
}
53+
}

0 commit comments

Comments
 (0)