From 707d795b47d1a9a5b2b10e923a016a8003488724 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 18 Sep 2026 14:45:19 -0400 Subject: [PATCH] fix(iam): allow frontend HCP apply to write deploy SSM and githubdeploy trust (PLAT-212) --- README.md | 20 ++- lib/org-governance-stack.ts | 9 +- ...xternal-dev-shoc-backend-deploy-trust.json | 34 ++++- .../shoc-frontend-resources.ts | 124 +++++++++++++----- 4 files changed, 146 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6d6aef9..17ba359 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,11 @@ secret-value APIs. IAM writes are limited to exact-role inline-policy and ordinary tag updates plus exact-profile tags; role descriptions remain stable and HCP receives no `UpdateRole` or `UpdateRoleDescription`. Live SHOC backend apply roles may `UpdateAssumeRolePolicy` only on the matching -`githubdeploy-shoc-backend-{dev,staging}` role. The OU SCP +`githubdeploy-shoc-backend-{dev,staging}` role. Live SHOC frontend apply roles +may `UpdateAssumeRolePolicy` only on the matching +`githubdeploy-shoc-frontend-new-{dev,staging}` role and may +`ssm:PutParameter` (plus tag add/remove) on +`/shoc-frontend-new/{dev,staging}/deploy/*`. The OU SCP `external-dev-iam-guardrails` no longer denies that action; the account-attached SCP `external-dev-shoc-backend-deploy-trust` restores the deny for every other `githubdeploy-*` and `hcptf-*` trust update. The SCP @@ -409,7 +413,7 @@ the POC/dev/staging deploy boundaries and 1,176 / 1,779 / 1,656 for their runtime boundaries, each below IAM's 6,144-character managed-policy limit. The external-dev IAM guardrail SCP is 4,968 compact characters against its 5,120-character Organizations limit. The account-attached SHOC backend deploy -trust SCP is 1,237 compact characters. Keep size assertions in every change. +trust SCP is 2,189 compact characters. Keep size assertions in every change. **External-dev SHOC frontend adoption uses separate gates and creates its boundaries first.** The two live retained boundaries are @@ -446,6 +450,8 @@ job: ownership handoff for HCP roles/boundaries where applicable. Import the existing site resources only after a no-replacement plan. Apply-role writes are limited to ordinary tags, `PutRolePolicy` on the exact deploy role, + `UpdateAssumeRolePolicy` on that same role, `PutParameter` (plus tag + add/remove) on `/shoc-frontend-new//deploy/*`, `PutBucketPolicy` on the exact bucket, `UpdateDistribution` on the exact distribution, `CreateInvalidation`/`GetInvalidation` on the exact distribution, `GetObject`/`GetObjectTagging`/`PutObject`/`PutObjectTagging` @@ -463,10 +469,14 @@ job: all enabled frontend roles and target-role guardrails are proven. Do not use a false gate as rollback after CloudFormation owns a role. -The apply roles explicitly deny role lifecycle/trust/boundary/managed-policy -changes, `PassRole`, secret and parameter reads, CloudFront/S3 create and +The apply roles explicitly deny role lifecycle/boundary/managed-policy +changes, `PassRole`, secret reads, CloudFront/S3 create and delete (including OAC mutation), and deletion of inline role or bucket -policies. `UpdateDistribution` is allowed on the exact pinned distribution ARN. +policies. Live apply roles may `UpdateAssumeRolePolicy` only on the matching +`githubdeploy-shoc-frontend-new-` role. `DenySecretAccess` still denies +Secrets Manager and KMS decrypt; SSM GetParameter/GetParameters/GetParametersByPath +are denied except `/shoc-frontend-new//deploy/*`, which plan and apply +may read by named GetParameter/GetParameters/DescribeParameters. `UpdateDistribution` is allowed on the exact pinned distribution ARN. `CreateInvalidation` and `GetInvalidation` are allowed on that same ARN so the Terraform invalidation action can run. `GetObject`/`GetObjectTagging`/`PutObject`/`PutObjectTagging` on `.release/current` lets Terraform own the release pointer, including the diff --git a/lib/org-governance-stack.ts b/lib/org-governance-stack.ts index a931191..c897b1b 100644 --- a/lib/org-governance-stack.ts +++ b/lib/org-governance-stack.ts @@ -438,9 +438,10 @@ export class OrgGovernanceStack extends cdk.Stack { retain(externalDevIamGuardrails); // Account-attached: the OU is at the 5-SCP quota. This lets - // hcptf-shoc-backend-{dev,staging} UpdateAssumeRolePolicy on the matching - // githubdeploy-shoc-backend-* role only. All other githubdeploy-* and - // hcptf-* trust mutation stays denied except org/CDK. + // hcptf-shoc-backend-{dev,staging} and hcptf-shoc-frontend-new-{dev,staging} + // UpdateAssumeRolePolicy on the matching githubdeploy-* role only. All + // other githubdeploy-* and hcptf-* trust mutation stays denied except + // org/CDK. const externalDevShocBackendDeployTrust = new organizations.CfnPolicy( this, "ExternalDevShocBackendDeployTrust", @@ -448,7 +449,7 @@ export class OrgGovernanceStack extends cdk.Stack { name: "external-dev-shoc-backend-deploy-trust", type: "SERVICE_CONTROL_POLICY", description: - "external-dev account: SHOC backend HCP apply roles may update matching githubdeploy trust", + "external-dev account: SHOC backend and frontend HCP apply roles may update matching githubdeploy trust", targetIds: ["396287094661"], content: scpContent("external-dev-shoc-backend-deploy-trust"), }, diff --git a/lib/scp/external-dev-shoc-backend-deploy-trust.json b/lib/scp/external-dev-shoc-backend-deploy-trust.json index 0418734..0db29f2 100644 --- a/lib/scp/external-dev-shoc-backend-deploy-trust.json +++ b/lib/scp/external-dev-shoc-backend-deploy-trust.json @@ -7,7 +7,9 @@ "Action": "iam:UpdateAssumeRolePolicy", "NotResource": [ "arn:aws:iam::396287094661:role/githubdeploy-shoc-backend-dev", - "arn:aws:iam::396287094661:role/githubdeploy-shoc-backend-staging" + "arn:aws:iam::396287094661:role/githubdeploy-shoc-backend-staging", + "arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev", + "arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging" ], "Condition": { "ArnNotLike": { @@ -47,6 +49,36 @@ ] } } + }, + { + "Sid": "ProtectShocFrontendDevGithubTrust", + "Effect": "Deny", + "Action": "iam:UpdateAssumeRolePolicy", + "Resource": "arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-dev", + "Condition": { + "ArnNotLike": { + "aws:PrincipalArn": [ + "arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", + "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", + "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-dev" + ] + } + } + }, + { + "Sid": "ProtectShocFrontendStagingGithubTrust", + "Effect": "Deny", + "Action": "iam:UpdateAssumeRolePolicy", + "Resource": "arn:aws:iam::396287094661:role/githubdeploy-shoc-frontend-new-staging", + "Condition": { + "ArnNotLike": { + "aws:PrincipalArn": [ + "arn:aws:iam::396287094661:role/OrganizationAccountAccessRole", + "arn:aws:iam::396287094661:role/cdk-hnb659fds-*", + "arn:aws:iam::396287094661:role/hcptf-shoc-frontend-new-staging" + ] + } + } } ] } diff --git a/lib/terraform-substrate/shoc-frontend-resources.ts b/lib/terraform-substrate/shoc-frontend-resources.ts index 098998b..6b826d8 100644 --- a/lib/terraform-substrate/shoc-frontend-resources.ts +++ b/lib/terraform-substrate/shoc-frontend-resources.ts @@ -75,13 +75,21 @@ const originAccessControlArn = (originAccessControlId: string): string => const hostedZoneArn = (hostedZoneId: string): string => `arn:aws:route53:::hostedzone/${hostedZoneId}`; +const deployParameterArn = (environment: FrontendEnvironment): string => + `arn:aws:ssm:us-east-1:${ACCOUNT_ID}:parameter/shoc-frontend-new/${environment.key}/deploy/*`; + +const isLiveFrontendEnvironment = ( + environment: FrontendEnvironment, +): boolean => environment.key === "dev" || environment.key === "staging"; + +const environmentSid = (environment: FrontendEnvironment): string => + environment.key.charAt(0).toUpperCase() + environment.key.slice(1); + const frontendReadPolicy = ( environment: FrontendEnvironment, ): Record => { const siteBucketArn = bucketArn(environment.bucketName); - return { - Version: "2012-10-17", - Statement: [ + const statements: Record[] = [ { Sid: "CallerIdentity", Effect: "Allow", @@ -226,15 +234,30 @@ const frontendReadPolicy = ( Action: "route53:GetChange", Resource: "arn:aws:route53:::change/*", }, - ], + ]; + if (isLiveFrontendEnvironment(environment)) { + statements.push({ + Sid: `Read${environmentSid(environment)}DeploySsm`, + Effect: "Allow", + Action: [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:DescribeParameters", + ], + Resource: deployParameterArn(environment), + }); + } + return { + Version: "2012-10-17", + Statement: statements, }; }; const frontendApplyPolicy = ( environment: FrontendEnvironment, -): Record => ({ - Version: "2012-10-17", - Statement: [ +): Record => { + const live = isLiveFrontendEnvironment(environment); + const statements: Record[] = [ { Sid: "DenyRoleLifecycleAndTrustMutation", Effect: "Deny", @@ -248,7 +271,6 @@ const frontendApplyPolicy = ( "iam:DetachRolePolicy", "iam:PassRole", "iam:PutRolePermissionsBoundary", - "iam:UpdateAssumeRolePolicy", "iam:UpdateRole", "iam:UpdateRoleDescription", ], @@ -293,15 +315,31 @@ const frontendApplyPolicy = ( { Sid: "DenySecretAccess", Effect: "Deny", + Action: live + ? ["kms:Decrypt", "secretsmanager:*"] + : [ + "kms:Decrypt", + "secretsmanager:*", + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ], + Resource: "*", + }, + ]; + if (live) { + statements.push({ + Sid: "DenyUnrelatedParameterReads", + Effect: "Deny", Action: [ - "kms:Decrypt", - "secretsmanager:*", "ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath", ], - Resource: "*", - }, + NotResource: deployParameterArn(environment), + }); + } + statements.push( { Sid: "LockHcpTerraformWorkspaceTag", Effect: "Deny", @@ -384,27 +422,51 @@ const frontendApplyPolicy = ( Action: ["iam:TagRole", "iam:UntagRole"], Resource: roleArn(environment.deployRoleName), }, - { - Sid: "ChangeExactSiteAliases", - Effect: "Allow", - Action: "route53:ChangeResourceRecordSets", - Resource: hostedZoneArn(environment.hostedZoneId), - Condition: { - "ForAllValues:StringEquals": { - "route53:ChangeResourceRecordSetsActions": [ - "CREATE", - "DELETE", - "UPSERT", - ], - "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ - environment.domainName, - ], - "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA"], - }, + ); + if (live) { + statements.push( + { + Sid: `Update${environmentSid(environment)}GithubDeployTrust`, + Effect: "Allow", + Action: "iam:UpdateAssumeRolePolicy", + Resource: roleArn(environment.deployRoleName), + }, + { + Sid: `Manage${environmentSid(environment)}DeploySsm`, + Effect: "Allow", + Action: [ + "ssm:PutParameter", + "ssm:AddTagsToResource", + "ssm:RemoveTagsFromResource", + ], + Resource: deployParameterArn(environment), + }, + ); + } + statements.push({ + Sid: "ChangeExactSiteAliases", + Effect: "Allow", + Action: "route53:ChangeResourceRecordSets", + Resource: hostedZoneArn(environment.hostedZoneId), + Condition: { + "ForAllValues:StringEquals": { + "route53:ChangeResourceRecordSetsActions": [ + "CREATE", + "DELETE", + "UPSERT", + ], + "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ + environment.domainName, + ], + "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA"], }, }, - ], -}); + }); + return { + Version: "2012-10-17", + Statement: statements, + }; +}; const assumeRolePolicy = ( workspace: string,