LoginSignup
4
1

More than 1 year has passed since last update.

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

Posted at

はじめに

当社では、AWS Control Tower を導入しており、外部認証基盤とAWS IAM Identity Center(旧 AWS SSO)を連携して、マルチアカウントのユーザー管理を行っています。

高権限のアクセス権限セットを利用した際に通知したいというケースがありましたので、再利用できるようにCDKでカスタムConstruct化しました。

通知イメージ

通知イメージ

前提

予備知識

CDK Intro WorkshopでカスタムConstructの作成フローを学べます。
詳細は以下をご参照ください。

環境

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

実装

カスタムConstructの定義

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

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

Constructのインターフェイスは以下の通りです。

No. 引数名 補足
1 roleArns string[] 検知したいIAM RoleのArn一覧
2 topic ITopic Construct外で定義済みのSNSトピック(任意)
3 topicName string Constructで作成されるSNSトピック名の指定(任意)
4 ruleName string Constructで作成されるEventsルール名の指定(任意)

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

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

./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を利用します。

./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,
    });
  }
}

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

通知の確認

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

ログイン画面

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

通知画面

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

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

CloudTrailConsole画面

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

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

CustomAdminの通知画面

おわりに

高権限のアクセス権限セットを利用した際に通知する方法を、AWS CDKのカスタムConstructで実装しました。
シンプルな例でしたが、お役に立てれば幸いです。

(付録)カスタムConstructのテストコード

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

./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"),
          },
        },
      ],
    });
  });
});

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1