実践!AWS CDK #15 IAM ロール

題字・息子たち
2021.07.01

はじめに

今回は IAM ロールを作成します。
簡単に行くと思いきや、意外とハマりどころがあったり学びが多い回でした。

前回の記事はこちら。

AWS 構成図

現状

前回まででこのような環境を構築してきました。

1

将来

将来的にはこの形にしていきます。

2

今回作成する IAM ロールは EC2 と RDS にアタッチするものとなります。

設計

プロパティは以下の通りです。

ロール名 信頼されたエンティティ AWS 管理ポリシー
devio-stg-role-ec2 ec2.amazonaws.com AmazonSSMManagedInstanceCore
AmazonRDSFullAccess
devio-stg-role-rds monitoring.rds.amazonaws.com AmazonRDSEnhancedMonitoringRole

EC2 へのアクセスは AWS Systems Manager の Session Manager から行うことを想定します。よって必要なポリシー AmazonSSMManagedInstanceCore を付与しています。(セキュリティグループで ssh のポートは開放しません)

また RDS へアクセスするために AmazonRDSFullAccess ポリシーも付与します。今回は FullAccess 権限を付与していますが、実際の構築では最小権限の原則に従って必要な権限のみを与えてください。

[お詫び]

すみません。これまたしばらく経ってから気づきましたが、RDS インスタンス内の DB にアクセスするだけであれば RDS に関するポリシーは不要でした。
適切なセキュリティグループを設定し、標準のクライアントアプリケーションまたは DB エンジン用のユーティリティを使用することで DB に接続可能です。

GitHub のソースコードからは近々 AmazonRDSFullAccess を取り除いておきます。


RDS では拡張モニタリングを有効化するため AmazonRDSEnhancedMonitoringRole ポリシーを付与します。

実装

IAM ロールに関する処理を行うクラスはこちら。

lib/resource/iamRole.ts

import * as cdk from '@aws-cdk/core';
import { CfnRole, CfnInstanceProfile, PolicyDocument, PolicyStatement, PolicyStatementProps, Effect, ServicePrincipal } from '@aws-cdk/aws-iam';
import { Resource } from './abstract/resource';

interface ResourceInfo {
    readonly id: string;
    readonly policyStatementProps: PolicyStatementProps;
    readonly managedPolicyArns: string[];
    readonly roleName: string;
    readonly assign: (role: CfnRole) => void;
}

export class IamRole extends Resource {
    public ec2: CfnRole;
    public rds: CfnRole;
    public instanceProfileEc2: CfnInstanceProfile;

    private readonly resources: ResourceInfo[] = [
        {
            id: 'RoleEc2',
            policyStatementProps: {
                effect: Effect.ALLOW,
                principals: [new ServicePrincipal('ec2.amazonaws.com')],
                actions: ['sts:AssumeRole']
            },
            managedPolicyArns: [
                'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore',
                'arn:aws:iam::aws:policy/AmazonRDSFullAccess'
            ],
            roleName: 'role-ec2',
            assign: role => this.ec2 = role
        },
        {
            id: 'RoleRds',
            policyStatementProps: {
                effect: Effect.ALLOW,
                principals: [new ServicePrincipal('monitoring.rds.amazonaws.com')],
                actions: ['sts:AssumeRole']
            },
            managedPolicyArns: [
                'arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole'
            ],
            roleName: 'role-rds',
            assign: role => this.rds = role
        }
    ];

    constructor() {
        super();
    }

    createResources(scope: cdk.Construct) {
        for (const resourceInfo of this.resources) {
            const role = this.createRole(scope, resourceInfo);
            resourceInfo.assign(role);
        }

        this.instanceProfileEc2 = new CfnInstanceProfile(scope, 'InstanceProfileEc2', {
            roles: [this.ec2.ref],
            instanceProfileName: this.ec2.roleName
        });
    }

    private createRole(scope: cdk.Construct, resourceInfo: ResourceInfo): CfnRole {
        const policyStatement = new PolicyStatement(resourceInfo.policyStatementProps);

        const policyDocument = new PolicyDocument({
            statements: [policyStatement]
        });

        const role = new CfnRole(scope, resourceInfo.id, {
            assumeRolePolicyDocument: policyDocument,
            managedPolicyArns: resourceInfo.managedPolicyArns,
            roleName: this.createResourceName(scope, resourceInfo.roleName)
        });

        return role;
    }
}

例によってリソース情報を定義したのち、生成メソッドをループで呼び出しています。

CfnRole インスタンス生成時のパラメーターには assumeRolePolicyDocument という必須プロパティがあり、このプロパティの型は PolicyDocument となっています。
ポリシーを記述する時の JSON のアレを表現するクラスです。

そして PolicyDocument クラスは statements というプロパティを持ち、この配列にポリシーの要素 Statement を含める形になります(型は PolicyStatement)。ここに指定する Statement の内容が ResourceInfo で定義している policyStatementProps に該当します。

また PolicyDocument クラスは fromJson() という static メソッドを持っています。このメソッドを利用すれば PolicyDocument や PolicyStatement クラスのインスタンス生成処理も不要になるので、JSON が静的な場合はこちらを使うのもいいかもしれません。

const json = {
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": { "Service": "ec2.amazonaws.com" },
            "Action": "sts:AssumeRole"
        }
    ]
};

const policyDocument = PolicyDocument.fromJson(json);

ベタ書きのほうがラクなときもありますよね。
JSON 部分を別ファイルに切り出して、使うときだけロードするというやり方だと JSON の管理もしやすそうです。AWS CDK ならではですね!

IAM ロールを作成したあとは CfnInstanceProfile クラスを利用し EC2 のインスタンスプロファイルも作成しています。
インスタンスプロファイルとは IAM ロールをインスタンスに渡すためのコンテナ だそうです。マネジメントコンソールで EC2 の IAM ロールを作成するときは IAM ロールと同じ名前でこのインスタンスプロファイルが作成されるため、普段は意識する必要はありません。しかし今回のように手動で IAM ロールを作成する場合はインスタンスプロファイルも作る必要があります。

メインのプログラムはこちら。
ハイライト部分を追記しました。

lib/devio-stack.ts

import * as cdk from '@aws-cdk/core';
import { Vpc } from './resource/vpc';
import { Subnet } from './resource/subnet';
import { InternetGateway } from './resource/internetGateway';
import { ElasticIp } from './resource/elasticIp';
import { NatGateway } from './resource/natGateway';
import { RouteTable } from './resource/routeTable';
import { NetworkAcl } from './resource/networkAcl';
import { IamRole } from './resource/iamRole';

export class DevioStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new Vpc();
    vpc.createResources(this);

    ~ 省略 ~

    // IAM Role
    const iamRole = new IamRole();
    iamRole.createResources(this);
  }
}

今回はコンストラクタに渡すパラメーターはありません。つまり依存関係無し。(久々)

問題発生

この状態で Cfn のテンプレートを出力すると次のようになってしまいます。

RoleEc2:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                Fn::Join:
                  - ""
                  - - ec2.
                    - Ref: AWS::URLSuffix
        Version: "2012-10-17"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        - arn:aws:iam::aws:policy/AmazonRDSFullAccess
      RoleName: devio-stg-role-ec2

ハイライト部分が不思議なフォーマットになっています。

プログラムでは Principal の Service には ec2.amazonaws.com を指定しているのですが、amazonaws.com の部分が AWS::URLSuffix と解釈されてしまっています。

AWS::URLSuffix

ドメインのサフィックスを返します。サフィックスは、通常 amazonaws.com ですが、リージョンによって異なります。たとえば、中国 (北京) リージョンのサフィックスは amazonaws.com.cn です。

一方 RDS のロールに関しては同じ文字列が存在するのですが、同様の現象は発生しません。

Principal:
  Service: monitoring.rds.amazonaws.com

バグでしょうか?
少々厄介で、テストにも影響してきます。

余談ですが、ec2.amazonaws.com の文字列を作成している部分は Join 関数を使わずとも Sub 関数で次のように書くことができます。

Principal:
  Service: !Sub ec2.${AWS::URLSuffix}

テスト

テストコードはこちら。

test/resource/iamRole.test.ts

import { expect, countResources, haveResource, haveResourceLike, anything } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as Devio from '../../lib/devio-stack';

test('IamRole', () => {
    const app = new cdk.App();
    const stack = new Devio.DevioStack(app, 'DevioStack');

    expect(stack).to(countResources('AWS::IAM::Role', 2));
    expect(stack).to(haveResourceLike('AWS::IAM::Role', {
        AssumeRolePolicyDocument: {
            Statement: [{
                Effect: 'Allow',
                Principal: {
                    Service: anything()
                },
                Action: 'sts:AssumeRole'
            }]
        },
        ManagedPolicyArns: [
            'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore',
            'arn:aws:iam::aws:policy/AmazonRDSFullAccess'
        ],
        RoleName: 'undefined-undefined-role-ec2'
    }));
    expect(stack).to(haveResourceLike('AWS::IAM::Role', {
        AssumeRolePolicyDocument: {
            Statement: [{
                Effect: 'Allow',
                Principal: {
                    Service: 'monitoring.rds.amazonaws.com'
                },
                Action: 'sts:AssumeRole'
            }]
        },
        ManagedPolicyArns: [
            'arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole'
        ],
        RoleName: 'undefined-undefined-role-rds'
    }));

    expect(stack).to(countResources('AWS::IAM::InstanceProfile', 1));
    expect(stack).to(haveResource('AWS::IAM::InstanceProfile', {
        Roles: anything(),
        InstanceProfileName: 'undefined-undefined-role-ec2'
    }));
});

以下を確認しています。

  • IAM ロールリソースが 2 つあること
  • インスタンスプロファイルリソースが 1 つあること
  • 各リソースのプロパティが正しいこと

上記の問題により EC2 のロールに関するテストでは Principal > Service の値を anything() としています。(本当は 'ec2.amazonaws.com' と書きたい!)
出力されるテンプレートは Fn::Join~ となっているため、'ec2.amazonaws.com' と書いてしまうと結果が一致せずにテストが失敗してしまいます。なんてこった

また今回は初めて haveResourceLike() というメソッドを使用しています。
haveResource() ではチェックする対象オブジェクトが更に子のオブジェクトを持っている場合、それは 完全一致でなければならない という制約があります。ここでは親のオブジェクトが AssumeRolePolicyDocument, 子のオブジェクトがその中身に該当します。
PolicyDocument クラスは自動的にバージョン情報(Version: "2012-10-17")をテンプレートに付与するようで、haveResource() メソッドを使用すると AssumeRolePolicyDocument の内容に予期しないキー Version が含まれている というメッセージと共にテストが失敗します。
実装していない部分をテストコードで書きたくはないので、haveResourceLike() メソッドを使用して完全一致ではなく部分一致のテストとすることでこの問題を回避します。

デプロイ

今回からデプロイ実行時に以下のような確認が発生します。

$ cdk deploy --no-path-metadata

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬────────────────┬────────┬────────────────┬──────────────────────────────────────┬───────────┐
│   │ Resource       │ Effect │ Action         │ Principal                            │ Condition │
├───┼────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${RoleEc2.Arn} │ Allow  │ sts:AssumeRole │ Service:ec2.${AWS::URLSuffix}        │           │
├───┼────────────────┼────────┼────────────────┼──────────────────────────────────────┼───────────┤
│ + │ ${RoleRds.Arn} │ Allow  │ sts:AssumeRole │ Service:monitoring.rds.amazonaws.com │           │
└───┴────────────────┴────────┴────────────────┴──────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬────────────┬──────────────────────────────────────────────────────────────────────┐
│   │ Resource   │ Managed Policy ARN                                                   │
├───┼────────────┼──────────────────────────────────────────────────────────────────────┤
│ + │ ${RoleEc2} │ arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore                 │
│ + │ ${RoleEc2} │ arn:aws:iam::aws:policy/AmazonRDSFullAccess                          │
├───┼────────────┼──────────────────────────────────────────────────────────────────────┤
│ + │ ${RoleRds} │ arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole │
└───┴────────────┴──────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)?

CFn のテンプレートに IAM リソースが含まれる場合、意図しないリソースが作成されないように注意を促すものになります。
マネジメントコンソール上の以下の部分に該当します。

3

確認した上でデプロイを実行しましょう。

確認

マネジメントコンソール上でリソースを確認してみましょう。

4

IAM ロールが 2 つできています。

それぞれの内容も意図通りです。

5

EC2 のロールではロール名と同名のインスタンスプロファイルあり。

6

RDS のロールに関してはインスタンスプロファイルはありません。

CloudFormation 版

今回のコードを CFn で書くと以下のようになります。

RoleEc2:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Action: sts:AssumeRole
          Effect: Allow
          Principal:
            Service: ec2.amazonaws.com
      Version: "2012-10-17"
    ManagedPolicyArns:
      - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
      - arn:aws:iam::aws:policy/AmazonRDSFullAccess
    RoleName: devio-stg-role-ec2
RoleRds:
  Type: AWS::IAM::Role
  Properties:
    AssumeRolePolicyDocument:
      Statement:
        - Action: sts:AssumeRole
          Effect: Allow
          Principal:
            Service: monitoring.rds.amazonaws.com
      Version: "2012-10-17"
    ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole
    RoleName: devio-stg-role-rds
InstanceProfileEc2:
  Type: AWS::IAM::InstanceProfile
  Properties:
    Roles:
      - Ref: RoleEc2
    InstanceProfileName: devio-stg-role-ec2

GitHub

今回のソースコードは コチラ です。

おわりに

IAM まわりは奥が深いですね。
ハマらないように気をつけていきましょう。

ポリシー用の JSON を読み込んでオブジェクトに設定できることがわかったのは大きな学びでした。今後も活用していきたいと思います。

次回のお題は「セキュリティグループ」です。

リンク