Skip to content
Open
13 changes: 12 additions & 1 deletion .github/workflows/base-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,18 @@ jobs:
name: lambda-${{ needs.metadata.outputs.tag }}
path: ./dist

- name: "Configure AWS Credentials"
- name: "Configure AWS Credentials (IAM Bootstrap Role)"
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-iam-bootstrap-role
aws-region: eu-west-2

- name: "Deploy IAM roles (iams-developer-roles stack)"
working-directory: ./infrastructure
run: |
make terraform env=${{ needs.metadata.outputs.environment }} stack=iams-developer-roles tf-command=apply workspace=default

- name: "Configure AWS Credentials (Main Deployment Role)"
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-api-deployment-role
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/cicd-2-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,18 @@ jobs:
name: lambda-${{ needs.metadata.outputs.version }}
path: dist/lambda.zip

- name: "Configure AWS Credentials"
- name: "Configure AWS Credentials (IAM Bootstrap Role)"
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-iam-bootstrap-role
aws-region: eu-west-2

- name: "Deploy IAM roles (iams-developer-roles stack)"
working-directory: ./infrastructure
run: |
make terraform env=dev stack=iams-developer-roles tf-command=apply workspace=default

- name: "Configure AWS Credentials (Main Deployment Role)"
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-api-deployment-role
Expand Down
60 changes: 60 additions & 0 deletions .github/workflows/iam-bootstrap-deploy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Manual IAM deployment for emergency or ad-hoc use.
# Normal IAM deployments happen automatically as part of cicd-2-publish and base-deploy.
name: "IAM Bootstrap | Deploy IAM Roles"

on:
workflow_dispatch:
inputs:
environment:
description: "Environment to deploy"
required: true
type: choice
options:
- dev
- test
- preprod
- prod

concurrency:
group: iam-bootstrap-${{ inputs.environment }}
cancel-in-progress: false

permissions:
contents: read
id-token: write

jobs:
deploy:
name: "Deploy IAM roles → ${{ inputs.environment }}"
runs-on: ubuntu-latest
timeout-minutes: 15
environment: ${{ inputs.environment }}
steps:
- name: "Checkout code"
uses: actions/checkout@v6

- name: "Resolve Terraform version"
id: vars
run: |
echo "terraform_version=$(grep '^terraform' .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT

- name: "Setup Terraform"
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ steps.vars.outputs.terraform_version }}

- name: "Configure AWS Credentials (IAM Bootstrap Role)"
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/service-roles/github-actions-iam-bootstrap-role
aws-region: eu-west-2

- name: "Terraform Plan"
working-directory: ./infrastructure
run: |
make terraform env=${{ inputs.environment }} stack=iams-developer-roles tf-command=plan workspace=default

- name: "Terraform Apply"
working-directory: ./infrastructure
run: |
make terraform env=${{ inputs.environment }} stack=iams-developer-roles tf-command=apply workspace=default
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# IAM management policy – scoped to project resources
resource "aws_iam_policy" "iam_bootstrap_iam_management" {
name = "${upper(var.project_name)}-iam-bootstrap-iam-management"
description = "Allows the IAM bootstrap role to manage project IAM resources"
path = "/service-policies/"

policy = data.aws_iam_policy_document.iam_bootstrap_iam_management.json

tags = merge(local.tags, { Name = "${upper(var.project_name)}-iam-bootstrap-iam-management" })
}

data "aws_iam_policy_document" "iam_bootstrap_iam_management" {
# Full IAM access for project-scoped resources
statement {
sid = "IamManageProjectResources"
effect = "Allow"
actions = [
"iam:GetRole*",
"iam:GetPolicy*",
"iam:ListRole*",
"iam:ListPolicies",
"iam:ListAttachedRolePolicies",
"iam:ListPolicyVersions",
"iam:ListPolicyTags",
"iam:ListOpenIDConnectProviders",
"iam:ListOpenIDConnectProviderTags",
"iam:GetOpenIDConnectProvider",
"iam:CreateRole",
"iam:DeleteRole",
"iam:UpdateRole",
"iam:UpdateAssumeRolePolicy",
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:CreatePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:TagRole",
"iam:TagPolicy",
"iam:UntagRole",
"iam:UntagPolicy",
"iam:PassRole",
"iam:TagOpenIDConnectProvider",
"iam:UntagOpenIDConnectProvider",
"iam:CreateOpenIDConnectProvider",
"iam:DeleteOpenIDConnectProvider",
"iam:UpdateOpenIDConnectProviderThumbprint",
"iam:AddClientIDToOpenIDConnectProvider",
"iam:RemoveClientIDFromOpenIDConnectProvider",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/service-roles/github-actions-api-deployment-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/service-roles/github-actions-iam-bootstrap-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${var.project_name}-terraform-developer-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/terraform-developer-role",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${upper(var.project_name)}-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${lower(var.project_name)}-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/service-policies/*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${local.stack_name}-*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com",
]
}

# Read-only IAM access for Terraform plan/discovery
statement {
sid = "IamReadOnly"
effect = "Allow"
actions = [
"iam:Get*",
"iam:List*",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/*",
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/*",
]
}

# DENY: Prevent modifying the bootstrap role itself
statement {
sid = "DenySelfModification"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:UpdateAssumeRolePolicy",
"iam:PutRolePermissionsBoundary",
"iam:DeleteRolePermissionsBoundary",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/service-roles/github-actions-iam-bootstrap-role",
]
}

# DENY: Prevent modifying the bootstrap permissions boundary
statement {
sid = "DenyBootstrapBoundaryModification"
effect = "Deny"
actions = [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion",
]
resources = [
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${lower(var.project_name)}-iam-bootstrap-permissions-boundary",
]
}
}

# Terraform state management policy
resource "aws_iam_policy" "iam_bootstrap_terraform_state" {
name = "${upper(var.project_name)}-iam-bootstrap-terraform-state"
description = "Allows the IAM bootstrap role to manage Terraform state for the iams-developer-roles stack"
path = "/service-policies/"

policy = data.aws_iam_policy_document.iam_bootstrap_terraform_state.json

tags = merge(local.tags, { Name = "${upper(var.project_name)}-iam-bootstrap-terraform-state" })
}

data "aws_iam_policy_document" "iam_bootstrap_terraform_state" {
# S3 state bucket access
statement {
sid = "TerraformStateS3Access"
effect = "Allow"
actions = [
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
]
resources = [
"${local.terraform_state_bucket_arn}",
"${local.terraform_state_bucket_arn}/*",
]
}
}

resource "aws_iam_role_policy_attachment" "iam_bootstrap_iam_management" {
role = aws_iam_role.github_actions_iam_bootstrap.name
policy_arn = aws_iam_policy.iam_bootstrap_iam_management.arn
}

resource "aws_iam_role_policy_attachment" "iam_bootstrap_terraform_state" {
role = aws_iam_role.github_actions_iam_bootstrap.name
policy_arn = aws_iam_policy.iam_bootstrap_terraform_state.arn
}
61 changes: 61 additions & 0 deletions infrastructure/stacks/iams-developer-roles/github_actions_role.tf
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,64 @@ resource "aws_iam_role" "github_actions" {
}
)
}


# GitHub Actions IAM Bootstrap Role
# It can update the main deployment role's policies but cannot modify itself.
resource "aws_iam_role" "github_actions_iam_bootstrap" {
name = "github-actions-iam-bootstrap-role"
description = "Role for GitHub Actions to deploy IAM infrastructure (iams-developer-roles stack only)"
permissions_boundary = aws_iam_policy.iam_bootstrap_permissions_boundary.arn
path = "/service-roles/"

assume_role_policy = data.aws_iam_policy_document.github_actions_iam_bootstrap_assume_role.json

tags = merge(
local.tags,
{
Name = "github-actions-iam-bootstrap-role"
}
)
}

data "aws_iam_policy_document" "github_actions_iam_bootstrap_assume_role" {
statement {
sid = "OidcAssumeRoleForIamBootstrap"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]

principals {
type = "Federated"
identifiers = [
aws_iam_openid_connect_provider.github.arn
]
}

condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}

# Only allow from main branch (and events triggered from main)
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main",
"repo:${var.github_org}/${var.github_repo}:environment:*",
]
}

# Only allow from the IAM bootstrap and base deployment workflows
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"${var.github_org}/${var.github_repo}/.github/workflows/iam-bootstrap-deploy.yaml@*",
"${var.github_org}/${var.github_repo}/.github/workflows/base-deploy.yml@*",
"${var.github_org}/${var.github_repo}/.github/workflows/cicd-2-publish.yaml@*",
]
}
}
}
Loading
Loading