はじめに
当社では、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トピックを受け取れるようにしています。また、プロジェクト毎の命名規則を適用できるように、topicName
とruleName
もインターフェイスに含めています。
ソースコードは以下の通りです。
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を利用します。
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をクリックすると、イベントの詳細が閲覧可能です。
CloudTrailのコンソールでEvent IDに対応する履歴を表示するには少々ラグがあるため、上記URLは数分置いてアクセスしてください。
④CustomAdministratorAccessでアクセスした場合も、同様に通知されました。
おわりに
高権限のアクセス権限セットを利用した際に通知する方法を、AWS CDKのカスタムConstructで実装しました。
シンプルな例でしたが、お役に立てれば幸いです。
(付録)カスタムConstructのテストコード
カスタムConstructのテストコードを掲載します。
アサーションテストの記述方法については、以下をご参考下さい。
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"),
},
},
],
});
});
});