fix(iam): pin HCP boundary ARNs and bootstrap trust window (PLAT-143)

Null on iam:PermissionsBoundary accepted any ceiling, including AdministratorAccess. Import apply cannot self-mutate hcptf-* while bootstrap trust is iam-bootstrap only; add a time-boxed exact StringEquals workspace grant instead of StringLike.
This commit is contained in:
Adam Moussa 2026-09-02 11:19:04 -04:00
parent 26f6ab879f
commit 5ed57abfd7
No known key found for this signature in database
5 changed files with 191 additions and 44 deletions

View file

@ -475,48 +475,62 @@ collapses the plan/apply role split. Never project-scoped variable sets.
- **General apply role `hcptf-bootstrap`:** trust pinned to
`organization:seahaven:project:seahaven-<env>:workspace:iam-bootstrap:run_phase:apply`
(exact `StringEquals` on `aud` and `sub`; never `StringLike` on
`run_phase`). IAM writes on `role/tf-managed/*` and `policy/tf-managed/*`.
`CreatePolicy` / `CreatePolicyVersion` only here. Must set a permissions
boundary on every `tf-managed` role it creates (`Null` false). Must not
mutate `githubdeploy-*`, `github-cfn-execution-role`, `cdk-hnb659fds-*`,
`run_phase` or workspace). IAM writes on `role/tf-managed/*` and
`policy/tf-managed/*`. `CreatePolicy` / `CreatePolicyVersion` only here.
`CreateRole` / `PutRolePolicy` / `AttachRolePolicy` /
`PutRolePermissionsBoundary` on `tf-managed` roles require
`iam:PermissionsBoundary` `StringLike` `policy/tf-managed/*` or
`policy/seahaven-lambda-execution-boundary*`. `Null` false is not enough:
it would accept `AdministratorAccess` as the ceiling. Must not mutate
`githubdeploy-*`, `github-cfn-execution-role`, `cdk-hnb659fds-*`,
`OrganizationAccountAccessRole`, `seahaven-*`. SCP
`protect-privileged-roles` covers `hcptf-bootstrap*` in prod and nonprod.
Manual apply. Not in external-dev.
`protect-privileged-roles` covers `hcptf-bootstrap*` in prod and nonprod
(OAA and CDK may still update trust). Manual apply. Not in external-dev.
- **General plan role `hcptf-bootstrap-plan`:** `ViewOnlyAccess` plus a
refresh sidecar. Never `ReadOnlyAccess`. Never IAM writes. Trust
`run_phase:plan` on the same `iam-bootstrap` workspace.
- **Scoped apply role `hcptf-<stack>`:** trust pinned to that stack's
workspace and `run_phase:apply`. No `seahaven-hcptf-iam-management`. May
manage `role/tf-managed/<prefix>-*` with a boundary required. May not
`CreatePolicy` / `CreatePolicyVersion`. May not `UpdateAssumeRolePolicy`,
`DeleteRole`, or `PutRolePolicy` on `hcptf-*` (including itself). Need more
apply-role permission later: switch the workspace back to `hcptf-bootstrap`,
apply, switch back.
manage `role/tf-managed/<prefix>-*` with `iam:PermissionsBoundary`
`StringLike` this stack's `policy/tf-managed/<prefix>*`, the shared
`seahaven-lambda-execution-boundary`, or
`seahaven-lambda-execution-boundary-<stack>`. May not `CreatePolicy` /
`CreatePolicyVersion`. May not `UpdateAssumeRolePolicy`, `DeleteRole`, or
`PutRolePolicy` on `hcptf-*` (including itself). Need more apply-role
permission later: `--allow-workspace <stack>-<env>` on the create script,
point that workspace's `TFC_AWS_*` at `hcptf-bootstrap`, apply, retarget
vars, re-run the script with no extra workspace. Do not leave a stack
workspace on bootstrap trust.
- **Scoped plan role:** `ViewOnlyAccess` plus a scoped refresh sidecar.
This does not re-open PLAT-52 AC1. On the HCP path, `CreatePolicy` lives only
on `hcptf-bootstrap`, which is not `githubdeploy-seahaven-org-baseline`. Do
not add `ArnLike` to the SAM guardrail in deploy-substrate.
on `hcptf-bootstrap`, which is not `githubdeploy-seahaven-org-baseline`.
Prefix matching is `StringLike` on the bootstrap/scoped HCP documents, not
on the SAM guardrail. Do not add `ArnLike` to deploy-substrate.
**First-apply runbook (new prod/dev HCP stack):**
1. Freeze the app's SAM/CDK CD. Secrets first (ARN references only in state).
2. HCP workspace `<stack>-<env>` exists. Auto-apply off. Vars still empty.
3. Point `TFC_AWS_APPLY_ROLE_ARN` / `TFC_AWS_PLAN_ROLE_ARN` at the general
pair, or run IAM from workspace `iam-bootstrap`. Never a project-scoped
variable set.
4. App PR adds `aws_iam_role` plan/apply (trust exact `StringEquals`), a
IAM lives in this workspace's state, so this workspace must assume
`hcptf-bootstrap` for the first apply. Default bootstrap trust does not
include it. Never a project-scoped variable set.
3. `scripts/create-hcptf-bootstrap-roles.sh --account prod|dev
--allow-workspace <stack>-<env>` (OAA). Trust stays exact `StringEquals`
on `iam-bootstrap` plus this one workspace. Never `StringLike`.
4. Point this workspace's `TFC_AWS_APPLY_ROLE_ARN` /
`TFC_AWS_PLAN_ROLE_ARN` at `hcptf-bootstrap` / `hcptf-bootstrap-plan`.
5. App PR adds `aws_iam_role` plan/apply (trust exact `StringEquals`), a
boundary at `path = "/tf-managed/"`, and exec roles with that boundary.
Copy `examples/hcptf-workspace-iam/hcp_iam.tf.example`. Cross-family
review + `/sh-security-review` still apply to IAM in the app PR. There is
no org-baseline IAM PR for the new stack.
5. One Manual apply. Roles and boundary exist (tolerate partial state on
6. One Manual apply. Roles and boundary exist (tolerate partial state on
non-IAM resources).
6. Switch workspace vars to the new scoped ARNs. Remove this workspace from
general-role trust if it was ever listed (dedicated `iam-bootstrap`
workspace is the pick, so this step is usually a no-op).
7. Second Manual apply with the scoped role. Prove the stack. Then seal.
8. Live-path proof, cutover, docs as before.
7. Switch workspace vars to the new scoped ARNs. Re-run the create script
with no `--allow-workspace` so trust is `iam-bootstrap` only again.
8. Second Manual apply with the scoped role. Prove the stack. Then seal.
9. Live-path proof, cutover, docs as before.
Plan role: `ViewOnlyAccess` (never `ReadOnlyAccess`) plus a scoped
plan-refresh sidecar. Apply role: scoped IAM statements from
@ -537,7 +551,12 @@ remaining SAM / unmigrated stacks.
Each PR adds `aws_iam_role` / inline policy resources matching live names,
`terraform import` (see `examples/hcptf-workspace-iam/hcp_iam.tf.example`),
and drops the attached `seahaven-hcptf-iam-management` once the scoped
statements live on the role. Lambda `permissions_boundary` may keep pointing
statements live on the role. Role names stay the same. The import apply
cannot run as `hcptf-<stack>`: live DenySelfMutation denies
`DetachRolePolicy` / `PutRolePolicy` on `hcptf-*`. Use the same
`--allow-workspace` window as first-apply, point `TFC_AWS_*` at bootstrap
for that one Manual apply, then retarget the original scoped ARNs and
revoke the extra trust. Lambda `permissions_boundary` may keep pointing
at `seahaven-lambda-execution-boundary-<stack>` in deploy-substrate for this
pass.

View file

@ -1,7 +1,25 @@
# Example: import an existing prod/dev hcptf-<stack> pair into app Terraform
# (PLAT-146). Copy into the consumer repo's terraform/ directory. Replace
# locals, then `terraform import` (or keep the import blocks) on a Manual
# apply. Do not recreate the role. Do not rotate TFC_AWS_*_ROLE_ARN.
# apply. Do not recreate the role. Role names stay `hcptf-STACK` /
# `hcptf-STACK-plan`.
#
# Live `seahaven-hcptf-iam-management` DenySelfMutation blocks DetachRolePolicy
# and PutRolePolicy on hcptf-* (including this role). The stack workspace
# cannot apply this file while TFC_AWS_* still points at hcptf-STACK, and
# hcptf-bootstrap trust is exact StringEquals for workspace iam-bootstrap
# only. Import apply sequence:
# 1. scripts/create-hcptf-bootstrap-roles.sh --account prod|dev \
# --allow-workspace STACK-prod
# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap /
# hcptf-bootstrap-plan (workspace vars, never a project set).
# 3. One Manual apply (import + detach seahaven-hcptf-iam-management +
# put scoped inline).
# 4. Point TFC_AWS_* back at hcptf-STACK / hcptf-STACK-plan.
# 5. Re-run the script without --allow-workspace to pin trust back to
# iam-bootstrap only.
# Later apply-role IAM edits use the same window. Do not add StringLike
# on bootstrap trust.
#
# SAM-only repos and SHOC/external-dev do not use this file.
@ -11,6 +29,7 @@ locals {
hcp_workspace = "STACK-prod"
apply_role = "hcptf-STACK"
plan_role = "hcptf-STACK-plan"
stack_name = "STACK"
stack_prefix = "STACK-"
}
@ -61,8 +80,9 @@ data "aws_iam_policy_document" "hcptf_plan_trust" {
}
# Rendered from lib/hcptf-bootstrap/scoped-apply-iam.json.tmpl. CreatePolicy
# stays on hcptf-bootstrap only. Exec-role writes are prefix-scoped. The role
# cannot PutRolePolicy on hcptf-* (including itself).
# stays on hcptf-bootstrap only. Exec-role writes are prefix-scoped. Boundary
# ARNs are StringLike-pinned (not Null); AdministratorAccess is not accepted.
# The role cannot PutRolePolicy on hcptf-* (including itself).
data "aws_iam_policy_document" "hcptf_scoped_iam" {
statement {
sid = "DenyCreatePolicy"
@ -76,9 +96,13 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "Null"
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = ["false"]
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {
@ -91,9 +115,13 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/${local.stack_prefix}*"]
condition {
test = "Null"
test = "StringLike"
variable = "iam:PermissionsBoundary"
values = ["false"]
values = [
"arn:aws:iam::${local.account_id}:policy/tf-managed/${local.stack_prefix}*",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-${local.stack_name}",
]
}
}
statement {

View file

@ -7,8 +7,11 @@
"Action": "iam:CreateRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"Condition": {
"Null": {
"iam:PermissionsBoundary": "false"
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary*"
]
}
}
},
@ -42,8 +45,11 @@
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/*",
"Condition": {
"Null": {
"iam:PermissionsBoundary": "false"
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary*"
]
}
}
},

View file

@ -16,8 +16,12 @@
"Action": "iam:CreateRole",
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*",
"Condition": {
"Null": {
"iam:PermissionsBoundary": "false"
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/__STACK_PREFIX__*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary-__STACK_NAME__"
]
}
}
},
@ -31,8 +35,12 @@
],
"Resource": "arn:aws:iam::__ACCOUNT_ID__:role/tf-managed/__STACK_PREFIX__*",
"Condition": {
"Null": {
"iam:PermissionsBoundary": "false"
"StringLike": {
"iam:PermissionsBoundary": [
"arn:aws:iam::__ACCOUNT_ID__:policy/tf-managed/__STACK_PREFIX__*",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary",
"arn:aws:iam::__ACCOUNT_ID__:policy/seahaven-lambda-execution-boundary-__STACK_NAME__"
]
}
}
},

View file

@ -13,10 +13,17 @@
# 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 only.
# --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-<env>, Manual
# apply, workspace-level TFC_AWS_*_ROLE_ARN only (never a project variable set).
#
@ -28,12 +35,14 @@ 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 ;;
-h|--help) sed -n '2,28p' "$0"; exit 0 ;;
--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
@ -49,17 +58,52 @@ case "$ACCOUNT_KEY" in
HCP_PROJECT="seahaven-dev"
;;
*)
echo "usage: $0 --account prod|dev [--dry-run] [--simulate]" >&2
echo "usage: $0 --account prod|dev [--dry-run] [--simulate] [--allow-workspace NAME]" >&2
exit 2
;;
esac
if [[ -n "$ALLOW_WORKSPACE" ]]; then
if [[ "$ALLOW_WORKSPACE" == "iam-bootstrap" ]]; then
echo "--allow-workspace iam-bootstrap 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" "$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" "$ALLOW_WORKSPACE" <<'PY'
import json, pathlib, sys
src, dest, account, project, extra = sys.argv[1:6]
text = pathlib.Path(src).read_text().replace("__ACCOUNT_ID__", account).replace("__HCP_PROJECT__", project)
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-}"
@ -92,6 +136,7 @@ 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 \
@ -99,12 +144,28 @@ if [[ "$SIMULATE" -eq 1 ]]; then
--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 \
@ -124,16 +185,37 @@ cleanup() {
}
trap cleanup EXIT
render_to "$TMPL/trust-apply.json.tmpl" "$WORKDIR/trust-apply.json"
render_to "$TMPL/trust-plan.json.tmpl" "$WORKDIR/trust-plan.json"
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: iam-bootstrap only"
fi
python3 - "$WORKDIR" <<'PY'
import json, pathlib, sys
root = pathlib.Path(sys.argv[1])
for p in root.glob("*.json"):
json.loads(p.read_text())
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() {
@ -186,4 +268,8 @@ echo " hcptf-bootstrap-plan: attached ViewOnlyAccess"
echo "done. Next: HCP workspace iam-bootstrap 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"