From 8de29e8fe7a6504079064fdb2adac21901c69bf6 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 14:33:43 +0000 Subject: [PATCH 01/16] Create "raw" managed policy using existing class props --- .../iam/policies/developer-policy.test.ts | 28 ++++++++++++++ .../iam/policies/developer-policy.ts | 37 +++++++++++++++++++ src/constructs/iam/policies/index.ts | 1 + 3 files changed, 66 insertions(+) create mode 100644 src/constructs/iam/policies/developer-policy.test.ts create mode 100644 src/constructs/iam/policies/developer-policy.ts 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..15b31cefdc --- /dev/null +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -0,0 +1,28 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { simpleGuStackForTesting } from "../../../utils/test"; +import { GuDeveloperPolicy } from "./developer-policy"; + +describe("GuManagedPolicy", () => { + test("if a single action is provided, the resulting Managed Policy resource's action will be a single item", () => { + const stack = simpleGuStackForTesting(); + new GuDeveloperPolicy(stack, "AllowS3GetObject", { + allow: { + actions: ["s3:GetObject"], + resources: ["*"], + }, + }); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { + PolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "s3:GetObject", + Effect: "Allow", + Resource: "*", + }, + ], + }, + }); + }); +}); diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts new file mode 100644 index 0000000000..04392370af --- /dev/null +++ b/src/constructs/iam/policies/developer-policy.ts @@ -0,0 +1,37 @@ +import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import type { ManagedPolicyProps } from "aws-cdk-lib/aws-iam"; +import type { GuStack } from "../../core"; +import type { GuAllowPolicyProps, GuDenyPolicyProps } from "./base-policy"; + +export type GuDeveloperPolicyProps = Omit & { + allow: GuAllowPolicyProps[]; + deny?: GuDenyPolicyProps[]; + permission: string; + description?: string; +}; + +export class GuDeveloperPolicy extends ManagedPolicy { + constructor(scope: GuStack, id: string, props: GuDeveloperPolicyProps) { + super(scope, id, { path: `/developer-policy/${props.permission}/`, ...props }); + for (const allowed of props.allow) { + this.addStatements( + new PolicyStatement({ + effect: Effect.ALLOW, + resources: allowed.resources, + actions: allowed.actions, + }), + ); + } + if (props.deny) { + for (const denied of props.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"; From 62c66742c0029466fc84f1e7be2c7d50c0cf9d42 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 14:37:22 +0000 Subject: [PATCH 02/16] Add 'raw' managed policy with existing props Create a new managed policy for 'raw' using existing class properties. --- .changeset/angry-parrots-confess.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/angry-parrots-confess.md diff --git a/.changeset/angry-parrots-confess.md b/.changeset/angry-parrots-confess.md new file mode 100644 index 0000000000..c2ccf91184 --- /dev/null +++ b/.changeset/angry-parrots-confess.md @@ -0,0 +1,5 @@ +--- +"@guardian/cdk": patch +--- + +Create "raw" managed policy using existing class props From 3a25978f00ac5ca0a790c2833d18af1d7d551a19 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 15:40:50 +0000 Subject: [PATCH 03/16] Apply suggestion from @kelvin-chappell Co-authored-by: kelvin-chappell <1722550+kelvin-chappell@users.noreply.github.com> --- src/constructs/iam/policies/developer-policy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index 15b31cefdc..d3f245aae5 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -2,7 +2,7 @@ import { Template } from "aws-cdk-lib/assertions"; import { simpleGuStackForTesting } from "../../../utils/test"; import { GuDeveloperPolicy } from "./developer-policy"; -describe("GuManagedPolicy", () => { +describe("GuDeveloperPolicy", () => { test("if a single action is provided, the resulting Managed Policy resource's action will be a single item", () => { const stack = simpleGuStackForTesting(); new GuDeveloperPolicy(stack, "AllowS3GetObject", { From 867175d52b3e1057c099fdc73749c7baab6b1931 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 15:41:00 +0000 Subject: [PATCH 04/16] Apply suggestion from @kelvin-chappell Co-authored-by: kelvin-chappell <1722550+kelvin-chappell@users.noreply.github.com> --- src/constructs/iam/policies/developer-policy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index d3f245aae5..3c6201fe92 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -3,7 +3,7 @@ import { simpleGuStackForTesting } from "../../../utils/test"; import { GuDeveloperPolicy } from "./developer-policy"; describe("GuDeveloperPolicy", () => { - test("if a single action is provided, the resulting Managed Policy resource's action will be a single item", () => { + test("if a single action is provided, the resulting Developer Policy resource's statement will have a single item", () => { const stack = simpleGuStackForTesting(); new GuDeveloperPolicy(stack, "AllowS3GetObject", { allow: { From fea14e03089bb7d4936b33381e75aedccf33b340 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 14:45:20 +0000 Subject: [PATCH 05/16] Commit tests --- .../iam/policies/developer-policy.test.ts | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index 3c6201fe92..c288a42c7f 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -6,13 +6,17 @@ describe("GuDeveloperPolicy", () => { test("if a single action is provided, the resulting Developer Policy resource's statement will have a single item", () => { const stack = simpleGuStackForTesting(); new GuDeveloperPolicy(stack, "AllowS3GetObject", { - allow: { - actions: ["s3:GetObject"], - resources: ["*"], - }, + allow: [ + { + actions: ["s3:GetObject"], + resources: ["*"] + } + ], + permission: "test123" }); Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { + Path: "/developer-policy/test123/", PolicyDocument: { Version: "2012-10-17", Statement: [ @@ -25,4 +29,52 @@ describe("GuDeveloperPolicy", () => { }, }); }); + 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", + }, + ], + }, + }); + }); }); From fc32b64eda0922a9d217fb61cfafef5dcc117a17 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Thu, 19 Feb 2026 16:02:47 +0000 Subject: [PATCH 06/16] Lint --- src/constructs/iam/policies/developer-policy.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index c288a42c7f..ec0593527a 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -9,10 +9,10 @@ describe("GuDeveloperPolicy", () => { allow: [ { actions: ["s3:GetObject"], - resources: ["*"] - } + resources: ["*"], + }, ], - permission: "test123" + permission: "test123", }); Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { From 3c01d58607940776080fa8287df4bfe0b49bee06 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Fri, 20 Feb 2026 09:28:35 +0000 Subject: [PATCH 07/16] Requested changes --- .changeset/angry-parrots-confess.md | 2 +- .../iam/policies/developer-policy.ts | 58 +++++++++++++++---- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/.changeset/angry-parrots-confess.md b/.changeset/angry-parrots-confess.md index c2ccf91184..067d8cc5d5 100644 --- a/.changeset/angry-parrots-confess.md +++ b/.changeset/angry-parrots-confess.md @@ -1,5 +1,5 @@ --- -"@guardian/cdk": patch +"@guardian/cdk": minor --- Create "raw" managed policy using existing class props diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts index 04392370af..a5e398737f 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -1,5 +1,5 @@ -import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam"; import type { ManagedPolicyProps } from "aws-cdk-lib/aws-iam"; +import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam"; import type { GuStack } from "../../core"; import type { GuAllowPolicyProps, GuDenyPolicyProps } from "./base-policy"; @@ -10,9 +10,44 @@ export type GuDeveloperPolicyProps = Omit & { 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: GuDeveloperPolicyProps) { - super(scope, id, { path: `/developer-policy/${props.permission}/`, ...props }); + super(scope, id, { + description: `${props.permission} developer policy`, + ...props, + path: `/developer-policy/${props.permission}/`, + }); for (const allowed of props.allow) { this.addStatements( new PolicyStatement({ @@ -22,16 +57,15 @@ export class GuDeveloperPolicy extends ManagedPolicy { }), ); } - if (props.deny) { - for (const denied of props.deny) { - this.addStatements( - new PolicyStatement({ - effect: Effect.DENY, - resources: denied.resources, - actions: denied.actions, - }), - ); - } + const { deny = [] } = props; + for (const denied of deny) { + this.addStatements( + new PolicyStatement({ + effect: Effect.DENY, + resources: denied.resources, + actions: denied.actions, + }), + ); } } } From cb13db0a43759505dc5926d4b074cb7b56d227dd Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Fri, 20 Feb 2026 10:44:01 +0000 Subject: [PATCH 08/16] Prune props harder --- src/constructs/iam/policies/developer-policy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts index a5e398737f..5537c3dc96 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -3,7 +3,7 @@ import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam"; import type { GuStack } from "../../core"; import type { GuAllowPolicyProps, GuDenyPolicyProps } from "./base-policy"; -export type GuDeveloperPolicyProps = Omit & { +export type GuDeveloperPolicyProps = Omit & { allow: GuAllowPolicyProps[]; deny?: GuDenyPolicyProps[]; permission: string; @@ -45,6 +45,7 @@ export class GuDeveloperPolicy extends ManagedPolicy { constructor(scope: GuStack, id: string, props: GuDeveloperPolicyProps) { super(scope, id, { description: `${props.permission} developer policy`, + managedPolicyName: props.permission, ...props, path: `/developer-policy/${props.permission}/`, }); From 2d95c9d0bea21079e147060c1820c6bb3a1b8109 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Fri, 20 Feb 2026 16:41:31 +0000 Subject: [PATCH 09/16] Remove managed policy name as it is dangerous. Add documentation --- .changeset/angry-parrots-confess.md | 3 ++- src/constructs/iam/policies/developer-policy.ts | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.changeset/angry-parrots-confess.md b/.changeset/angry-parrots-confess.md index 067d8cc5d5..d65c7f97f3 100644 --- a/.changeset/angry-parrots-confess.md +++ b/.changeset/angry-parrots-confess.md @@ -2,4 +2,5 @@ "@guardian/cdk": minor --- -Create "raw" managed policy using existing class props +Add a class for safe instantiation of managed policies with a specific structure of path +which enables them to be discoverable. diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts index 5537c3dc96..3192b4c779 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -1,12 +1,23 @@ -import type { ManagedPolicyProps } from "aws-cdk-lib/aws-iam"; import { Effect, ManagedPolicy, PolicyStatement } from "aws-cdk-lib/aws-iam"; import type { GuStack } from "../../core"; import type { GuAllowPolicyProps, GuDenyPolicyProps } from "./base-policy"; -export type GuDeveloperPolicyProps = Omit & { +export type GuDeveloperPolicyProps = { + /** + * 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; }; @@ -45,7 +56,6 @@ export class GuDeveloperPolicy extends ManagedPolicy { constructor(scope: GuStack, id: string, props: GuDeveloperPolicyProps) { super(scope, id, { description: `${props.permission} developer policy`, - managedPolicyName: props.permission, ...props, path: `/developer-policy/${props.permission}/`, }); From 4276e77b6e443219c88df21eb86b8a3d97c3a0f8 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Mon, 23 Feb 2026 16:26:04 +0000 Subject: [PATCH 10/16] Rename after discussions --- src/constructs/iam/policies/index.ts | 2 +- ...policy.test.ts => workload-policy.test.ts} | 10 +++--- ...developer-policy.ts => workload-policy.ts} | 33 ++++++++++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) rename src/constructs/iam/policies/{developer-policy.test.ts => workload-policy.test.ts} (89%) rename src/constructs/iam/policies/{developer-policy.ts => workload-policy.ts} (72%) diff --git a/src/constructs/iam/policies/index.ts b/src/constructs/iam/policies/index.ts index e8e9365bf9..745de55b73 100644 --- a/src/constructs/iam/policies/index.ts +++ b/src/constructs/iam/policies/index.ts @@ -6,7 +6,7 @@ export * from "./describe-ec2"; export * from "./dynamodb"; export * from "./kcl"; export * from "./log-shipping"; -export * from "./developer-policy"; +export * from "./workload-policy"; export * from "./parameter-store-read"; export * from "./s3-get-object"; export * from "./s3-put-object"; diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/workload-policy.test.ts similarity index 89% rename from src/constructs/iam/policies/developer-policy.test.ts rename to src/constructs/iam/policies/workload-policy.test.ts index ec0593527a..a138724d40 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/workload-policy.test.ts @@ -1,11 +1,11 @@ import { Template } from "aws-cdk-lib/assertions"; import { simpleGuStackForTesting } from "../../../utils/test"; -import { GuDeveloperPolicy } from "./developer-policy"; +import { GuWorkloadPolicy } from "./workload-policy"; describe("GuDeveloperPolicy", () => { test("if a single action is provided, the resulting Developer Policy resource's statement will have a single item", () => { const stack = simpleGuStackForTesting(); - new GuDeveloperPolicy(stack, "AllowS3GetObject", { + new GuWorkloadPolicy(stack, "AllowS3GetObject", { allow: [ { actions: ["s3:GetObject"], @@ -16,7 +16,7 @@ describe("GuDeveloperPolicy", () => { }); Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { - Path: "/developer-policy/test123/", + Path: "/workload-policy/test123/", PolicyDocument: { Version: "2012-10-17", Statement: [ @@ -31,7 +31,7 @@ describe("GuDeveloperPolicy", () => { }); test("if multiple actions are provided, the resulting Managed Policy resource's action will container all items", () => { const stack = simpleGuStackForTesting(); - new GuDeveloperPolicy(stack, "AllowS3GetObject", { + new GuWorkloadPolicy(stack, "AllowS3GetObject", { allow: [ { actions: ["s3:GetObject"], @@ -54,7 +54,7 @@ describe("GuDeveloperPolicy", () => { Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { Description: "testtesttest", - Path: "/developer-policy/test321/", + Path: "/workload-policy/test321/", PolicyDocument: { Version: "2012-10-17", Statement: [ diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/workload-policy.ts similarity index 72% rename from src/constructs/iam/policies/developer-policy.ts rename to src/constructs/iam/policies/workload-policy.ts index 3192b4c779..975d05bd1f 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/workload-policy.ts @@ -1,8 +1,9 @@ +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 GuDeveloperPolicyProps = { +export type GuWorkloadPolicyProps = { /** * List of explicitly allowed permissions given by this policy. */ @@ -52,14 +53,33 @@ export type GuDeveloperPolicyProps = { * aws:cdk:path: janus-resources-for-testing-managed-policy-tagging/justin-testing/Resource* ``` * ``` */ -export class GuDeveloperPolicy extends ManagedPolicy { - constructor(scope: GuStack, id: string, props: GuDeveloperPolicyProps) { +export class GuWorkloadPolicy extends ManagedPolicy { + constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) { super(scope, id, { description: `${props.permission} developer policy`, ...props, - path: `/developer-policy/${props.permission}/`, + 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, @@ -68,6 +88,11 @@ export class GuDeveloperPolicy extends ManagedPolicy { }), ); } + + if (!valid) { + throw new Error("Overly broad permission present, see annotations for details"); + } + const { deny = [] } = props; for (const denied of deny) { this.addStatements( From b5108fa36447af476d462fa7fb376e1576f78a19 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Mon, 23 Feb 2026 16:32:47 +0000 Subject: [PATCH 11/16] Add tests for permissions on * --- .../iam/policies/workload-policy.test.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/constructs/iam/policies/workload-policy.test.ts b/src/constructs/iam/policies/workload-policy.test.ts index a138724d40..99e43b01ba 100644 --- a/src/constructs/iam/policies/workload-policy.test.ts +++ b/src/constructs/iam/policies/workload-policy.test.ts @@ -3,13 +3,13 @@ import { simpleGuStackForTesting } from "../../../utils/test"; import { GuWorkloadPolicy } from "./workload-policy"; describe("GuDeveloperPolicy", () => { - test("if a single action is provided, the resulting Developer Policy resource's statement will have a single item", () => { + 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: ["*"], + resources: ["s3:///log-bucket"], }, ], permission: "test123", @@ -23,12 +23,28 @@ describe("GuDeveloperPolicy", () => { { Action: "s3:GetObject", Effect: "Allow", - Resource: "*", + 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", { From 0f338231adb70bcffd45ef7d52060d85b3ce070a Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Mon, 23 Feb 2026 16:38:21 +0000 Subject: [PATCH 12/16] Add better explanation to change set --- .changeset/angry-parrots-confess.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.changeset/angry-parrots-confess.md b/.changeset/angry-parrots-confess.md index d65c7f97f3..83bd7edbf6 100644 --- a/.changeset/angry-parrots-confess.md +++ b/.changeset/angry-parrots-confess.md @@ -4,3 +4,13 @@ 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. From 29190f30166ec1a34abd743e624cefb4cdb90013 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Mon, 23 Feb 2026 16:40:07 +0000 Subject: [PATCH 13/16] Lint --- src/constructs/iam/policies/workload-policy.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/constructs/iam/policies/workload-policy.ts b/src/constructs/iam/policies/workload-policy.ts index 975d05bd1f..0f43ba4d26 100644 --- a/src/constructs/iam/policies/workload-policy.ts +++ b/src/constructs/iam/policies/workload-policy.ts @@ -64,9 +64,8 @@ export class GuWorkloadPolicy extends ManagedPolicy { let valid = true; for (const allowed of props.allow) { - // validity checks - const name = allowed.policyName ?? allowed.actions.join(",") + ' on ' + allowed.resources.join(","); + 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`); From 53fe25e84a69d2981db14c6a9da428a5fe90721c Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Wed, 25 Feb 2026 11:53:05 +0000 Subject: [PATCH 14/16] Revert name change --- .tool-versions | 1 + ...olicy.test.ts => developer-policy.test.ts} | 12 ++++++------ ...workload-policy.ts => developer-policy.ts} | 19 +++++++++++++------ src/constructs/iam/policies/index.ts | 2 +- 4 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 .tool-versions rename src/constructs/iam/policies/{workload-policy.test.ts => developer-policy.test.ts} (89%) rename src/constructs/iam/policies/{workload-policy.ts => developer-policy.ts} (88%) 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/workload-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts similarity index 89% rename from src/constructs/iam/policies/workload-policy.test.ts rename to src/constructs/iam/policies/developer-policy.test.ts index 99e43b01ba..6ea7da97e5 100644 --- a/src/constructs/iam/policies/workload-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -1,11 +1,11 @@ import { Template } from "aws-cdk-lib/assertions"; import { simpleGuStackForTesting } from "../../../utils/test"; -import { GuWorkloadPolicy } from "./workload-policy"; +import { GuDeveloperPolicy } from "./developer-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", { + new GuDeveloperPolicy(stack, "AllowS3GetObject", { allow: [ { actions: ["s3:GetObject"], @@ -16,7 +16,7 @@ describe("GuDeveloperPolicy", () => { }); Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { - Path: "/workload-policy/test123/", + Path: "/developer-policy/test123/", PolicyDocument: { Version: "2012-10-17", Statement: [ @@ -33,7 +33,7 @@ describe("GuDeveloperPolicy", () => { test("throws an error if a wide-open permissions is requested", () => { const stack = simpleGuStackForTesting(); expect(() => { - new GuWorkloadPolicy(stack, "AllowS3GetObject", { + new GuDeveloperPolicy(stack, "AllowS3GetObject", { allow: [ { actions: ["s3:GetObject"], @@ -47,7 +47,7 @@ describe("GuDeveloperPolicy", () => { test("if multiple actions are provided, the resulting Managed Policy resource's action will container all items", () => { const stack = simpleGuStackForTesting(); - new GuWorkloadPolicy(stack, "AllowS3GetObject", { + new GuDeveloperPolicy(stack, "AllowS3GetObject", { allow: [ { actions: ["s3:GetObject"], @@ -70,7 +70,7 @@ describe("GuDeveloperPolicy", () => { Template.fromStack(stack).hasResourceProperties("AWS::IAM::ManagedPolicy", { Description: "testtesttest", - Path: "/workload-policy/test321/", + Path: "/developer-policy/test321/", PolicyDocument: { Version: "2012-10-17", Statement: [ diff --git a/src/constructs/iam/policies/workload-policy.ts b/src/constructs/iam/policies/developer-policy.ts similarity index 88% rename from src/constructs/iam/policies/workload-policy.ts rename to src/constructs/iam/policies/developer-policy.ts index 0f43ba4d26..25a3ad8c1c 100644 --- a/src/constructs/iam/policies/workload-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -7,19 +7,26 @@ export type GuWorkloadPolicyProps = { /** * List of explicitly allowed permissions given by this policy. */ - allow: GuAllowPolicyProps[]; + readonly allow: GuAllowPolicyProps[]; /** * List of explicitly denied permissions which can be used to fine tune this policy by pruning the allow permissions. */ - deny?: GuDenyPolicyProps[]; + 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. */ - permission: string; + readonly permission: string; /** * An optional description of the policy which will be displayed if present. */ - description?: string; + readonly description?: string; }; /** @@ -53,12 +60,12 @@ export type GuWorkloadPolicyProps = { * aws:cdk:path: janus-resources-for-testing-managed-policy-tagging/justin-testing/Resource* ``` * ``` */ -export class GuWorkloadPolicy extends ManagedPolicy { +export class GuDeveloperPolicy extends ManagedPolicy { constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) { super(scope, id, { description: `${props.permission} developer policy`, ...props, - path: `/workload-policy/${props.permission}/`, + path: `/developer-policy/${props.permission}/`, }); let valid = true; diff --git a/src/constructs/iam/policies/index.ts b/src/constructs/iam/policies/index.ts index 745de55b73..e8e9365bf9 100644 --- a/src/constructs/iam/policies/index.ts +++ b/src/constructs/iam/policies/index.ts @@ -6,7 +6,7 @@ export * from "./describe-ec2"; export * from "./dynamodb"; export * from "./kcl"; export * from "./log-shipping"; -export * from "./workload-policy"; +export * from "./developer-policy"; export * from "./parameter-store-read"; export * from "./s3-get-object"; export * from "./s3-put-object"; From 83eab6572d4e06016e831e5a730199d1f56cc953 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Wed, 25 Feb 2026 12:08:02 +0000 Subject: [PATCH 15/16] Re-instate statements --- .../iam/policies/developer-policy.test.ts | 19 +++++++++++++- .../iam/policies/developer-policy.ts | 26 +++++++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index 6ea7da97e5..34a22fd31f 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -1,6 +1,7 @@ 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", () => { @@ -30,7 +31,7 @@ describe("GuDeveloperPolicy", () => { }); }); - test("throws an error if a wide-open permissions is requested", () => { + test("throws an error if a wide-open policy is allowed", () => { const stack = simpleGuStackForTesting(); expect(() => { new GuDeveloperPolicy(stack, "AllowS3GetObject", { @@ -45,6 +46,22 @@ describe("GuDeveloperPolicy", () => { }).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", { diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts index 25a3ad8c1c..b34f1c09c6 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -7,7 +7,7 @@ export type GuWorkloadPolicyProps = { /** * List of explicitly allowed permissions given by this policy. */ - readonly allow: GuAllowPolicyProps[]; + readonly allow?: GuAllowPolicyProps[]; /** * List of explicitly denied permissions which can be used to fine tune this policy by pruning the allow permissions. */ @@ -62,6 +62,7 @@ export type GuWorkloadPolicyProps = { */ export class GuDeveloperPolicy extends ManagedPolicy { constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) { + super(scope, id, { description: `${props.permission} developer policy`, ...props, @@ -70,7 +71,28 @@ export class GuDeveloperPolicy extends ManagedPolicy { let valid = true; - for (const allowed of props.allow) { + 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) { From cbfba77a0da898bce1d71a36019b1245e40cbe47 Mon Sep 17 00:00:00 2001 From: Justin Rowles Date: Wed, 25 Feb 2026 13:48:37 +0000 Subject: [PATCH 16/16] Lint --- src/constructs/iam/policies/developer-policy.test.ts | 2 +- src/constructs/iam/policies/developer-policy.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/constructs/iam/policies/developer-policy.test.ts b/src/constructs/iam/policies/developer-policy.test.ts index 34a22fd31f..ebe7d4a79a 100644 --- a/src/constructs/iam/policies/developer-policy.test.ts +++ b/src/constructs/iam/policies/developer-policy.test.ts @@ -1,7 +1,7 @@ import { Template } from "aws-cdk-lib/assertions"; +import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; 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", () => { diff --git a/src/constructs/iam/policies/developer-policy.ts b/src/constructs/iam/policies/developer-policy.ts index b34f1c09c6..307687f579 100644 --- a/src/constructs/iam/policies/developer-policy.ts +++ b/src/constructs/iam/policies/developer-policy.ts @@ -62,7 +62,6 @@ export type GuWorkloadPolicyProps = { */ export class GuDeveloperPolicy extends ManagedPolicy { constructor(scope: GuStack, id: string, props: GuWorkloadPolicyProps) { - super(scope, id, { description: `${props.permission} developer policy`, ...props,