Spec: terraform-aws-bootstrap v0.2¶
- Module:
phpboyscout/terraform-aws-bootstrap - Released as:
v0.2.0(minor — additive, backward-compatible). - Driven by:
phpboyscout/infraGitLab 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.
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 rolesgl-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/iamversions. v0.1 uses~> 5.0. Upstream is on 5.62 as of writing. Leave as~> 5.0in 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_providerisn't user-overridable. If GitLab self-hosted ever lands as a v0.3+ ask, theurlbecomes a per-deployment input. v0.2 hard-codeshttps://gitlab.comfor 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¶
- Spec lands. This file at
terraform-aws-bootstrap/docs/development/specs/2026-05-12-bootstrap-v0.2.md. Statusapproved. modules/automation-iam/: add per-provider locals; hand-roll the GitLab IDP + role + trust policy + policy attachments; gate each block withcount = local.is_<provider> ? 1 : 0. New variablesci_provider,gitlab_project,subject_filterskeeps the existing default-overriding semantics. Outputs reduce both paths to a single role-ARN.- Root: thread
ci_provider,gitlab_projectto the sub-module call; addenable_state_backendtoggle on thestate_backendsub-module count; marktfstate_*outputs as nullable. examples/minimal/: keep current example as the GitHub variant; addexamples/gitlab/cloning it but withci_provider = "gitlab"+gitlab_project = "example-group/example-project".- CHANGELOG
[0.2.0]entry describing the additions. - CI green on the seven gate jobs.
- Merge
develop → mainas a release PR; tagv0.2.0on 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-gitlabaudience override. If a self-hosted GitLab consumer shows up, theaudvalue needs to be per-instance. Track as v0.2.x.v1.0symmetry refactor. Hand-roll both providers, drop theterraform-aws-modules/iamdependency entirely; reduce the external surface to nativeaws_*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+.