fix(iam): allow frontend HCP apply to write deploy SSM and githubdeploy trust (PLAT-212)

This commit is contained in:
Adam Moussa 2026-09-18 14:45:19 -04:00
parent c63b5e9779
commit 707d795b47
No known key found for this signature in database
4 changed files with 146 additions and 41 deletions

View file

@ -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/<env>/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-<env>` role. `DenySecretAccess` still denies
Secrets Manager and KMS decrypt; SSM GetParameter/GetParameters/GetParametersByPath
are denied except `/shoc-frontend-new/<env>/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

View file

@ -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"),
},

View file

@ -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"
]
}
}
}
]
}

View file

@ -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<string, unknown> => {
const siteBucketArn = bucketArn(environment.bucketName);
return {
Version: "2012-10-17",
Statement: [
const statements: Record<string, unknown>[] = [
{
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<string, unknown> => ({
Version: "2012-10-17",
Statement: [
): Record<string, unknown> => {
const live = isLiveFrontendEnvironment(environment);
const statements: Record<string, unknown>[] = [
{
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,