😌

AWS CDKを使ってIAMユーザーの作成~Slack DMをPub/Subなlambdaで自動化してみました

2022/02/05に公開

先日、タイトルのような件を全て実装させていただく機会がありまして、少し知見としてまとめてみました。

私は業務委託で参画させていただいていたのですが、スタートアップのチームでして、エンジニアが3名ほど、製品をリリースしたばかりという、基本的に何でもやりたいことができてしまうフェーズであったため、比較的攻めた構成でやらせていただいた次第です。

今回の動作環境

node: 14.x
aws-cdk: 1.134.0
typescript: ~3.9.7

今回やりたいこと

今回は、CTOやリードエンジニアが、手作業でIAMユーザーおよび関連ロールを手動で用意しているという状況で、スタートアップであることから弊プロジェクトにおける手作業による作業の属人化をなくしたいというのが強い要望でした。

要求

  • 新しくジョインした人が、自分でPullRequest(以下、PR)を作成できる
  • レビュアーや担当者が、新しくジョインした人のPRに対してレビュー・approveを行える
  • PRのapproveののち、マージを行えばCI経由でクラウド(今回はAWSのみ)にIAMユーザー設定が立ち上がる

AWS CDK(TypeScript) + github actionsに決める

やりたいことをざっとヒアリングして、社内の状況からして、ほとんど即決でAWS CDKを採用しました。

AWS クラウド開発キット (AWS CDK) は、最新のプログラミング言語を使用してクラウドインフラストラクチャをコードとして定義し、それを AWS CloudFormation を通じてデプロイするためのオープンソースのソフトウェア開発フレームワークです。
参考: AWS CDKとは?

AWS CDKについては、弊PJがマルチテナント構成のプロジェクトであったこともあり、もともとの環境構築は全てAWS CDK化されている状況で、ざっくりとした知見もあり、少なくともCDKが学習コストも高くなく、使い勝手のいい点がわかっていたこと。さらにGCP上にもデプロイしたいといった考慮ポイントが無視できる状況であったため、AWS CDKを採用しました。

また、私自身がフロントエンドにかなり傾倒していたのもあり、言語は静的型付けで書ける & 手に馴染んでいたTypeScriptを採用しました。

できたもの

全体像

IAMユーザーを作成しても、作成完了したユーザー情報(AWS初回ログイン情報やアカウントシークレット)をどう通知しようかという問題がありました。

一番簡単な表示方法としては、AWS CDKのCloudFormationログをCI上で表示させるというものがありますが、もちろんそれではまずいので、社内チャットツールとして利用しているSlackでのチャンネルもしくはDMに送りたいという話になってきました。

作成したIAMユーザーをどうやってPRを作成したユーザーと紐付けて、slackにDMをするのか?

ここが一番悩んだポイントでした。結局、AWS SecretsManagerをAWS CDK上で使って、ランダムな初回パスワードを作成し、それをSecretsManager上に保存をかけてしまっていたので、それと一緒にユーザーの一時情報として、slackユーザー名もSecretsManagerに保存できるように作ってしまいました。

   // 作成するユーザーインスタンスの定義
    const users: DatagustoUserBase[] = [
      {
        name: "Administrator",
        groups: [adminGroup],
        slackName: "admin"
      },
      {
        name: "mikana0918",
        groups: [s3ControllerGroup],
        slackName: "mikana0918"
      }
    ]
    
    users.map((u: DatagustoUserBase) => {
      const { name, groups, slackName } = u

      // Secretの第二引数keyをスコープ内でユニークにさせる必要があるので、名前を振って識別させている
      const passwordSecret = new secretsmanager.Secret(this, `${name}IAMUserInitialPassword`, {
        generateSecretString: {
          excludeUppercase: false,    // - 1文字以上のアルファベット大文字 (A~Z) を含む
          excludeLowercase: false,    // - 1文字以上のアルファベット小文字 (a~z) を含む
          excludeNumbers: false,      // - 1つ以上の数字を含む
          requireEachIncludedType: true, 
          excludePunctuation: false,  // - 1つ以上の英数字以外の文字 (! @ # $ % ^ & * ( ) _ + - = [ ] { } | ') を含む
          includeSpace: false,        // 初回はスペースなしで発行
          passwordLength: 20,         // - 最小文字数は 12 文字
          generateStringKey: "initialPassword",
          secretStringTemplate: JSON.stringify({ 
            slackName: slackName ?? name, // slackネームをDM送付のため欲しいが、なければnameでトライ
          }),
        },
        secretName: `initial-pass-for-${name}`, 
      })

      // 自分のsecretを見るためのロール
      // newするたびに、丁度↑で作成したsecretのarnをresouceに反映してくれるので、
      // secretvalueの数だけroleも作ってしまっている。
      const grantPermissionToUseSecret = new Role(this, `GrantedPermissionToUseSecretFor${name}Role`)
      // 自分のsecretはr/wどちらもできる
      passwordSecret.grantRead(grantPermissionToUseSecret)
      passwordSecret.grantWrite(grantPermissionToUseSecret)

      // ユーザーをnewして作成
      const user = new User(this, name, { userName: name, groups, passwordResetRequired: true, password: passwordSecret.secretValue })
    })
  }

IAMユーザーの作成イベントをどうやって拾うのか?

これについてはCloudTrail証跡のログを使うことで、IAMのCreateUserイベントをリッスンし、AWS CDK経由でCloudFormationがIAMユーザースタックを作成し、IAMユーザーを作成したイベントをEvent Bridgeから後続の処理(ここではlambda関数)に流すことで実現をしました。

元々、EventBrdigeはCloudwatch logsという機能で提供されていたものですが、最近はEventBridgeという個別のマネージドサービスとして統合されているようです。(一部、オンライン記事が古いとCloudwatch logs経由という記載があり、ややこしかったです)

EventBridgeとCloudTrail証跡を連携して使う際の注意点としては、EventBridgeにおいて利用するイベントバスをDefault Event Busというデフォルトのものに設定しておかないと、証跡イベントが取得できないといった仕様があった(2021年12月31日現在)ため、ここについては注意が必要そうです。

(また、もちろんですがIAMイベントについてはus-east-1リージョンではなければ扱えないサービスがいくつかあるので、これについても注意が必要です)

イベント駆動 / PubSubで作ってみた

社内でIAMユーザーを新規作成し、その作成したユーザーに対してDMを送付するだけなので、分散型のアーキテクチャはもちろんスケーラビリティについて、全く考慮する必要はないです。

PubSubについては単純に触ってみたく、一度、実際に作ってみてどんなものか試してみたかったというところが大きいです。

ただ、こういった通知を送付する処理については、input/outputのインターフェイスが同じだがユースケースが少しずつ違う・サーバーレス関数として振る舞いが少しずつ違う(DMを送付する、チャンネルに投稿する、もしくはEメールに直接送信するetc)こともあり、さらに通知を発火するタイミングで特別な処理を挟みたい(管理者にも通知する、履歴として別のチャネルにも通知する、特殊な考慮を挟みたいetc)場合なども起こり得るため、あくまでPublisherとSubscriberの間での疎結合を実現するためPubSubを採用した形です。

通知の場合は、まさにPublisher/Subscriber間でのメッセージの受け渡しを行うだけなので、これについてもまさに通知に必要なメッセージ内容の受け渡しのみになるため、イベントさえあればいかなるチャネルへも通知を送付することができるようになるでしょう。

また、AWS CDK上においてlambdaとPubSub(AWS SNSトピック)の管理については、かなり単純なコードの記述で済みますし、IAMの管理設定もそのままコードで渡せてしまうので、PubSubでイベント駆動するから難しいということは全くなく、むしろ普通のアプリケーションを書いている感覚でサーバーレスなアーキテクチャを作れてしまうので、非常に便利だなと思った次第です。

CloudTrail~EventBridge~lambda & SecretsManagerで連携するサンプルコード

これはまた別件ではありますが、AWS CDKを組む上で、フロントエンドのコンポーネント指向と似た形で、コンストラクト指向で組むべきだという考え方があります。

  • StackではなくConstructでアプリをモデル化する
  • 環境変数ではなく、API (プロパティ、メソッド) で設定する
  • インフラストラクチャのユニットテスト
  • ステートフルリソースのスコープとIDを変更しない
  • コンプライアンスのためにConstructを使わない

参考: AWS CDKでクラウドアプリケーションを開発するためのベストプラクティス - Amazon Web Servicesブログ

一度、公式のベストプラクティスを参照していただきたいのですが、これはざっくり言えば各種マネージドサービスのリソース定義をコンストラクトとして、単体のコンストラクタを持つクラスの中で一度定義してしまって、各種スタックを(CloudFormationでスタックとして立ち上げる単位で)呼び出していく、という考え方のことを指していると思います。

それもそのはずで、コンストラクト志向で明確にコンストラクトを定義してコンポーネント(構成要素)のように参照されてくれないと、コードが幾分カオスになってしまいがちなので、ここについてはAWS CDK導入段階から、ベストプラクティへの考慮が必要そうです。

lambdaコンストラクトの定義
import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as sns from '@aws-cdk/aws-sns'
import { Construct } from "constructs";
import { SnsDestination } from "@aws-cdk/aws-lambda-destinations"
import { SNSTopicConstruct } from "../sns/sns-topic-construct"

export class CreateUserLambda extends cdk.Construct {
  public lambda: lambda.Function;

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

    // トピックの定義
    const topicConstruct = new SNSTopicConstruct(this, "sns-on-create-new-iam-user")

    this.lambda = new lambda.Function(this, "CreateUserLambda", {
      runtime: lambda.Runtime.PYTHON_3_9,
      code: lambda.Code.fromAsset("lib/lambda-handler"),
      handler: "create_user.lambda_handler",
      // sns topic for successful invocations
      onSuccess: new SnsDestination(topicConstruct.createNewIAMUserTopic),
      environment: {
        CREATED_USER_PUB_TOPIC_ARN: process.env.CREATED_USER_PUB_TOPIC_ARN ?? ""
      }
    });
  }
}

ここでは、成功時のSNS呼び出しをonSuccessとしてSNSの参照先を渡しています。

また、環境変数についてはenvironmentオプションから渡すことができます。そのため、ここではIAMユーザーが作成されたというトピックのARNをAWS CDKプロジェクトの環境変数から渡しています。

lambdaのコードについてもCDKのプロジェクト内でコミットしてしまっており、ここではランタイムをPythonで書いてしまったので、.pyのlambdaハンドラを見に行ってもらっています。

IAMユーザー作成イベントをリッスンし、メッセージ作成するlambda関数のCFnスタック定義
import * as cdk from "@aws-cdk/core";
import { CreateUserLambda } from "./constructs/lambda/lambda-on-create-user";
import { Policy, PolicyStatement, Effect } from '@aws-cdk/aws-iam';
import * as events from '@aws-cdk/aws-events';
import * as targets from '@aws-cdk/aws-events-targets';

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

    // ラムダの定義
    const lambdaConstruct = new CreateUserLambda(
      this,
      "CreateUserLambda"
    );

    // SecretsManagerをLambdaが利用するためのポリシー定義
    // ポリシーステートメント
    const lambdaPolicyStatement = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret",
        "secretsmanager:ListSecretVersionIds",
      ],
      resources: [
        "*" // 本来的には、IAMユーザーを作成するために作った一時パスワードのみに対しての制限にしたかった
      ]
    })

    // SNSのpubを許可
    const snsTopicPolicy = new PolicyStatement({
      actions: ['sns:Publish'],
      resources: ['*'],
    });

    const lambdaPolicyName = "LambdaOnIAMCreateUserPolicy"
    const onIAMCreateUserPolicy = new Policy(this, lambdaPolicyName, {
      policyName: lambdaPolicyName,
      statements: [lambdaPolicyStatement, snsTopicPolicy]
    })

    // AWSイベントを受信できるデフォルトのイベントバスをarnから作成
    const bus = events.EventBus.fromEventBusArn(this, "default-event-bus", process.env.AWS_DEFAULT_EVENT_BUS_ARN ?? "")

    // lambda attach policy
    lambdaConstruct.lambda.role?.attachInlinePolicy(onIAMCreateUserPolicy)

    // Event Bridgeのターゲットを作成
    const lambdaTarget = new targets.LambdaFunction(lambdaConstruct.lambda)
  
    // CloudTrailのIAMユーザーの作成イベントをリッスンさせる
    const rule = new events.Rule(this, 'put event rule', {
        description: 'listens to CloudTrail IAM user creation event',
        eventPattern: {
          detailType: [
            "AWS API Call via CloudTrail" // CloudTrail経由のイベントのみをクエリする
          ],
          source: [
            "aws.iam"
          ],
          detail: {
            eventSource: [
              "iam.amazonaws.com",
              "cloudtrail.amazonaws.com"
            ],
            eventName: [
              "CreateUser"
            ]
          }
      },
      eventBus: bus,
      targets: [lambdaTarget]
    });
  }
}

lambdaハンドラ側に記載のあるコードで、SNSのイベントのパブリッシュまで行ってしまっています。

これで、CloudTrail~EventBridge~lambda & SecretsManagerまでの連携ができました。

SNSをsubscribeしてslackでbot chatを開始するサンプルコード

どちらかといえばlambda関数の方が主な処理を担当しているため、この辺りは非常に簡素です。
逆にいえば、SNSとlambdaの連携だけなどであれば、ここまで簡単に書けてしまうということでして、AWS CDKの便利さを実感できました。

SNSトピックのコンストラクト定義
import * as cdk from "@aws-cdk/core";
import * as sns from '@aws-cdk/aws-sns'
import { Construct } from "constructs";

export class SNSTopicConstruct extends cdk.Construct {
  public createNewIAMUserTopic: sns.Topic

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

    this.createNewIAMUserTopic = new sns.Topic(this, 'sns-topic-create-new-iam-user-info', {
      displayName: 'created new IAM user info',
    });
  }
}

新規で作成したIAMユーザーの情報(ID、パスワード、slackname)を配信するトピックです。

lambdaのコンストラクト定義
import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import { Construct } from "constructs";
import { SnsDestination } from "@aws-cdk/aws-lambda-destinations"
import { SNSTopicConstruct } from "../sns/sns-topic-construct"

export class CreateUserLambda extends cdk.Construct {
  public lambda: lambda.Function;

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

    // トピックの定義
    const topicConstruct = new SNSTopicConstruct(this, "sns-on-create-new-iam-user")

    this.lambda = new lambda.Function(this, "CreateUserLambda", {
      runtime: lambda.Runtime.PYTHON_3_9,
      code: lambda.Code.fromAsset("lib/lambda-handler"),
      handler: "create_user.lambda_handler",
      // sns topic for successful invocations
      onSuccess: new SnsDestination(topicConstruct.createNewIAMUserTopic),
      environment: {
        CREATED_USER_PUB_TOPIC_ARN: process.env.CREATED_USER_PUB_TOPIC_ARN ?? ""
      }
    });
  }
}

construct定義していたSNSをimportし、lambda関数の実行が成功した場合のonSuccessフックにSNSのディスティネーションとして登録しています。

スタックとして組み立て
import * as cdk from "@aws-cdk/core";
import { NewUserInfoBySlackNameViaYukiNagato, NotifyToChannel } from "./constructs/lambda/lambda-on-create-user-info"
import { SnsEventSource } from '@aws-cdk/aws-lambda-event-sources';
import { SNSTopicConstruct } from "./constructs/sns/sns-topic-construct"
export class LambdaOnNotifySlack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { 
    super(scope, id, props);

    // トピックの定義
    const topicConstruct = new SNSTopicConstruct(this, "sns-on-create-new-iam-user")

    // ラムダの定義
    const lambdaConstructToSendDm = new NewUserInfoBySlackNameViaYukiNagato(this, "NewUserInfoBySlackNameViaYukiNagato")
    const lambdaConstructToNotifyChannel = new NotifyToChannel(this, "NewUserInfoNotifyToChannel")

    // トピックのsubscribe
    lambdaConstructToSendDm.lambda.addEventSource(new SnsEventSource(topicConstruct.createNewIAMUserTopic))
    lambdaConstructToNotifyChannel.lambda.addEventSource(new SnsEventSource(topicConstruct.createNewIAMUserTopic))
  }
}

lambdaがSNSトピックをsubscribeできるように登録してやります。

今回は、作成したユーザーのslackと、管理者へCCをするような形で別途チャンネルにも登録情報を飛ばす必要があったので、複数のサブスクライバーlambdaをSNSトピックへリッスンさせています。

完成 🎉

あとはslackAPIをぶん回して、ユーザーを検索、会話の立ち上げ、メッセージのポストを行えば完成です。お疲れ様でした。

全体像

slackAPIについては詳細は省略しましたが、この部分も実際に作ってみるとかなり面倒で、複数APIを順々にコールして、ようやく当該ユーザーにDMを飛ばせるようになるというところですので、ぜひ挑戦してみてください😭

弊PJでは対有機生命体コンタクト用ヒューマノイド・インターフェイスさんが通知してくれるようです。

まとめ・感想

AWS CDKをTypeScriptで初めて触って、さらにAWSのマネージドサービスで触ったことのないサービスもいくつか触れながら、完全サーバーレス・Pub/Subな感じで作ってみました。

実際に作ってみるとかなりAWS側の仕様で惑わされたことも多かったのですが、AWS CDKだとAWSマネジメントコンソールで触っているとあまり気にしないところも、実装で確認しながら少しずつ進められるので、CDKで作る箇所を増やせば増やすほど、横展開を行う際に爆速でインフラの構築を行うことができそうだなと感じました。

また、今回は全くできていなかったのですが、jestライクにテストを書きながらCDKの開発を進められるとのことで、今後はテストも書きつつインフラのコード化も頑張っていきたいなと感じました。

いくつかハマってしまったところや反省点

  • lambdaを書くときにruntimeがnodeだとnode_modulesの管理が面倒なので、初めからPythonにしておけばよかった
  • AWS CDKを書く際にコンストラクト指向・ベースで書いていかないと、コードベースがカオスになることに気づくのに時間がかってしまった
  • Event Bridgeで、AWS Eventを取得できるイベントバスがDefaultだということがあまりわかっておらず、CDKでイベントバスを作ってみたものの、全く疎通ができずにハマった
  • CloudTrailからIAM証跡イベントを取得する際に、当初リージョンが東京で確か全くイベントが取れなくて泣きかけた(その結果、スタック全てをus-east-1に移してしまいました)
  • slackの外部連携の仕様やAPIなど色々ありすぎて、調査に時間がかかってしまった
    Twitterでバブっていましたら、slackの瀬良さんにご回答いただけまして、非常に助かりました。。。この場を借りて感謝申し上げます🙇‍♂️

https://twitter.com/seratch_ja/status/1466671233656045572

参考記事

Discussion