GitHub Actions × OIDC:認証情報を保持しない安全なAWSデプロイ自動化

|
GitHub Actions CI/CD AWS OIDC S3 CloudFront Terraform

GitHub Actions を使って S3+CloudFront のLP自動デプロイを構築。OIDC認証でアクセスキー不要の安全なCI/CDパイプラインを実現する手順を解説。

はじめに:「GitHub Secretsにアクセスキーを置く」の何が問題か

GitHub ActionsでAWSにデプロイする最初の実装として、多くのエンジニアが次の構成を取る。

# ❌ よく見るが危険なパターン
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ap-northeast-1

これは動く。しかし「セキュリティ上の負債」を抱えたまま運用を続けることになる。

アクセスキー管理の問題構造:

  長期トークン (Access Key) の特性:
  ┌─────────────────────────────────────────────┐
  │  有効期限: なし(手動でrotateするまで永久に有効)  │
  │  保存場所: GitHub Secrets(暗号化されているが存在する) │
  │  漏洩リスク: ログ出力・fork・Actions の脆弱性      │
  │  権限剥奪:  IAMコンソールで手動削除が必要           │
  └─────────────────────────────────────────────┘

  実際のインシデントパターン:
  1. サードパーティActionのサプライチェーン攻撃
  2. デバッグ用printでログにキーが出力
  3. リポジトリのforkでSecretsが継承(旧仕様)
  4. 退職したメンバーのキーが残存

根本的な問題はキーの「永続性」にある。 OIDC(OpenID Connect)はこの問題を「キーを発行しない」という設計で解決する。


Part 1:OIDCの仕組み ── 一時トークンだけで認証する

OIDCは「GitHubがAWSに対して身元を証明し、AWSが一時的なSTSトークンを発行する」プロトコルだ。

OIDC認証フロー:

  1. GitHub Actions がワークフロー実行時に
     GitHub の OIDC エンドポイントから
     「このJobはリポジトリXのブランチYで動いている」
     という署名付きJWTトークンを取得する

  2. AWSのIAM OIDC Providerが
     そのJWTの署名をGitHubの公開鍵で検証する

  3. IAM Roleの trust policy が
     「リポジトリX・ブランチYからの要求のみ許可」
     という Condition で一致を確認する

  4. STSが有効期限付きの一時トークン(最大1時間)を発行する

  結果:
  ✅ アクセスキーの保存: ゼロ
  ✅ トークンの有効期限: Job終了で自動失効
  ✅ 権限剥奪: IAM Roleを変更するだけ(キー削除不要)

長期トークンを「保管するセキュリティ」から、一時トークンを「都度発行するセキュリティ」へのパラダイムシフトだ。


Part 2:Terraformで OIDC Provider と IAM Role を構築する

IAMリソースもインフラの一部としてTerraformでコード管理する。

IAM OIDC Provider の作成

# modules/github-oidc/main.tf

# GitHub Actions の OIDC Provider を AWS に登録
resource "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"

  # GitHub の OIDC エンドポイントのクライアントID
  client_id_list = ["sts.amazonaws.com"]

  # GitHub OIDC の thumbprint
  # 参考: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments
  thumbprint_list = [
    "6938fd4d98bab03faadb97b34396831e3780aea1",
    "1c58a3a8518e8759bf075b76b750d4f2df264fcd"
  ]
}

デプロイ専用 IAM Role の作成

# modules/github-oidc/main.tf (続き)

variable "github_org"  { type = string }
variable "github_repo" { type = string }
variable "allowed_branches" {
  type    = list(string)
  default = ["main"]
  description = "デプロイを許可するブランチ一覧"
}

# Trust Policy: どのGitHubリポジトリ・ブランチからの要求を信頼するか
data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    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"]
    }

    # ブランチ単位でデプロイ権限を絞る
    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      # "repo:org/repo:ref:refs/heads/main" の形式
      values = [
        for branch in var.allowed_branches :
        "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${branch}"
      ]
    }
  }
}

resource "aws_iam_role" "github_actions_deploy" {
  name               = "${var.github_repo}-github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json

  tags = {
    ManagedBy = "terraform"
    Purpose   = "github-actions-oidc-deploy"
  }
}

デプロイに必要な最小権限ポリシーの付与

# S3 + CloudFront デプロイ専用ポリシー(最小権限)
data "aws_iam_policy_document" "deploy_policy" {
  # S3: デプロイ対象バケットへの読み書き
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject",
      "s3:ListBucket"
    ]
    resources = [
      "arn:aws:s3:::${var.s3_bucket_name}",
      "arn:aws:s3:::${var.s3_bucket_name}/*"
    ]
  }

  # CloudFront: キャッシュ無効化のみ(設定変更は不可)
  statement {
    effect = "Allow"
    actions = [
      "cloudfront:CreateInvalidation"
    ]
    resources = [
      "arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${var.cloudfront_distribution_id}"
    ]
  }

  # Terraform plan/apply に必要な読み取り権限
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:ListBucket"
    ]
    resources = [
      "arn:aws:s3:::${var.tfstate_bucket_name}",
      "arn:aws:s3:::${var.tfstate_bucket_name}/*"
    ]
  }
}

resource "aws_iam_role_policy" "deploy" {
  name   = "deploy-policy"
  role   = aws_iam_role.github_actions_deploy.id
  policy = data.aws_iam_policy_document.deploy_policy.json
}

# Terraform apply 用の Role(plan より広い権限が必要)
resource "aws_iam_role_policy_attachment" "terraform_apply" {
  role       = aws_iam_role.github_actions_deploy.name
  policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
  # Note: 本番では PowerUserAccess より絞った独自ポリシーを推奨
}

output "role_arn" {
  value       = aws_iam_role.github_actions_deploy.arn
  description = "GitHub Actions ワークフローで指定する IAM Role ARN"
}

Part 3:GitHub Actions ワークフローの実装

LP デプロイの全体像

ワークフロー設計:

  PR作成 / push
  ├── [lint-and-test]  構文チェック・テスト
  │
  ├── [terraform-plan] ← main以外のブランチでも実行
  │     terraform init
  │     terraform plan -out=tfplan
  │     → PRにplanの差分をコメント投稿
  │
  └── (mainブランチマージ後)
      ├── [terraform-apply]  インフラ変更の適用
      │     terraform apply tfplan
      └── [deploy-frontend]  フロントエンドのデプロイ
            aws s3 sync ./dist s3://...
            aws cloudfront create-invalidation ...

.github/workflows/deploy.yml

name: Deploy LP to AWS

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# 同一PRで複数のJobが走った場合、古いものをキャンセル
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  AWS_REGION: ap-northeast-1
  TF_VERSION: "1.7.0"

permissions:
  id-token: write   # OIDC トークン取得に必須
  contents: read
  pull-requests: write  # PRへのコメント投稿

jobs:
  # ──────────────────────────────
  # Job 1: Terraform Plan(PR時)
  # ──────────────────────────────
  terraform-plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

    steps:
      - uses: actions/checkout@v4

      # OIDC認証: アクセスキー不要
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
          # role-session-nameでどのWorkflowかを識別できる
          role-session-name: github-actions-${{ github.run_id }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        working-directory: terraform/environments/prd
        run: terraform init

      - name: Terraform Plan
        id: plan
        working-directory: terraform/environments/prd
        run: |
          terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
          echo "exitcode=$?" >> $GITHUB_OUTPUT
        continue-on-error: true

      # planの結果をPRにコメント投稿
      - name: Comment Plan Result on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const planOutput = fs.readFileSync(
              'terraform/environments/prd/plan_output.txt', 'utf8'
            );
            const maxLength = 65000;
            const truncated = planOutput.length > maxLength
              ? planOutput.substring(0, maxLength) + '\n... (truncated)'
              : planOutput;

            const body = `## Terraform Plan Result

            \`\`\`hcl
            ${truncated}
            \`\`\`

            > Plan generated at: ${new Date().toISOString()}
            > Commit: ${{ github.sha }}`;

            // 既存のbotコメントを更新(重複を防ぐ)
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const botComment = comments.data.find(
              c => c.user.type === 'Bot' && c.body.includes('Terraform Plan Result')
            );

            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

      - name: Terraform Plan Status Check
        if: steps.plan.outputs.exitcode == '1'
        run: exit 1

  # ──────────────────────────────
  # Job 2: Terraform Apply(mainマージ後)
  # ──────────────────────────────
  terraform-apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production  # GitHub Environments で承認フローを追加可能

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
          role-session-name: github-actions-terraform-apply-${{ github.run_id }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init & Apply
        working-directory: terraform/environments/prd
        run: |
          terraform init
          terraform apply -auto-approve

  # ──────────────────────────────
  # Job 3: Frontend Deploy(mainマージ後)
  # ──────────────────────────────
  deploy-frontend:
    name: Deploy Frontend
    runs-on: ubuntu-latest
    needs: terraform-apply  # インフラ更新後にデプロイ
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
          role-session-name: github-actions-deploy-frontend-${{ github.run_id }}

      - name: Build
        run: |
          npm ci
          npm run build

      # --delete: S3から削除されたファイルを同期
      # --cache-control: ブラウザキャッシュ戦略
      - name: Sync to S3
        run: |
          # HTMLは短いキャッシュ(内容が変わりやすい)
          aws s3 sync ./dist s3://${{ secrets.S3_BUCKET_NAME }} \
            --exclude "*" \
            --include "*.html" \
            --cache-control "public, max-age=0, must-revalidate" \
            --delete

          # アセットは長いキャッシュ(ハッシュ付きファイル名)
          aws s3 sync ./dist s3://${{ secrets.S3_BUCKET_NAME }} \
            --exclude "*.html" \
            --cache-control "public, max-age=31536000, immutable"

      # CloudFront の HTML キャッシュのみ無効化(アセットはハッシュで管理)
      - name: Invalidate CloudFront Cache
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*.html" "/index.html"

Part 4:Terraform plan を「差分の可視化」として機能させる

PR上のコメントが実際にどう見えるかを示す。

GitHubのPRに自動投稿されるコメントの例:

  ## Terraform Plan Result

  Terraform will perform the following actions:

    # aws_cloudfront_distribution.lp will be updated in-place
    ~ resource "aws_cloudfront_distribution" "lp" {
        ~ price_class = "PriceClass_100" -> "PriceClass_All"
      }

    # aws_s3_bucket_versioning.lp will be updated in-place
    ~ resource "aws_s3_bucket_versioning" "lp" {
        ~ versioning_configuration {
            ~ status = "Suspended" -> "Enabled"
          }
      }

  Plan: 0 to add, 2 to change, 0 to destroy.

  > Plan generated at: 2025-01-08T10:30:00.000Z
  > Commit: a1b2c3d

このコメントがあることで、インフラの変更をコードレビューと同じプロセスで承認できる。 「このPRをマージするとS3のバージョニングが有効になる」という情報が、コードの変更と同じPRに集約される。


Part 5:セキュリティの多層防御

OIDCは出発点に過ぎない。さらに絞り込むための設計を示す。

ブランチ保護ルール

GitHub リポジトリ設定:

  Branch protection rules for "main":
  ✅ Require a pull request before merging
  ✅ Require approvals: 1
  ✅ Require status checks to pass:
     - terraform-plan
     - lint-and-test
  ✅ Restrict who can push to matching branches

GitHub Environments による承認フロー

# 本番デプロイに人間の承認を挟む
jobs:
  terraform-apply:
    environment: production  # この1行で承認フローが発動
GitHub Environments "production" の設定:

  Required reviewers: [your-github-username]
  Wait timer: 0 minutes
  Deployment branches: main only

  → マージ後、指定レビュワーが承認するまでJobが待機する
  → 深夜の誤マージでも、朝に確認してからApplyできる

Condition の細かい制御

# プルリクエストからのplan実行も許可(readOnlyロールで)
condition {
  test     = "StringLike"
  variable = "token.actions.githubusercontent.com:sub"
  values = [
    # mainブランチへのpush(apply用)
    "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main",
    # PRのhead(plan用)
    "repo:${var.github_org}/${var.github_repo}:pull_request"
  ]
}
権限分離の設計:

  PR(plan実行):
  - sub: "repo:org/repo:pull_request"
  - 権限: terraform state の read + S3 read のみ

  mainブランチ(apply + deploy):
  - sub: "repo:org/repo:ref:refs/heads/main"
  - 権限: terraform apply + S3 write + CloudFront invalidation

Conclusion:「認証情報の管理」から「信頼関係の設計」へ

アクセスキーの管理は「秘密を守る」セキュリティだ。OIDCは「誰を信頼するか」を設計するセキュリティだ。

アプローチの変化:

  アクセスキー方式:
  「このキーを持っている者を信頼する」
  → キーが漏れれば誰でも信頼される
  → キーの保管場所がセキュリティの境界

  OIDC方式:
  「このリポジトリのこのブランチで動いている者を信頼する」
  → コンテキスト(どこで・何が・誰が)で認証する
  → 条件を変えるだけで権限を即時剥奪できる
比較軸 アクセスキー OIDC
有効期限 なし(永続) Job終了で自動失効
漏洩時の影響 キーが使えれば即悪用 トークンは短命で影響限定
権限剥奪 キーを手動削除 IAM Roleを変更するだけ
監査ログ 誰がキーを使ったか不明 Roleセッション名でJob特定可能
管理コスト キーのローテーション必要 ローテーション不要

この設計は「GitHub ActionsというCI/CDツールの設定」ではない。「信頼境界をどこに引くか」というセキュリティアーキテクチャの決断だ。

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋