diff --git a/.github/workflows/base-deploy.yml b/.github/workflows/base-deploy.yml index a174e548..6679d669 100644 --- a/.github/workflows/base-deploy.yml +++ b/.github/workflows/base-deploy.yml @@ -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 diff --git a/.github/workflows/cicd-2-publish.yaml b/.github/workflows/cicd-2-publish.yaml index e59eeb0b..2fe0121b 100644 --- a/.github/workflows/cicd-2-publish.yaml +++ b/.github/workflows/cicd-2-publish.yaml @@ -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 diff --git a/.github/workflows/iam-bootstrap-deploy.yaml b/.github/workflows/iam-bootstrap-deploy.yaml new file mode 100644 index 00000000..30378258 --- /dev/null +++ b/.github/workflows/iam-bootstrap-deploy.yaml @@ -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 diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_iam_bootstrap_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_iam_bootstrap_policies.tf new file mode 100644 index 00000000..281ee919 --- /dev/null +++ b/infrastructure/stacks/iams-developer-roles/github_actions_iam_bootstrap_policies.tf @@ -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 +} diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_role.tf b/infrastructure/stacks/iams-developer-roles/github_actions_role.tf index a1305fde..ed3c7598 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_role.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_role.tf @@ -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@*", + ] + } + } +} diff --git a/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf b/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf index 2dca544d..91c1e94d 100644 --- a/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf +++ b/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf @@ -295,3 +295,148 @@ resource "aws_iam_policy" "permissions_boundary" { } ) } + +data "aws_iam_policy_document" "iam_bootstrap_permissions_boundary" { + # Allow IAM operations on project-scoped resources + statement { + sid = "AllowProjectIamOperations" + 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:DeletePolicy*", + "iam:TagRole", + "iam:TagPolicy", + "iam:UntagRole", + "iam:UntagPolicy", + "iam:PassRole", + "iam:CreateServiceLinkedRole", + "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", + ] + } + + # Allow read-only IAM access for Terraform plan/state discovery + statement { + sid = "AllowIamReadAccess" + 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/*", + ] + } + + # Allow Terraform state bucket access + statement { + sid = "AllowTerraformStateAccess" + effect = "Allow" + actions = [ + "s3:ListBucket", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + ] + resources = [ + "${local.terraform_state_bucket_arn}", + "${local.terraform_state_bucket_arn}/*", + ] + } + + # Allow Terraform state locking via DynamoDB + statement { + sid = "AllowTerraformStateLocking" + effect = "Allow" + actions = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ] + resources = [ + "arn:aws:dynamodb:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:table/${var.project_name}-*-terraform-lock", + ] + } + + # DENY: Prevent the bootstrap role from modifying its own policies + statement { + sid = "DenyBootstrapSelfModification" + 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 the bootstrap role from modifying its own 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", + ] + } +} + +resource "aws_iam_policy" "iam_bootstrap_permissions_boundary" { + name = "${lower(var.project_name)}-iam-bootstrap-permissions-boundary" + description = "Permissions boundary for the GitHub Actions IAM Bootstrap role - scoped to IAM and Terraform state only" + policy = data.aws_iam_policy_document.iam_bootstrap_permissions_boundary.json + + tags = merge( + local.tags, + { + Stack = "iams-developer-roles" + } + ) +}