【AWS CDK】高権限ロールの利用通知をカスタム Construct 化してみた

# はじめに

当社では、[AWS Control Tower](https://docs.aws.amazon.com/ja_jp/controltower/latest/userguide/what-is-control-tower.html) を導入しており、外部認証基盤と[AWS IAM Identity Center](https://docs.aws.amazon.com/ja_jp/singlesignon/latest/userguide/what-is.html)（旧 AWS SSO）を連携して、マルチアカウントのユーザー管理を行っています。  

高権限の[アクセス権限セット](https://docs.aws.amazon.com/ja_jp/singlesignon/latest/userguide/permissionsetsconcept.html)を利用した際に通知したいというケースがありましたので、再利用できるようにCDKでカスタムConstruct化しました。  

## 通知イメージ

![通知イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/8179b854-bffb-d1c3-30ea-b5399a9dc160.png)

# 前提

## 予備知識

[CDK Intro Workshop](https://cdkworkshop.com/ja/)でカスタムConstructの作成フローを学べます。  
詳細は以下をご参照ください。

https://cdkworkshop.com/ja/20-typescript/40-hit-counter.html

## 環境

- Control Tower v3.0
- CDK v2.54.0
- Jest v27.5.1

# 実装

## カスタムConstructの定義

本Constructでは主に以下を作成しています。

- SNSトピック
- Eventsルール
  - AssumeRole検知のイベントパターン
  - メール用のメッセージテンプレート

Constructの[インターフェイス](https://typescriptbook.jp/reference/object-oriented/interface)は以下の通りです。

|No.|引数名|型|補足|
|---|---|---|---|
|1|roleArns|string[]|検知したいIAM RoleのArn一覧|
|2|topic|[ITopic](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_sns.ITopic.html)|Construct外で定義済みのSNSトピック(任意) |
|3|topicName|string|Constructで作成されるSNSトピック名の指定(任意)|
|4|ruleName|string|Constructで作成されるEventsルール名の指定(任意)|

::: note info
既存のSNSトピックに連携させたい場合も十分あり得るため、```topic```でConstruct外からSNSトピックを受け取れるようにしています。また、プロジェクト毎の命名規則を適用できるように、```topicName```と```ruleName```もインターフェイスに含めています。
:::

ソースコードは以下の通りです。

```ts: ./lib/constructs/notifyAssumeRoles.ts
import { Construct } from "constructs";
import { Topic, ITopic } from "aws-cdk-lib/aws-sns";
import { Rule, EventField, RuleTargetInput } from "aws-cdk-lib/aws-events";
import { SnsTopic } from "aws-cdk-lib/aws-events-targets";

export interface NotifyAssumeRolesProps {
  roleArns: string[];
  topic?: ITopic;
  topicName?: string;
  ruleName?: string;
}

export class NotifyAssumeRoles extends Construct {
  public readonly topic: ITopic;
  public readonly eventsRule: Rule;

  constructor(scope: Construct, id: string, props: NotifyAssumeRolesProps) {
    super(scope, id);

    // SNSトピックがConstruct外から連携されない場合、新規作成
    if (props.topic !== undefined) {
      this.topic = props.topic;
    } else
      this.topic = new Topic(this, "NotifyAssumeRolesTopic", {
        displayName: "Detection of AssumeRoles",
        topicName: props.topicName, // props指定なしの場合、トピック名は自動付与
      });

    // Propsで指定されたroleArnsをAssumeRoleした際に検知
    this.eventsRule = new Rule(this, "NotifyAssumeRolesRule", {
      ruleName: props.ruleName, // props指定なしの場合、ルール名は自動付与
      eventPattern: {
        source: ["aws.sts"],
        detailType: ["AWS API Call via CloudTrail"],
        detail: {
          eventSource: ["sts.amazonaws.com"],
          eventName: [{ prefix: "AssumeRole" }],
          requestParameters: {
            roleArn: props.roleArns.map((roleArn) => ({
              prefix: roleArn,
            })),
          },
        },
      },
    });

    // EventsルールのターゲットにSNSトピックとメッセージテンプレートを指定
    this.eventsRule.addTarget(
      new SnsTopic(this.topic, {
        message: RuleTargetInput.fromMultilineText(InputTemplate),
      })
    );
  }
}

// メールのメッセージに埋め込むCloudTrailコンソールURL
const TrailUrl =
  "https://console.aws.amazon.com/cloudtrail/home?" +
  `region=${EventField.fromPath("$.region")}` +
  `#/events/${EventField.fromPath("$.detail.eventID")}`;

// メールで表示するメッセージのテンプレート
const InputTemplate = `Detected AssumeRole.

  * Account: ${EventField.fromPath("$.detail.recipientAccountId")}
  * User   : ${EventField.fromPath("$.detail.userIdentity.principalId")}
  * RoleArn: ${EventField.fromPath("$.detail.requestParameters.roleArn")}
  * Time   : ${EventField.fromPath("$.detail.eventTime")}

For more details open the CloudTrail console at the below link.
${TrailUrl}`;

```

## カスタムConstructの利用

今回は以下のスタックで、カスタムConstructを利用します。  

```ts: ./lib/detectionStack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { NotifyAssumeRoles } from "./constructs/notifyAssumeRoles";
import { Subscription, SubscriptionProtocol } from "aws-cdk-lib/aws-sns";

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

    // アクセス権限セットで作成されるIAMロールのプレフィックス
    const rolePrefix =
      `arn:aws:iam::${Stack.of(this).account}:role/` +
      `aws-reserved/sso.amazonaws.com/${Stack.of(this).region}/`;

    // IAMロールのARNは前方一致で検知
    const notify = new NotifyAssumeRoles(this, "AdminAccess", {
      roleArns: [
        `${rolePrefix}AWSReservedSSO_AWSAdministratorAccess`,
        `${rolePrefix}AWSReservedSSO_CustomAdministratorAccess`,
      ],
    });

    // デモ用 (メールのリンクで簡単に解除でき、ドリフト発生しやすいので要注意)
    new Subscription(this, "Subscription", {
      topic: notify.topic,
      endpoint: "xxx@test.com", // 通知先のメルアド
      protocol: SubscriptionProtocol.EMAIL,
    });
  }
}

```

::: note warn
本スタックは、ControlTowerのHomeリージョンと同じリージョンにデプロイします。
:::

# 通知の確認

①AWS IAM Identity Centerのコンソールから、当該アカウントに高権限（AWSAdministratorAccess）でアクセスします。

![ログイン画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/ec20069c-6828-056c-5974-8b7dbd8bb77e.jpeg)

②メールが通知されました。

![通知画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/50312791-2e15-25e9-2d98-ddef0878a3f2.jpeg)


::: note warn
Event BridgeとSNSトピックは**少なくとも1回の配信**をするため、複数の同じメールを受信する場合があります。(以下参照)

- [Amazon EventBridge の特徴](https://aws.amazon.com/jp/eventbridge/features/)
- [Amazon SNS のよくある質問](https://aws.amazon.com/jp/sns/faqs/)
:::

③対象アカウントにサインインしたブラウザで、メールのURLをクリックすると、イベントの詳細が閲覧可能です。

![CloudTrailConsole画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/246ec48e-c892-ded4-3dfa-71508b116f77.jpeg)

::: note warn
CloudTrailのコンソールでEvent IDに対応する履歴を表示するには少々ラグがあるため、上記URLは数分置いてアクセスしてください。
:::

④CustomAdministratorAccessでアクセスした場合も、同様に通知されました。

![CustomAdminの通知画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/36a9ea5c-c0d7-1754-5583-19b3f1d161f0.jpeg)

# おわりに

高権限の[アクセス権限セット](https://docs.aws.amazon.com/ja_jp/singlesignon/latest/userguide/permissionsetsconcept.html)を利用した際に通知する方法を、AWS CDKのカスタムConstructで実装しました。  
シンプルな例でしたが、お役に立てれば幸いです。

# （付録）カスタムConstructのテストコード

カスタムConstructのテストコードを掲載します。  
アサーションテストの記述方法については、以下をご参考下さい。

https://qiita.com/y_matsuo_/items/bc27db7942179ffb5825

```ts: ./test/notifyAssumeRoles.test.ts
import * as cdk from "aws-cdk-lib";
import { Template, Match, Capture } from "aws-cdk-lib/assertions";
import { NotifyAssumeRoles } from "../lib/constructs/notifyAssumeRoles";
import { Topic } from "aws-cdk-lib/aws-sns";

describe("SNSトピックの新規作成時", () => {
  let template: Template;

  beforeEach(() => {
    const stack = new cdk.Stack();
    new NotifyAssumeRoles(stack, "Test", {
      roleArns: [
        "arn:aws:iam::012345678901:role/TestRole1",
        "arn:aws:iam::012345678901:role/TestRole2",
      ],
      topicName: "TestTopic",
      ruleName: "TestRule",
    });
    template = Template.fromStack(stack);
  });

  test("スナップショットテスト", () => {
    expect(template).toMatchSnapshot();
  });

  describe("リソース数の確認", () => {
    test("SNSトピックが1つ存在する", () => {
      template.resourceCountIs("AWS::SNS::Topic", 1);
    });

    test("Eventsルールが1つ存在する", () => {
      template.resourceCountIs("AWS::Events::Rule", 1);
    });
  });

  describe("SNSトピックのプロパティチェック", () => {
    test("Display名にAssumeRoleの検知である旨を示している", () => {
      template.hasResourceProperties("AWS::SNS::Topic", {
        TopicName: "TestTopic",
        DisplayName: "Detection of AssumeRoles",
      });
    });
  });

  describe("Eventsルールのプロパティチェック", () => {
    test("ターゲットがSNSトピックである", () => {
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        Targets: [
          {
            Arn: {
              Ref: Match.stringLikeRegexp("TestNotifyAssumeRolesTopic"),
            },
          },
        ],
      });
    });

    test("イベントソースがSTSである", () => {
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        EventPattern: {
          source: ["aws.sts"],
          detail: {
            eventSource: ["sts.amazonaws.com"],
          },
        },
      });
    });

    test("detail-typeがCloudTrail経由のイベントである", () => {
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        EventPattern: {
          "detail-type": ["AWS API Call via CloudTrail"],
        },
      });
    });

    test("イベント名にAssumeRoleのPrefixが指定されている", () => {
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        EventPattern: {
          detail: {
            eventName: [
              {
                prefix: "AssumeRole",
              },
            ],
          },
        },
      });
    });

    test("roleArnにPropsで渡されたロールのPrefixが指定されている", () => {
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        EventPattern: {
          detail: {
            requestParameters: {
              roleArn: [
                { prefix: "arn:aws:iam::012345678901:role/TestRole1" },
                { prefix: "arn:aws:iam::012345678901:role/TestRole2" },
              ],
            },
          },
        },
      });
    });
  });

  describe("入力テンプレートのチェック", () => {
    let inputTemplate: Capture;

    beforeEach(() => {
      inputTemplate = new Capture();
      template.hasResourceProperties("AWS::Events::Rule", {
        Name: "TestRule",
        Targets: [
          {
            InputTransformer: {
              InputTemplate: inputTemplate,
            },
          },
        ],
      });
    });

    test("アカウントIDが含まれている", () => {
      expect(inputTemplate.asString()).toEqual(
        expect.stringContaining("<detail-recipientAccountId>")
      );
    });

    test("ユーザーのプリンシパルIdが含まれている", () => {
      expect(inputTemplate.asString()).toEqual(
        expect.stringContaining("<detail-userIdentity-principalId>")
      );
    });

    test("リクエストのRoleArnが含まれている", () => {
      expect(inputTemplate.asString()).toEqual(
        expect.stringContaining("<detail-requestParameters-roleArn>")
      );
    });

    test("イベントの時間が含まれている", () => {
      expect(inputTemplate.asString()).toEqual(
        expect.stringContaining("<detail-eventTime>")
      );
    });

    test("CloudTrailコンソールのURLが含まれている", () => {
      expect(inputTemplate.asString()).toEqual(
        expect.stringMatching(
          "https://console.aws.amazon.com/cloudtrail/home" +
            ".region.<region>#/events/<detail-eventID>"
        )
      );
    });
  });
});

describe("SNSトピックの外部連携時", () => {
  test("コンストラクト外から渡したTopicがRuleのターゲットで指定される", () => {
    // WHEN
    const stack = new cdk.Stack();
    new NotifyAssumeRoles(stack, "TopicTest", {
      roleArns: ["arn:aws:iam::012345678901:role/TestRole1"],
      topic: new Topic(stack, "TestTopic2"),
      ruleName: "TestRule",
    });
    const template = Template.fromStack(stack);
    // THEN
    template.hasResourceProperties("AWS::Events::Rule", {
      Name: "TestRule",
      Targets: [
        {
          Arn: {
            Ref: Match.stringLikeRegexp("TestTopic2"),
          },
        },
      ],
    });
  });
});

```