SaaSでテナント分離をどのようにするかは重要な課題。
サイロモデルの場合、各テナントごとのデータは、DBテーブルやクラスタがわかれるため、割とシンプル。
しかし、プールモデルにした場合、同一リソースを共有するため、アクセス制御が必要。
- といった話がこの記事で記載されている。
- Cognito ユーザプールセットアップ
- API Gateway オーソライザーの設定
- APIGW から Lambda へのヘッダ伝搬
- STS assume_role とセッションポリシー(動的ポリシー)
- 動的ポリシーをセッションポリシーとして assume_role
- 動的ポリシーの動作確認
- Lambda のコードサンプル
といった話がこの記事で記載されている。
例えば、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 だけど、こんな感じ。
ただ、これ具体的にどう実装するねんというのが記載なく、ちょっと一通り触ってみました。
ざっと以下の順序です。
Cognito ユーザプールセットアップ
サーバーワークスさんのページが Cognito ユーザプールセットアップから API Gateway との連携までまとまっていてわかりやすい。
ので、ポイントだけつらつら書く。
Cognito ユーザプールの作成
- デフォルトを確認する
アプリクライアントの追加
- 設定項目
- アプリクライアント名
- クライアントシークレット生成のチェックをオフ
認証用の管理API~ のチェックをオン
ユーザープールのアプリクライアントの設定 - Amazon Cognito
- 認証されていない API 操作 (認証されたユーザーがない操作) を呼び出すための許可を持つユーザープール内のエンティティ
- この例には、登録、サインイン、忘れたパスワードの処理などの操作
- これらの API を呼び出すには、アプリクライアント ID とオプションのクライアントシークレットが必要
- アプリクライアント ID またはシークレットをセキュア化して、認証されたクライアントアプリのみがこれらの認証されていない API を呼び出すことができるようにするのは、お客様の責任
- ユーザープールのアプリクライアントの設定 - Amazon Cognito
- (クライアントシークレットを生成) オプションはオフにします。クライアントシークレットは、アプリケーションのサーバー側のコンポーネントでクライアントシークレットを保護できる場合に使用
- →ブラウザ上のJavaScriptなどシークレットを保護できない場合は、シークレットを割り当てない(パブリッククライアント)
- 認証されていない API 操作 (認証されたユーザーがない操作) を呼び出すための許可を持つユーザープール内のエンティティ
- 設定項目
ユーザの作成
aws cognito-idp admin-set-user-password \ --user-pool-id ap-southeast-1_XXXXXX \ --username user01 \ --password XXXXXXX \ --permanent
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 トークンの理解
こちらのクラメソさんの記事がわかりやすい。
IDトークン
- API GatewayではCognitoをオーサライザーに設定している場合、ヘッダにCognitoで発行した期限切れでないIDトークンを指定することでAPIが呼び出せます。 また、認証されたユーザーのIDに関するクレーム(情報)が含まれているので、それをアプリケーションで利用できます。 → JWT (Jason Web Token) JSON web トークンの検証 - Amazon Cognito
❯❯❯ 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)
- アプリクライアントから各ユーザ属性の更新を可能にする
$ aws cognito-idp update-user-attributes \ --user-attributes Name=custom:my-attribute,Value=hogepiyo \ --access-token ${ACCESS_TOKEN}
リフレッシュトークン
- 主に新しいIDトークンおよびアクセストークンの取得に使用
- 更新トークンをサインイン時に保存しておけば、更新トークンの有効期限が切れるまで、有効なIDトークンとアクセストークンを取得し続けることができます
- 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
ちなみに、Lambda によるカスタムオーソライザの場合
こちらがわかりやすい。
- 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
event.json
headers
がevent.json
に含まれるaws-lambda-developer-guide/event.json at main · awsdocs/aws-lambda-developer-guide · GitHub
{ "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できる。
セッションポリシー :
IAM でのポリシーとアクセス許可 - AWS Identity and Access Management
- セッションの作成に使用する IAM エンティティ (ユーザーまたはロール) のアイデンティティベースのポリシーとセッションポリシーの共通部分
- →セッションポリシーを付与するロールには、同レベル以上のポリシーを付与しておかないと権限不足になる
assume_role リファレンス:
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ユーザのuser01
と user02
でアクセス可能なパスが異なる
(パスをキーとして 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)