Skip to content

Commit

Permalink
feat(iam): Permissions Boundaries (#12777)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
rix0rrr authored Feb 1, 2021
1 parent 2c8a409 commit 415eb86
Show file tree
Hide file tree
Showing 10 changed files with 354 additions and 4 deletions.
2 changes: 1 addition & 1 deletion packages/@aws-cdk/assert/lib/assertions/have-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class HaveResourceAssertion extends JestFriendlyAssertion<StackInspector>
for (const logicalId of Object.keys(inspector.value.Resources || {})) {
const resource = inspector.value.Resources[logicalId];
if (resource.Type === this.resourceType) {
const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource;
const propsToCheck = this.part === ResourcePart.Properties ? (resource.Properties ?? {}) : resource;

// Pass inspection object as 2nd argument, initialize failure with default string,
// to maintain backwards compatibility with old predicate API.
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-codebuild/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './cache';
export * from './build-spec';
export * from './file-location';
export * from './linux-gpu-build-image';
export * from './untrusted-code-boundary-policy';

// AWS::CodeBuild CloudFormation Resources:
export * from './codebuild.generated';
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as iam from '@aws-cdk/aws-iam';
import { Construct } from 'constructs';

/**
* Construction properties for UntrustedCodeBoundaryPolicy
*/
export interface UntrustedCodeBoundaryPolicyProps {
/**
* The name of the managed policy.
*
* @default - A name is automatically generated.
*/
readonly managedPolicyName?: string;

/**
* Additional statements to add to the default set of statements
*
* @default - No additional statements
*/
readonly additionalStatements?: iam.PolicyStatement[];
}

/**
* Permissions Boundary for a CodeBuild Project running untrusted code
*
* This class is a Policy, intended to be used as a Permissions Boundary
* for a CodeBuild project. It allows most of the actions necessary to run
* the CodeBuild project, but disallows reading from Parameter Store
* and Secrets Manager.
*
* Use this when your CodeBuild project is running untrusted code (for
* example, if you are using one to automatically build Pull Requests
* that anyone can submit), and you want to prevent your future self
* from accidentally exposing Secrets to this build.
*
* (The reason you might want to do this is because otherwise anyone
* who can submit a Pull Request to your project can write a script
* to email those secrets to themselves).
*
* @example
*
* iam.PermissionsBoundary.of(project).apply(new UntrustedCodeBoundaryPolicy(this, 'Boundary'));
*/
export class UntrustedCodeBoundaryPolicy extends iam.ManagedPolicy {
constructor(scope: Construct, id: string, props: UntrustedCodeBoundaryPolicyProps = {}) {
super(scope, id, {
managedPolicyName: props.managedPolicyName,
description: 'Permissions Boundary Policy for CodeBuild Projects running untrusted code',
statements: [
new iam.PolicyStatement({
actions: [
// For logging
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',

// For test reports
'codebuild:CreateReportGroup',
'codebuild:CreateReport',
'codebuild:UpdateReport',
'codebuild:BatchPutTestCases',
'codebuild:BatchPutCodeCoverages',

// For batch builds
'codebuild:StartBuild',
'codebuild:StopBuild',
'codebuild:RetryBuild',

// For pulling ECR images
'ecr:GetDownloadUrlForLayer',
'ecr:BatchGetImage',
'ecr:BatchCheckLayerAvailability',

// For running in a VPC
'ec2:CreateNetworkInterfacePermission',
'ec2:CreateNetworkInterface',
'ec2:DescribeNetworkInterfaces',
'ec2:DeleteNetworkInterface',
'ec2:DescribeSubnets',
'ec2:DescribeSecurityGroups',
'ec2:DescribeDhcpOptions',
'ec2:DescribeVpcs',

// NOTABLY MISSING:
// - Reading secrets
// - Reading parameterstore
],
resources: ['*'],
}),
...props.additionalStatements ?? [],
],
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, haveResourceLike, arrayWith } from '@aws-cdk/assert';
import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';
import { Test } from 'nodeunit';
import * as codebuild from '../lib';

export = {
'can attach permissions boundary to Project'(test: Test) {
// GIVEN
const stack = new cdk.Stack();

// WHEN
const project = new codebuild.Project(stack, 'Project', {
source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }),
});
iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary'));

// THEN
expect(stack).to(haveResourceLike('AWS::IAM::Role', {
PermissionsBoundary: { Ref: 'BoundaryEA298153' },
}));

test.done();
},

'can add additional statements Boundary'(test: Test) {
// GIVEN
const stack = new cdk.Stack();

// WHEN
const project = new codebuild.Project(stack, 'Project', {
source: codebuild.Source.gitHub({ owner: 'a', repo: 'b' }),
});
iam.PermissionsBoundary.of(project).apply(new codebuild.UntrustedCodeBoundaryPolicy(stack, 'Boundary', {
additionalStatements: [
new iam.PolicyStatement({
actions: ['a:a'],
resources: ['b'],
}),
],
}));

// THEN
expect(stack).to(haveResourceLike('AWS::IAM::ManagedPolicy', {
PolicyDocument: {
Statement: arrayWith({
Effect: 'Allow',
Action: 'a:a',
Resource: 'b',
}),
},
}));

test.done();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ export = {
});

// THEN
expect(stack).notTo(haveResource('AWS::ECR::Repository', {}));
expect(stack).to(haveResource('AWS::ECR::Repository', {}));

test.done();
},
Expand Down
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-ecs/test/test.aws-log-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export = {
test.done();
},

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

// THEN
expect(stack).notTo(haveResource('AWS::Logs::LogGroup', {}));
expect(stack).to(haveResource('AWS::Logs::LogGroup', {}));

test.done();
},
Expand Down
44 changes: 44 additions & 0 deletions packages/@aws-cdk/aws-iam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,50 @@ const newPolicy = new Policy(stack, 'MyNewPolicy', {
});
```

## Permissions Boundaries

[Permissions
Boundaries](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html)
can be used as a mechanism to prevent privilege esclation by creating new
`Role`s. Permissions Boundaries are a Managed Policy, attached to Roles or
Users, that represent the *maximum* set of permissions they can have. The
effective set of permissions of a Role (or User) will be the intersection of
the Identity Policy and the Permissions Boundary attached to the Role (or
User). Permissions Boundaries are typically created by account
Administrators, and their use on newly created `Role`s will be enforced by
IAM policies.

It is possible to attach Permissions Boundaries to all Roles created in a construct
tree all at once:

```ts
// This imports an existing policy.
const boundary = iam.ManagedPolicy.fromManagedPolicyArn(this, 'Boundary', 'arn:aws:iam::123456789012:policy/boundary');

// This creates a new boundary
const boundary2 = new iam.ManagedPolicy(this, 'Boundary2', {
statements: [
new iam.PolicyStatement({
effect: iam.Effect.DENY,
actions: ['iam:*'],
resources: ['*'],
}),
],
});

// Directly apply the boundary to a Role you create
iam.PermissionsBoundary.of(role).apply(boundary);

// Apply the boundary to an Role that was implicitly created for you
iam.PermissionsBoundary.of(lambdaFunction).apply(boundary);

// Apply the boundary to all Roles in a stack
iam.PermissionsBoundary.of(stack).apply(boundary);

// Remove a Permissions Boundary that is inherited, for example from the Stack level
iam.PermissionsBoundary.of(customResource).clear();
```

## OpenID Connect Providers

OIDC identity providers are entities in IAM that describe an external identity
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-iam/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './identity-base';
export * from './grant';
export * from './unknown-principal';
export * from './oidc-provider';
export * from './permissions-boundary';

// AWS::IAM CloudFormation Resources:
export * from './iam.generated';
53 changes: 53 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/permissions-boundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Node, IConstruct } from 'constructs';
import { CfnRole, CfnUser } from './iam.generated';
import { IManagedPolicy } from './managed-policy';

/**
* Modify the Permissions Boundaries of Users and Roles in a construct tree
*
* @example
*
* const policy = ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess');
* PermissionsBoundary.of(stack).apply(policy);
*/
export class PermissionsBoundary {
/**
* Access the Permissions Boundaries of a construct tree
*/
public static of(scope: IConstruct): PermissionsBoundary {
return new PermissionsBoundary(scope);
}

private constructor(private readonly scope: IConstruct) {
}

/**
* Apply the given policy as Permissions Boundary to all Roles in the scope
*
* Will override any Permissions Boundaries configured previously; in case
* a Permission Boundary is applied in multiple scopes, the Boundary applied
* closest to the Role wins.
*/
public apply(boundaryPolicy: IManagedPolicy) {
Node.of(this.scope).applyAspect({
visit(node: IConstruct) {
if (node instanceof CfnRole || node instanceof CfnUser) {
node.permissionsBoundary = boundaryPolicy.managedPolicyArn;
}
},
});
}

/**
* Remove previously applied Permissions Boundaries
*/
public clear() {
Node.of(this.scope).applyAspect({
visit(node: IConstruct) {
if (node instanceof CfnRole || node instanceof CfnUser) {
node.permissionsBoundary = undefined;
}
},
});
}
}
Loading

0 comments on commit 415eb86

Please sign in to comment.