kashinoki38 blog

something like tech blog

Cognito id token を使った sts による動的ポリシー生成

SaaSでテナント分離をどのようにするかは重要な課題。
サイロモデルの場合、各テナントごとのデータは、DBテーブルやクラスタがわかれるため、割とシンプル。
しかし、プールモデルにした場合、同一リソースを共有するため、アクセス制御が必要。

といった話がこの記事で記載されている。

aws.amazon.com

例えば、API Gateway (w/Cognito認証) → Lambda → DynamoDB といった構成で、プールモデルにすると、この記事でいう「アクセス先(DynamoDB)の共有」、「アクセス元(API Gateway&Lambda)の共有」をしていることになる。
なので、「動的なポリシー生成」が必要となる。

この記事に Cognito と DynamoDB を使う場合の方針は記載されていて、以下の通り。

テナントコンテキストに応じて IAM ポリシーをアタッチする方法について、ここではAWS Security Token Service (AWS STS) を使用した動的なポリシー生成のメカニズムを紹介したいと思います。 Amazon Cognito でのサインイン後、ユーザーは取得したトークンと共にシステムにアクセスします。リクエストの HTTPヘッダには Authorization ヘッダが含まれており、この例では JWT が付与されています。システムでは受け取ったテナント固有情報を JWT から取得し、テンプレートに注入することで IAM ポリシーを生成し、STS に対してこのポリシーに応ずるクレデンシャルの発行を依頼します。クレデンシャルが発行された後は、テナントごとのポリシーに基づいて DynamoDB の操作を行い、ユーザに対してレスポンス結果を返却します。 以下のスニペットは動的なポリシー生成に用いられる IAM ポリシーのテンプレート例になります。

{
    "Version": "2012-10-17",
    "Effect": "Allow",
    "Action": [
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Query",
    ],
    "Resource": [
        "arn:aws:dynamodb:*:*:table/MyTable"
    ],
    "Condition": {
        "ForAllValues:StringEquals": {
            "dynamodb: LeadingKeys": [ "{{tenant}}" ]
        }
    }
}

要するに、 Cognito の id token が Authorization ヘッダに入ってくるので、それをBase64で復号すると、各ユーザの属性としてテナント情報等を事前に設定しておけば取れますと。
そのテナント情報を dynamodb: LeadingKeys という Condition をに使えば、対象テナントの情報を持っている行のみにアクセスさせられますということ。(行レベルアクセスの動的生成)

この記事では EC2 だけど、こんな感じ。
f:id:kashionki38:20211003180244p:plain

ただ、これ具体的にどう実装するねんというのが記載なく、ちょっと一通り触ってみました。
ざっと以下の順序です。

  • Cognito ユーザプール作成
  • API Gateway のオーソライザー設定
  • Lamdba で動的ポリシー生成

Cognito ユーザプールセットアップ

blog.serverworks.co.jp

サーバーワークスさんのページが Cognito ユーザプールセットアップから API Gateway との連携までまとまっていてわかりやすい。

ので、ポイントだけつらつら書く。

Cognito ユーザプールの作成

  • デフォルトを確認する
  • アプリクライアントの追加

    • 設定項目
      • アプリクライアント名
      • クライアントシークレット生成のチェックをオフ
      • 認証用の管理API~ のチェックをオン

        • ユーザープールのアプリクライアントの設定 - Amazon Cognito

          • 認証されていない API 操作 (認証されたユーザーがない操作) を呼び出すための許可を持つユーザープール内のエンティティ
            • この例には、登録、サインイン、忘れたパスワードの処理などの操作
          • これらの API を呼び出すには、アプリクライアント ID とオプションのクライアントシークレットが必要
            • アプリクライアント ID またはシークレットをセキュア化して、認証されたクライアントアプリのみがこれらの認証されていない API を呼び出すことができるようにするのは、お客様の責任
          • ユーザープールのアプリクライアントの設定 - Amazon Cognito
            • (クライアントシークレットを生成) オプションはオフにします。クライアントシークレットは、アプリケーションのサーバー側のコンポーネントでクライアントシークレットを保護できる場合に使用
            • →ブラウザ上のJavaScriptなどシークレットを保護できない場合は、シークレットを割り当てない(パブリッククライアント)
  • ユーザの作成

    • ユーザ作成直後はステータスが"FORCE_CHANGE_PASSWORD"となり初期パスワードを設定しないと使えない状態
    • 下記コマンドをaws cli で実行し作成したユーザにパスワードを設定
      • →CONFIRMED になる
aws cognito-idp admin-set-user-password \
  --user-pool-id ap-southeast-1_XXXXXX \
  --username user01 \
  --password XXXXXXX \
  --permanent
  • ユーザの ID Token の取得
aws cognito-idp admin-initiate-auth --user-pool-id ap-northeast-1_xxxxxx --client-id xxxxxxxxxxx --auth-flow ADMIN_NO_SRP_AUTH --auth-parameters USERNAME=user01,PASSWORD=Password123!
{
    "ChallengeParameters": {},
    "AuthenticationResult": {
        "AccessToken": "eyJraWQiOiJcL25I~~~~~~~Kb-34D67aD1sbYQ",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "eyJjdHkiOiJKVR~~~~~~OG40dGsIdq7lydfw",
        "IdToken": "eyJraWQiOiI4XC91T1Z~~~~~~~~~~~GfzL--D8fg"
    }
}

各 Cognito トークンの理解

dev.classmethod.jp

こちらのクラメソさんの記事がわかりやすい。

IDトーク

  • API GatewayではCognitoをオーサライザーに設定している場合、ヘッダにCognitoで発行した期限切れでないIDトークンを指定することでAPIが呼び出せます。 また、認証されたユーザーのIDに関するクレーム(情報)が含まれているので、それをアプリケーションで利用できます。 → JWT (Jason Web Token) JSON web トークンの検証 - Amazon Cognito
    • JSON ウェブトークン (JWT) は、次の 3 つのセクションで構成されます。
      1. ヘッダー
      2. Payload
      3. 署名
    • Base64url 文字列でエンコードされ、ドット「.」文字で区切られています。
    • Payload の取得
❯❯❯ aws cognito-idp admin-initiate-auth --user-pool-id ap-northeast-1_xxxxxxxxxx --client-id xxxxxxxxx --auth-flow ADMIN_NO_SRP_AUTH --auth-parameters USERNAME=user01,PASSWORD=Password123! |jq .AuthenticationResult.IdToken | cut -d "." -f 2 | base64 -d | jq
base64: invalid input
{
  "sub": "xxxxxxx-dec0-4551-ba39-xxxxxxxxxx",
  "email_verified": true,
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxx",
  "cognito:username": "user01",
  "origin_jti": "xxxxxx-xxxx-xxxx-a64f-xxxxxxxxxxxx",
  "aud": "xxxxxxxxxxxxxxxxxxxxxx",
  "event_id": "xxxxxxxx-1f9b-4de8-a94c-xxxxxxxxxx",
  "token_use": "id",
  "auth_time": 1632211769,
  "exp": 1632215369,
  "iat": 1632211769,
  "jti": "7341bd8f-xxxx-xxxx-bd2f-xxxxxxxxxxxxx",
  "email": "hoge@example.com"
}

アクセストーク

  • 主にユーザープールのユーザー属性の追加・変更・削除に使用
  • = JWT
  • アクセストークンを使ったユーザ情報の取得(get-user)
$ aws cognito-idp get-user \
  --access-token ${ACCESS_TOKEN}
{
    "Username": "email@example.com",
    "UserAttributes": [
        {
            "Name": "sub",
            "Value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        },
        {
            "Name": "email_verified",
            "Value": "true"
        },
        {
            "Name": "email",
            "Value": "email@example.com"
        }
    ]
}
  • ユーザ属性の更新(update-usser-attributes)
    • アプリクライアントから各ユーザ属性の更新を可能にする
    • f:id:kashionki38:20211003182648p:plain
$ aws cognito-idp update-user-attributes \
--user-attributes Name=custom:my-attribute,Value=hogepiyo \
--access-token ${ACCESS_TOKEN}

リフレッシュトーク

  • 主に新しいIDトークンおよびアクセストークンの取得に使用
  • 更新トークンをサインイン時に保存しておけば、更新トークンの有効期限が切れるまで、有効なIDトークンとアクセストークンを取得し続けることができます
    • IDトークンとアクセストークンの有効期限は1時間
    • 更新トークンの有効期限は、アプリクライアントの設定で1〜3650(日)の任意の値を指定できます
  • ID token と アクセストークンを取得
$ aws cognito-idp admin-initiate-auth \
  --user-pool-id ${USER_POOL_ID} \
  --client-id ${CLIENT_ID} \
  --auth-flow REFRESH_TOKEN_AUTH \
  --auth-parameters "REFRESH_TOKEN=${REFRESH_TOKEN}"

API Gateway オーソライザーの設定

Cognito の自動チェック

Amazon Cognito ユーザープールと統合された REST API を呼び出す - Amazon API Gateway

  • オーソライザーの作成
    • トークンのソースとして Authorization を指定
      • →Authorization ヘッダに id token を格納してリクエストすると認可される
  • オーソライザーのテスト
    • 作成したユーザのid token を入力
  • リソース>メソッド>メソッドリクエス
    • 認可 でオーソライザーを指定 -APIのデプロイ

ちなみに、Lambda によるカスタムオーソライザの場合

dev.classmethod.jp

こちらがわかりやすい。
- API GatewayのカスタムオーソライザーでIdTokenの検証を行うには、 - Cognitoの自動チェックと、 -- 今回紹介するLambda Functionでチェックする2種類の方法 -Lambda Functionを利用する場合のメリットは、IdTokenの検証以外の認可処理を行うことができるという点です。例えば、IP制限や特定のユーザエージェントだけ許可するなど、用途に応じた処理を書くことができます。

Authorization ヘッダによる認可の検証

Authorization ヘッダに id token を入れないとリクエストが通らない のを確認できる。
id token なし

❯❯❯ curl https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/horiuyas-20210827
{"message":"Unauthorized"}

id token あり

❯❯❯ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/horiuyas-20210827 \
-H "Authorization: eyJraWQiOiI4~~~~~~~~~~~~-D8fg"
{"horiuyas-20210827": [{"timestamp": "2021-09-07T10:41:00", "value": 17},…, {"timestamp": "2021-09-07T10:40:02", "value": 18}]}

APIGW から Lambda へのヘッダ伝搬

API Gateway からの event.json の項目に headers が含まれる

API Gateway で Lambda プロキシ統合を設定する - Amazon API Gateway

{
    "resource": "/",
    "path": "/",
    "httpMethod": "GET",
    "requestContext": {
        "resourcePath": "/",
        "httpMethod": "GET",
        "path": "/Prod/",
        ...
    },
    "headers": {
        "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
        "accept-encoding": "gzip, deflate, br",
        "Host": "70ixmpl4fl.execute-api.us-east-2.amazonaws.com",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
        "X-Amzn-Trace-Id": "Root=1-5e66d96f-7491f09xmpl79d18acf3d050",
        ...
    },
    "multiValueHeaders": {
        "accept": [
            "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
        ],
        "accept-encoding": [
            "gzip, deflate, br"
        ],
        ...
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "body": null,
    "isBase64Encoded": false
}

Lambda Python での event の扱い

つまり、
Authorization ヘッダ:event['headers']['Authorization']

Authorization ヘッダの Base64デコードと、属性の抽出

    # event['headers']['Authorization'] から Authorization ヘッダを取得可能
    token = event['headers']['Authorization'].split('.')
    # base64 でデコード
    payload=base64.b64decode(token[1] + '=' * (-len(token[1]) % 4))
    payload_dict=json.loads(payload)

    # IAMポリシー内で参照する tenant 変数に usename を代入
    tenant=[payload_dict['cognito:username']]

STS assume_role とセッションポリシー(動的ポリシー)

sts の assume_role を使って動的ポリシー生成とそのポリシーへのAssumeを実施する。
動的ポリシーは assume_role のセッションポリシーとして、JSON形式でポリシーを渡すことで動的なポリシーをAssumeできる。

セッションポリシー :

assume_role リファレンス:

  • STS — Boto3 Docs 1.21.15 documentation
    • パラメータ
      • RoleArn (string) -- [REQUIRED]
        • AssumeしたいRoleのARN
        • 上述の通り、動的生成されるポリシーと同レベル以上のポリシーを付与しておかないと権限不足になるため注意
      • RoleSessionName (string) -- [REQUIRED]
        • 任意のセッション名を付与
      • PolicyArns (list) 
        • 事前定義のPolicyがあれば10個までARNで指定可能
      • Policy (string) 
        • JSON形式でPolicyを付与可能。そのため、動的に生成したJSONを渡すことで動的ポリシーとして動作可能。

assume_role した結果でセッション生成

assume_role で帰ってくる response に一時的なキーが含まれるので、それでセッションを再定義する。

response = client.assume_role(
    RoleArn=IAM_ROLE_ARN,
    RoleSessionName=IAM_ROLE_SESSION_NAME
)
session = Session(aws_access_key_id=response['Credentials']['AccessKeyId'],
    aws_secret_access_key=response['Credentials']['SecretAccessKey'],
    aws_session_token=response['Credentials']['SessionToken'],
    region_name=REGION_NAME)

動的ポリシーをセッションポリシーとして assume_role

事前テンプレートとしてJSONテンプレートを作っておいて、ConditionのみJinja2で変数を埋め込む
これにより、dynamodb:LeadingKeys の Condition 部分に、動的に変数を埋め込むことが可能。

import boto3
from jinja2 import Template

# IAMポリシー内で参照する tenant 変数に usename を代入
tenant=payload_dict['cognito:username']

# IAMポリシーのテンプレート、dynamodb:LeadingKeys でPKが指定したキーである行以外へのアクセス権限を制限できる
policy_template='''{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:BatchGetItem",
                "dynamodb:Query"
            ],
            "Resource": [
                "arn:aws:dynamodb:*:*:table/horiuyas-iot-20210827"
            ],
            "Condition": {
                "ForAllValues:StringEquals": {
                    "dynamodb:LeadingKeys": [ "{{tenant}}" ]
                }
            }
        }
    ]
}
'''

template = Template(policy_template)
# Jinja2 にて tenant 変数を IAMポリシーテンプレートに代入
scoped_policy = template.render(tenant=tenant)

assume_role のレスポンスを使ってセッションを生成。

# 作成したIAMポリシーJSONを利用して、assume_role
# RoleArn で指定している IAMロールには動的生成されるポリシーと同レベル以上のポリシーを付与しく必要がある(今回だとdynamodb:GetItem, BatchGetItem, Query)
# セッションポリシーとしてJSONを渡すことで、動的なポリシーとして assume_role 時に付与される
client = boto3.client('sts')
response=client.assume_role(
    RoleArn='arn:aws:iam::xxxxxxxxxx:role/lambda-dynamodb-assumerole',
    RoleSessionName='lambda-dynamodb-assumerole-session',
    Policy=scoped_policy
)

session = Session(
    aws_access_key_id=response['Credentials']['AccessKeyId'],
    aws_secret_access_key=response['Credentials']['SecretAccessKey'],
    aws_session_token=response['Credentials']['SessionToken'])

dynamodb = session.resource('dynamodb')

動的ポリシーの動作確認

Cognitoユーザのuser01user02 でアクセス可能なパスが異なる
(パスをキーとして DynamoDB に get-item するコード)

user01
PKが horiuyas-20210827 の行のみアクセス可能。

❯❯❯ curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/horiuyas-20210827 -H "Authorization: eyJraWQiOiI4XC91~~~~~~~~~cO_pusg"
{"horiuyas-20210827": [{"timestamp": "2021-09-21T01:33:02", "value": 23},…, {"timestamp": "2021-09-07T10:40:18", "value": 23}, {"timestamp": "2021-09-07T10:40:16", "value": 18}]}

❯❯❯ curl https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/2-horiuyas-20210921 -H "Authorization: eyJraWQiOiI4XC91~~~~~cO_pusg"
AccessDeniedException

user02
PKが 2-horiuyas-20210921 の行のみアクセス可能。

❯❯❯ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/horiuyas-20210827 -H "Authorization: eyJraWQiOiI4X~~~~~~~~okGQ"
AccessDeniedException

❯❯❯ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/handson/datas/2-horiuyas-20210921 -H "Authorization: eyJraWQiOiI4X~~~~~okGQ"
{"2-horiuyas-20210921": [{"timestamp": "2021-09-21T05:25:01", "value": 18}, …, {"timestamp": "2021-09-21T05:22:36", "value": 21}]}

Lambda のコードサンプル

Python3 です。
DynamoDB のセッションを取るところまで。

def lambda_handler(event, context):
    #Lambda Proxy response back template
    HttpRes = {
        "statusCode": 200,
        "headers": {"Access-Control-Allow-Origin" : "*"},
        "body": "",
        "isBase64Encoded": False
    }
    # event['headers']['Authorization'] から Authorization ヘッダを取得可能
    token = event['headers']['Authorization'].split('.')
    # base64 でデコード
    payload=base64.b64decode(token[1] + '=' * (-len(token[1]) % 4))
    payload_dict=json.loads(payload)

    # IAMポリシー内で参照する tenant 変数に username を代入
    tenant=payload_dict['cognito:username']

    # IAMポリシーのテンプレート、dynamodb:LeadingKeys でPKが指定したキーである行以外へのアクセス権限を制限できる
    policy_template='''{
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": [
                    "dynamodb:GetItem",
                    "dynamodb:BatchGetItem",
                    "dynamodb:Query"
                ],
                "Resource": [
                    "arn:aws:dynamodb:*:*:table/horiuyas-iot-20210827"
                ],
                "Condition": {
                    "ForAllValues:StringEquals": {
                        "dynamodb:LeadingKeys": [ "{{tenant}}" ]
                    }
                }
            }
        ]
    }
    '''

    template = Template(policy_template)
    # Jinja2 にて tenant 変数を IAMポリシーテンプレートに代入
    scoped_policy = template.render(tenant=tenant)

    # 作成したIAMポリシーJSONを利用して、assume_role
    # RoleArn で指定している IAMロールには動的生成されるポリシーと同レベル以上のポリシーを付与しておく必要がある(今回だとdynamodb:GetItem, BatchGetItem, Query)
    # セッションポリシーとしてJSONを渡すことで、動的なポリシーとして assume_role 時に付与される
    client = boto3.client('sts')
    response=client.assume_role(
        RoleArn='arn:aws:iam::xxxxxxxxx:role/lambda-dynamodb-assumerole',
        RoleSessionName='lambda-dynamodb-assumerole-session',
        Policy=scoped_policy
    )

    # assume_role した response を利用して、AWSへのセッションを再定義
    session = Session(
        aws_access_key_id=response['Credentials']['AccessKeyId'],
        aws_secret_access_key=response['Credentials']['SecretAccessKey'],
        aws_session_token=response['Credentials']['SessionToken'])
    dynamodb = session.resource('dynamodb')
    table  = dynamodb.Table(TABLE_NAME)