AWS

CloudFormation作成時に、動的な外部パラメータを利用する方法(Secrets Manager)

はじめに

弊社はAWSのサービスを積極的に利用しています。
その内、S3やAWSユーザについては、CloudFormationを使って自動作成しています。
※ CloudFormation についてはAWSの公式サイトなどを参照ください。

今回、動的な外部参照値を利用した CloudFormation の作成方法について説明いたします。

CloudFormation 利用の背景

利用するシステムとしてAWSユーザを利用して、S3バケットにアクセスしています。
その際

①:AWSユーザは所属によって特定のS3バケットのみ参照可能としたい
②:AWSユーザはセキュリティ上の理由から、所属する拠点のIPアドレス制限を行いたい
③:IPアドレスは定期的にメンテナンス可能とする

等の理由で制御を入れる必要性があり、作成する数も多いことから CloudFormationを採用しています。

このIPアドレスは「動的な外部参照値」であり、Secrets Manager上に値を管理し、CloudFormation実行時に呼び出して設定を行っております。

対象のAWSリソース

上述①~③の条件を適用するために、以下AWSリソースを作成します。

IAM
ポリシー
・特定のS3バケットのみ参照可能するために利用
・IPアドレスの制限を入れるために利用
IAM
グループ
ユーザの所属に利用。グループにポリシーを割り当てています。
IAM
ユーザ
ログインするユーザ。グループに所属させることで制御を入れています。
ユーザ自体は画面上で簡易に登録できるため、CloudFormationでは作っていません。
S3ユーザがアクセスするS3バケットです。S3自体にはポリシーは入れていません。
※デフォルト設定
Secrets Manager拠点別のIPアドレスの一覧を管理しています。
あまり種類もないことから、CloudFormationではなく、画面上から作成しています。
Lambda関数Secrets Managerを取得する際の関数をCloudFormation作成時に合わせて作成しています。
※現在は関数を作らずとも、取得方法が確立されているようです 。詳細はこちら

◇構成のイメージ

CloudFormationの作成例

CloudFormation はS3用とIAM用で2種類作成しています。

S3

事前にテンプレートファイルを準備した後、S3バケットを自動作成します。

なお、テンプレートはCloudFormationのデザイナツールで作成可能ですが、手書きで作成しております。
背景として本コードをGit管理したかったのと、ymlファイルにて日本語のコメントを書きたかったからです。
※ デザイナツール を通すと残念ながら文字化けしてしまいます。

#S3バケット作成
# --------------------------------------------------------
# パラメータ定義
# -------------------------------------------------------
Parameters:
  #拠点名を入力
  placeName:
    Description: input_place_name
    Type: String

Resources:
  # ----------------------------------
  # S3バケット  拠点名で作成
  # ----------------------------------
  S3Bucket:
    Type: 'AWS::S3::Bucket'
    Properties:
      #バケット名は拠点名
      BucketName: !Sub '${placeName}'
      #アクセス制限(バケット作成時のデフォルト)
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

Outputs:
  S3BucketName:
    Value: !Sub '${placeName}'

解説

CloudFormation実行時のパラメータ(引数)を最初に定義しています、指定した「placeName」でS3バケットが作成されます。

※パラメータは非常に便利ですが、注意点として、ほぼ一度きりの設定と考えてください。
例えば「placeName」をAからBに変えることができるのですが、S3バケットを削除してから新しく作る動きになります。そのため、一度運用が始まってその後変更を行いたい場合などは注意して実行してください。

実行方法

①AWSコンソール->CloudFormationメニューにて、スタックの作成を選択します。

②「テンプレートの指定」で先ほど作成したファイルを指定します。

③パラメータにバケットの名前を指定します。※ここではsample20211228としています。

④その後の入力は全てデフォルト設定としています。
最終的に、「CREATE_COMPLETE」のアイコンが表示さればOKです。
新しく「 sample20211228 」のS3バケットが作成されています。

なお、本実行はコマンドベースでも可能となります。慣れてくるとこちらの方が簡単です。

aws cloudformation create-stack \
 --stack-name hogehoge  \
 --capabilities CAPABILITY_IAM  \
 --template-body file:///home/centos/hogehoge/hoge.yml
 --parameters ParameterKey=placeName,ParameterValue=hoge   

IAMグループとポリシー

続いて、IAMグループおよびIAMポリシーを作成します。
この2つはセットで、作成したIAMグループに IAMポリシーを紐づけています。
IAMポリシーは「IPアドレスの制限」が必要で、IPアドレスを管理している、「Secrets Manager」から取得用の「Lambda関数」を作成した後、取得しています。

# --------------------------------------------------------
# パラメータ定義
# -------------------------------------------------------
Parameters:
  #拠点名
  placename:
    Description: input_place_name
    Type: String
  #Secrets Managerの名前(拠点のIPアドレス一覧)
  secretName:
    Type: String
    Default: ipaddress-place
    AllowedValues : ["ipaddress-place"]
  #バージョン番号(変えないとSecrets Managerを再取得しません)
  version:
    Type: String
    Description: input_version_on_change
    Default: 0

Resources:
  # --------------------------------------------------------
  # Secrets Managerの値を見るためにラムダ関数を作成
  # -------------------------------------------------------
  CustomResource:
    Type: Custom::PythonLambdaExecution
    Properties:
      ServiceToken: !GetAtt CustomFunction.Arn
      Version: !Ref version
  CustomFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: !Sub |
          import cfnresponse
          import boto3
          import json

          def handler(event, context):
            if event['RequestType'] == "Create" or event['RequestType'] == "Update":

              client = boto3.client('secretsmanager')
              secret_str = client.get_secret_value(SecretId='${secretName}')['SecretString']
              data = json.loads(secret_str)
              result = {
                'value' : data['ipaddress-${placename}']
              }
              cfnresponse.send(event, context, cfnresponse.SUCCESS, result)
            if event['RequestType'] == "Delete":
              cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
      Handler: index.handler
      Runtime: python3.6
      Timeout: 30
      Role: !GetAtt LambdaExecutionRole.Arn
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
            - sts:AssumeRole
      Path: "/"
      Policies:
        - PolicyName: custom_resource_policy_secretsmanager
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:*
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: '*'
  # ----------------------------------
  # IAMグループ(ポリシーをインライン定義)
  # ----------------------------------
  IAMGROUP:
    Type: 'AWS::IAM::Group'
    Properties:
      #グループ名 group-拠点名
      GroupName: !Sub 'group-${placename}'
      Policies:
        #ポリシー名 group-拠点名
        - PolicyName: !Sub 'group-${placename}'
          PolicyDocument:
            Statement:
              - Effect: Allow
                #利用可能権限
                Action:
                  - 's3:ListBucket'
                  - 's3:GetBucketLocation'
                  - 's3:PutObject'
                  - 's3:GetObject'
                  - 's3:DeleteObject'
                #特定のバケット名のみ参照可能とする
                Resource: 
                  - !Sub 'arn:aws:s3:::${placename}*'
                  - !Sub 'arn:aws:s3:::${placename}*/*'
                #特定のIPアドレスのみ参照可能とする。
                #Lambda関数を利用して、Secrets Managerの値を取得
                Condition:
                  IpAddress:
                    aws:SourceIp: !Split
                      - ','
                      - !GetAtt CustomResource.value

解説

同様にパラメータを定義しています。
①「placename」で作成したS3バケットを参照可能としています。
※正確には「arn:aws:s3:::${placename}*」と定義しているため、「placename」で始まるバケットが対象となります。
②「secretName」で「Secrets Manager」上の名前を指定しています。
なお、ルールとして、キーが「’ipaddress-${placename}’」であるシークレット値を取得しています。
③「version」は「Secrets Manager」取得時に何らかのバージョン値を入れないと、再取得してくれず入れています。
※現在はLambda関数を作らずとも、取得方法があるため改善されているかもしれません。詳細はこちら

          def handler(event, context):
            if event['RequestType'] == "Create" or event['RequestType'] == "Update":

              client = boto3.client('secretsmanager')
              secret_str = client.get_secret_value(SecretId='${secretName}')['SecretString']
              data = json.loads(secret_str)
              result = {
                'value' : data['ipaddress-${placename}']
              }
 

Lambda関数の部分ですが、42行目の「get_secret_value」で「Secrets Manager」の名前を指定した後、45行目で 「’ipaddress-${placename}’」 のシークレットキーの値を取得しています。

  # ----------------------------------
  # IAMグループ(ポリシーをインライン定義)
  # ----------------------------------
  IAMGROUP:
    Type: 'AWS::IAM::Group'
    Properties:
      #グループ名 group-拠点名
      GroupName: !Sub 'group-${placename}'
      Policies:
        #ポリシー名 group-拠点名
        - PolicyName: !Sub 'group-${placename}'
          PolicyDocument:
            Statement:
              - Effect: Allow
                #利用可能権限
                Action:
                  - 's3:ListBucket'
                  - 's3:GetBucketLocation'
                  - 's3:PutObject'
                  - 's3:GetObject'
                  - 's3:DeleteObject'
                #特定のバケット名のみ参照可能とする
                Resource: 
                  - !Sub 'arn:aws:s3:::${placename}*'
                  - !Sub 'arn:aws:s3:::${placename}*/*'
                #特定のIPアドレスのみ参照可能とする。
                #Lambda関数を利用して、Secrets Managerの値を取得
                Condition:
                  IpAddress:
                    aws:SourceIp: !Split
                      - ','
                      - !GetAtt CustomResource.value

実際のAWSリソースの作成は82行目から記載しております。
IAMグループ名は’group-${placename}’のルールで作成し、IAMポリシーも同様の名前としています。
・本グループに所属するユーザは基本的な操作のみを許容させています。(96~102行目)
・「placename」で始まるバケットのみを参照可能としています。(104~106行目)
 本記載ですが、「’arn:aws:s3:::${S3バケット名}/${placename}’」といった書き方で、フォルダ毎に制御をおこなうことも可能です。
・前述のLambda関数を利用して「Secrets Managerの値」を取得し、IPアドレス制限を入れています(110~113行目)。

実行方法

①S3と同様、CloudFormationの画面上から実施します。※詳細は割愛
実行が完了すると、IAMグループとIAMポリシーが新たに作成されます。

▽作成したIAMポリシーの詳細

②一度作成した後、「Secrets Manager」の内容が変更となった場合、CloudFormationの画面上で更新を行います。更新後の手順は作成時と同じです。注意点として「version」のパラメータは前回と違う値を指定してください。
※作成時同様コマンドでも実行可能です。

最後に

作成したIAMグループにIAMユーザを所属させた後、
IAMユーザでログインすると、アクセス制限がかかった状態でS3バケットを参照可能となります。


ABOUT ME
Fukunaga
Fukunaga
リードエンジニアしてます。フロントからサーバサイドまで、フルスタックで担当。 DBチューニングが得意。最近はキントーン開発、AI/分析作業など従事しています。