実践!AWS CDK #19 Secrets Manager

題字・息子たち
2021.07.15

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

今回は Secrets Manager にシークレットを保存します。
RDS でのデータベース作成時に指定する マスターパスワード を自動生成する目的です。パスワードはソースコードに組み込みたくありませんからね!

前回の記事はこちら。

AWS 構成図

未来の構成図です。

1

今回は一番下にいる RDS のための回です。

設計

Aurora DB クラスターの マスターユーザー名マスターパスワード をシークレットとして保存します。

2

プロパティは以下の通り。

シークレット名
devio-stg-secrets-rds-cluster

以下 2 つのキーを作成します。(パスワードは自動生成)

シークレットキー
MasterUsername admin
MasterUserPassword 自動生成

MasterUserPassword

項目
パスワードの長さ 16 文字
除外文字 "@/\'

Aurora DB クラスターのマスターパスワードの仕様は以下の通りです。

  • Aurora MySQL の場合、パスワードには 8~41 個の印刷可能な ASCII 文字を使用する必要があります。
  • Aurora PostgreSQL の場合は、8~128 個の印刷可能な ASCII 文字を使用する必要があります。
  • /、"、@、またはスペースは使用できません。

公式のサンプルを参考にしたのと、コンソール上(上記の画面キャプチャ)で 単一引用符(')はダメ と書かれていたので ", @, /, \, ' の記号 5 つを含めないように設定します。

シークレットのローテーションは行いません。

実装

Secrets Manager に関する処理を行うクラスはこちら。

lib/resource/secretsManager.ts

import * as cdk from '@aws-cdk/core';
import { CfnSecret } from '@aws-cdk/aws-secretsmanager';
import { Resource } from './abstract/resource';

export const OSecretKey = {
    MasterUsername: 'MasterUsername',
    MasterUserPassword: 'MasterUserPassword'
} as const;
type SecretKey = typeof OSecretKey[keyof typeof OSecretKey];

interface ResourceInfo {
    readonly id: string;
    readonly description: string;
    readonly generateSecretString: CfnSecret.GenerateSecretStringProperty;
    readonly resourceName: string;
    readonly assign: (secret: CfnSecret) => void;
}

export class SecretsManager extends Resource {
    public rdsCluster: CfnSecret;

    private static readonly rdsClusterMasterUsername = 'admin';
    private readonly resources: ResourceInfo[] = [{
        id: 'SecretRdsCluster',
        description: 'for RDS cluster',
        generateSecretString: {
            excludeCharacters: '"@/\\\'',
            generateStringKey: OSecretKey.MasterUserPassword,
            passwordLength: 16,
            secretStringTemplate: `{"${OSecretKey.MasterUsername}": "${SecretsManager.rdsClusterMasterUsername}"}`
        },
        resourceName: 'secrets-rds-cluster',
        assign: secret => this.rdsCluster = secret
    }];

    constructor() {
        super();
    };

    createResources(scope: cdk.Construct) {
        for (const resourceInfo of this.resources) {
            const secret = this.createSecret(scope, resourceInfo);
            resourceInfo.assign(secret);
        }
    }

    public static getDynamicReference(secret: CfnSecret, secretKey: SecretKey): string {
        return `{{resolve:secretsmanager:${secret.ref}:SecretString:${secretKey}}}`;
    }

    private createSecret(scope: cdk.Construct, resourceInfo: ResourceInfo): CfnSecret {
        const secret = new CfnSecret(scope, resourceInfo.id, {
            description: resourceInfo.description,
            generateSecretString: resourceInfo.generateSecretString,
            name: this.createResourceName(scope, resourceInfo.resourceName)
        });

        return secret;
    }
}

前回同様、まずは Secrets Manager に関する Construct を利用するために @aws-cdk/aws-secretsmanager をインストールします。

$ npm install @aws-cdk/aws-secretsmanager

今回作成するリソースは 1 つなのですが、このクラスに関してはベタ書きするのが気持ち悪かったので従来のように ResourceInfo インタフェースを用意し、リソース生成処理をループで回しています。(ループは 1 回で終わるし、今後リソースが増える予定もないのですが)

除外文字に関しては以下の部分で設定しています。(\ はエスケープ文字)

excludeCharacters: '"@/\\\'',

今回のキモはこの部分です。

export const OSecretKey = {
    MasterUsername: 'MasterUsername',
    MasterUserPassword: 'MasterUserPassword'
} as const;
type SecretKey = typeof OSecretKey[keyof typeof OSecretKey];

キー値を列挙するためにこのようなオブジェクト(OSecretKey)と型(SecretKey)を宣言しました。

どうやら TypeScript は Enum が推奨されていないらしいですね。
公式ドキュメントに従って Enum のような処理を行うためにこのコードを書きました。

これで OSecretKey.MasterUsernameOSecretKey.MasterUserPassword という記述で各値の文字列にアクセスでき、SecretKey 型の値はここでは 'MasterUsername' または 'MasterUserPassword' に縛ることができます。

かなり複雑ですが、一度理解してしまえばその後は深く考えずに利用できそうです。(数学の公式に似てます)
なお、type SecretKey = typeof OSecretKey[keyof typeof OSecretKey]; の部分に関しては以下の記事がとてもわかりやすかったです。ありがとうございました。

次のコードは定数化しているため長くなってしまっているのですが・・・

secretStringTemplate: `{"${OSecretKey.MasterUsername}": "${SecretsManager.rdsClusterMasterUsername}"}`

やっていることはこうです。

SecretStringTemplate: '{"MasterUsername": "admin"}'

こちらは JSON.stringify() メソッドを使って次のように書くこともできます。

secretStringTemplate: JSON.stringify({ MasterUsername: 'admin' })

保存されたシークレットを取得する際は以下の static メソッドを利用します。

public static getDynamicReference(secret: CfnSecret, secretKey: SecretKey): string {
    return `{{resolve:secretsmanager:${secret.ref}:SecretString:${secretKey}}}`;
}

このメソッドは CfnSecret のインスタンスとシークレットのキーをパラメーターに受け取り、動的な参照(Dynamic References)を作成し、返却します。
上記のコードでは ${secret.ref} 部分にはシークレットの ARN が、${secretKey} 部分には 'MasterUsername' または 'MasterUserPassword' が代入されます。

L2 の Construct である Secret を利用すれば fromSecretAttributes()secretValueFromJson() というメソッドで値を取得できるようなのですが、fromSecretAttributes() は使用時に id を指定しなければならず、かつプロジェクト内で重複が許されないという仕様だったため使うのをやめました。
私はいにしえの方法で進めていきたいと思います。

メインのプログラムはこちら。
ハイライト部分を追記しました。

lib/devio-stack.ts

import * as cdk from '@aws-cdk/core';
import { Vpc } from './resource/vpc';
import { Subnet } from './resource/subnet';
import { InternetGateway } from './resource/internetGateway';
import { ElasticIp } from './resource/elasticIp';
import { NatGateway } from './resource/natGateway';
import { RouteTable } from './resource/routeTable';
import { NetworkAcl } from './resource/networkAcl';
import { IamRole } from './resource/iamRole';
import { SecurityGroup } from './resource/securityGroup';
import { Ec2 } from './resource/ec2';
import { Alb } from './resource/alb';
import { SecretsManager } from './resource/secretsManager';

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

    // VPC
    const vpc = new Vpc();
    vpc.createResources(this);

    ~ 省略 ~

    // Secrets Manager
    const secretsManager = new SecretsManager();
    secretsManager.createResources(this);
  }
}

シークレットの値取得はこんな感じで。

lib/devio-stack.ts

~ 省略 ~
import { SecretsManager, OSecretKey } from './resource/secretsManager';

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

    ~ 省略 ~

    // Secrets Manager
    const secretsManager = new SecretsManager();
    secretsManager.createResources(this);

    const masterUsername = SecretsManager.getDynamicReference(secretsManager.rdsCluster, OSecretKey.MasterUsername);
    const masterUserPassword = SecretsManager.getDynamicReference(secretsManager.rdsCluster, OSecretKey.MasterUserPassword);
  }
}

テスト

テストコードはこちら。

test/resource/secretsManager.test.ts

import { expect, countResources, haveResource } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as Devio from '../../lib/devio-stack';

test('SecretsManager', () => {
    const app = new cdk.App();
    const stack = new Devio.DevioStack(app, 'DevioStack');

    expect(stack).to(countResources('AWS::SecretsManager::Secret', 1));
    expect(stack).to(haveResource('AWS::SecretsManager::Secret', {
        Description: 'for RDS cluster',
        GenerateSecretString: {
            ExcludeCharacters: '"@/\\\'',
            GenerateStringKey: 'MasterUserPassword',
            PasswordLength: 16,
            SecretStringTemplate: '{"MasterUsername": "admin"}'
        },
        Name: 'undefined-undefined-secrets-rds-cluster'
    }));
});

以下を確認しています。

  • シークレットのリソースが 1 つあること
  • リソースのプロパティが正しいこと

確認

マネジメントコンソール上でリソースを確認してみましょう。

3

シークレットが作成されています。

4

キーと値も OK。
パスワードは 16 文字でランダムに作成されました。

値取得の確認

保存したシークレットから正しく値が取得できることも確認しようと思います。

動作確認用のセキュリティグループを 2 つ作成し、説明 の欄に値を設定します。

// Secrets Manager
const secretsManager = new SecretsManager();
secretsManager.createResources(this);

new CfnSecurityGroup(this, 'test-username', {
  groupDescription: SecretsManager.getDynamicReference(secretsManager.rdsCluster, OSecretKey.MasterUsername)
});
new CfnSecurityGroup(this, 'test-password', {
  groupDescription: SecretsManager.getDynamicReference(secretsManager.rdsCluster, OSecretKey.MasterUserPassword)
});

5

正しい値が設定されていることが確認できました。(が、セキュリティグループの 説明 は許容されている文字が RDS のパスワードより少なく、何度か作成に失敗しました)

ご注意

はじめはセキュリティグループの 説明 ではなく VPC の タグ で確認しようと試みたのですが、リソースのタグでは確認不可 でした。(2 時間くらいハマりました)

// Secrets Manager
const secretsManager = new SecretsManager();
secretsManager.createResources(this);

new CfnVPC(this, 'test-vpc', {
  cidrBlock: '10.0.0.0/16',
  // ダメ。ゼッタイ。
  tags: [{
    key: 'MasterUsername',
    value: SecretsManager.getDynamicReference(secretsManager.rdsCluster, OSecretKey.MasterUsername)
  }]
});

6

公式情報は見つけられなかったのですが、CFn や CDK で Secrets Manager の値取得を試したい場合はセキュリティグループなどのプロパティに値を設定して確認するようにしましょう。
少なくともリソースの タグ を利用するのはやめましょう。

CloudFormation 版

今回のコードを CFn で書くと以下のようになります。

SecretRdsCluster:
  Type: AWS::SecretsManager::Secret
  Properties:
    Description: for RDS cluster
    GenerateSecretString:
      ExcludeCharacters: "\"@/\\'"
      GenerateStringKey: MasterUserPassword
      PasswordLength: 16
      SecretStringTemplate: '{"MasterUsername": "admin"}'
    Name: devio-stg-secrets-rds-cluster

GitHub

今回のソースコードは コチラ です。

おわりに

Secrets Manager を利用することで「RDS の DB 作成時に必要なパスワード」を自動生成でき、それを AWS Cloud に保存して必要な時に取得するということが可能です。

DB 作成時の パスワードどこに置くか問題 は度々発生すると思うので、一つの方法として参考にしてください。

次回はいよいよ最後のリソース RDS まわりを構築します!が、こんな感じで少し刻んでいこうと思います。

  1. サブネットグループ
  2. パラメータグループ
  3. クラスター
  4. インスタンス

それでは。

リンク