AWS CodePipelineの手動承認をカスタマイズして、匿名で承認出来るようにしてみた

2022.05.19

いわさです。

CodePipilineでは手動承認のアクションが用意されていて、デプロイ前に管理者の承認を得たのちにデプロイを実施するなどを行うことが出来ます。
そんな手動承認アクションですが、CodePipelineでの主な設定先はSNSトピックを指定するくらいです。
一見カスタマイズ性があまり高くないように見えますが、実際にはSNSトピックから様々な拡張を行うことが出来ます。

本日は、カスタム例として以下のように手動承認フローをカスタマイズしてみました。

  • メール通知内容のカスタマイズ
  • 匿名で承認(AWSへのサインイン不要でメールから即承認)

手動承認のおさらい

手動承認アクションを設定すると、パイプラインの特定ステージで手動での承認が行われるまでパイプラインを待機させることが出来ます。
そして待機状態に入ったあとは、コンソールのレビューボタンで承認か拒否を選択することでパイプラインが再開されます。

また、手動承認アクションの追加のオプションとして大きなところとしてはSNSトピックを指定することが出来ます。

例えば、こちらにEメールを連携したSNSトピックを指定すると、以下のように手動承認が必要な旨のメールが送信されます。
受信者はメールに記載されているURLにアクセスし、AWSマネジメントコンソールへサインインします。

サインイン後はCodePipelineの対象パイプラインのフロー画面に遷移するので、そこでレビュー操作を行う形となっています。

デフォルトのSNS通知を使ったEメール承認の流れはこのような形となっています。

CodePipeline手動承認アクションの追加の情報

シンプルにEメール購読しているSNSトピックを設定した場合は上記のような動きとなりますが、実際にはSNSトピックは様々な使い方が出来ます。
SNSへのJSON出力を利用してSQSやLambdaで処理することで、通知する方法やフォーマットなどもカスタマイズ出来ます。

以下は手動承認アクションで発生するJSON出力の例です。

{
    "region": "us-east-2",
    "consoleLink": "https://console.aws.amazon.com/codepipeline/home?region=us-east-2#/view/MyFirstPipeline",
    "approval": {
        "pipelineName": "MyFirstPipeline",
        "stageName": "MyApprovalStage",
        "actionName": "MyApprovalAction",
        "token": "1a2b3c4d-573f-4ea7-a67E-XAMPLETOKEN",
        "expires": "2016-07-07T20:22Z",
        "externalEntityLink": "http://example.com",
        "approvalReviewLink": "https://console.aws.amazon.com/codepipeline/home?region=us-east-2#/view/MyFirstPipeline/MyApprovalStage/MyApprovalAction/approve/1a2b3c4d-573f-4ea7-a67E-XAMPLETOKEN",
        "customData": "Review the latest changes and approve or reject within seven days."
    }
}

CodePipeline でのマニュアルの承認通知の JSON データ形式 - AWS CodePipeline

次に承認の方法です。
マネジメントコンソールへサインインし、レビュー操作を行う以外に、上記出力から得られるトークンとcodepipeline:PutApprovalResult権限さえあれば、CLIでもAPIからでも手動承認が可能です。
うまく組み合わせることで通知方法や承認方法はいくらでもカスタマイズできそうです。

カスタマイズしてみよう

この記事では以下のような形でカスタマイズしてみることにしました。

SNSで直接メールを送らずにLambdaで一度通知内容を加工します。
通知分には、別で用意するHTTPエンドポイントへリクエストが送信されるようなURLをトークン付きで構成します。
これによってメール通知内容をカスタマイズすることが出来ます。

承認のためにリクエストを受けるHTTPエンドポイントも用意しましょう。
ここでは、匿名アクセスが出来て、実行ロールが対象パイプラインへの承認権限を持っている、Lambdaの関数URLを使ってみます。
API Gatewayでも良いのですが、今回は検証用途なのでLambda関数URLをCloudFormationで構築してみることに。

CloudFormationテンプレートの全体は以下のリポジトリにおいておきます。
この記事ではポイントとなる部分だけ少し抜粋します。

以下は、別のSNSへ送信するLambda処理です。
前述のJSON出力にアクセスして、必要なパラメータを取得し、URLパラメータに利用しています。

メール送信部分のLambda

  SendNotifyFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-SendNotify-Function
      Description: !Sub ${AWS::StackName}-SendNotify-Function
      Code:
        ZipFile: !Sub
        - |
          import json
          import boto3
          def handler(event, context):
            client = boto3.client('sns')
            for record in event['Records']:
              codePipelineMessage = json.loads(record['Sns']['Message'])
              pipelineName = codePipelineMessage['approval']['pipelineName']
              stageName = codePipelineMessage['approval']['stageName']
              actionName = codePipelineMessage['approval']['actionName']
              token = codePipelineMessage['approval']['token']
              url = '${approval_url}' + '?pipelinename=' + pipelineName + '&stagename=' + stageName + '&actionname=' + actionName + '&token=' + token + '&action='
              request = {
                'TopicArn': '${dest_topic_arn}',
                'Message': 
                  '承認\n' + url + 'approve\n' + '拒否\n' + url + 'deny\n',
                'Subject': 'hoge'
              }
              client.publish(**request)
        - { dest_topic_arn: !Ref snsTopicNotify, approval_url: !GetAtt ApprovalUrl.FunctionUrl }
      Handler: index.handler
      MemorySize: 128
      Role: !GetAtt SendNotifyFunctionExecRole.Arn
      Runtime: python3.8
      Timeout: 10

以下は、匿名での承認リクエストを受けるLambdaです。
今回初めて関数URL構築したのですが、AWS::Lambda::Urlを追加で設定するのですね。なるほど。
そして、関数URLを匿名アクセスで構築する場合は、AWS::Lambda::PermissionFunctionUrlAuthTypeなどの設定も必要です。

リクエスト受けるLambda

  ApprovalUrl:
    Type: AWS::Lambda::Url
    Properties: 
      AuthType: NONE
      TargetFunctionArn: !GetAtt ApprovalFunction.Arn
  ApprovalFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-Approval-Function
      Description: !Sub ${AWS::StackName}-Approval-Function
      Code:
        ZipFile: |
          import json
          import boto3
          def handler(event, context):
            client = boto3.client('codepipeline')
            hoge = 'Approved' if event['queryStringParameters']['action'] == 'approve' else 'Rejected'
            client.put_approval_result(
              pipelineName=event['queryStringParameters']['pipelinename'],
              stageName=event['queryStringParameters']['stagename'],
              actionName=event['queryStringParameters']['actionname'],
              result={
                'summary':'',
                'status': hoge
              },
              token=event['queryStringParameters']['token']
            )
            return hoge
      Handler: index.handler
      MemorySize: 128
      Role: !GetAtt ApprovalFunctionExecRole.Arn
      Runtime: python3.8
      Timeout: 10
  ApprovalFunctionExecRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument: 
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal: 
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: !Sub ${AWS::StackName}-ApprovalFunction
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
                - Effect: "Allow"
                  Action: "codepipeline:PutApprovalResult"
                  Resource: 
                    - Fn::Join:
                      - ""
                      - - !Ref PipelineArn 
                        - "/*/*"
  ApprovalFunctionInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunctionUrl
      FunctionName: !GetAtt ApprovalFunction.Arn
      Principal: '*'
      FunctionUrlAuthType: NONE

メールのリンクにリクエストを送信してみる

これで、CodePipelineを時刻してみると、2つ目のSNSで設定した通知先に承認URLと拒否URLが通知されます。

承認
https://2n4oioxgaibaw663les6gpgi240ofzuz.lambda-url.ap-northeast-1.on.aws/?pipelinename=hoge-pipeline&stagename=hoge&actionname=hogehoge&token=b66dae12-c0ae-456e-9d1a-4e8266be6181&action=approve
拒否
https://2n4oioxgaibaw663les6gpgi240ofzuz.lambda-url.ap-northeast-1.on.aws/?pipelinename=hoge-pipeline&stagename=hoge&actionname=hogehoge&token=b66dae12-c0ae-456e-9d1a-4e8266be6181&action=deny

試してみましょう。

承認

$ curl "https://2n4oioxgaibaw663les6gpgi240ofzuz.lambda-url.ap-northeast-1.on.aws/?pipelineName=hoge-pipeline&stageName=hoge&actionName=hogehoge&token=27db0033-2119-459a-91fa-07ae4c45d060&action=approve"
Approved

承認されました。

拒否

$ curl "https://2n4oioxgaibaw663les6gpgi240ofzuz.lambda-url.ap-northeast-1.on.aws/?pipelinename=hoge-pipeline&stagename=hoge&actionname=hogehoge&token=b66dae12-c0ae-456e-9d1a-4e8266be6181&action=deny"
Rejected

拒否されました。

さいごに

本日は、CodePipelineの手動承認アクションをカスタマイズしてみました。
ちなみに、トークンの期間は1週間で、変更は出来ないようです。
この記事では、匿名で承認出来るようにしてみましたが、あくまでもカスタマイズ例なので実際にご利用される際はセキュリティなど別途ご検討ください。