AWS CDK で IAM 最小権限を設計する:Grant メソッドと PolicyStatement の使い分け
AWS CDK でコンストラクト間の権限を付与する方法を解説。Grant メソッド・addToRolePolicy・PolicyStatement の使い分け、ARN のハードコードを避けるテクニック、ユニットテストによる権限検証まで実運用ベースで整理します。
はじめに
AWS CDK でインフラを書くとき、IAM の設定を後回しにして * で固めてしまう、という経験は誰にでもあるはずです。* の権限は「とりあえず動く」状態は作れますが、本番運用では意図しないリソースへのアクセスを許してしまうリスクになります。
CDK は IAM 最小権限を実現しやすい仕組みを複数持っています。本記事では Grant メソッド・addToRolePolicy・PolicyStatement 直書き という 3 つの手段を使い分けながら、必要な権限だけを付与するパターンを整理します。
CDK が提供する 3 つの権限付与手段
CDK で権限を付与するときは、大きく 3 つのアプローチがあります。
| アプローチ | 書き場所 | 向いているケース |
|---|---|---|
| Grant メソッド | リソース側(bucket.grantRead(role) 等) |
標準的な権限(読み書き・呼び出し等) |
addToRolePolicy |
ロール側 | 細かい条件を付けたいとき |
PolicyStatement 直接構築 |
ロール / ポリシー | Grant がない操作・クロスアカウント等 |
原則として Grant メソッドを最初に探し、なければ addToRolePolicy、それでも難しければ PolicyStatement という優先順位で書くと、* を書く機会を大幅に減らせます。

図2: CDK IAM 権限付与手段の選択フロー(Grant → addToRolePolicy → PolicyStatement の優先順位)
Grant メソッドの実例
CDK の多くの L2 コンストラクトには grantXxx 形式のメソッドが用意されています。
S3 バケット
const bucket = new s3.Bucket(this, 'DataBucket');
const lambdaFn = new lambda.Function(this, 'Processor', { ... });
// Lambda に読み取り権限だけ付与
bucket.grantRead(lambdaFn);
// 書き込みも必要な場合
bucket.grantReadWrite(lambdaFn);
// アップロードのみ(PutObject だけ)
bucket.grantPut(lambdaFn);
grantRead は s3:GetObject / s3:ListBucket などを自動で解決し、Resource も bucket.bucketArn と bucket.bucketArn + "/*" を適切に設定してくれます。自分で ARN を書く必要がありません。
DynamoDB テーブル
const table = new dynamodb.Table(this, 'UserTable', {
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
});
const apiLambda = new lambda.Function(this, 'ApiHandler', { ... });
// 読み取りのみ(GetItem・Query・Scan・BatchGetItem)
table.grantReadData(apiLambda);
// 書き込みのみ(PutItem・UpdateItem・DeleteItem・BatchWriteItem)
table.grantWriteData(apiLambda);
// 読み書き両方
table.grantReadWriteData(apiLambda);
grantReadData は GSI を含む全インデックスに対して適切な権限を設定します。arn:aws:dynamodb:*:*:table/*/index/* といった ARN のバリエーションを自分で書く必要がなくなります。

図1: table.grantReadWriteData(fn) の 1 行が裏で行うこと(Lambda 実行ロールへのインラインポリシー自動生成)
Lambda 関数の呼び出し
const processorFn = new lambda.Function(this, 'Processor', { ... });
const triggerFn = new lambda.Function(this, 'Trigger', { ... });
// triggerFn が processorFn を呼び出せる権限
processorFn.grantInvoke(triggerFn);
grantInvoke は lambda:InvokeFunction を processorFn.functionArn に限定して付与します。
SQS キュー
const queue = new sqs.Queue(this, 'JobQueue');
const workerFn = new lambda.Function(this, 'Worker', { ... });
// メッセージの送信権限
queue.grantSendMessages(workerFn);
// メッセージの受信・削除権限
queue.grantConsumeMessages(workerFn);
addToRolePolicy で PolicyStatement を書くケース
Grant メソッドで表現できない権限(条件付き権限・特定 API だけの制限など)は addToRolePolicy を使います。
IAM 条件でバケットのプレフィクスを絞る
const workerFn = new lambda.Function(this, 'Worker', { ... });
workerFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: [`${bucket.bucketArn}/uploads/*`], // uploads/ 以下のみ
}));
grantRead はバケット全体を対象にしますが、addToRolePolicy を使えばプレフィクス単位で絞れます。
条件(Condition)を付ける
workerFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: [`${bucket.bucketArn}/output/*`],
conditions: {
StringEquals: {
's3:x-amz-server-side-encryption': 'aws:kms',
},
},
})
);
「KMS 暗号化なしのアップロードは拒否する」のような制約を Condition で表現できます。PutObject で平文ファイルをアップされるのを防ぐ場合などに使います。
SecretManager から特定シークレットだけ読む
const secret = new secretsmanager.Secret(this, 'DbPassword');
apiLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [secret.secretArn], // このシークレット 1 つのみ
})
);
// または Secret コンストラクトの Grant を使う(こちらが推奨)
secret.grantRead(apiLambda);
secretsmanager:GetSecretValue には L2 の secret.grantRead() もあるので、そちらを先に確認してください。
Resource ARN を * にしないテクニック
ARN を * で書きたくなる状況のほとんどは、コンストラクトのプロパティを参照することで解決できます。
コンストラクトが持つ ARN プロパティを使う
// Bad: ARN をハードコード / * で代用
new iam.PolicyStatement({
actions: ['sqs:SendMessage'],
resources: ['arn:aws:sqs:ap-northeast-1:123456789012:job-queue'],
});
// Good: コンストラクトのプロパティを参照
new iam.PolicyStatement({
actions: ['sqs:SendMessage'],
resources: [queue.queueArn],
});
デプロイ環境(dev / prod)でアカウント ID やリージョンが変わっても、コンストラクト参照なら自動追従します。
Arn.format で動的 ARN を組み立てる
既存リソース(CDK 管理外)を参照するときは Arn.format を使います。
import { Arn, ArnFormat, Stack } from 'aws-cdk-lib';
const stack = Stack.of(this);
const tableArn = Arn.format(
{
service: 'dynamodb',
resource: 'table',
resourceName: 'LegacyUserTable',
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
},
stack
);
workerFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['dynamodb:GetItem'],
resources: [tableArn],
})
);
Stack.of(this) からアカウント ID・リージョンを引けるため、ハードコードを避けられます。
GSI の ARN も忘れない
DynamoDB の GSI に対してクエリするには、テーブル ARN だけでなく GSI の ARN も resources に含める必要があります。
workerFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['dynamodb:Query'],
resources: [
table.tableArn,
`${table.tableArn}/index/*`, // 全 GSI を対象
],
})
);
table.grantReadData() はこの GSI ARN も自動処理します。手書き時の忘れがちポイントです。
ユニットテストで権限を検証する
CDK には assertions パッケージが用意されており、生成される CloudFormation テンプレートの IAM ポリシーをユニットテストでアサートできます。
import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { MyStack } from '../lib/my-stack';
describe('IAM 権限テスト', () => {
let template: Template;
beforeAll(() => {
const app = new App();
const stack = new MyStack(app, 'TestStack');
template = Template.fromStack(stack);
});
it('Lambda は S3 の GetObject 権限のみを持つ', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: [
{
Action: 's3:GetObject',
Effect: 'Allow',
Resource: { 'Fn::Join': ['', [{ 'Fn::GetAtt': ['DataBucket', 'Arn'] }, '/*']] },
},
],
},
});
});
it('Lambda ロールに s3:* が含まれない', () => {
const policies = template.findResources('AWS::IAM::Policy');
Object.values(policies).forEach((policy) => {
const statements = policy.Properties.PolicyDocument.Statement as Array<{
Action: string | string[];
Effect: string;
}>;
statements.forEach((stmt) => {
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
expect(actions).not.toContain('s3:*');
});
});
});
});
template.hasResourceProperties は CloudFormation リソースのプロパティをアサートします。「意図した権限が付いているか」だけでなく「意図しない広い権限が入り込んでいないか」も検証できます。
Snapshot テストで差分を検出する
権限が意図せず増えたことを検知するには Snapshot テストも有効です。
it('IAM ポリシーのスナップショット', () => {
const policies = template.findResources('AWS::IAM::Policy');
expect(policies).toMatchSnapshot();
});
初回実行でスナップショットを保存し、以降は差分があった場合にテストが失敗します。PR で IAM が変わったことをレビュー時に気づけます。
実装ノート
この記事で書いた方針は、私が個人開発で運用しているインフラ(infra/root/ecs-service-stack.ts)で実際に踏んでいるものです。ECS Fargate のスタックでは、コンテナを ECR から引くための Task Execution Role と、アプリ実行時に使う Task Role を最初から別々のロールとして定義しています。役割が違う権限を 1 つのロールに混ぜると後で棚卸ししづらくなるので、自分は「pull 用」と「実行時用」を分けるところから始めました。
ECR の pull 権限も * ではなく、対象リポジトリの ARN に絞っています。
// 実際の infra/root/ecs-service-stack.ts より(抜粋)
const ecrRepositoryArn = `arn:aws:ecr:${this.region}:${this.account}:repository/${ecrRepoName}`;
taskExecutionRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ecr:BatchCheckLayerAvailability', 'ecr:GetDownloadUrlForLayer', 'ecr:BatchGetImage'],
resources: [ecrRepositoryArn], // 特定リポジトリのみ
})
);
ただし ecr:GetAuthorizationToken だけは AWS の仕様上 Resource を * にせざるを得ません。ここは「絞りたいのに絞れない例外」として、コメントを残したうえで別ステートメントに切り出しています。最小権限を徹底すると言っても、こういう AWS 側の制約で * が避けられない箇所は確かに存在するので、自分は「なぜ * なのか」を必ずコードに書き残すようにしています。
ハマったポイント
実際に CDK で IAM を書いていて、私が繰り返し引っかかったポイントを挙げます。
PassRole は明示的に付ける
ECS タスクや Lambda を CDK で定義すると、サービスがロールを引き受けるための iam:PassRole が必要なケースがあります。Grant メソッドは PassRole を付けないので注意が必要です。
executorFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ['iam:PassRole'],
resources: [taskRole.roleArn],
})
);
循環参照に注意
コンストラクト A がコンストラクト B のロールに権限を付与し、同時に B が A のロールにも付与するような場合、CDK が循環依存を検出してエラーになることがあります。権限付与の向きを一方向に統一するか、iam.Grant.addToPrincipalOrResource を使って回避します。
cdk diff で IAM 変更を必ず確認する
cdk diff は IAM の変更を [~] AWS::IAM::Policy として出力します。デプロイ前にこの出力を確認して、意図しない権限追加・削除がないかチェックする習慣をつけましょう。
[~] AWS::IAM::Policy WorkerFunctionPolicy
└─ [~] Properties
└─ [~] PolicyDocument
└─ [~] Statement
└─ @@ -1,6 +1,13 @@
+ {
+ "Action": "s3:DeleteObject",
+ "Effect": "Allow",
+ "Resource": "arn:aws:s3:::data-bucket/*"
+ }
この差分が予期しないものなら、コードに戻って修正します。
Managed Policy の安易な利用を避ける
// 避ける: AdministratorAccess や AmazonS3FullAccess のような広い Managed Policy
role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonS3FullAccess'));
// 推奨: 必要な操作だけ個別に付与
bucket.grantRead(role);
AWS 提供の Managed Policy は便利ですが、サービス全体への権限を与えてしまうものが多く、最小権限に反します。L2 コンストラクトの Grant メソッドに慣れると、Managed Policy を使う場面が自然と減ります。
ちなみに自分の実運用の ECS Task Execution Role では、ECR pull のような細かい権限は自前のステートメントで絞りつつ、ベースとなる AmazonECSTaskExecutionRolePolicy だけは AWS マネージドポリシーをそのまま使っています。「全部を手書きで最小化する」より、「広すぎる Managed Policy だけ避けて、安全な範囲のマネージドは活用する」という線引きにしているのが自分の運用です。
現在の運用
自分の実運用では、デプロイ用の IAM ポリシーを Core / Application / Container / Integration の 4 つに分割して運用しています。これは最初から 4 分割を狙ったわけではなく、IAM マネージドポリシーの 6144 文字制限に引っかかったのがきっかけで、権限の責務ごとに切り分けた結果です。実際に運用してみると、ECR/ECS/Batch 系をいじるときは Container ポリシーだけ見ればよい、という形で見通しが良くなりました。
正直に書くと、このデプロイ用ポリシー群はまだ Resource を * にしている部分が残っていて、ドキュメント上も「将来的に改善検討」と明記しています。一方で、Claude Code に渡している閲覧専用ユーザーのほうは、secretsmanager:GetSecretValue や kms:Decrypt、認証ユーザーテーブルへの dynamodb:Scan などを明示的に Deny して PII の閲覧経路を物理的に塞いでいます。「Allow を絞る」だけでなく「危険な操作を Deny で潰す」二段構えにしているのが、今の自分のやり方です。
まとめ
AWS CDK での IAM 設計をまとめます。
- Grant メソッドを最初に探す: L2 コンストラクトの
grantRead/grantInvokeなどは ARN・アクション両方を適切に設定してくれる - Grant で足りないときは
addToRolePolicy: 条件・プレフィクス絞りなど細かい要件はここで書く - ARN はコンストラクトプロパティか
Arn.formatで組み立てる:*のハードコードを排除する - ユニットテストで権限を固定化する:
assertionsと Snapshot テストを組み合わせて、権限の意図しない変化を PR で検出する cdk diffを習慣にする: IAM 変更の差分はデプロイ前に必ず目視確認する
* で固めると「動く状態」は早く作れますが、運用が続くほどリスクが積み上がります。CDK が提供するツールを活用すれば、最小権限を保ちながら開発速度を落とさない設計ができます。