mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-09-30 23:13:17 +00:00
HCP workspace names are org-unique, so prod and dev cannot both be iam-bootstrap. Pin trust to iam-bootstrap-prod and iam-bootstrap-dev.
287 lines
11 KiB
Bash
Executable file
287 lines
11 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.
|
|
#
|
|
# 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,36p' "$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)"
|
|
|
|
if [[ "$SIMULATE" -eq 1 ]]; then
|
|
APPLY_ARN="arn:aws:iam::${ACCOUNT_ID}:role/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 hcptf-* (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
|
|
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"
|
|
if [[ "$DRY_RUN" -eq 0 ]]; then
|
|
aws iam create-role \
|
|
--role-name "$name" \
|
|
--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"
|
|
|
|
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"
|
|
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"
|