【Python x AWS】FastAPIとboto3を利用してSecrets Managerからシークレット（会社コード_API_KEY）を取得してAPIの認証機能を実現する方法

# 概要（実現したこと）
* FastAPIとAWS SDK for Python (Boto3)を使用して、AWS Secrets Managerからシークレットを取得し、取得したシークレットとHTTPヘッダーのX_API_KEYの値を比較し、一致しない場合はエラーを返すエンドポイントを定義しました。
    * 具体的には、`{会社コード}_api_key`というシークレットキーがある場合のみ認証させる方法を実現しました。
* 本記事は、FastAPIでAWSシークレットマネージャーを使って認証させたいと考えている方のご参考になればと思います。

## 前提
* Python：3.9.9
* boto3：1.26.41
* AWSの認証情報（アクセスキーとシークレットキー）は設定済とします。以下の記事にもまとめています。
    * [【Python×AWS】botocore.exceptions.NoCredentialsErrorエラー対応。clientとresourceの違いも](https://qiita.com/Ryo-0131/items/2f6f1125855c61d1a8c6)

## 実行時の大枠の流れ
* AWS Secrets Managerの所定のシークレット(`sample_secret`)で会社コードを含んだシークレットキー（`{会社コード}_api_key`）を登録する。
* リクエストボディから会社コードを取得し、取得した会社コードに応じてAWS Secrets Managerからシークレットを取得
    * AWS APIキーを取得するためのJSONファイルは、get_settings()関数を使用して設定
    * get_secret()関数は、指定されたシークレット名でAWS Secrets Managerからシークレットを取得（Boto3を利用)
    * シークレットを文字列として読み込み、JSONフォーマットに変換
    * 取得したシークレットのうち、`company_api_key`で指定された値を返す
* 取得したシークレットとHTTPヘッダーのX_API_KEYの値を比較し、一致しない場合はエラーを返す
    * また、会社コードに応じたシークレットキーが存在しない場合もエラーを返す

# 実装手順
* ①AWS Secrets Managerでシークレットキーを登録
* ②jsonファイルでシークレットネームとリージョンネームを指定
* ③Pythonスクリプトを実装

## ①AWS Secrets Managerでシークレットキーを登録
* AWSコンソール→AWS Secrets Managerから、新規にシークレット（秘密情報）を登録します。
* シークレットの名前は「`sample_secret`」とします。今回登録するシークレットの情報は以下の通りです。
    * シークレットネーム：`sample_secret`
    * シークレットキー: `Foo01_api_key`
    * シークレットキーの値: `hogehoge`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710373/2f59c1bf-fbc9-6a9c-6c0e-9d68f2d7c980.png)


* 登録が完了すると以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2710373/5d3a3fdf-5f48-1c86-6223-91cecf1bd000.png)

* また、クライアントは以下のリクエストを送る前提です。
    * X-API-KEY：`hogehoge`
    * 会社コード：`Foo01`
* シークレットキーは一つのシークレットの中で複数登録が可能です。
    * 仮に`Foo01`という会社コード以外に`Hoge01`という会社コードが必要になってもここに登録するだけで認証が実装できるようにします。
* 今回は例なので、X-API-KEYを`hogehoge`としていますが、セキュリティ的には少なくとも32文字以上のランダムな文字列が推奨されています（アルファベットの大文字と小文字、数字、および特殊文字を含めるなどするとより良い）。また、定期的にAPIキーをローテーションすることもセキュリティ上のベストプラクティスの一つです。

## ②jsonファイルでシークレットネームとリージョンネームを指定
* jsonファイル`aws_api_key.json`を以下に保存

```json
{
    "secret_name": "sample_secret",
    "region_name": "ap-northeast-1"
}
```

## ③Pythonスクリプトを実装
* 以下が実装したソースコードです。Sampleのところやエラーメッセージのところはそれぞれ置き換えてください。
* 自分自身、シークレットマネージャーのクライアントが何をしてくれているのかよくわからなかったので、整理の意味も込めてインラインコメントで丁寧に流れを記載しました。ご参考になればと思います。

```python
from typing import Dict, Union
from fastapi import APIRouter, Header, status
from config import get_settings
from schemas.sample_request import SampleRequest
import json
import boto3
from botocore.exceptions import ClientError

router = APIRouter()
settings = get_settings()

# HTTP POSTメソッドを使ってリクエストを受け取り、それに応じてレスポンスを返すエンドポイントを定義
# エンドポイントのURLは、/sample_apiとなっている
@router.post('/sample_api')
def sample_api(
        request: SampleRequest,
        x_api_key: Union[str, None] = Header(default=None)) -> Dict:

    # AWS APIキーを取得するためのJSONファイルを読み込む
        # settings.app_pathは、このFastAPIアプリケーションのルートディレクトリを表す
    with open(settings.app_path / 'aws_api_key.json', 'r') as f:
        key_json: Dict = json.load(f)

    # secret_nameとregion_nameは、AWS Secrets Managerのシークレットを取得するために必要な情報
    secret_name = key_json.get('secret_name')
    region_name = key_json.get('region_name')
    # company_api_keyは、会社コードに応じたシークレットを特定するためのキーとして使用
    company_api_key = f"{request.company_cd}_api_key"
    print(company_api_key) # この結果はリクエストボディに含まれるcompany_cdフィールドの値Foo01_api_keyが出力される

    # 会社コードに応じたAWS Secrets Managerからのシークレットを取得する
    try:
        # get_secret()関数を呼び出して、AWS Secrets Managerからシークレットを取得
        # return(jsonDict[company_api_key])とされているため、取得したシークレットのうち、company_api_keyで指定された値を返される。
        # 返された値をsecret: strに代入。secret変数には、company_api_keyで指定された値(hogehoge)が格納される
        secret: str = get_secret(secret_name, region_name, company_api_key)
        print(secret) # この結果はhogehoge（シークレットキーの値）が出力される

    # 会社コードに応じたシークレットキーが存在しなかった場合は、エラーを返す
    except KeyError:
        return('会社コードによるキーエラーの場合に返す内容を実装する')
    
    # X_API_KEYが一致しない場合は、エラーを返す
    if x_api_key != secret:
        return('X_API_KEYエラーの場合に返す内容を実装する')

    # 他にエラーチェックを記載する場合は続けて記載or実装する
    # if request.request_id == None: など...

    return ('レスポンスボディの内容をここに記載')

# AWSシークレットマネージャからキーを取得する
def get_secret(secret_name, region_name, company_api_key) -> str:
    # AWS SDK for Python (Boto3)を使用して、シークレットマネージャーのクライアントを作成
    session = boto3.session.Session()
    client = session.client(
        service_name='secretsmanager',
        region_name=region_name
    )
    
    # 指定されたsecret_nameでシークレットを取得
        # withステートメントによりaws_api_key.jsonで指定したシークレット(sample_secret)が取得されている
    try:
        get_secret_value_response = client.get_secret_value(
            SecretId=secret_name
        )
        print(secret_name) # この結果はsample_secretが出力される
    except ClientError as e:
        raise e
        # 例えば、jsonファイルのsecret_nameの値を適当にすると、以下のエラーが出る
        # botocore.errorfactory.ResourceNotFoundException: An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secrets Manager can't find the specified secret.
    
    # シークレットを文字列として読み込み、JSONフォーマットに変換
    secret = get_secret_value_response['SecretString']
    jsonDict: Dict = json.loads(secret)

    print(get_secret_value_response) # この結果はARNから先ほど登録したAPIキーの情報までを持っている辞書型の変数が出力される
    print(secret) # この結果は['SecretString']に該当する{"Foo01_api_key":"hogehoge"}が出力される
    print(company_api_key) # この結果はFoo01_api_keyが出力される

    # シークレットのJSONデータから、指定されたシークレットキーに対応する値を取得する
        # 今回はcompany_api_keyで指定されたキーに対応する値を取得し、その値を返す
    return(jsonDict[company_api_key])　 #　辞書型の変数からcompany_api_keyで指定された値(hogehoge)を取り出して返す
```

# その他
* 今回、JSONフォーマットに変換する際に`jsonDict`を利用していますが、以下の記事によれば「astモジュール の literal_eval() を使用することで辞書型」にすることができるようでこちらのほうが一般的？なのかもしれません。そちらで実装される場合は以下記事をご参考ください。
    * [PythonでAWS Secrets ManagerからAPIキーを取得するときのちょっとしたポイント](https://dev.classmethod.jp/articles/secrets_manager_tips_get_api_key/)
    * [【AWS】Secrets Manager でクレデンシャルを管理する](https://qiita.com/kiyo27/items/bbaf5680a47179141f14)
* AWS Secrets Managerは簡単にAPIキーを生成できて良いですよね。Pythonとの連携もこうやって実装できるので助かります。
* 今回、所定のシークレットの中に、シークレットキーに複数登録できるような運用にしましたが、シークレット自体をたくさん作成して運用する方法もあります。その場合、`secret_name`と`_api_key`を連結させて認証させる必要があり、実際にやってみたら実装できました。ただし、シークレットが大量に作成されていく可能性があるので、どのような運用にするのかは組織の方針に従うのが良いと思います。
* それにしても、「シークレットマネージャー」の中に「シークレット（ネーム）」があってその中に「シークレットキー」と「シークレットキーの値」があるので、用語がどうも混ざりやすい...。「シークレット（ネーム）」は「秘密情報」とかに統一して、「シークレットキー」と「シークレットヴァリュー」とかのほうがわかりやすい気がする...。
* 「boto3 シークレットマネージャー」とかググっても日本語の記事かなり少ない印象。こちらの記事がエンジニア同志の助けになればと思います。