diff --git a/.changeset/angry-parrots-confess.md b/.changeset/angry-parrots-confess.md new file mode 100644 index 0000000000..83bd7edbf6 --- /dev/null +++ b/.changeset/angry-parrots-confess.md @@ -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. diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..ef53d49d5b --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +npm latest diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts new file mode 100644 index 0000000000..34a22fd31f --- /dev/null +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -0,0 +1,113 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { simpleGuStackForTesting } from "../../../utils/test"; +import { GuDeveloperPolicy } from "./developer-policy"; +import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; + +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 GuDeveloperPolicy(stack, "AllowS3GetObject", { + allow: [ + { + actions: ["s3:GetObject"], + resources: ["s3:///log-bucket"], + }, + ], + permission: "test123", + }); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { + Path: "/developer-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 policy is allowed", () => { + const stack = simpleGuStackForTesting(); + expect(() => { + new GuDeveloperPolicy(stack, "AllowS3GetObject", { + allow: [ + { + actions: ["s3:GetObject"], + resources: ["*"], + }, + ], + permission: "test123", + }); + }).toThrow("Overly broad permission present, see annotations for details"); + }); + + test("throws an error if a wide-open statement is requested", () => { + const stack = simpleGuStackForTesting(); + expect(() => { + new GuDeveloperPolicy(stack, "AllowS3GetObject", { + statements: [ + new PolicyStatement({ + effect: Effect.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 GuDeveloperPolicy(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: "/developer-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", + }, + ], + }, + }); + }); +}); diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts new file mode 100644 index 0000000000..b34f1c09c6 --- /dev/null +++ b/src/constructs/iam/policies/developer-policy.ts @@ -0,0 +1,135 @@ +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. + */ + readonly allow?: GuAllowPolicyProps[]; + /** + * List of explicitly denied permissions which can be used to fine tune this policy by pruning the allow permissions. + */ + readonly deny?: GuDenyPolicyProps[]; + /** + * Initial set of permissions to add to this policy document. + * You can also use `addPermission(statement)` to add permissions later. + * + * @default - No statements. + */ + readonly statements?: PolicyStatement[]; + /** + * The unique identifier of the policy, which will be displayed when creating credentials. + */ + readonly permission: string; + /** + * An optional description of the policy which will be displayed if present. + */ + readonly description?: string; +}; + +/** + * 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 GuDeveloperPolicy extends ManagedPolicy { + constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) { + + super(scope, id, { + description: `${props.permission} developer policy`, + ...props, + path: `/developer-policy/${props.permission}/`, + }); + + let valid = true; + + const { statements = [] } = props; + for (const statement of statements) { + if (statement.effect === Effect.ALLOW) { + for (const action of statement.actions) { + if (action === "*") { + const name = statement.actions.join(","); + Annotations.of(this).addError(`Action of '*' found in ${name} ALLOW permission`); + valid = false; + } + } + for (const resource of statement.resources) { + if (resource === "*") { + const name = statement.actions.join(","); + Annotations.of(this).addError(`Resource of '*' found in ${name} ALLOW permission`); + valid = false; + } + } + } + } + + const { allow = [] } = props; + for (const allowed of 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, + }), + ); + } + } +} diff --git a/src/constructs/iam/policies/index.ts b/src/constructs/iam/policies/index.ts index 7b974a6a15..e8e9365bf9 100644 --- a/src/constructs/iam/policies/index.ts +++ b/src/constructs/iam/policies/index.ts @@ -6,6 +6,7 @@ export * from "./describe-ec2"; export * from "./dynamodb"; export * from "./kcl"; export * from "./log-shipping"; +export * from "./developer-policy"; export * from "./parameter-store-read"; export * from "./s3-get-object"; export * from "./s3-put-object";