【AWS CDK】「CDKを用いたKMSマルチリージョンキー実装 ~APP分割での半自動化編~」

## はじめに
AWS KMSには、複数のリージョンで同じキーを利用できるマルチリージョンキーという機能があります。
[KMSマルチリージョンキー](https://docs.aws.amazon.com/ja_jp/kms/latest/developerguide/multi-region-keys-overview.html)

このマルチリージョンキーを利用する際は、ソースとなるマルチリージョンキーをDeployした後に、利用するリージョンにてレプリカキーをDeployする必要があります。
CDKを用いて実装する場合、Stackコードはリージョンに紐つくため、1Stackコードでは実装することは出来ず、ソースとなるマルチリージョンキーをDeployするStack、利用するリージョンにてレプリカキーをDeployするStackの2つに分割する必要があります。

この分割を1APP 2Stack構成では現状実現できません。
なぜ実現できないのかは、以下のリンクの記事にて紹介させて頂いておりますので、合わせてご確認ください。
[CDKを用いたKMSマルチリージョンキー実装](https://qiita.com/akwayne/items/6066f78a7e5767c3eac9)

この問題を回避する場合、APPを分割する必要があります。
その際、ソースとなるマルチリージョンキーのARNをレプリカキーをDeployするAPPに渡す部分がちょっとした課題です。
ソースとなるマルチリージョンキーDeploy後にARNを取得して埋め込んであげてもいいんですが、少しでも楽にするための半自動化構成を組みましたので、ご紹介させて頂きます。
この半自動化構成はStack props(引数のセット)であるcrossRegionReferencesを利用した際の挙動から着想を得ています。

※ 本ブログに記載した内容は個人の見解であり、所属する会社、組織とは全く関係ありません。

## APP構成の構造概要
今回の構成では以下2つのAPPに分けて実装しており、Deploy順序に依存関係があります。
| Deploy順序 | APP名 | APP種別 | 構成要素 | 
|---|---|---|---|
| 1| sample-source-kms | ソースとなるリージョンにマルチリージョンキーをDeployする | マルチリージョンキー, SSM Parameter Store |
| 2 | sample-replica-kms | レプリカキーをDeployする | source-kmsでDeployされるマルチリージョンキーに紐つくレプリカキー, SDKを用いてSSM Parameter Storeからvalueを取得 |

それぞれのAPPに分けてご紹介させて頂きます。

## sample-source-kms
コードとしては普通です。
強いてこのAPPのポイントを上げるなら、SSM Parameter Storeに登録する箇所のみです。
**APPコード**
~~~typescript:sample-source-kms.ts
// マルチリージョンキー用Stack
const sourceKms = new SourceKmsStack(app, 'SourceKms', {
    env: {
    region: "ap-northeast-1",
    account: "xxxxx"
  },
});
~~~

**Stackコード**
~~~typescript:sample-source-kms-stack.ts
export class SourceKmsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);
    const kms= new SourceKms(this, "mySourceKms")
  }
~~~

**Constructsコード**
~~~typescript:sample-source-kms-constructs.ts
export class SourceKms extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    // マルチリージョンキー
    const mySourceKms = new kms.CfnKey(this, 'mySourceKms', {
      description: 'sourcekey',
      enabled: true,
      keyUsage: 'ENCRYPT_DECRYPT',
      multiRegion: true,
      pendingWindowInDays: 7,
      });
    // SSM Parameter Store
    new ssm.StringParameter(this, "KMSARN",{
      parameterName: "multi-kms-ARN",
      stringValue: mySourceKms.attrArn
    })
  }
}
~~~

**new ssm.StringParameter(this, "KMSARN",{**
このsample-source-kms APPのポイントになります。
上段で構成されたマルチリージョンキーのARNをDeploy先のSSM Parameter Storeに「multi-kms-ARN」という名前で格納しています。
※CDKの場合、Deploy先RegionはStackコードでしているので、マルチリージョンキーのDeploy先と同じRegionにSSM Parameter StoreもDeployします。
この部分が、crossRegionReferencesを用いた場合の挙動とは違います。
crossRegionReferencesを用いた場合、参照先のRegionにDeployされます。

このSSM Parameter Storeの値を後段のsample-replica-kmsで取得し、自動で埋め込みます。
つまり、他APPに値を連携するためにSSM Parameter Storeを経由する構造が、今回の構成のポイントになります。


## sample-replica-kms
このAPPでは、パラメータの外だしを活用しています。
※利用しているパラメータの外だし手法に関しては、以下のリンクの記事をご参照ください。
[【AWS CDK】「CDKにおけるパラメータの外だし方法 ~自前TypeScript実装編~」](https://qiita.com/akwayne/items/619e34b4d94e4ecd542d)
パラメータを外に出すことにより、簡単にパラメータ置換を出来るようにしています。

このパラメータファイルに対して、APPコードでAWS SDK for JavaScriptを利用して、SSM Parameter Storeから「multi-kms-ARN」のvalueを取得し、更新しています。
そのうえで、Constructsコード内で必要なソースとなるマルチリージョンキーのARNをパラメータファイルから呼び込んでいます。

それぞれのセクションに分割してご紹介させて頂きます。

### APPコード
今回の構成では、このsample-replica-kms APPコードが大きなポイントとなり、以下3つのセクションで構成されます。
- パラメータファイルを置換する処理
- パラメータファイル外出しのための処理
- レプリカ―を生成するStackをDeployするための処理

「パラメータファイルを置換する処理」に関して、要素を分けて紹介させて頂きます。

※「パラメータファイル外出しのための処理」以下のリンクの記事をご参照ください。
[【AWS CDK】「CDKにおけるパラメータの外だし方法 ~自前TypeScript実装編~」](https://qiita.com/akwayne/items/619e34b4d94e4ecd542d)


~~~typescript:sample-replica-kms.ts
// パラメータファイルを置換する処理

//SDKを使うため等の準備
import * as AWS from 'aws-sdk';
import * as fs from 'fs';
AWS.config.update({ region: 'ap-northeast-1' });
const ssm = new AWS.SSM();

// SSM Parameter Storeから値を取得する関数
async function getParameterValue(): Promise<string> {
  const params = {
    Name: 'multi-kms-ARN'
  };
  try {
    const response = await ssm.getParameter(params).promise();
    const parameterValue = response.Parameter?.Value;
    if (parameterValue === undefined) {
      throw new Error('Parameter value is undefined');
    }
    return parameterValue;
  } catch (error) {
    console.error('Error retrieving parameter value:', error);
    throw error;
  }
}

// ファイルの読み書きを行う関数
function updateFileWithValue(value: string) {
  try {
    const filePath = './config/Dev.ts';
    let fileContent = fs.readFileSync(filePath, 'utf8');
    const source = "multi-kms-ARN"
    fileContent = fileContent.replace(source, value);
    fs.writeFileSync(filePath, fileContent);
    console.log('File content updated successfully.');
  } catch (error) {
    console.error('Error updating file content:', error);
    throw error;
  }
}

// メイン処理
(async function main() {
  try {
    const parameterValue = await getParameterValue();
    updateFileWithValue(parameterValue);
  } catch (error) {
    console.error('An error occurred:', error);
  }
})();

//パラメータファイル外だしのための処理
const envName = process.env.TS_ENV;
if (envName == null) {
  console.error(
    "Error: 環境変数'TS_ENV'にパラメータファイル名となる環境名を設定してください。\n \
    　例: export TS_ENV=Dev"
  );
  process.exit(1);
}

//レプリカ―を生成するStackをDeployするための処理
const app = new cdk.App();
const replicaKms =  new ReplicaKmsStack(app, 'ReplicaKms', {
    env: {
    region: "ap-northeast-3",
    account: "xxxx"
  },
  tsEnv: envName,
});
~~~

#### SDKを使うための準備
AWS SDK for JavaScriptや、ode.jsのfsモジュールをインポートします。

**AWS.config.update({ region: 'ap-northeast-1' });**
SDKのグローバル設定をしています。
今回の目的はsample-source-kms APPで格納されたSSM Parameter Storeの値を取得することです。
sample-source-kmsはap-northeast-1に対して実行されているため、SDKのグローバル設定はap-northeast-1としています。

#### SSM Parameter Storeから値を取得する関数
SSM Parameter Storeから値を非同期に取得します。
**async function getParameterValue(): Promise<string> {**
Promiseを返す非同期の処理を行い、resolveかrejectを返します。

**const response = await ssm.getParameter(params).promise();**
ssm.getParameter(params).promise() を非同期に実行し、SSMパラメータストアから値を取得します。getParameterメソッドの戻り値であるPromiseを返し、response変数に代入します。

**const parameterValue = response.Parameter?.Value;**
responseからValueを取得し、parametervalueに格納します。
response.Parameterがnullもしくはundefinedの場合、parameterValueはundefinedになります。

#### ファイルの読み書きを行う関数
ファイルの内容を読み込み、特定のテキストを指定した値で置換し、上書き保存します。

**function updateFileWithValue(value: string) {**
この関数はstring型の引数を受け取ります。

**let fileContent = fs.readFileSync(filePath, 'utf8');**
指定されたPathのファイルをutf8形式でテキストとして読み、fileContentに格納します。

**fileContent = fileContent.replace(source, value);**
replace() Methodを用いて、「multi-kms-ARN」を置換対象として、受け取った引数と置換処理をし、fileContentに格納します。

**fs.writeFileSync(filePath, fileContent);**
fs.writeFileSyncメソッドを使用し、fileContentを指定したファイルパスに同期的に書き込み、ファイルを上書き保存します。

#### メイン処理
メイン処理です。各関数を呼び出し、SSM Parameter Storeからvalueを取得し、パラメータファイルを置換します。

**(async function main() {**
非同期関数を宣言しています。IIFE（Immediately Invoked Function Expression）として即座に実行されます。

### Stackコード
~~~typescript:sample-replica-kms-stack.ts
export interface ReplicaKmsStackProps extends cdk.StackProps {
  tsEnv: string;
}
export class ReplicaKmsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ReplicaKmsStackProps) {
    super(scope, id, props);
    const config = require("../config/" + props.tsEnv);
    new ReplicaKms(this, "myReplicaKms", {
      sourceArn: config.kms.sourceArn
    }
    )
~~~

### Constructsコード
~~~typescript:sample-replica-kms-constructs.ts
export interface ReplicaKmsProps {
  sourceArn: string
}
export class ReplicaKms extends Construct {
  constructor(scope: Construct, id: string, props: ReplicaKmsProps) {
    super(scope, id);
    new kms.CfnReplicaKey(this, "replicaKey", {
    keyPolicy: {
      "Id": "key-policy",
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "Enable IAM User Permissions",
          "Effect": "Allow",
          "Principal": {
            "AWS": "arn:aws:iam::xxxx:root"
          },
          "Action": "kms:*",
          "Resource": "*"
        }
        ]
    },
    primaryKeyArn: props.sourceArn,
    description: "sample replica kms",
    pendingWindowInDays: 7
    });
~~~

### パラメータコード
~~~typescript:Dev.ts
export const kms = {
  sourceArn: "multi-kms-ARN",
};
~~~


## sample-replica-kms実行結果
cdk synthesizeを実行するとパラメータファイルが以下のようになります。
~~~typescript:Dev.ts
export const kms = {
  sourceArn: "arn:aws:kms:ap-northeast-1:xxxx:key/mrk-xxxx",
};
~~~
無事SSM Parameter Storeから取得したvalueで置換出来ていることが分かります。

Constructsコードはこのパラメータファイルからパラメータを取得しているの、sample-source-kmsのマルチリージョンキーのレプリカキーがDeployされます。

## まとめ
KMSマルチリージョンキーの課題に対して、AWS SDKを用いて、半ば力業で半自動化してみました。
個人的にcrossRegionReferencesの発想はすごく魅力に感じて、そこから着想を得た構成となっております。
作ってみた感想にはなりますが、この方式を利用することにより、CDKの世界観がまた一段と広がったような感覚です。
マルチリージョン系の機能には同じような課題があると思うので、こんな感じで色々作ってみて、今後公開してみようかな？と考えています。
マルチリージョンやAPP分割等のユースケースで値を引き渡したい時に活用できるかと思われますので、似たような課題に直面された際は、ご紹介させて頂いた内容を参考にして頂けると幸いです。
※ 本ブログに記載した内容は個人の見解であり、所属する会社、組織とは全く関係ありません。