seahaven-org-baseline/scripts/create-hcptf-bootstrap-roles.sh
Adam Moussa 8cbc98d927
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>
2026-09-28 16:27:10 +00:00

348 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# create-hcptf-bootstrap-roles.sh — create the out-of-band HCP general apply/plan
# pair in seahaven-prod and seahaven-dev (PLAT-145). Not a CDK stack. Not
# external-dev.
#
# Prerequisites:
# * org-governance has deployed protect-privileged-roles to prod/nonprod
# (hcptf-bootstrap* is SCP-protected). Do not create the roles first.
# * Caller can sts:AssumeRole OrganizationAccountAccessRole in the target.
# * The account already has oidc-provider/app.terraform.io (Retain).
#
# Usage:
# scripts/create-hcptf-bootstrap-roles.sh --account prod|dev [--dry-run]
# scripts/create-hcptf-bootstrap-roles.sh --account prod --simulate
# 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 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
# "iam-bootstrap". --allow-workspace NAME adds one extra exact sub for that
# HCP workspace (first-apply / import window). Re-run with no --allow-workspace
# to pin trust back to iam-bootstrap-<env> only. Never StringLike. SCP blocks
# hcptf-bootstrap from updating its own trust; this script assumes OAA.
#
# After create: HCP workspace iam-bootstrap-<env> in project seahaven-<env>,
# Manual apply, workspace-level TFC_AWS_*_ROLE_ARN only (never a project
# variable set).
#
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TMPL="$ROOT/lib/hcptf-bootstrap"
ACCOUNT_KEY=""
DRY_RUN=0
SIMULATE=0
ALLOW_WORKSPACE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--account) ACCOUNT_KEY="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--simulate) SIMULATE=1; shift ;;
--allow-workspace) ALLOW_WORKSPACE="$2"; shift 2 ;;
-h|--help) sed -n '2,35p' "$0"; exit 0 ;;
-*) echo "unknown flag: $1" >&2; exit 2 ;;
*) echo "unexpected argument: $1" >&2; exit 2 ;;
esac
done
case "$ACCOUNT_KEY" in
prod)
ACCOUNT_ID="011934824531"
HCP_PROJECT="seahaven-prod"
BOOTSTRAP_WORKSPACE="iam-bootstrap-prod"
;;
dev)
ACCOUNT_ID="710827005802"
HCP_PROJECT="seahaven-dev"
BOOTSTRAP_WORKSPACE="iam-bootstrap-dev"
;;
*)
echo "usage: $0 --account prod|dev [--dry-run] [--simulate] [--allow-workspace NAME]" >&2
exit 2
;;
esac
if [[ -n "$ALLOW_WORKSPACE" ]]; then
if [[ "$ALLOW_WORKSPACE" == "$BOOTSTRAP_WORKSPACE" ]]; then
echo "--allow-workspace ${BOOTSTRAP_WORKSPACE} is the default; omit the flag" >&2
exit 2
fi
if [[ ! "$ALLOW_WORKSPACE" =~ ^[a-z0-9]([a-z0-9-]{0,88}[a-z0-9])?$ ]]; then
echo "invalid --allow-workspace '$ALLOW_WORKSPACE' (lowercase kebab, no wildcards)" >&2
exit 2
fi
fi
render_to() {
local src="$1"
local dest="$2"
sed -e "s/__ACCOUNT_ID__/${ACCOUNT_ID}/g" \
-e "s/__HCP_PROJECT__/${HCP_PROJECT}/g" \
-e "s/__BOOTSTRAP_WORKSPACE__/${BOOTSTRAP_WORKSPACE}/g" \
"$src" > "$dest"
}
# Render a trust template. Optional extra workspace becomes a second exact
# StringEquals sub (OR). Does not use StringLike.
render_trust() {
local src="$1"
local dest="$2"
python3 - "$src" "$dest" "$ACCOUNT_ID" "$HCP_PROJECT" "$BOOTSTRAP_WORKSPACE" "$ALLOW_WORKSPACE" <<'PY'
import json, pathlib, sys
src, dest, account, project, bootstrap_ws, extra = sys.argv[1:7]
text = (
pathlib.Path(src).read_text()
.replace("__ACCOUNT_ID__", account)
.replace("__HCP_PROJECT__", project)
.replace("__BOOTSTRAP_WORKSPACE__", bootstrap_ws)
)
data = json.loads(text)
if extra:
cond = data["Statement"][0]["Condition"]["StringEquals"]
sub = cond["app.terraform.io:sub"]
if isinstance(sub, str):
sub = [sub]
phase = "apply" if ":run_phase:apply" in sub[0] else "plan"
added = f"organization:seahaven:project:{project}:workspace:{extra}:run_phase:{phase}"
if added not in sub:
sub.append(added)
cond["app.terraform.io:sub"] = sub
pathlib.Path(dest).write_text(json.dumps(data, indent=2) + "\n")
PY
}
ORIG_AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID-}"
ORIG_AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY-}"
ORIG_AWS_SESSION_TOKEN="${AWS_SESSION_TOKEN-}"
restore_creds() {
if [[ -n "${ORIG_AWS_ACCESS_KEY_ID}" ]]; then
export AWS_ACCESS_KEY_ID="$ORIG_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$ORIG_AWS_SECRET_ACCESS_KEY"
export AWS_SESSION_TOKEN="$ORIG_AWS_SESSION_TOKEN"
else
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
fi
}
trap restore_creds EXIT
CREDS="$(aws sts assume-role \
--role-arn "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
--role-session-name plat-145-hcptf-bootstrap \
--query Credentials --output json)"
export AWS_ACCESS_KEY_ID
export AWS_SECRET_ACCESS_KEY
export AWS_SESSION_TOKEN
AWS_ACCESS_KEY_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["AccessKeyId"])' <<<"$CREDS")"
AWS_SECRET_ACCESS_KEY="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SecretAccessKey"])' <<<"$CREDS")"
AWS_SESSION_TOKEN="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["SessionToken"])' <<<"$CREDS")"
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="$(role_arn hcptf-bootstrap)"
echo "== simulate ${APPLY_ARN} =="
echo "-- CreateRole with tf-managed boundary (expect allowed) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/tf-managed/example" \
--context-entries 'ContextKeyName=iam:PermissionsBoundary,ContextKeyValues=arn:aws:iam::'"${ACCOUNT_ID}"':policy/tf-managed/example,ContextKeyType=string' \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- CreateRole with AdministratorAccess boundary (expect implicitDeny) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreateRole \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/tf-managed/example" \
--context-entries 'ContextKeyName=iam:PermissionsBoundary,ContextKeyValues=arn:aws:iam::aws:policy/AdministratorAccess,ContextKeyType=string' \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
echo "-- CreatePolicy on tf-managed (expect allowed) --"
aws iam simulate-principal-policy \
--policy-source-arn "$APPLY_ARN" \
--action-names iam:CreatePolicy \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:policy/tf-managed/example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
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 \
--resource-arns "arn:aws:iam::${ACCOUNT_ID}:role/hcptf-example" \
--query 'EvaluationResults[].{Action:EvalActionName,Decision:EvalDecision}' \
--output table
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/githubdeploy-example" \
"arn:aws:iam::${ACCOUNT_ID}:role/cdk-hnb659fds-example" \
"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
WORKDIR="$(mktemp -d)"
cleanup() {
rm -rf "${WORKDIR:-}"
restore_creds
}
trap cleanup EXIT
render_trust "$TMPL/trust-apply.json.tmpl" "$WORKDIR/trust-apply.json"
render_trust "$TMPL/trust-plan.json.tmpl" "$WORKDIR/trust-plan.json"
render_to "$TMPL/apply-policy.json.tmpl" "$WORKDIR/apply-policy.json"
render_to "$TMPL/plan-refresh-policy.json.tmpl" "$WORKDIR/plan-refresh.json"
if [[ -n "$ALLOW_WORKSPACE" ]]; then
echo "trust extra workspace: ${ALLOW_WORKSPACE} (exact StringEquals; re-run without this flag to revoke)"
else
echo "trust: ${BOOTSTRAP_WORKSPACE} only"
fi
python3 - "$WORKDIR" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
for p in root.glob("*.json"):
data = json.loads(p.read_text())
dump = json.dumps(data)
if p.name.startswith("trust-"):
if "StringLike" in dump:
raise SystemExit(f"{p.name}: trust must stay StringEquals")
subs = data["Statement"][0]["Condition"]["StringEquals"]["app.terraform.io:sub"]
if isinstance(subs, str):
subs = [subs]
for s in subs:
if "*" in s or "?" in s:
raise SystemExit(f"{p.name}: wildcard in sub {s}")
if p.name == "apply-policy.json":
if '"Null"' in dump:
raise SystemExit("apply-policy must not use Null on PermissionsBoundary")
if "AdministratorAccess" in dump:
raise SystemExit("apply-policy must not name AdministratorAccess")
PY
create_or_update_role() {
local name="$1"
local trust_file="$2"
if aws iam get-role --role-name "$name" >/dev/null 2>&1; then
echo " $name: exists, updating trust"
if [[ "$DRY_RUN" -eq 0 ]]; then
aws iam update-assume-role-policy --role-name "$name" --policy-document "file://${trust_file}"
fi
else
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
fi
fi
}
echo "== roles =="
create_or_update_role hcptf-bootstrap "$WORKDIR/trust-apply.json"
create_or_update_role hcptf-bootstrap-plan "$WORKDIR/trust-plan.json"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "dry-run: skipping PutRolePolicy / AttachRolePolicy"
exit 0
fi
aws iam put-role-policy \
--role-name hcptf-bootstrap \
--policy-name hcptf-bootstrap-iam-factory \
--policy-document "file://${WORKDIR}/apply-policy.json"
echo " hcptf-bootstrap: put inline hcptf-bootstrap-iam-factory"
aws iam put-role-policy \
--role-name hcptf-bootstrap-plan \
--policy-name hcptf-bootstrap-plan-refresh \
--policy-document "file://${WORKDIR}/plan-refresh.json"
echo " hcptf-bootstrap-plan: put inline hcptf-bootstrap-plan-refresh"
aws iam attach-role-policy \
--role-name hcptf-bootstrap-plan \
--policy-arn arn:aws:iam::aws:policy/job-function/ViewOnlyAccess \
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=${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."
fi
echo "Then: $0 --account ${ACCOUNT_KEY} --simulate"