Skip to content

feat: CI role that creates IAM roles without privilege escalation#32

Merged
nbrieussel merged 3 commits into
mainfrom
feat/aws-ci-role-creator-boundary
Jun 13, 2026
Merged

feat: CI role that creates IAM roles without privilege escalation#32
nbrieussel merged 3 commits into
mainfrom
feat/aws-ci-role-creator-boundary

Conversation

@nbrieussel

Copy link
Copy Markdown
Contributor

Context

Follow-up to #31. The CI OIDC role could only do aws s3 ls (AmazonS3ReadOnlyAccess). Goal: let it run terraform apply that creates IAM roles, while making self-escalation impossible — using the terraform-aws-modules/iam modules, as discussed.

Mechanism (rationale inline in iam-ci.tf)

  • Permissions boundary tf-managed-boundary caps every CI-created role: effective perms = intersection(attached policies, boundary). "Admin minus a hardened deny-list" (no IAM users, no boundary tampering, no org/account, can't edit itself).
  • CI grant tf-managed-ci (replaces AmazonS3ReadOnlyAccess): tight S3 state R/W + IAM role/policy management scoped to /tf-managed/<org>/<repo>/, allowed only when the request stamps our boundary. iam:PassRole path-scoped. Global backstop Deny on any role whose boundary isn't ours. DeleteRolePermissionsBoundary deliberately not granted (that would be an escalation).
  • The CI role carries no boundary itself (it must set boundaries on children, which the boundary forbids); constrained by its own tight policy instead.

Closes the 3 escalation vectors

  1. Create a role more powerful than self → boundary clamps it.
  2. iam:PassRole a powerful role → PassRole path-scoped to bounded roles only.
  3. Weaken the guardrail → explicit Denies on the boundary policy + DeleteRolePermissionsBoundary not granted.

Verification (aws iam simulate-principal-policy)

Test Result
CreateRole in-path with our boundary allowed
CreateRole in-path without boundary explicitDeny
CreateRole out-of-path implicitDeny
DeleteRolePermissionsBoundary (escalation) implicitDeny
Rewrite the boundary policy explicitDeny
PutObject on state bucket allowed

Repo-wide contract (⚠️ behaviour change)

Every aws_iam_role the CI applies must set permissions_boundary (= permissions_boundary_arn output) and path (= managed_path output), or the apply is rejected. CI feeds the path via TF_VAR_role_path=/tf-managed/${{ github.repository }}/. Documented in CLAUDE.md + aws-github-oidc/README.md.

State

Already terraform apply'd to account 503577850357 (applied locally with admin SSO, as this root always is). vars.AWS_GITHUB_ACTIONS_ROLE_ARN unchanged (same role).

Follow-ups (not in this PR)

  • Wire the first CI workflow that actually runs terraform apply (with TF_VAR_role_path).
  • Optional: a CI smoke test that asserts the boundary (create a bounded role, attempt an unbounded one).

🤖 Generated with Claude Code

Nicolas Brieussel and others added 3 commits June 13, 2026 16:24
…ge escalation

Extend the GitHub OIDC CI role so it can run `terraform apply` that CREATES IAM
roles, while making self-escalation impossible. Built on the
terraform-aws-modules/iam modules (iam-policy), consistent with the role itself.

Mechanism (full rationale inline in iam-ci.tf):
- Permissions boundary `tf-managed-boundary` caps every CI-created role:
  effective perms = intersection(attached policies, boundary). "Admin minus a
  hardened deny-list" — no IAM users, no boundary tampering, no org/account
  actions, can't edit itself.
- CI grant `tf-managed-ci` (replaces the old AmazonS3ReadOnlyAccess): tight S3
  state R/W + IAM role/policy management scoped to a repo path
  /tf-managed/<org>/<repo>/, allowed only when the request stamps our boundary.
  iam:PassRole is path-scoped; a global backstop Deny refuses any role whose
  boundary isn't ours. DeleteRolePermissionsBoundary is deliberately NOT granted.
- The CI role carries no boundary itself (it must set boundaries on children,
  which the boundary forbids); it is constrained by its own tight policy.

Verified with `aws iam simulate-principal-policy`: CreateRole in-path with our
boundary = allowed; without boundary = explicitDeny; out-of-path = implicitDeny;
DeleteRolePermissionsBoundary = denied; rewriting the boundary = explicitDeny;
state PutObject = allowed.

Repo-wide contract (documented in CLAUDE.md + README): every aws_iam_role the CI
applies must set permissions_boundary + path; CI feeds the path via
TF_VAR_role_path=/tf-managed/${{ github.repository }}/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CI grant was tightened to bucket-scoped S3 access (no s3:ListAllMyBuckets),
so `aws s3 ls` with no arg now AccessDenies. Target the state bucket directly
(s3:ListBucket), which the grant allows — a more meaningful smoke test anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nbrieussel
nbrieussel marked this pull request as ready for review June 13, 2026 14:37
@nbrieussel
nbrieussel merged commit e696bb6 into main Jun 13, 2026
4 checks passed
@nbrieussel
nbrieussel deleted the feat/aws-ci-role-creator-boundary branch June 13, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant