From 960e4619b46e470727336319f33314378eb1247a Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:37:05 +0000 Subject: [PATCH] fix(iam): allow frontend HCP apply to write deploy SSM and githubdeploy trust (PLAT-212) (#150) * fix(iam): allow frontend HCP apply to write deploy SSM and githubdeploy trust (PLAT-212) * fix(iam): grant frontend HCP plan named SSM describe and tag reads (PLAT-212) * fix(iam): allow frontend githubdeploy to read deploy SSM (PLAT-212) HCP apply already writes /shoc-frontend-new//deploy/*, but the githubdeploy ceiling omitted GetParameter so Deploy Web cannot resolve bucket and distribution after origin moves to the bucket root. * fix(iam): allow staging HCP apply to update the SHOC backend EB stack (PLAT-213) * fix(iam): allow staging HCP apply to use the Elastic Beanstalk bucket (PLAT-213) * fix(iam): allow staging HCP apply to copy the current release zip (PLAT-213) * fix(iam): allow staging HCP apply versioned ACLs on EB env objects (PLAT-213) * fix(iam): give staging HCP apply the proven Elastic Beanstalk bucket grants (PLAT-213) * fix(iam): allow staging HCP apply to write CloudFormation template buckets (PLAT-213) * fix(iam): let staging HCP apply read Elastic Beanstalk service templates (PLAT-213) --- README.md | 31 +++- lib/org-governance-stack.ts | 9 +- ...xternal-dev-shoc-backend-deploy-trust.json | 34 ++++- .../shoc-frontend-resources.ts | 140 ++++++++++++++---- .../terraform-substrate.template.yaml | 70 +++++++++ 5 files changed, 240 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6d6aef9..5313bc8 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,15 +413,18 @@ 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 `shoc-frontend-new-{dev,staging}-deploy-boundary`. Each permits only bucket location/list/version reads, object get/put/current and version delete, -`GetDistribution`/`GetDistributionConfig`, and invalidation create/read for -one exact distribution. Dev is pinned to `E2CWLM1AFB964P`; staging is pinned -to `E2JDVEZ6EGD49J`. The frontend tf-poc rehearsal is retired: its site, +`GetDistribution`/`GetDistributionConfig`, invalidation create/read for +one exact distribution, and `GetParameter`/`GetParameters` on +`/shoc-frontend-new//deploy/*`. Dev is pinned to `E2CWLM1AFB964P`; staging is pinned +to `E2JDVEZ6EGD49J`. SCP `ProtectDeploymentPrincipalLifecycle` still denies +`iam:UpdateRoleDescription` on `githubdeploy-*` for HCP apply roles; the +frontend Terraform role must ignore description drift. The frontend tf-poc rehearsal is retired: its site, HCP workspace, and GitHub deploy role are gone. `enableShocFrontendPocRoles` is false and the five `shocFrontendPoc*` identifiers are empty, so `ShouldManageShocFrontendPocRoles` stays false. After the stack update, @@ -446,6 +453,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 +472,16 @@ 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. `iam:UpdateRoleDescription` stays in that deny and in the OU SCP; +HCP cannot change githubdeploy descriptions. 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/ListTagsForResource. DescribeParameters +is a collection API, so it is named on `*` (not `ssm:Get*`). `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..980edf8 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,38 @@ 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:ListTagsForResource", + ], + Resource: deployParameterArn(environment), + }, + { + Sid: `Describe${environmentSid(environment)}DeploySsm`, + Effect: "Allow", + Action: "ssm:DescribeParameters", + Resource: "*", + }, + ); + } + 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 +279,6 @@ const frontendApplyPolicy = ( "iam:DetachRolePolicy", "iam:PassRole", "iam:PutRolePermissionsBoundary", - "iam:UpdateAssumeRolePolicy", "iam:UpdateRole", "iam:UpdateRoleDescription", ], @@ -293,15 +323,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 +430,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, @@ -671,6 +741,14 @@ export class ShocFrontendResources extends Construct { wrapDistributionStatement(readDistributionStatement), wrapDistributionStatement(invalidationStatement), ); + if (isLiveFrontendEnvironment(environment)) { + boundaryStatements.push({ + Sid: "ReadDeployParams", + Effect: "Allow", + Action: ["ssm:GetParameter", "ssm:GetParameters"], + Resource: deployParameterArn(environment), + }); + } const deployBoundary = new iam.CfnManagedPolicy( this, diff --git a/lib/terraform-substrate/terraform-substrate.template.yaml b/lib/terraform-substrate/terraform-substrate.template.yaml index 3667122..d3c4244 100644 --- a/lib/terraform-substrate/terraform-substrate.template.yaml +++ b/lib/terraform-substrate/terraform-substrate.template.yaml @@ -3563,6 +3563,76 @@ Resources: - elasticbeanstalk:UpdateEnvironment - elasticbeanstalk:UpdateTagsForResource Resource: arn:aws:elasticbeanstalk:us-east-1:396287094661:environment/shoc-backend/shoc-backend-staging + - Sid: ManageStagingEnvironmentStack + Effect: Allow + Action: + - cloudformation:CancelUpdateStack + - cloudformation:DescribeStackEvents + - cloudformation:DescribeStackResource + - cloudformation:DescribeStackResources + - cloudformation:DescribeStacks + - cloudformation:GetTemplate + - cloudformation:ListStackResources + - cloudformation:UpdateStack + Resource: arn:aws:cloudformation:us-east-1:396287094661:stack/awseb-e-6c9m4vb62z-stack/* + - Sid: DescribeDeploymentResources + Effect: Allow + Action: + - autoscaling:Describe* + - ec2:Describe* + - elasticloadbalancing:Describe* + Resource: "*" + - Sid: ManageStagingEnvironmentAsg + Effect: Allow + Action: + - autoscaling:PutNotificationConfiguration + - autoscaling:ResumeProcesses + - autoscaling:SuspendProcesses + Resource: arn:aws:autoscaling:us-east-1:396287094661:autoScalingGroup:*:autoScalingGroupName/awseb-e-6c9m4vb62z-stack-* + - Sid: StagingBeanstalkObjects + Effect: Allow + Action: + - s3:Delete* + - s3:Get* + - s3:Put* + Resource: arn:aws:s3:::elasticbeanstalk-us-east-1-396287094661/* + - Sid: StagingBeanstalkBuckets + Effect: Allow + Action: + - s3:GetBucket* + - s3:ListBucket + - s3:PutBucketOwnershipControls + - s3:PutBucketPolicy + - s3:PutBucketPublicAccessBlock + Resource: arn:aws:s3:::elasticbeanstalk-us-east-1-396287094661 + # Elastic Beanstalk stages the CloudFormation template for + # configuration UpdateStack calls in its AWS-owned regional + # bucket and CloudFormation fetches it with the caller's + # credentials. Zip deploys never touch this path. + - Sid: ReadBeanstalkServiceTemplates + Effect: Allow + Action: + - s3:GetObject + - s3:GetObjectVersion + Resource: arn:aws:s3:::elasticbeanstalk-us-east-1/* + - Sid: ManageCloudFormationTemplates + Effect: Allow + Action: + - s3:CreateBucket + - s3:GetBucket* + - s3:ListBucket + - s3:PutBucketPolicy + - s3:PutBucketOwnershipControls + - s3:PutBucketPublicAccessBlock + - s3:PutEncryptionConfiguration + Resource: arn:aws:s3:::cf-templates-* + - Sid: ManageCloudFormationTemplateObjects + Effect: Allow + Action: + - s3:Get* + - s3:Put* + - s3:Delete* + Resource: arn:aws:s3:::cf-templates-*/* - Sid: PutStagingRuntimePolicy Effect: Allow Action: iam:PutRolePolicy