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 <amoussa1229@users.noreply.github.com>

* 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 <amoussa1229@users.noreply.github.com>

* 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 <amoussa1229@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
This commit is contained in:
Adam Moussa 2026-09-28 16:27:10 +00:00 • committed by GitHub
parent 8ff8ba1a87
commit 8cbc98d927
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 8 deletions

View file

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

View file

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

View file

@ -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-<env> 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."