Aurora MySQLのシークレットローテーションをCloudFormationでやる場合の7箇条

AWS Secrets Manager（以下Secrets Manager）には、Amazon Aurora（以下Aurora）と連携して、ネイティブ認証のDBユーザーのシークレットを自動ローテーションする機能がある。
とある案件で、これにCloudFormationでトライしたところ際限なくハマったので、同じ道を行かれる方のために備忘録を残しておきます。

## TL;DR
- ローテーション設定をCloudFormationで作成するのは、マネジメントコンソールより格段に敷居が高い。
- **シングルユーザー戦略**のローテーションの場合、以下の**3箇条**を守れば何とかなる。

| 番号 | 課題と対策 |
| :--- | :--- |
| 1. | ローテーションLambdaからSecrets ManagerのAPIエンドポイントへの通信経路を確保すること。 |
| 2. | ローテーションLambdaからAuroraクラスターへの通信経路を確保すること。 |
| 3. | 対象のDBユーザーを作成し、しかるべき権限を付与すること。 |


- **マルチユーザー戦略**のローテーションの場合、上記に加えて、さらに以下の**4箇条**を守る必要がある。

| 番号 | 課題と対策 |
| :--- | :--- |
| 4. | 対象のDBユーザー名は10文字以内に収めること。 |
| 5. | マスターユーザーにマネージドローテーションを利用している場合、マスターユーザーとは別に、シークレットローテーション専用のスーパーユーザーと、そのローテーション設定を作成すること。 |
| 6. | アプリユーザーのシークレットに`masterarn`キーを追加すること。 |
| 7. | 上記のキーと値は、ダブルクォートで囲むこと。 |

- **マネージドローテーション**は、特に気にすることがない。もう全部コレになればよいのに。

## 構成

データベースをセキュアサブネットに、Lambdaをプライベートサブネットに配置する、比較的一般的な構成。
（簡略化のため、AZは省略。）
![スクリーンショット 2023-05-26 0.48.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609876/18d30b22-d73b-ae78-35c0-1741d8f28206.png)


## そもそもシークレットローテーションとは
**Secrets Managerシークレットの値を、定期的にローテーションすること**である。
シークレットには様々な用途があるが、ここでは、Aurora MySQLに接続するパスワードを格納したシークレットに話を絞る。

### Auroraの認証方式
Auroraの認証には大きく分けてネイティブ認証とIAM認証がある。
[スロットル](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAMDBAuth.html#:~:text=In%20general%2C%20consider%20using%20IAM%20database%20authentication%20when%20your%20applications%20create%20fewer%20than%20200%20connections%20per%20second%2C%20and%20you%20don%27t%20want%20to%20manage%20usernames%20and%20passwords%20directly%20in%20your%20application%20code.)やパスワード管理のバランス上、アプリからのアクセスはネイティブ認証、人によるアクセスはIAM認証（マスターユーザーを除く）という設計上の選択を取ることが多いが（そうでないケースも勿論ある）、その際、ネイティブ認証のパスワードをどこで管理するか？という問題が出てくる。

### Secrets Managerシークレット
そこで、LambdaやFargateなどのアプリケーションからアクセスでき、秘匿情報を安全に管理可能なパラメーターストアとして登場するのが、Secrets Managerである。Secrets Managerではシークレットという定義体を作成し、ここに秘匿情報（ここではDBパスワード）を格納して管理する。
同様の機能にSSM Parameter StoreのSecure Stringというものもあるのだが、AuroraやRDSではSecrets Managerとの統合が進んでいる関係で、Secrets Managerが併用されることが多い印象。特に、RDS Proxyを用いる場合は必須となる（[参考](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-setup.html#rds-proxy-secrets-arns)）。

### シークレットローテーションとその自動化
ともあれ、これでパスワードを安全に利用・管理できるようになるわけだが、メンテナンスフリーというわけではなく、以下のような設計上・運用上の課題が出てくる。
- シークレットに格納されたパスワードの定期的なローテーション
- シークレットへのアクセス権の管理

※後者も大事なテーマであるが、論点を絞るため本稿では割愛する。

多くの組織では、パスワードの運用ルールが内規で定められている。90日ごとに更新すべしとか、10文字以上で3種類以上の文字種の組み合わせが必要であるとか、そうしたものだ。
中には、人が利用するパスワードについてのみ規定されていて、アプリケーションの使用するパスワードについては明確な取り決めがないとか、頻繁な変更はむしろ望ましくないといった考え方で設計を進めるケースもあるかも知れないが、一般的には定期的なローテーションが要件として求められることが多いと思う。

そうすると、次はこれを自動で、運用負荷を下げつつ行いたいというニーズが必然的に出てくる。
これを行うのが、[マネージドローテーション](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/rotate-secrets_managed.html)や[ローテーション](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/rotating-secrets.html)などのSecrets Managerの機能である。前者はマスターユーザーを完全マネージドで、後者はユーザーアカウント内に作成されるLambda関数（コードは[github](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas)に公開されている）を用いて、シークレットの更新とDBユーザーのパスワード更新を行う。
生成されるパスワードはランダム化することができ、文字数や文字種の指定も可能である。

#### 認証のキャッシュとローテーション戦略
話はこれで終わりではなく、実はもう一つ課題が存在する。
アプリケーションがシークレットにアクセスする際、毎回APIを叩いていては効率が悪いし、コストもかかるので、多くの場合キャッシュが検討の俎上に上がる（例えば、Lambdaの場合は公式ドキュメントに[このような記載](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/retrieving-secrets_lambda.html)がある）。

当然ながら、キャッシュとローテーションの相性はよくない。ローテーションの結果、キャッシュされている認証情報が使えない時間帯が生じるからだ。これに対する解決策として提示されているのが、**マルチユーザー戦略のローテーション構成**というもので、簡単にいうと2つのDBユーザーを内部で保持することで、実質2世代分のパスワードを保持・返却できるようにしている。ローテーション直後に古い認証情報でアクセスしても、ひと世代前のパスワードがまだ使えるので接続を拒否されることなく動作可能となる、という仕組みである。
認証情報エラーのリスクは低減されるが、ユーザーと権限を複製するためのスーパーユーザーを用意する必要があるなど、構成はいささか複雑になる。
一方、2つのDBユーザーを持たず、単一ユーザーでシンプルにパスワードローテーションを行う構成もあり、こちらは**シングルユーザー戦略のローテーション構成**と呼ばれる。人による利用の場合など、キャッシュを使う必要がないようなケースではこちらで十分だが、アプリケーションユーザーとしてDB接続する場合はキャッシュが事実上必須と思われるのと、逆に人によるアクセスではIAM認証が使われることが多いので、結果あまり出番はないかも。

## ハマリポイント

前置きが長くなった。
以下、CloudFormationテンプレート（抜粋）とハマリポイントを記載する。

### シングルユーザー戦略

#### CloudFormationテンプレート（抜粋）

以下のような形になる。
なお、実際のテンプレートではこの前段でAuroraクラスター（`AuroaDbCluster`）やRDS Proxy、各種セキュリティグループ等の作成が入るが、本筋から外れるため省略している（その他、`DeletionPolicy`など細かい箇所も簡略化のため端折っている）。
また、こちらも詳細は省くが、VPCを作成するためのテンプレートが別途存在し、`${ProductName}-vpc-subnet-net-privateXX`というエクスポート名でクロススタックリファレンスを作成している前提としている。

```yaml:singleuser.yml
  # アプリケーションユーザー用
  SecretAuroraAppUser:
    Type: AWS::SecretsManager::Secret
    Properties:
      GenerateSecretString:
        SecretStringTemplate: '{"username": "app_user"}'
        GenerateStringKey: password
        PasswordLength: 32
        ExcludeCharacters: '"@/\'
      KmsKeyId: alias/aws/secretsmanager

  SecretAuroraAttachmentAppUser:
    Type: AWS::SecretsManager::SecretTargetAttachment
    Properties:
      SecretId: !Ref SecretAuroraRotationUser
      TargetId: !Ref AuroraDbCluster
      TargetType: AWS::RDS::DBCluster

  SecretAuroraRotationScheduleAppUser:
    Type: AWS::SecretsManager::RotationSchedule
    Properties:
      SecretId: !Ref SecretAuroraAppUser
      HostedRotationLambda:
        RotationType: MySQLSingleUser  # シングルユーザー戦略
        ExcludeCharacters: '"@/\'
        VpcSecurityGroupIds: !Ref SecurityGroupApp
        VpcSubnetIds:
          !Join
            - ","
            - - Fn::ImportValue: !Sub ${ProductName}-vpc-subnet-net-private01
              - Fn::ImportValue: !Sub ${ProductName}-vpc-subnet-net-private02
      RotateImmediatelyOnUpdate: False
      RotationRules:
        AutomaticallyAfterDays: 90
    DependsOn: SecretAuroraAttachmentAppUser
```

#### ハマリポイントと対策

**1. ローテーションLambdaからSecrets ManagerのAPIエンドポイントへの通信経路を確保すること。**
まず、LambdaがSecrets ManagerのAPIエンドポイントに通信し、シークレットの取得やパスワードの生成を行うための経路が必要である。
検証環境であればVPC　Lambdaが不要だったり、IGWやNATGWが付いていたりして、特に意識することなく疎通できていることが多いが、冒頭に挙げたような閉域構成の場合は、VPCエンドポイントへの経路を明示的に作る必要が出てくる。
具体的には以下。
- インターフェイス型VPCエンドポイントを作成し、VPC内の最低一つのサブネットにアタッチしておく。
    - これでENIとローカルIPに解決されるDNSレコードが作成され、インターネットへの経路なしでAPIにアクセスできるようになる。
- VPCエンドポイント側のセキュリティグループで、Lambda（のセキュリティグループ）からの443/tcpを許可する。
- エンドポイントポリシーを書く場合は、ポリシーが通信をブロックしていないことも確認する。

**2. ローテーションLambdaからAuroraクラスターへの通信経路を確保すること。**
こちらは、LambdaがAuroraデータベースに指定したDBユーザーで接続してパスワード変更をするための経路。ルーティングは基本的に暗黙のルーター（local）で問題ないが、セキュリティグループの設定に注意する。
- Lambda側のセキュリティグループでAurora側セキュリティグループへの3306/tcpへのアウトバウンドを許可する。
- Aurora側でLambdaからのインバウンドを許可する。

**3. 対象のDBユーザーを作成し、しかるべき権限を付与すること。**
Secrets Managerのローテーション機能は（マネージドローテーションか、Hosted Lambda関数かを問わず）、指定したDBユーザーで接続してパスワードを更新したりテストしたりはやってくれるが、DBユーザー自体は作ってくれない。GRANTもしてくれないので、これらは自力で実施する必要がある。CloudFormationテンプレートでカスタムリソースを作ってもいいが、DBユーザーの数が多くなければ、疎通確認を兼ねて自力でSQLを叩いてしまった方が早い。

具体的には、マスターユーザーで接続の上、以下のようなSQLを流してユーザーの作成と権限付与を行う（ちなみに自分はここでGRANTした権限が不十分で、1時間くらい溶かす羽目になった。`SET PASSWORD`での変更テストはできていたので油断した。。。）。

```sql:create-appuser.sql
CREATE USER 'app_user'@'%' IDENTIFIED BY "<パスワード>";

GRANT
ALTER, ALTER ROUTINE, CREATE, CREATE ROUTINE, CREATE TEMPORARY TABLES, CREATE VIEW, DELETE, DROP, EVENT, EXECUTE, INDEX, INSERT, LOCK TABLES, REFERENCES, SELECT, SHOW VIEW, TRIGGER, UPDATE
ON <DB_NAME>.* TO 'app_user'@'%';
```

### マルチユーザー戦略

#### CloudFormationテンプレート（抜粋）

シングルユーザー戦略に比べて長いが、これは1)アプリケーションユーザーと2)ローテーション専用ユーザー、都合2セットのシークレットとローテーション構成を作っていることが理由。

```yaml:multiuser.yml
  # ローテーション専用ユーザー用
  # 任意のキーを追加する必要がありGenerateSecretStringを使えないため、パスワードはすぐにローテーションすることが望ましい
  SecretAuroraRotationUser:
    Type: AWS::SecretsManager::Secret
    Properties:
      SecretString: !Sub
       '{
          "username": "rotate_usr",
          "password": "rotate-me-immediately",
          "engine": "mysql",
          "port": "${DBPort}",
          "dbname": "${DBName}",
          "host": "${AuroraDbCluster.Endpoint.Address}"
        }'  # ダブルクォートを忘れないこと！！
      KmsKeyId: alias/aws/secretsmanager

  SecretAuroraAttachmentRotationUser:
    Type: AWS::SecretsManager::SecretTargetAttachment
    Properties:
      SecretId: !Ref SecretAuroraRotationUser
      TargetId: !Ref AuroraDbCluster
      TargetType: AWS::RDS::DBCluster

  SecretAuroraRotationScheduleRotationUser:
    Type: AWS::SecretsManager::RotationSchedule
    Properties:
      SecretId: !Ref SecretAuroraRotationUser
      HostedRotationLambda:
        RotationType: MySQLSingleUser  # キャッシュ対策不要なのでシングルユーザー戦略でOK
        ExcludeCharacters: '"@/\'
        VpcSecurityGroupIds: !Ref SecurityGroupApp
        VpcSubnetIds:
          !Join
            - ","
            - - Fn::ImportValue: !Sub ${AppName}-vpc-subnet-net-private01
              - Fn::ImportValue: !Sub ${AppName}-vpc-subnet-net-private02
      RotateImmediatelyOnUpdate: False
      RotationRules:
        AutomaticallyAfterDays: 90
    DependsOn: SecretAuroraAttachmentRotationUser

  # アプリケーションユーザー
  # こちらのローテーションはマルチユーザー戦略
  SecretAuroraAppUser:
    Type: AWS::SecretsManager::Secret
    Properties:
      GenerateSecretString:
        SecretStringTemplate: !Sub  # usernameだけでなくmasterarnを追記するのがポイント
         '{
            "username": "app_usr",
            "masterarn": "${SecretAuroraRotationUser}"
          }'
        GenerateStringKey: password
        PasswordLength: 32
        ExcludeCharacters: '"@/\'
      KmsKeyId: alias/aws/secretsmanager

  SecretAuroraAttachmentAppUser:
    Type: AWS::SecretsManager::SecretTargetAttachment
    Properties:
      SecretId: !Ref SecretAuroraAppUser
      TargetId: !Ref AuroraDbCluster
      TargetType: AWS::RDS::DBCluster

  SecretAuroraRotationScheduleAppUser:
    Type: AWS::SecretsManager::RotationSchedule
    Properties:
      SecretId: !Ref SecretAuroraAppUser
      HostedRotationLambda:
        RotationType: MySQLMultiUser  # アプリユーザーはキャッシュ対策のためにマルチユーザー戦略にする
        SuperuserSecretArn: !Ref SecretAuroraRotationUser
        ExcludeCharacters: '"@/\'
        VpcSecurityGroupIds: !Ref SecurityGroupApp
        VpcSubnetIds:
          !Join
            - ","
            - - Fn::ImportValue: !Sub ${AppName}-vpc-subnet-net-private01
              - Fn::ImportValue: !Sub ${AppName}-vpc-subnet-net-private02
      RotateImmediatelyOnUpdate: False
      RotationRules:
        AutomaticallyAfterDays: 45  # 倍の期間保持するので周期は半分にする
    DependsOn: SecretAuroraAttachmentAppUser
```

#### ハマリポイントと対策

再掲になるが、マルチユーザー戦略は、スーパーユーザーが代替ユーザーを作成して二世代のパスワードを併用できるようにすることで、ローテーション時に「あれ？キャッシュしてたユーザーが急に使えなくなった！」を避ける仕組みである。
自分自身のパスワードをリセットするだけでよかったシングルユーザー戦略とは異なり、多少込み入った仕組みで動くため、以下のような新たなハマリポイントが出てくる。

**4. 対象のDBユーザー名は10文字以内に収めること。**
代替ユーザーを作る際、`_clone`という6文字の文字列が自動付与されることが理由。
MySQLは16文字がユーザー名の上限なので、元のユーザー名が11文字以上だと、超過してしまいLambdaがユーザーを作れなくなる。

> 2023/7/12追記
MySQL 5.7以降はユーザー名制限32文字じゃない？と同僚から指摘を貰いました。確かに、でもLambdaはコケたはず、何故に...と思って[マルチユーザー戦略で使われるLambdaコード](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/tree/master/SecretsManagerRDSMySQLRotationMultiUser)を調べたら、16文字超えるとエラーを返すようハードコードされてました。改修してもらわないといけないですね。

> 2023/12/7追記
改修して貰えたようです。互換性のためデフォルトは16文字としつつ、パラメーターで32文字まで行けるようにしたとのこと。
https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/issues/110

**5. マスターユーザーにマネージドローテーションを利用している場合、マスターユーザーとは別に、シークレットローテーション専用のスーパーユーザーと、そのローテーション設定を作成すること。**
シークレットローテーションを実行するユーザーのシークレットには、接続先Auroraクラスターを特定するためのキーが必要なのだが、マネージドローテーションで自動作成されるシークレットには、キーが`username`と`password`しかなく、接続先のAuroraクラスターを見つけることができない。このため、別途ユーザーの複製権限を持ったスーパーユーザーとシークレットを作成し、ローテーション用ユーザーとして指定する必要がある。
留意点は三点。

- このユーザーには、[こちら](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/reference_secret_json_structure.html)に定義されているJSON構造に則って、CloudFormation内でキーを指定する。
    - 自分が確認したのは`host`キーの不足によるエラーだけだが、他のキーも一式指定しておいた方が無難。
- スーパーユーザー権限を与える際、ロールを用いる場合はアクティベート設定も忘れないこと（GRANTだけだと有効にならない）。以下、設定例。
```sql
GRANT rds_superuser_role TO 'rotate_usr'@'%';
SET DEFAULT ROLE 'rds_superuser_role' TO 'rotate_usr'@'%';
```
- このユーザー自身のシークレットのローテーションは、シングルユーザー戦略でよい。
    - 基本、利用しないので、キャッシュ問題を考慮する必要がないため。

余談だが、マネージドローテーションで自動作成されるシークレットにキーを手動で追加しようとすると、以下のように怒られる（そもそもマネージドなものに手を加えるのはバッドプラクティスなので、別にできなくてよいのだが）。

```
This secret is managed by Amazon RDS (service ID: rds), and you must use that service to update it. 
```

**6. アプリユーザーのシークレットに`masterarn`キーを追加すること。**
前述の通り、マルチユーザー戦略ではシークレットに`masterarn`キーが必要である。
が、CloudFormationのドキュメント上は、これに関して[Secretリソース](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secret.html)にも[RotationScheduleリソース](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-rotationschedule.html#cfn-secretsmanager-rotationschedule-hostedrotationlambda)にも詳細な記載がない。[Hosted Rotation Lambda](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-secretsmanager-rotationschedule-hostedrotationlambda.html)まで辿ると、`SuperuserSecretArn`というのがあるので、ここにローテーション用ユーザーのARNを指定すればOKかと思えば、エラーこそでないものの、まったくキーが追加される気配がない。

結論から言えば、Secretの`SecretStringTemplate`セクションに`masterarn`を追加する必要があった（ちなみにシングルユーザー戦略の場合は`username`だけでよく、他のキーは自動追加される）。
いや、分からんって。。

```yaml:抜粋
  SecretAuroraAppUser:
    Type: AWS::SecretsManager::Secret
    Properties:
      GenerateSecretString:
        SecretStringTemplate: !Sub
         '{
            "username": "app_usr",
            "masterarn": "${SecretAuroraRotationUser}"
          }'  # ここ
```

**7. 上記のキーと値は、ダブルクォートで囲むこと。**
自分の場合、最後の壁となったのがこれ。

```yaml:さらに抜粋
            # ここ
            "masterarn": "${SecretAuroraRotationUser}"
```
キーだけでなく、!Subで置き換える **「値」** の方もダブルクォートで囲まないとダメ、という点に注意。
ダブルクォートなくても通るパターンもあるので、気付きにくい。

## まとめ
ハマリどころ多数だが、一度作ってしまえばCloudFormationテンプレートはやはり楽だし人的エラーも少ない。
AuroraのようにステートフルなリソースはCLIで作成するパターンもあっていいと思うが、CloudFormationで作成・管理する運用を志向する場合も少なくないと思うので、上記の情報が一助になれば幸いです。

## 参考情報
[AWS Secrets Manager シークレットのマネージドローテーション](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/rotate-secrets_managed.html)
[AWS Secrets Managerシークレットのローテーション](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/rotating-secrets.html)
[AWS Secrets Manager におけるローテーションのトラブルシューティング](https://docs.aws.amazon.com/ja_jp/secretsmanager/latest/userguide/troubleshoot_rotation.html)
[AWS Secrets Manager でのデータベース認証情報の設定](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/rds-proxy-setup.html#rds-proxy-secrets-arns)
[IAM database authentication](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.IAMDBAuth.html#:~:text=In%20general%2C%20consider%20using%20IAM%20database%20authentication%20when%20your%20applications%20create%20fewer%20than%20200%20connections%20per%20second%2C%20and%20you%20don%27t%20want%20to%20manage%20usernames%20and%20passwords%20directly%20in%20your%20application%20code.)
[Lambdaコード(マルチユーザー戦略)](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/tree/master/SecretsManagerRDSMySQLRotationMultiUser)
[Lambdaコード(シングルユーザー戦略)](https://github.com/aws-samples/aws-secrets-manager-rotation-lambdas/tree/master/SecretsManagerRDSMySQLRotationSingleUser)