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..75e143c 100755 --- a/scripts/create-hcptf-bootstrap-roles.sh +++ b/scripts/create-hcptf-bootstrap-roles.sh @@ -16,7 +16,8 @@ # 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. # # Default trust is exact StringEquals for workspace iam-bootstrap- only. # HCP workspace names are org-unique, so prod and dev cannot both be @@ -145,8 +146,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 \ @@ -239,10 +273,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 +315,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."