Skip to content
16 changes: 16 additions & 0 deletions .changeset/angry-parrots-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@guardian/cdk": minor
---

Add a class for safe instantiation of managed policies with a specific structure of path
which enables them to be discoverable.

This enables teams to define sets of permissions which are re-usable and can be used to
create credentials suitable to approach a given workload, consistent with the Principle
of Least Privilege. This is preferred to existing workflows where a wide-ranging
developer role is used.

These can be reused in multiple locations, so, for example, an EC2 instance can be given
a specific set of permissions which are also identically available for a support task.
Changing one would then change the other, ensuring encapsulation of requirements in a
single place.
1 change: 1 addition & 0 deletions src/constructs/iam/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./describe-ec2";
export * from "./dynamodb";
export * from "./kcl";
export * from "./log-shipping";
export * from "./workload-policy";
export * from "./parameter-store-read";
export * from "./s3-get-object";
export * from "./s3-put-object";
Expand Down
96 changes: 96 additions & 0 deletions src/constructs/iam/policies/workload-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Template } from "aws-cdk-lib/assertions";
import { simpleGuStackForTesting } from "../../../utils/test";
import { GuWorkloadPolicy } from "./workload-policy";

describe("GuDeveloperPolicy", () => {
test("if a single action is provided, the resulting Workload Policy resource's statement will have a single item", () => {
const stack = simpleGuStackForTesting();
new GuWorkloadPolicy(stack, "AllowS3GetObject", {
allow: [
{
actions: ["s3:GetObject"],
resources: ["s3:///log-bucket"],
},
],
permission: "test123",
});

Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", {
Path: "/workload-policy/test123/",
PolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "s3:GetObject",
Effect: "Allow",
Resource: "s3:///log-bucket",
},
],
},
});
});

test("throws an error if a wide-open permissions is requested", () => {
const stack = simpleGuStackForTesting();
expect(() => {
new GuWorkloadPolicy(stack, "AllowS3GetObject", {
allow: [
{
actions: ["s3:GetObject"],
resources: ["*"],
},
],
permission: "test123",
});
}).toThrow("Overly broad permission present, see annotations for details");
});

test("if multiple actions are provided, the resulting Managed Policy resource's action will container all items", () => {
const stack = simpleGuStackForTesting();
new GuWorkloadPolicy(stack, "AllowS3GetObject", {
allow: [
{
actions: ["s3:GetObject"],
resources: ["arn:aws:s3:::mybucket/mypath"],
},
{
actions: ["s3:GetObject"],
resources: ["arn:aws:s3:::mybucket/myotherpath"],
},
],
deny: [
{
actions: ["s3:GetObject"],
resources: ["arn:aws:s3:::mybucket/mypath/butnotthispath"],
},
],
permission: "test321",
description: "testtesttest",
});

Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", {
Description: "testtesttest",
Path: "/workload-policy/test321/",
PolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Action: "s3:GetObject",
Effect: "Allow",
Resource: "arn:aws:s3:::mybucket/mypath",
},
{
Action: "s3:GetObject",
Effect: "Allow",
Resource: "arn:aws:s3:::mybucket/myotherpath",
},
{
Action: "s3:GetObject",
Effect: "Deny",
Resource: "arn:aws:s3:::mybucket/mypath/butnotthispath",
},
],
},
});
});
});
106 changes: 106 additions & 0 deletions src/constructs/iam/policies/workload-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { Annotations } from "aws-cdk-lib";
import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam";
import type { GuStack } from "../../core";
import type { GuAllowPolicyProps, GuDenyPolicyProps } from "./base-policy";

export type GuWorkloadPolicyProps = {
/**
* List of explicitly allowed permissions given by this policy.
*/
allow: GuAllowPolicyProps[];
/**
* List of explicitly denied permissions which can be used to fine tune this policy by pruning the allow permissions.
*/
deny?: GuDenyPolicyProps[];
/**
* The unique identifier of the policy, which will be displayed when creating credentials.
*/
permission: string;
/**
* An optional description of the policy which will be displayed if present.
*/
description?: string;
Comment on lines 10 to 22
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we document these properties please? TypeDoc strings make their way to the documentation site.

};

/**
* Creates a structured `AWS::IAM::ManagedPolicy` resource to manage arbitrary permissions on general account
* resources which can then be used to create limited permission credentials for use with specific activities.
*
* The permission scope is not controlled. This class should be used with care to create minimal permissions.
* To that end, broad ALLOW permissions can pruned with narrower optional DENY permissions.
*
* `permission` is prefixed with `/developer-policy/` and postfixed with `/` to construct the path. This will
* be used for discovery and display. It is restricted to the same character set as AWS `path`.
*
* `description` is optionally used to construct the AWS Managed Policy description and used for display.
*
* ```yaml
* TestingECAE2E87:
* Type: AWS::IAM::ManagedPolicy
* Properties:
* Description: This is testing stuff
* Path: /developer-policy/read-from-mybucket-under-mypath/
* PolicyDocument:
* Statement:
* - Action: s3:GetObject
* Effect: Allow
* Resource: arn:aws:s3:::mybucket/mypath
* - Action: s3:GetObject
* Effect: Deny
* Resource: arn:aws:s3:::mybucket/mypath/butnotthispath
* Version: "2012-10-17"
* Metadata:
* aws:cdk:path: janus-resources-for-testing-managed-policy-tagging/justin-testing/Resource* ```
* ```
*/
export class GuWorkloadPolicy extends ManagedPolicy {
constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) {
super(scope, id, {
description: `${props.permission} developer policy`,
...props,
path: `/workload-policy/${props.permission}/`,
});

let valid = true;

for (const allowed of props.allow) {
// validity checks
const name = allowed.policyName ?? allowed.actions.join(",") + " on " + allowed.resources.join(",");
for (const resource of allowed.resources) {
if (resource === "*") {
Annotations.of(this).addError(`Resource of '*' found in ${name} ALLOW permission`);
valid = false;
}
}
for (const action of allowed.actions) {
if (action === "*") {
const name = allowed.policyName ?? allowed.actions.join(",");
Annotations.of(this).addError(`Action of '*' found in ${name} ALLOW permission`);
valid = false;
}
}
this.addStatements(
new PolicyStatement({
effect: Effect.ALLOW,
resources: allowed.resources,
actions: allowed.actions,
}),
);
}

if (!valid) {
throw new Error("Overly broad permission present, see annotations for details");
}

const { deny = [] } = props;
for (const denied of deny) {
this.addStatements(
new PolicyStatement({
effect: Effect.DENY,
resources: denied.resources,
actions: denied.actions,
}),
);
}
}
}