diff --git a/.github/workflows/aws-auth-check.yml b/.github/workflows/aws-auth-check.yml index c68a66e..49f4b72 100644 --- a/.github/workflows/aws-auth-check.yml +++ b/.github/workflows/aws-auth-check.yml @@ -39,5 +39,8 @@ jobs: role-to-assume: ${{ vars.AWS_GITHUB_ACTIONS_ROLE_ARN }} aws-region: eu-west-3 - - name: List S3 buckets - run: aws s3 ls + # List the state bucket specifically (s3:ListBucket on that bucket) rather + # than `aws s3 ls` with no arg, which needs account-wide s3:ListAllMyBuckets + # — the CI grant is intentionally scoped to the state bucket only. + - name: List the Terraform state bucket + run: aws s3 ls s3://id-terraform-state20260612164136440800000001 diff --git a/CLAUDE.md b/CLAUDE.md index 3ef8a5b..09396a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,8 @@ terraform-state-bucket/ # shared AWS S3 bucket holding every root's remote sta cluster/ local/ # minikube — local dev and debugging. Using local backend (e.g. local files) scaleway/ # Scaleway Kapsule cluster + ArgoCD bootstrap (homelab; WIP, not yet wired into mise) -github-ci/ # Scaleway IAM identity GitHub Actions authenticates with +github-ci/ # Scaleway IAM identity GitHub Actions authenticates with +aws-github-oidc/ # keyless GitHub-OIDC -> AWS role for CI (Terraform state + bounded IAM) ``` Otherwise, this repo, for now, is an agregate of terraform root modules without specific structure yet. @@ -78,6 +79,12 @@ Same bootstrap pattern as `local/`, but with the Kapsule cluster + node pool (`D Standalone root (not under `cluster/` — provisions no cluster) that stands up the **Scaleway IAM identity GitHub Actions uses to authenticate to Scaleway**: a dedicated IAM application + a least-privilege policy (`ObjectStorageReadOnly`, project-scoped) + an API key, with the key written into Infisical. GitHub secrets (`SCW_ACCESS_KEY` / `SCW_SECRET_KEY`) are still set manually via `gh secret set`. Keyless GitHub-OIDC → Scaleway is a non-goal — blocked upstream (Scaleway IAM is not an OIDC relying party). See `github-ci/README.md`. +### `aws-github-oidc/` + +Standalone root (provisions no cluster) for **keyless GitHub-OIDC → AWS** CI access, built on the `terraform-aws-modules/iam` modules: an OIDC provider + a role (`github-actions-terraform`) GitHub Actions assumes via short-lived tokens (trust scoped to `repo:IntegratedDynamic/infrastructure:*`). The role grant (`tf-managed-ci`) gives Terraform-state R/W on the state bucket **plus privilege-escalation-safe IAM role management**. Applied locally by an admin; `role_arn` is wired to CI via `vars.AWS_GITHUB_ACTIONS_ROLE_ARN`. See `aws-github-oidc/README.md`. + +**Permissions-boundary contract (repo-wide):** any `aws_iam_role` that the CI applies **must** set `permissions_boundary` (= the `permissions_boundary_arn` output, `tf-managed-boundary`) and `path` (= the `managed_path` output, `/tf-managed///`), or the apply is rejected by the CI grant's conditions. In CI, feed the path via `TF_VAR_role_path=/tf-managed/${{ github.repository }}/`. The boundary caps every CI-created role to "admin minus a hardened deny-list" so a role-creating role can't escalate. Rationale is documented inline in `aws-github-oidc/iam-ci.tf`. + ## Conventions **Branches**: `/` — lowercase, hyphens only. Types: `feature/`, `bugfix/`, `hotfix/`, `ci/`, `chore/`. diff --git a/aws-github-oidc/README.md b/aws-github-oidc/README.md index 1c6dc41..018e42c 100644 --- a/aws-github-oidc/README.md +++ b/aws-github-oidc/README.md @@ -22,24 +22,71 @@ so those keep using a scoped, static Scaleway API key (see `terraform-ci/` / `github-ci/`). OIDC here covers **only** the AWS/S3 side. Revisit Scaleway OIDC if/when Scaleway ships it. +All resources are built from the [`terraform-aws-modules/iam`](https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest) +modules (`iam-oidc-provider`, `iam-role`, `iam-policy`). + ## What it creates -- `aws_iam_openid_connect_provider.github_actions` — the GitHub Actions OIDC - provider (`https://token.actions.githubusercontent.com`, audience - `sts.amazonaws.com`). **One per AWS account** — if one already exists in the - account, import it rather than creating a duplicate. -- `aws_iam_role.github_actions` (`github-actions-terraform`) — the role CI - assumes. Its **trust policy** is scoped to exactly this repo - (`repo:IntegratedDynamic/infrastructure:*`) with audience `sts.amazonaws.com`, - so only workflows in this repo can assume it. -- `aws_iam_role_policy.github_actions_s3` (`terraform-state-s3`) — least - privilege on exactly the state bucket: `s3:ListBucket` + - `s3:GetBucketVersioning` on the bucket, and `s3:GetObject` / `s3:PutObject` / - `s3:DeleteObject` on its objects (the S3-native locking the backend uses with - `use_lockfile = true` rides on object R/W, so no DynamoDB table is needed). - -`role_arn` is exposed as a Terraform output — it's a public identifier, not a -secret. +- **OIDC provider** (`iam-oidc-provider`) — the GitHub Actions OIDC issuer + (`https://token.actions.githubusercontent.com`, audience `sts.amazonaws.com`). + **One per AWS account** — if one already exists, import it rather than + duplicating. +- **CI role** `github-actions-terraform` (`iam-role`, `enable_github_oidc`) — the + role CI assumes. Its **trust policy** is scoped to exactly this repo + (`repo:IntegratedDynamic/infrastructure:*`), so only this repo's workflows can + assume it. Permissions come from the CI grant below. +- **CI grant** `tf-managed-ci` (`iam-policy`) — a tight policy: Terraform state + R/W on the state bucket only, plus **privilege-escalation-safe** IAM role + management (see next section). The S3-native lock (`use_lockfile = true`) rides + on object R/W, so no DynamoDB table is needed. +- **Permissions boundary** `tf-managed-boundary` (`iam-policy`) — the ceiling + attached to every role the CI creates. + +Outputs `role_arn`, `permissions_boundary_arn`, and `managed_path` are all public +identifiers, not secrets. + +## Creating IAM roles from CI — the permissions-boundary contract + +The CI role can run `terraform apply` that **creates IAM roles**, without being +able to escalate its own privileges. This rests on two mechanisms (full rationale +inline in [`iam-ci.tf`](./iam-ci.tf)): + +1. **Permissions boundary** (`tf-managed-boundary`) caps every CI-created role: + effective perms = `intersection(attached policies, boundary)`. Even + `AdministratorAccess` on a child role is clamped. The boundary is + "admin minus a hardened deny-list" (no IAM users, no boundary tampering, no + org/account actions, can't edit itself). +2. **A repo-scoped path** `/tf-managed/IntegratedDynamic/infrastructure/`. The CI + grant only allows `iam:CreateRole` / `Attach*` / `Put*` **when the request + stamps our boundary**, and only on ARNs under this path. `iam:PassRole` is + likewise path-scoped. A global backstop Deny refuses to touch any role whose + boundary isn't ours. + +> ⚠️ **Contract for every root that creates roles:** each `aws_iam_role` the CI +> applies **must** set `permissions_boundary = ` and +> `path = `, or the apply is rejected. With the `iam-role` module +> those are the `permissions_boundary` and `path` inputs. In CI, feed the path +> automatically: +> +> ```yaml +> env: +> TF_VAR_role_path: /tf-managed/${{ github.repository }}/ +> ``` +> +> `${{ github.repository }}` resolves to `IntegratedDynamic/infrastructure`, +> matching the pin exactly. IAM paths are **case-sensitive**. + +Verify the guardrails with the IAM policy simulator, e.g.: + +```bash +ROLE=$(terraform -chdir=aws-github-oidc output -raw role_arn) +B=$(terraform -chdir=aws-github-oidc output -raw permissions_boundary_arn) +# CreateRole without our boundary -> explicitDeny +aws iam simulate-principal-policy --policy-source-arn "$ROLE" \ + --action-names iam:CreateRole \ + --resource-arns "arn:aws:iam::503577850357:role/tf-managed/IntegratedDynamic/infrastructure/x" \ + --query 'EvaluationResults[0].EvalDecision' --output text +``` ## Credentials @@ -90,7 +137,7 @@ No `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets are required. The role and provider live entirely in this root's state. -- **Revoke CI access** — delete the role (or tighten/detach the inline policy); +- **Revoke CI access** — delete the role (or detach the `tf-managed-ci` policy); any workflow assuming it then fails. - **Revoke everything** — `terraform destroy` removes the role and the OIDC provider. Mind that the OIDC provider is account-wide; only destroy it if no diff --git a/aws-github-oidc/iam-ci.tf b/aws-github-oidc/iam-ci.tf new file mode 100644 index 0000000..7586dc2 --- /dev/null +++ b/aws-github-oidc/iam-ci.tf @@ -0,0 +1,269 @@ +# ============================================================================= +# CI role that can run `terraform apply` to CREATE IAM ROLES — without being +# able to escalate its own privileges. +# +# A role that holds iam:CreateRole + iam:AttachRolePolicy is effectively admin: +# nothing stops it from minting a role with AdministratorAccess and using it. +# We close the three classic escalation vectors: +# +# 1. Create a role more powerful than yourself -> a PERMISSIONS BOUNDARY caps +# every role the CI creates, so effective perms = intersection(attached +# policies, boundary). Even AdministratorAccess on a child role is clamped. +# 2. iam:PassRole a powerful role to a service you control -> PassRole is +# scoped to the managed path only (where every role carries the boundary). +# 3. Weaken the guardrail itself (rewrite/detach the boundary) -> explicit +# Denies on the boundary policy ARN and on DeleteRolePermissionsBoundary. +# +# Design decisions (see also README.md / CLAUDE.md): +# - Boundary philosophy: "admin minus a hardened deny-list" (pragmatic for a +# homelab — workloads run freely, only escalation is blocked). +# - CI grant: tight — S3 state R/W + bounded IAM under one path only. +# - The CI role does NOT carry the boundary itself: the boundary Denies +# PutRolePermissionsBoundary, but the CI role legitimately needs that action +# to stamp the boundary onto its children. The CI role is constrained by its +# own (tight) policy instead. +# - All CI-managed roles/policies live under a repo-scoped path +# (/tf-managed///) so the CI's IAM actions can be ARN-scoped to +# exactly that subtree — never your SSO roles, the boundary, or its own role. +# ============================================================================= + +data "aws_caller_identity" "current" {} +data "aws_partition" "current" {} + +locals { + # Repo-scoped path for every role/policy the CI manages. Mirrors + # `${{ github.repository }}` (= "IntegratedDynamic/infrastructure"), so the CI + # workflow can feed `TF_VAR_role_path=/tf-managed/${{ github.repository }}/` + # and each managed role derives its path automatically. IAM paths are + # case-sensitive and must match this pin exactly, or the boundary condition + # rejects the apply. + managed_path = "/tf-managed/${var.github_org}/${var.github_repo}/" + + # The boundary ARN is built from a *fixed* name (not a resource reference) on + # purpose: the boundary policy denies edits to itself, which would otherwise + # create a self-referential cycle in the graph. + boundary_arn = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy/${var.boundary_name}" + + # ARN globs the CI may operate on — the managed path subtree only. + managed_role_arns = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:role${local.managed_path}*" + managed_policy_arns = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy${local.managed_path}*" +} + +# ----------------------------------------------------------------------------- +# Permissions boundary — the CEILING for every role the CI creates. +# "Admin minus deny-list": allow everything, then carve out the actions that +# would let a bounded role escalate or escape the boundary. +# ----------------------------------------------------------------------------- +data "aws_iam_policy_document" "boundary" { + # Baseline: full access. The Denies below are what actually matter. + statement { + sid = "AdminBaseline" + effect = "Allow" + actions = ["*"] + resources = ["*"] + } + + # No IAM users / long-lived credentials, and no tampering with ANY permissions + # boundary. A child role carrying this boundary therefore cannot mint users, + # hand out access keys, or strip/replace boundaries to climb out. + statement { + sid = "DenyUsersAndBoundaryTampering" + effect = "Deny" + actions = [ + "iam:CreateUser", + "iam:CreateLoginProfile", + "iam:CreateAccessKey", + "iam:UpdateLoginProfile", + "iam:PutUserPolicy", + "iam:AttachUserPolicy", + "iam:PutRolePermissionsBoundary", + "iam:DeleteRolePermissionsBoundary", + "iam:PutUserPermissionsBoundary", + "iam:DeleteUserPermissionsBoundary", + ] + resources = ["*"] + } + + # Account-/org-level levers are off-limits to bounded workloads. + statement { + sid = "DenyAccountAndOrg" + effect = "Deny" + actions = [ + "organizations:*", + "account:*", + ] + resources = ["*"] + } + + # The ceiling must not be editable by anything wearing it. + statement { + sid = "DenyEditingTheBoundaryItself" + effect = "Deny" + actions = [ + "iam:CreatePolicyVersion", + "iam:SetDefaultPolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + ] + resources = [local.boundary_arn] + } +} + +module "boundary_policy" { + source = "terraform-aws-modules/iam/aws//modules/iam-policy" + version = "6.6.1" + + name = var.boundary_name + path = "/" + description = "Permissions boundary capping every role the CI creates under ${local.managed_path}. Prevents privilege escalation." + policy = data.aws_iam_policy_document.boundary.json + + tags = { + Terraform = "true" + Environment = "dev" + } +} + +# ----------------------------------------------------------------------------- +# CI grant — what the GitHub OIDC role itself may do. Deliberately tight: +# S3 state R/W + bounded IAM management under the managed path only. +# ----------------------------------------------------------------------------- +data "aws_iam_policy_document" "ci" { + # Terraform remote state: list + read/write/delete objects (the S3-native lock + # is just an object, so Put/Delete covers locking). Scoped to the state bucket. + statement { + sid = "TerraformStateBucket" + effect = "Allow" + actions = ["s3:ListBucket", "s3:GetBucketVersioning", "s3:GetBucketLocation"] + resources = [var.state_bucket_arn] + } + statement { + sid = "TerraformStateObjects" + effect = "Allow" + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${var.state_bucket_arn}/*"] + } + + # Read across IAM is needed for plan/refresh (reading current role/policy state) + # and is not an escalation vector on its own. + statement { + sid = "IamRead" + effect = "Allow" + actions = ["iam:Get*", "iam:List*"] + resources = ["*"] + } + + # Create/attach roles — but ONLY when the request stamps OUR boundary. This is + # the core guardrail: the CI cannot create or extend a role that isn't capped. + statement { + sid = "IamManageRolesRequireBoundary" + effect = "Allow" + actions = [ + "iam:CreateRole", + "iam:AttachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + ] + resources = [local.managed_role_arns] + condition { + test = "StringEquals" + variable = "iam:PermissionsBoundary" + values = [local.boundary_arn] + } + } + + # Lifecycle actions that don't take the boundary condition key. Note the + # ABSENCE of iam:DeleteRolePermissionsBoundary: letting the CI strip a boundary + # off a child it had attached AdministratorAccess to would be an escalation. + statement { + sid = "IamManageRolesLifecycle" + effect = "Allow" + actions = [ + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + "iam:UpdateAssumeRolePolicy", + ] + resources = [local.managed_role_arns] + } + + # Customer-managed policies the CI creates also live under the managed path. + statement { + sid = "IamManagePolicies" + effect = "Allow" + actions = [ + "iam:CreatePolicy", + "iam:DeletePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + "iam:TagPolicy", + "iam:UntagPolicy", + ] + resources = [local.managed_policy_arns] + } + + # PassRole only for bounded roles under the managed path — so the CI can't pass + # a pre-existing powerful role to a service it controls. + statement { + sid = "PassManagedRolesOnly" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [local.managed_role_arns] + } + + # Backstop Deny (defense in depth, independent of the path scoping above): + # never edit the boundary policy itself. + statement { + sid = "DenyTouchingBoundaryPolicy" + effect = "Deny" + actions = [ + "iam:CreatePolicyVersion", + "iam:SetDefaultPolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + ] + resources = [local.boundary_arn] + } + + # Backstop Deny: refuse to create/extend ANY role (even outside the path — + # e.g. your SSO roles or the CI role itself) whose boundary isn't ours. For + # CreateRole this checks the boundary in the request; for Attach/Put it checks + # the target's existing boundary. A missing key fails StringEquals, so the + # Deny fires and unboundaried principals stay untouchable. + statement { + sid = "DenyUnlessOurBoundary" + effect = "Deny" + actions = [ + "iam:CreateRole", + "iam:AttachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + ] + resources = ["*"] + condition { + test = "StringNotEquals" + variable = "iam:PermissionsBoundary" + values = [local.boundary_arn] + } + } +} + +module "ci_policy" { + source = "terraform-aws-modules/iam/aws//modules/iam-policy" + version = "6.6.1" + + name = "tf-managed-ci" + path = "/" + description = "Grant for the GitHub Actions CI role: Terraform state R/W + Ability to create role under ${local.managed_path} + privilege-escalation-safe IAM management under ${local.managed_path}." + policy = data.aws_iam_policy_document.ci.json + + tags = { + Terraform = "true" + Environment = "dev" + } +} diff --git a/aws-github-oidc/main.tf b/aws-github-oidc/main.tf index 772b8e8..bedc664 100644 --- a/aws-github-oidc/main.tf +++ b/aws-github-oidc/main.tf @@ -23,20 +23,26 @@ module "iam_oidc_provider" { } # Role GitHub Actions assumes via OIDC. Trust is scoped to this repo only -# (repo:/:* — the module prefixes "repo:" itself). S3 read access is -# account-wide for now (intentional; tighten to the state bucket later). +# (repo:/:* — the module prefixes "repo:" itself). Its permissions +# come from the tight, escalation-safe CI grant (see iam-ci.tf): Terraform state +# R/W + IAM role management bounded under the managed path. +# +# Note: this role deliberately carries NO permissions boundary. The boundary +# Denies PutRolePermissionsBoundary, which the CI role itself needs in order to +# stamp the boundary onto the roles it creates. The CI role is constrained by +# module.ci_policy instead. See iam-ci.tf for the full rationale. module "iam_role_github_oidc" { source = "terraform-aws-modules/iam/aws//modules/iam-role" version = "6.6.1" name = "github-actions-terraform" - description = "Assumed by GitHub Actions (${var.github_org}/${var.github_repo}) via OIDC for Terraform S3 state access." + description = "Assumed by GitHub Actions (${var.github_org}/${var.github_repo}) via OIDC: Terraform state + bounded IAM role creation." enable_github_oidc = true oidc_wildcard_subjects = ["${var.github_org}/${var.github_repo}:*"] policies = { - S3ReadOnly = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess" + tf-managed-ci = module.ci_policy.arn } tags = { diff --git a/aws-github-oidc/outputs.tf b/aws-github-oidc/outputs.tf index 7baf9e2..ec88246 100644 --- a/aws-github-oidc/outputs.tf +++ b/aws-github-oidc/outputs.tf @@ -6,3 +6,19 @@ output "role_arn" { description = "ARN of the IAM role GitHub Actions assumes via OIDC (set as vars.AWS_GITHUB_ACTIONS_ROLE_ARN)." value = module.iam_role_github_oidc.arn } + +# Every role the CI creates MUST carry this boundary, or the apply is rejected by +# the CI grant's conditions. Roots that create roles should attach it via the +# iam-role module's `permissions_boundary` input. +output "permissions_boundary_arn" { + description = "ARN of the permissions boundary to attach to every CI-managed role." + value = local.boundary_arn +} + +# The repo-scoped path every CI-managed role/policy must sit under. In CI, feed +# this to Terraform as TF_VAR_role_path=/tf-managed/${{ github.repository }}/ — +# it resolves to exactly this value. IAM paths are case-sensitive. +output "managed_path" { + description = "IAM path prefix all CI-managed roles and policies must use." + value = local.managed_path +} diff --git a/aws-github-oidc/variables.tf b/aws-github-oidc/variables.tf index 96306f4..dd08b36 100644 --- a/aws-github-oidc/variables.tf +++ b/aws-github-oidc/variables.tf @@ -33,3 +33,13 @@ variable "state_bucket_arn" { type = string default = "arn:aws:s3:::id-terraform-state20260612164136440800000001" } + +# Name of the permissions-boundary managed policy. Kept as a stable, fixed name +# (not a generated one) so its ARN can be constructed deterministically — the +# boundary references its own ARN in a Deny, and the CI grant references it in +# every guardrail condition. See iam-ci.tf. +variable "boundary_name" { + description = "Name of the permissions-boundary policy capping all CI-created roles." + type = string + default = "tf-managed-boundary" +}