AWS IAM 最小権限の法則:実務で事故を防ぐポリシー設計と動的生成のパターン

|
AWS IAM Security Well-Architected Policy ゼロトラスト

「とりあえずFullAccess」はNG。最小権限の原則に基づいたIAMポリシー設計の実践パターンを、LP構築・CI/CD・Lambda実行ロールの実例で解説。

はじめに:「動いているから良い」が事故の温床になる

IAMポリシーの設定ミスが引き起こす事故には、大きく2つの類型がある。

事故の類型:

  類型A: 過剰権限による情報漏洩・破壊
  ────────────────────────────────────
  Lambda に AdministratorAccess を付与
  → Lambdaの脆弱性を突かれ、攻撃者がAWS環境全体を掌握
  → S3バケットの全データ取得・EC2の大量起動・他リソース破壊

  類型B: 権限不足による運用事故
  ────────────────────────────────────
  デプロイ用ロールの権限を絞りすぎて
  → 深夜リリース中に「AccessDenied」が出てデプロイ停止
  → 緊急で AdministratorAccess を付与して解決
  → そのまま外すのを忘れる(よくある)

どちらも「権限設計を後回しにした」結果だ。「最小権限」は絞ることが目的ではなく、「必要な権限を正確に把握すること」が本質だ。


Part 1:IAMポリシー設計の3原則

実務で使える判断軸として整理する。

原則1:アイデンティティではなくユースケースで考える

❌ よくある考え方:
  「開発者用ロール」「本番用ロール」という人物軸での設計
  → 一人の開発者が S3・EC2・Lambda・RDS に触れる巨大ロールが生まれる

✅ 推奨する考え方:
  「S3バケットXにファイルをアップロードする」
  「Lambda関数Yを実行してCloudWatchにログを書く」
  という動詞+リソース軸での設計
  → ユースケース単位の小さなロールが生まれ、組み合わせで使う

原則2:Action を動詞レベルで絞る

権限の粒度の比較:

  NG:  "s3:*"
  △:   "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"
  ✅:   「このLambdaはオブジェクトを読むだけ」なら "s3:GetObject" のみ

  実際の確認コマンド:
  # CloudTrailでLambdaが実際に呼んだAPIを確認する
  aws cloudtrail lookup-events \
    --lookup-attributes AttributeKey=Username,AttributeValue=<role-name> \
    --query 'Events[].{API:EventName, Time:EventTime}' \
    --output table

原則3:Resource を ARN レベルで絞る

// ❌ NG: アカウント全体のS3を許可
{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": "*"
}

// ✅ 推奨: 特定バケットの特定パスのみ
{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": [
    "arn:aws:s3:::my-lp-assets-prd/uploads/*",
    "arn:aws:s3:::my-lp-assets-prd/public/*"
  ]
}

Part 2:実務ロール別のポリシー設計パターン

パターンA:CI/CDデプロイロール(S3 + CloudFront)

LP案件でよく使う構成。S3へのファイル同期とCloudFrontのキャッシュ無効化だけを許可する。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "S3BucketLevelAccess",
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": "arn:aws:s3:::my-lp-assets-prd"
    },
    {
      "Sid": "S3ObjectAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-lp-assets-prd/*"
    },
    {
      "Sid": "CloudFrontInvalidation",
      "Effect": "Allow",
      "Action": "cloudfront:CreateInvalidation",
      "Resource": "arn:aws:cloudfront::123456789012:distribution/ABCDEFGHIJKLMN"
    }
  ]
}

ポイント: s3:ListBucketResource にバケット ARN(末尾 /* なし)を指定する。オブジェクト操作は /* が必要。この違いを間違えると AccessDenied になる。

パターンB:Lambda実行ロール(最小構成)

Lambda に付与するロールは「このLambdaが実際に呼ぶサービス」だけに絞る。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudWatchLogs",
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:ap-northeast-1:123456789012:log-group:/aws/lambda/my-function:*"
    },
    {
      "Sid": "SESEmailSend",
      "Effect": "Allow",
      "Action": "ses:SendEmail",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "ses:FromAddress": "noreply@example.com"
        }
      }
    },
    {
      "Sid": "DynamoDBAccess",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem"
      ],
      "Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/contact-form-entries"
    }
  ]
}

ポイント: SES に Condition で送信元アドレスを固定している。Lambdaが侵害されても、攻撃者は noreply@example.com からしかメールを送れない。

パターンC:Terraform apply ロール(動的な権限絞り込み)

Terraform apply には「作成するリソースへの権限」が必要だが、それ以外は不要だ。管理対象リソースを明示的にリストアップしてポリシーを構成する。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "S3InfraManagement",
      "Effect": "Allow",
      "Action": [
        "s3:CreateBucket", "s3:DeleteBucket",
        "s3:GetBucketPolicy", "s3:PutBucketPolicy",
        "s3:GetBucketVersioning", "s3:PutBucketVersioning",
        "s3:GetBucketWebsite", "s3:PutBucketWebsite"
      ],
      "Resource": "arn:aws:s3:::my-lp-*"
    },
    {
      "Sid": "CloudFrontManagement",
      "Effect": "Allow",
      "Action": [
        "cloudfront:CreateDistribution",
        "cloudfront:GetDistribution",
        "cloudfront:UpdateDistribution",
        "cloudfront:TagResource"
      ],
      "Resource": "*"
    },
    {
      "Sid": "TFStateAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject", "s3:PutObject",
        "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"
      ],
      "Resource": [
        "arn:aws:s3:::my-lp-tfstate-prd/*",
        "arn:aws:dynamodb:ap-northeast-1:123456789012:table/my-lp-tflock-prd"
      ]
    },
    {
      "Sid": "IAMRoleManagement",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole", "iam:DeleteRole",
        "iam:GetRole", "iam:PassRole",
        "iam:AttachRolePolicy", "iam:DetachRolePolicy",
        "iam:PutRolePolicy", "iam:DeleteRolePolicy"
      ],
      "Resource": "arn:aws:iam::123456789012:role/my-lp-*"
    }
  ]
}

ポイント: Resource のパターンに my-lp-* というプレフィックスを使っている。プロジェクト命名規則を統一することで、ワイルドカードでも「このプロジェクトのリソースのみ」という範囲を保てる。


Part 3:Terraformによる動的ポリシー生成

ポリシーにARNをハードコードすると、リソースが再作成された際に手動更新が発生する。Terraformの data ブロックと aws_iam_policy_document を組み合わせると、ARNを動的に参照できる。

# リソースのARNを動的に取得
data "aws_s3_bucket" "lp_assets" {
  bucket = aws_s3_bucket.lp.id
}

data "aws_cloudfront_distribution" "lp" {
  id = aws_cloudfront_distribution.lp.id
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# ポリシードキュメントをコードで生成
data "aws_iam_policy_document" "deploy_policy" {
  statement {
    sid    = "S3BucketAccess"
    effect = "Allow"
    actions = ["s3:ListBucket", "s3:GetBucketLocation"]
    # ハードコードせず、リソース参照で ARN を取得
    resources = [data.aws_s3_bucket.lp_assets.arn]
  }

  statement {
    sid    = "S3ObjectAccess"
    effect = "Allow"
    actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
    resources = ["${data.aws_s3_bucket.lp_assets.arn}/*"]
  }

  statement {
    sid    = "CloudFrontInvalidation"
    effect = "Allow"
    actions = ["cloudfront:CreateInvalidation"]
    resources = [data.aws_cloudfront_distribution.lp.arn]
  }

  statement {
    sid    = "CloudWatchLogs"
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${var.function_name}:*"
    ]
  }
}

# 生成したポリシードキュメントをロールに付与
resource "aws_iam_role_policy" "deploy" {
  name   = "deploy-inline-policy"
  role   = aws_iam_role.deploy.id
  policy = data.aws_iam_policy_document.deploy_policy.json
}

動的生成の利点:

ハードコード方式の問題:
  S3バケットを作り直す
  → ARN変更
  → ポリシーを手動更新しないと権限が壊れる
  → 人間のミスが入る余地

動的参照方式:
  S3バケットを作り直す
  → terraform apply が ARN を自動で引き直す
  → ポリシーも自動で更新される
  → コードが常に正典

Part 4:Permission Boundary でガードレールを引く

「Terraform apply ロールが新しいIAMロールを作れる」状態では、そのロールが自分より強い権限を持つロールを作れてしまう(権限昇格攻撃)。Permission Boundary はこれを防ぐ仕組みだ。

# 最大でも与えられる権限の上限を定義する Boundary Policy
resource "aws_iam_policy" "permission_boundary" {
  name = "lp-project-boundary"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        # この境界内では S3・CloudFront・Lambda・CloudWatch のみ許可
        Effect = "Allow"
        Action = [
          "s3:*",
          "cloudfront:*",
          "lambda:*",
          "logs:*",
          "ses:SendEmail"
        ]
        Resource = "*"
      },
      {
        # IAM の自己権限以上の操作は明示的に拒否
        Effect = "Deny"
        Action = [
          "iam:CreateUser",
          "iam:AttachUserPolicy",
          "organizations:*"
        ]
        Resource = "*"
      }
    ]
  })
}

# 新しいロールを作成する際に Boundary を強制付与
resource "aws_iam_role" "lambda_exec" {
  name                 = "my-lp-lambda-exec"
  assume_role_policy   = data.aws_iam_policy_document.lambda_trust.json
  permissions_boundary = aws_iam_policy.permission_boundary.arn
}
Permission Boundary の効果:

  攻撃者が Lambda の脆弱性を突いて
  IAM ロールを新規作成しようとしても:

  境界ポリシーで "iam:CreateUser" が Deny されているため
  どんな操作をしても IAM ユーザー/ロールの作成は不可能

  → 爆発半径(Blast Radius)を S3・Lambda の範囲に封じ込められる

Part 5:IAM Access Analyzer で継続的に検証する

ポリシーを設計しても、運用中に「使われていない権限」が蓄積する。IAM Access Analyzerはこれを検出する。

# 未使用の権限を検出(AWS CLI)
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::123456789012:role/my-lp-deploy-role

# 90日以上使われていないアクションを確認
aws iam get-service-last-accessed-details \
  --job-id <job-id> \
  --query 'ServicesLastAccessed[?LastAuthenticated==`null` || LastAuthenticated<=`2024-10-01T00:00:00`].{Service:ServiceName, LastUsed:LastAuthenticated}' \
  --output table
継続的な権限レビューのフロー:

  1. 初期設計: 推測ではなくユースケースから設計する
  2. 初回デプロイ後 30日: CloudTrail で実際に呼ばれた API を確認
  3. 初回デプロイ後 90日: Access Analyzer で未使用権限を洗い出す
  4. 四半期ごと: 使われていない権限を削除するPRを作成
  5. メンバー離脱時: そのメンバーが使っていたロールの権限を見直す

Terraform管理下であれば、権限の削除もPRとして履歴に残る。「なぜこの権限を削除したか」がコードの変更履歴として追跡できる。


Conclusion:「設定ではなく設計」としてIAMを捉える

IAMポリシーは「AWSを使うための設定作業」ではなく、「システムの信頼境界を定義する設計作業」だ。

IAMを「設定」として扱う場合:
  → アクセスできないエラーが出たら権限を追加する
  → 気づけばAdministratorAccessが乱立する
  → 誰がどのリソースに触れるか把握できなくなる

IAMを「設計」として扱う場合:
  → ユースケースを列挙してから権限を導出する
  → コードで管理するので変更履歴が残る
  → 未使用権限を定期的に棚卸しできる
観点 場当たり的な権限付与 設計ベースの最小権限
初期コスト 低い 高い(ユースケース列挙が必要)
運用コスト 高い(謎のAccessDeniedが頻発) 低い
事故時の爆発半径 大きい Permission Boundaryで限定可能
権限の把握 不可能 コードとCloudTrailで追跡可能
メンバー変更時 危険(権限が残存) ロール単位で剥奪・追加

「動いているから問題ない」は、IAMにおいては「まだ問題が顕在化していない」と同義だ。

この記事をシェア

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

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