From 8cbc98d9277e56f0c6056bde8d19480a2579c597 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:27:10 +0000 Subject: [PATCH] feat(scp): deny iam changes on the platform path (PLAT-233) (#159) * feat(scp): deny iam changes on role/platform unless the platform principal (PLAT-233) Adds ProtectPlatformPath beside the existing name denies in the prod/nonprod SCP and the security OU copy. New hcptf-bootstrap creates use /platform/. Existing roles are not recreated. Co-authored-by: Adam Moussa * fix(scp): use live bootstrap ARNs and close the platform path gaps (PLAT-233) Resolve simulate and printed role ARNs from iam:GetRole so a /platform/ create is not reported as an unpathed role. Deny boundary changes on role/platform/*, and match both Identity Center SSO role ARN shapes. Co-authored-by: Adam Moussa * fix(scp): deny platform-path changes in bootstrap simulate (PLAT-233) Add a simulate case for role/platform/hcptf-example and fail when CreateRole, PutRolePolicy, or DeleteRole is allowed. The unpathed hcptf-* import check stays. Co-authored-by: Adam Moussa --------- Co-authored-by: Cursor Agent Co-authored-by: Adam Moussa --- lib/org-governance-stack.ts | 29 +++++++++- lib/scp/protect-privileged-roles.json | 27 +++++++++ scripts/create-hcptf-bootstrap-roles.sh | 75 ++++++++++++++++++++++--- 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/lib/org-governance-stack.ts b/lib/org-governance-stack.ts index c897b1b..2c201cc 100644 --- a/lib/org-governance-stack.ts +++ b/lib/org-governance-stack.ts @@ -260,7 +260,7 @@ export class OrgGovernanceStack extends cdk.Stack { name: "protect-privileged-roles", type: "SERVICE_CONTROL_POLICY", description: - "prod/nonprod: protect break-glass, CDK exec, githubdeploy, and hcptf-bootstrap roles", + "prod/nonprod: protect break-glass, CDK exec, githubdeploy, hcptf-bootstrap, and /platform/ roles", targetIds: [prodOu.attrId, nonprodOu.attrId], content: scpContent("protect-privileged-roles"), }); @@ -338,6 +338,33 @@ export class OrgGovernanceStack extends cdk.Stack { }, }, }, + { + Sid: "ProtectPlatformPath", + Effect: "Deny", + Action: [ + "iam:CreateRole", + "iam:UpdateAssumeRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:PutRolePermissionsBoundary", + "iam:DeleteRolePermissionsBoundary", + "iam:TagRole", + "iam:UntagRole", + ], + Resource: "arn:aws:iam::*:role/platform/*", + Condition: { + ArnNotLike: { + "aws:PrincipalArn": [ + "arn:aws:iam::*:role/OrganizationAccountAccessRole", + "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_Platform_*", + ], + }, + }, + }, { Sid: "ProtectDelegatedAdminMembership", Effect: "Deny", diff --git a/lib/scp/protect-privileged-roles.json b/lib/scp/protect-privileged-roles.json index 202ec22..1d8777b 100644 --- a/lib/scp/protect-privileged-roles.json +++ b/lib/scp/protect-privileged-roles.json @@ -31,6 +31,33 @@ ] } } + }, + { + "Sid": "ProtectPlatformPath", + "Effect": "Deny", + "Action": [ + "iam:CreateRole", + "iam:UpdateAssumeRolePolicy", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:PutRolePermissionsBoundary", + "iam:DeleteRolePermissionsBoundary", + "iam:TagRole", + "iam:UntagRole" + ], + "Resource": "arn:aws:iam::*:role/platform/*", + "Condition": { + "ArnNotLike": { + "aws:PrincipalArn": [ + "arn:aws:iam::*:role/OrganizationAccountAccessRole", + "arn:aws:iam::*:role/aws-reserved/sso.amazonaws.com/*AWSReservedSSO_Platform_*" + ] + } + } } ] } diff --git a/scripts/create-hcptf-bootstrap-roles.sh b/scripts/create-hcptf-bootstrap-roles.sh index 72ed30a..207980a 100755 --- a/scripts/create-hcptf-bootstrap-roles.sh +++ b/scripts/create-hcptf-bootstrap-roles.sh @@ -16,7 +16,11 @@ # scripts/create-hcptf-bootstrap-roles.sh --account prod --allow-workspace STACK-prod # # --simulate runs iam:SimulatePrincipalPolicy against the apply role. Requires -# the role to already exist. +# the role to already exist. The policy source is the live Role.Arn from +# iam:GetRole, so an existing unpathed role and a /platform/ create both match. +# The platform-path case must come back denied. role/hcptf-* does not cover +# role/platform/*, and ProtectPlatformPath denies that resource for every +# principal except OrganizationAccountAccessRole and the Platform SSO role. # # Default trust is exact StringEquals for workspace iam-bootstrap- only. # HCP workspace names are org-unique, so prod and dev cannot both be @@ -44,7 +48,7 @@ while [[ $# -gt 0 ]]; do --dry-run) DRY_RUN=1; shift ;; --simulate) SIMULATE=1; shift ;; --allow-workspace) ALLOW_WORKSPACE="$2"; shift 2 ;; - -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -h|--help) sed -n '2,35p' "$0"; exit 0 ;; -*) echo "unknown flag: $1" >&2; exit 2 ;; *) echo "unexpected argument: $1" >&2; exit 2 ;; esac @@ -145,8 +149,41 @@ AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["Se echo "account: ${ACCOUNT_ID} (${ACCOUNT_KEY})" echo "caller: $(aws sts get-caller-identity --query Arn --output text)" +# Live ARN, checked against the role path. IAM role names are unique per +# account, so GetRole finds either /hcptf-bootstrap or /platform/hcptf-bootstrap. +role_arn() { + local name="$1" + local line arn path + # Command substitution so set -e stops when GetRole fails. A missing role + # must not fall through to a printed ARN. + line="$(aws iam get-role --role-name "$name" \ + --query 'Role.[Arn,Path]' --output text)" + arn="${line%%$'\t'*}" + path="${line#*$'\t'}" + path="${path%$'\r'}" + case "$path" in + /) + [[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/${name}" ]] || { + echo "${name}: ARN ${arn} does not match path ${path}" >&2 + exit 1 + } + ;; + /platform/) + [[ "$arn" == "arn:aws:iam::${ACCOUNT_ID}:role/platform/${name}" ]] || { + echo "${name}: ARN ${arn} does not match path ${path}" >&2 + exit 1 + } + ;; + *) + echo "${name}: unexpected path ${path} (ARN ${arn})" >&2 + exit 1 + ;; + esac + printf '%s\n' "$arn" +} + if [[ "$SIMULATE" -eq 1 ]]; then - APPLY_ARN="arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap" + APPLY_ARN="$(role_arn hcptf-bootstrap)" echo "== simulate ${APPLY_ARN} ==" echo "-- CreateRole with tf-managed boundary (expect allowed) --" aws iam simulate-principal-policy \ @@ -171,7 +208,7 @@ if [[ "$SIMULATE" -eq 1 ]]; then --resource-arns "arn:aws:iam::${ACCOUNT_ID}:policy/tf-managed/example" \ --query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \ --output table - echo "-- PutRolePolicy on hcptf-* (expect allowed; import/first-apply path) --" + echo "-- PutRolePolicy on unpathed role/hcptf-example (expect allowed; import/first-apply path) --" aws iam simulate-principal-policy \ --policy-source-arn "$APPLY_ARN" \ --action-names iam:PutRolePolicy iam:DetachRolePolicy \ @@ -187,6 +224,24 @@ if [[ "$SIMULATE" -eq 1 ]]; then "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \ --query 'EvaluationResults[].{Action:EvalActionName,Resource:EvalResourceName,Decision:EvalDecision}' \ --output table + echo "-- IAM changes on role/platform/hcptf-example (expect denied) --" + platform_sim="$(aws iam simulate-principal-policy \ + --policy-source-arn "$APPLY_ARN" \ + --action-names iam:CreateRole iam:PutRolePolicy iam:DeleteRole \ + --resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/platform/hcptf-example" \ + --query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \ + --output table)" + printf '%s\n' "$platform_sim" + if grep -q 'allowed' <<<"$platform_sim"; then + echo "role/platform/* simulation allowed an IAM change" >&2 + exit 1 + fi + for action in iam:CreateRole iam:PutRolePolicy iam:DeleteRole; do + grep -q "$action" <<<"$platform_sim" || { + echo "role/platform/* simulation missing ${action}" >&2 + exit 1 + } + done exit 0 fi @@ -239,10 +294,14 @@ create_or_update_role() { aws iam update-assume-role-policy --role-name "$name" --policy-document "file://${trust_file}" fi else - echo " $name: create" + echo " $name: create on /platform/" + # Path is create-only. IAM cannot move an existing role onto /platform/. + # Prod and dev already have hcptf-bootstrap and hcptf-bootstrap-plan, so + # this branch does not run for them. Do not delete and recreate to set a path. if [[ "$DRY_RUN" -eq 0 ]]; then aws iam create-role \ --role-name "$name" \ + --path /platform/ \ --assume-role-policy-document "file://${trust_file}" \ --description "HCP Terraform ${name} (PLAT-145). Console/CLI owned. Manual apply only." \ --tags Key=Project,Value=hcp-bootstrap Key=Owner,Value=adam@seahavenind.com Key=ManagedBy,Value=cli @@ -277,9 +336,11 @@ aws iam attach-role-policy \ 2>/dev/null || true echo " hcptf-bootstrap-plan: attached ViewOnlyAccess" +APPLY_ARN="$(role_arn hcptf-bootstrap)" +PLAN_ARN="$(role_arn hcptf-bootstrap-plan)" echo "done. Next: HCP workspace ${BOOTSTRAP_WORKSPACE} in ${HCP_PROJECT}, Manual apply," -echo " TFC_AWS_APPLY_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap" -echo " TFC_AWS_PLAN_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/hcptf-bootstrap-plan" +echo " TFC_AWS_APPLY_ROLE_ARN=${APPLY_ARN}" +echo " TFC_AWS_PLAN_ROLE_ARN=${PLAN_ARN}" if [[ -n "$ALLOW_WORKSPACE" ]]; then echo "First-apply/import window: point workspace ${ALLOW_WORKSPACE} TFC_AWS_* at the pair above," echo " apply, retarget scoped ARNs, then re-run this script with no --allow-workspace."