Skip to content

Spec: terraform-aws-bootstrap v0.2

  • Module: phpboyscout/terraform-aws-bootstrap
  • Released as: v0.2.0 (minor — additive, backward-compatible).
  • Driven by: phpboyscout/infra GitLab migration spec decision D1 (dual-provider OIDC) + D2 (state-backend opt-out).

Summary

v0.2 extends modules/automation-iam so it can provision the AWS-side trust shape for either GitHub Actions or GitLab CI/CD, gated by a new input ci_provider. Existing v0.1.x consumers do not have to change anything — the default stays "github" and the GitHub code paths are untouched.

v0.2 also adds a root-level enable_state_backend toggle that gates the state-backend sub-module call, for consumers (such as phpboyscout/infra post-migration) that use a non-AWS state backend (GitLab-managed HTTP state) and don't need this module to provision S3 + KMS.

Nothing else changes. No new sub-modules, no behaviour changes to existing inputs, no breaking renames.

Motivation

Single force: phpboyscout/infra is migrating off GitHub onto GitLab (see migration spec) and needs the AWS-side OIDC trust to be issued for https://gitlab.com tokens. This module is the only place in our toolchain that provisions that trust. Adding GitLab as an additive option here lets the migration proceed without forking or replacing this module.

The state-backend toggle pairs with the migration's D2 (GitLab-managed state for phpboyscout/infra). Without the toggle, phpboyscout/infra would have to keep an S3 state bucket alive purely for symmetry, which defeats the simplification this migration is supposed to deliver.

OSS rationale (the public consumer angle): GitLab is a common alternative to GitHub Actions for AWS-managed orgs, and a baseline module that forces a CI provider choice would be less useful than one that handles both. Keeping both paths open is consistent with the v0.1 design ethos (narrow, factual, no framework imposition).

Decisions

D1 — Single sub-module, ci_provider switch

The new path lives inside modules/automation-iam/, not in a sibling sub-module. One input ci_provider = "github" | "gitlab" (default "github") picks the provider. Per-provider locals build the issuer URL, audience, thumbprint, default subject-filter list, and OIDC provider ARN.

Why one sub-module: the two providers share ~80% of the structure (IDP resource + IAM role + trust policy keyed off OIDC claims + policy attachments). Splitting into siblings would duplicate that shape with cosmetic differences. A single sub-module with per-provider locals keeps the divergence honest.

Why a ci_provider enum rather than per-provider enable_* toggles: provisioning both at the same time would mean two OIDC IDPs and two roles in the same apply — fine in theory but unusual in practice and a footgun. Forcing the consumer to pick one keeps the mental model simple. Multi-IDP consumers can call the sub-module twice with different provider settings.

D2 — Asymmetric implementation: GitHub keeps the upstream wrap; GitLab is hand-rolled

The v0.1 GitHub path wraps terraform-aws-modules/iam-github-oidc-{provider,role}. v0.2 leaves that intact. The new GitLab path is hand-rolled:

Component GitHub path GitLab path
OIDC IDP module "oidc_provider" { source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-provider" } resource "aws_iam_openid_connect_provider" "gitlab"
IAM role module "role" { source = "terraform-aws-modules/iam/aws//modules/iam-github-oidc-role" } resource "aws_iam_role" "gitlab" + aws_iam_role_policy_attachment
Trust policy Generated by the upstream module from subjects Hand-rolled data.aws_iam_policy_document

Why asymmetric: the symmetric (hand-roll both) variant requires moved {} blocks to keep v0.1 state stable on upgrade, which is a small migration step but a real one. For a minor release the zero-migration path is more important than the cleanup. A symmetric v1.0 (when we're allowed to break) can hand-roll both.

Net diff: ~80 lines of HCL added under the GitLab path; ~0 lines changed under the GitHub path.

D3 — Audience claim sts.amazonaws.com for both providers

GitHub Actions' default audience for aws-actions/configure-aws-credentials is sts.amazonaws.com. GitLab CI's id_tokens directive lets pipelines pick any audience. v0.2 standardises on sts.amazonaws.com for both providers so the trust policies differ only in issuer URL and subject claim shape.

# .gitlab-ci.yml — pipeline side
plan:
  id_tokens:
    GITLAB_OIDC_TOKEN:
      aud: sts.amazonaws.com

Why sts.amazonaws.com and not, say, https://gitlab.com: matches the AWS-native convention; symmetric with GitHub Actions; eliminates one more difference in the trust policy.

D4 — Subject filter defaults: per-provider, plan + apply

The provider-specific defaults inside modules/automation-iam/main.tf:

Provider Default subjects
github ["repo:${var.github_repo}:ref:refs/heads/main", "repo:${var.github_repo}:pull_request"]
gitlab ["project_path:${var.gitlab_project}:ref_type:branch:ref:main", "project_path:${var.gitlab_project}:ref_type:mr:ref:*"]

Both shapes use StringLike (the wildcard form in the GitLab MR default needs it; GitHub's exact strings work under StringLike unchanged).

Consumers wanting plan-vs-apply role split (the phpboyscout/infra pattern) call this sub-module twice — once per role — and override subject_filters to the per-role shape:

module "plan_role" {
  source          = "./modules/automation-iam"
  ci_provider     = "gitlab"
  gitlab_project  = "phpboyscout/infra"
  role_name       = "gl-oidc-plan-prod"
  subject_filters = ["project_path:phpboyscout/infra:*"]
  policy_arns     = { ReadOnlyAccess = "arn:aws:iam::aws:policy/ReadOnlyAccess" }
}

module "apply_role" {
  source                = "./modules/automation-iam"
  ci_provider           = "gitlab"
  gitlab_project        = "phpboyscout/infra"
  role_name             = "gl-oidc-apply-prod"
  subject_filters       = ["project_path:phpboyscout/infra:ref_type:branch:ref:main"]
  create_oidc_provider  = false   # already created by the plan role
}

D5 — Well-known thumbprints; consumers don't override

aws_iam_openid_connect_provider requires a non-empty thumbprint_list but AWS validates the SSL cert chain server-side and the value is essentially ignored for well-known IDPs. v0.2 hard-codes the documented values per provider:

  • GitHub: 6938fd4d98bab03faadb97b34396831e3780aea1
  • GitLab: 0f1d378d223a195a4d6cd23b25aebc3536f5fa92

If either rotates we'd ship a v0.2.x patch. No input to override — this is a footgun avoidance call.

D6 — Root-level enable_state_backend toggle (default true)

New root input gates the state-backend sub-module call:

variable "enable_state_backend" {
  description = "Whether to provision the S3 state backend sub-module."
  type        = bool
  default     = true
}

module "state_backend" {
  count  = var.enable_state_backend ? 1 : 0
  source = "./modules/state-backend"
  # ...
}

When false, the corresponding outputs (tfstate_bucket_name, tfstate_backend_config, tfstate_kms_key_id, etc.) return null.

Why a root-level toggle vs. expecting consumers to call sub-modules directly: stays consistent with the v0.1 ergonomic — callers use the root for the common-case, drop down to sub-modules for finer control. A single toggle is less invasive than "call the sub-modules yourself."

D7 — New inputs are at the root AND on the sub-module

Both surfaces gain the new inputs. The root threads them down:

Root input Sub-module input Default
ci_provider ci_provider "github"
gitlab_project gitlab_project null (required when ci_provider = "gitlab")

Validation: when ci_provider = "gitlab" the gitlab_project value must match ^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)+$ (group/project, or group/subgroup/project for nested groups). When ci_provider = "github" the existing github_repo validation applies. Cross-validation enforces that github_repo is set when provider is github, gitlab_project is set when provider is gitlab.

D8 — Naming convention: role prefix follows provider

v0.2 doesn't introduce a role_prefix input — var.role_name is still caller-supplied. The phpboyscout convention is:

  • gh-oidc-plan-<env> / gh-oidc-apply-<env> for GitHub-OIDC roles
  • gl-oidc-plan-<env> / gl-oidc-apply-<env> for GitLab-OIDC roles

Consumers (specifically phpboyscout/infra/bootstrap) pass the appropriate name. No module-side enforcement; the convention lives in the migration spec.

Open questions

  • OQ1 — Pin upstream terraform-aws-modules/iam versions. v0.1 uses ~> 5.0. Upstream is on 5.62 as of writing. Leave as ~> 5.0 in v0.2 (allow minor / patch via Dependabot/Renovate), pin tighter, or bump to ~> 5.62? Tentative: leave ~> 5.0 — matches v0.1 behaviour and the existing Dependabot story.
  • OQ2 — Validate the OIDC issuer URL in aws_iam_openid_connect_provider isn't user-overridable. If GitLab self-hosted ever lands as a v0.3+ ask, the url becomes a per-deployment input. v0.2 hard-codes https://gitlab.com for the SaaS instance only. Tentative: accept; self-hosted is v0.3+.

Module surface (v0.2 target)

Inputs (additive vs. v0.1)

Name Type Default Required when
ci_provider string "github" always (validated)
gitlab_project string null ci_provider = "gitlab"
enable_state_backend bool true — (root only)

github_repo becomes optional in v0.2 (default null). Validation enforces it's set when ci_provider = "github".

All other v0.1 inputs are unchanged.

Outputs (unchanged)

automation_role_arn and oidc_provider_arn outputs at the root are the union of the two paths — they return the relevant provider's ARNs.

tfstate_bucket_name, tfstate_backend_config, tfstate_kms_key_id return null when enable_state_backend = false.

Implementation plan

  1. Spec lands. This file at terraform-aws-bootstrap/docs/development/specs/2026-05-12-bootstrap-v0.2.md. Status approved.
  2. modules/automation-iam/: add per-provider locals; hand-roll the GitLab IDP + role + trust policy + policy attachments; gate each block with count = local.is_<provider> ? 1 : 0. New variables ci_provider, gitlab_project, subject_filters keeps the existing default-overriding semantics. Outputs reduce both paths to a single role-ARN.
  3. Root: thread ci_provider, gitlab_project to the sub-module call; add enable_state_backend toggle on the state_backend sub-module count; mark tfstate_* outputs as nullable.
  4. examples/minimal/: keep current example as the GitHub variant; add examples/gitlab/ cloning it but with ci_provider = "gitlab" + gitlab_project = "example-group/example-project".
  5. CHANGELOG [0.2.0] entry describing the additions.
  6. CI green on the seven gate jobs.
  7. Merge develop → main as a release PR; tag v0.2.0 on the merge commit.

Risk register

Risk Mitigation
GitLab thumbprint changes; pipelines stop trusting the IDP Ship a v0.2.x patch updating the constant; document in the IDP resource comment. AWS validates the cert chain server-side regardless of the thumbprint value for well-known IDPs, so the practical impact is low.
Audience choice (sts.amazonaws.com) collides with downstream consumers expecting https://gitlab.com The audience is var-overridable on a per-instance basis if needed (not yet in v0.2 scope; track as a v0.2.x follow-on input if a real ask comes in). Document the choice in the README.
iam-github-oidc-role upstream version drift breaks the GitHub path Pin remains ~> 5.0, same as v0.1. Renovate / Dependabot bumps cover real changes; v0.2 doesn't change this risk profile.
enable_state_backend = false consumers forget to declare their own backend Root README explicitly documents this; we don't try to be clever.

Follow-ups

  • automation-iam-gitlab audience override. If a self-hosted GitLab consumer shows up, the aud value needs to be per-instance. Track as v0.2.x.
  • v1.0 symmetry refactor. Hand-roll both providers, drop the terraform-aws-modules/iam dependency entirely; reduce the external surface to native aws_* resources. Allowed-breaking change for v1.0.
  • Self-hosted GitLab (https://<custom>). Out of scope for v0.2; candidate v0.3+ if a consumer asks.
  • GitHub Enterprise (https://<custom>). Same: out of scope for v0.2; candidate v0.3+.