#!/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- 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- only. Never StringLike. SCP blocks # hcptf-bootstrap from updating its own trust; this script assumes OAA. # # After create: HCP workspace iam-bootstrap- in project seahaven-, # 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"