Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/aws-auth-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<org>/<repo>/`), 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**: `<type>/<description>` — lowercase, hyphens only. Types: `feature/`, `bugfix/`, `hotfix/`, `ci/`, `chore/`.
Expand Down
81 changes: 64 additions & 17 deletions aws-github-oidc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <permissions_boundary_arn>` and
> `path = <managed_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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading