Cognito + API Gateway + Lambda で実行権限を動的に制御したい

記事タイトルとURLをコピーする

はじめに

こんにちは。アプリケーションサービス部の保田(ほだ)です。

最近さつまいもが滅茶苦茶美味しいということを再認識しました。
1センチぐらいの厚さに切ったのを茹でてオプションで塩をちょっとかけるだけで美味です。

という訳で今日は Lambda のポリシーを動的に制御する方法を考えます。

真面目な導入

例えば API Gateway の背後に Lambda を置いたとします。
そのLambda が他の AWS リソースへアクセスするためには、予め実行ロールとして IAM ロールを付与しておくことになります。
Lambda はその IAM Role を引き受けて(AssumeRole して)いろんなことができるわけです。

この IAM Role はあらかじめ作っておく必要があり、IAM Role に付与された IAM Policy もあらかじめ作っておかなければなりません。

今日はそこをどうにかして、 Lambda の実行権限を動的に縛ってみたいと思います。

元ネタ

以下のブログがベースになっています。

ただし、行間を埋めたといったモノではなく一つの解釈例だと捉えていただきたいです。

状況設定

各テナントとそこに属するユーザーは次のようになっているとします。

テナント名 ユーザー ID メールアドレス
A A-user01 A-user01@example.com
A A-user02 A-user02@example.com
B B-user01 B-user01@example.com
B B-user02 B-user02@example.com
C C-user01 C-user01@example.com

そして、ユーザー情報を管理する DynamoDB のテーブル Users があったとし、それがテナントごとに分離されていないとします。 テーブルの構造としては次のようなものを仮定します。

項目名 パーティションキー ソートキー サンプル値 備考
tenant - A テナント名
user_id - A-user01 ユーザー ID
email - - A-user01@example.com ユーザーのメールアドレス

tenant と user_id 以外は適当です。

要するに最低限 tenant と user_id によってアイテムが一意に定まるようになっていれば良いとします。

やりたいこと

先ほどの DynamoDB に対して、あるテナントに属するユーザーがアクセスする際に「自分のテナントのアイテムだけ取得できる」ようにします。

今回は User テーブルに対するアクセスを制御しますが、これが例えばそのテナントごとの製品情報などでも良いと思います。パッと良い例が思いつかないですが。

項目名 パーティションキー ソートキー サンプル値 備考
tenant - A テナント名
product_id - - A-xxxxx 製品 ID
xxxx - - xxxx 特定のテナントに属する何らかの情報
yyyy - - yyyy 特定のテナントに属する何らかの情報

それではやっていきましょう。

DynamoDB のテーブルを用意する

CLI でサクッと作ります。せっかくですのでコマンドも記載します。

コマンド

$ aws dynamodb create-table \
    --table-name Users \
    --attribute-definitions \
        AttributeName=tenant,AttributeType=S \
        AttributeName=user_id,AttributeType=S \
    --key-schema \
        AttributeName=tenant,KeyType=HASH \
        AttributeName=user_id,KeyType=RANGE \
    --billing-mode=PAY_PER_REQUEST

テスト用のデータもサクッと用意します。

コマンド

$ aws dynamodb batch-write-item \
    --request-items \
    "{\"Users\": [
        {
            \"PutRequest\": {
                \"Item\": {
                    \"tenant\": {\"S\": \"A\"},
                    \"user_id\": {\"S\": \"A-user01\"},
                    \"email\": {\"S\": \"A-user01@example.com\"}
                }
            }
        },
        {
            \"PutRequest\": {
                \"Item\": {
                    \"tenant\": {\"S\": \"A\"},
                    \"user_id\": {\"S\": \"A-user02\"},
                    \"email\": {\"S\": \"A-user02@example.com\"}
                }
            }
        },
        {
            \"PutRequest\": {
                \"Item\": {
                    \"tenant\": {\"S\": \"B\"},
                    \"user_id\": {\"S\": \"B-user01\"},
                    \"email\": {\"S\": \"B-user01@example.com\"}
                }
            }
        },
        {
            \"PutRequest\": {
                \"Item\": {
                    \"tenant\": {\"S\": \"B\"},
                    \"user_id\": {\"S\": \"B-user02\"},
                    \"email\": {\"S\": \"B-user02@example.com\"}
                }
            }
        },
        {
            \"PutRequest\": {
                \"Item\": {
                    \"tenant\": {\"S\": \"C\"},
                    \"user_id\": {\"S\": \"C-user01\"},
                    \"email\": {\"S\": \"C-user01@example.com\"}
                }
            }
        }
    ]
}"

Cognito User Pool を作る

Cognito も CLI で準備します。

ユーザープールを作成する

コマンド

$ aws cognito-idp create-user-pool \
    --pool-name demo-userpool \
    --schema \
        Name=email,Required=true \
        Name=name,Required=true

出力としてユーザープールの ID が返却されますのでメモしておきます。

ユーザー作成

テスト用ユーザーも一人用意しておきます。

コマンド

aws cognito-idp admin-create-user \
    --user-pool-id ap-northeast-1_XXXXXXXXX \ # メモしたユーザープールの ID
    --username A-user01 \
    --temporary-password P@ssw0rd \
    --message-action SUPPRESS \
    --user-attributes \
        Name=email,Value=A-user01@example.com

パスワードの変更を求められるので変更しておきます。

コマンド

aws cognito-idp admin-set-user-password \
    --user-pool-id ap-northeast-1_XXXXXXXXX \
    --username A-user01 \
    --password newP@ssw0rd \
    --permanent

アプリクライアント作成

ユーザープールとアプリケーションを紐づける、アプリクライアントを作成します。

コマンド

aws cognito-idp create-user-pool-client \
    --user-pool-id ap-northeast-1_XXXXXXXXX \
    --client-name app-web \
    --explicit-auth-flows \
        ALLOW_ADMIN_USER_PASSWORD_AUTH \
        ALLOW_REFRESH_TOKEN_AUTH

出力にアプリクライアント ID が返却されますのでメモしておきます。

グループを作る

今回、すべてのテナントのユーザーをこの一つのユーザープール内に作成します。

グループ を作り、各ユーザーがどのグループに所属するかでマルチテナントを制御するとします。

コマンド

aws cognito-idp create-group \
    --group-name A \
    --user-pool-id ap-northeast-1_XXXXXXXXX

A-user01 さんを A に所属させます。

コマンド

aws cognito-idp admin-add-user-to-group \
    --user-pool-id ap-northeast-1_XXXXXXXXX \
    --username A-user01 \
    --group-name A

Lambda 関数と API Gateway と Cognito Authorizer を作る

Lambda と API Gateway と Cognito Authorizer を作ります。 Serverless Framework で一気に片付けます。

serverless.yml

まず serverless.yml がこちらです。

参考: Serverless Framework の API Gateway Authorizer 実装を解説します!😎

service: multi-tenant-sample

provider:
  name: aws
  region: ap-northeast-1
  runtime: python3.9
  stage: dev
  profile: default

package:
  exclude:
    - node_modules/**
    - package-lock.json
    - package.json
    - iam-role.yml # 後で説明します

functions:
  Backend:
    handler: main.handler
    environment:
      TENANT_ROLE: tenant-role
      USER_TABLE: Users
      REGION: ${self:provider.region}
      ACCOUNT_ID: ${aws:accountId}
    timeout: 30
    events:
      - http:
          path: check
          method: get
          integration: lambda-proxy # ヘッダ等の情報も全部欲しいのでプロキシ統合
          authorizer:
            type: COGNITO_USER_POOLS
            authorizerId: !Ref CognitoAuthorizer

resources:
  Resources:
    CognitoAuthorizer:
      Type: AWS::ApiGateway::Authorizer
      Properties:
        Name: CognitoAuthorizer
        RestApiId: !Ref ApiGatewayRestApi # Serverless Framework 側でよしなに生成される API Gateway の論理 ID がコレ
        IdentitySource: method.request.header.Authorization
        Type: COGNITO_USER_POOLS
        ProviderARNs:
          - arn:aws:cognito-idp:${self:provider.region}:${aws:accountId}:userpool/ap-northeast-1_XXXXXXXXX # べた書きでごめんなさい

Lambda

Lambda のコードがこちらです。

import boto3
import json
import os
 
from boto3.dynamodb.conditions import Key
 
 
def handler(event, context):
    # Cognito ユーザーが属するグループを取得する
    tenant = event['requestContext']['authorizer']['claims']['cognito:groups']
 
    account_id = os.environ['ACCOUNT_ID']
    role_name = os.environ['TENANT_ROLE']
    table_name = os.environ['USER_TABLE']
    region = os.environ['REGION']
 
    policy_document = {
        'Version': '2012-10-17',
        'Statement': [
            {
                "Effect": "Allow",
                "Action": [
                    "dynamodb:UpdateItem",
                    "dynamodb:GetItem",
                    "dynamodb:PutItem",
                    "dynamodb:DeleteItem",
                    "dynamodb:Query"
                ],
                "Resource": [
                    f"arn:aws:dynamodb:{region}:{account_id}:table/{table_name}"
                ],
                "Condition": {
                    "ForAllValues:StringEquals": {
                        "dynamodb:LeadingKeys": [
                            tenant
                        ]
                    }
                }
            }
        ]
    }
 
    sts = boto3.client('sts')
    credential = sts.assume_role(
        RoleArn=f'arn:aws:iam::{account_id}:role/{role_name}',
        RoleSessionName='my-session', # セッション名を一意にするため本当はランダムな文字列が良い
        Policy=json.dumps(policy_document), # アクセス制限を設ける
    )['Credentials']
 
    session = boto3.Session(
        aws_access_key_id=credential['AccessKeyId'],
        aws_secret_access_key=credential['SecretAccessKey'],
        aws_session_token=credential['SessionToken'],
    )
 
    dynamodb = session.resource('dynamodb')
    table = dynamodb.Table(table_name)
  
    # 自分の所属するテナントのアイテムしか取得できない
    res = table.query(
        KeyConditionExpression=Key('tenant').eq(tenant)
    )['Items']
 
    # サンプルとしてとりあえずそのまま返す
    return {
        "statusCode": 200,
        "body": json.dumps(res, ensure_ascii=False)
    }
 

あと必要なもの

詳しくはのちほど説明しますが、 Lambda が一時的に引き受けるロールを別途定義します。 ファイル名は iam-role.yml とします。

AWSTemplateFormatVersion: 2010-09-09
 
Description: IAM
 
Parameters:
  TrustRoleName:
    Type: String
    Default: multi-tenant-sample-dev-ap-northeast-1-lambdaRole # 元々の Lambda のロール
 
Resources:
  TenantRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: tenant-role
      ManagedPolicyArns:
        - !Ref IamPolicy
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub arn:aws:iam::${AWS::AccountId}:role/${TrustRoleName} # Lambda が引き受けられるようにする
            Action: sts:AssumeRole
  TenantPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: tenant-policy
      PolicyDocument: {
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Action": [
                "dynamodb:*" # 全部許可しておく
            ],
            "Resource": [
                !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/Users"
            ]
          }
        ]
      }
 

何はともあれデプロイ

.
├── iam-role.yml
├── main.py
└── serverless.yml

先ほどのファイル群を同じ階層に並べまして、以下の手順でデプロイします。

$ sls deploy
$ aws cloudformation deploy \
    --template-file ./iam-role.yml \
    --stack-name multi-tenant-demo-iam-role-stack \
    --capabilities CAPABILITY_NAMED_IAM

iam-role.yml は後から作るのがポイントです。

どういうこと?

何をするのか、できるのかをみていきます。

f:id:swx-kazuma-hoda:20211203193700p:plain
構成図

まず、ユーザーは Cognito でログイン(InitiateAuth)すると ID トークン が返却されます。 ユーザーは、その ID トークンを Authorization ヘッダにセットして API Gateway を叩きます。

curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/check \
    -H "Authorization:${ID_TOKEN}"

すると、 API Gateway の Cognito オーソライザーが、紐づけられたユーザープールに対して「この ID トークンを付与されたログイン済みのユーザーはいるか。あとトークンの有効期限は大丈夫か」を確認します。

OK だった場合、バックエンドの Lambda にリクエストが飛びます。

今回のように API Gateway の統合リクエストにて Lambda プロキシ統合を有効にしていると、このとき API を叩いた Cognito ユーザーが「どのグループに属しているか」が Lambda の引数 event に入ってきます。

そこからユーザーが属するテナントを特定し、そのテナントに紐づく DynamoDB のレコードしか取得できないようにしています。

もう少し具体的に

まず認証をクリアすると、 Lambda の引数 event にはこんな変数が入ってきます。

event の中身

{
    "resource": "/check",
    "path": "/check",
    "httpMethod": "GET",
    "headers": {
        "Accept": "*/*",
        "Authorization": "eyJraWQiOiJRd1RUTThyK1lvT1gyeVoxanRXbU8zcXZBRHBJanplYkRMYzZDMWhDaElFPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiIzMzFjM2M2Ny01MDZmLTQ1NmQtYWZlMy05MzlkM2VkYzViYjEiLCJjb2duaXRvOmdyb3VwcyI6WyJBIl0sImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1ub3J0aGVhc3QtMS5hbWF6b25hd3MuY29tXC9hcC1ub3J0aGVhc3QtMV94ZnVBWUlBM3AiLCJjb2duaXRvOnVzZXJuYW1lIjoiQS11c2VyMDEiLCJvcmlnaW5fanRpIjoiZmJhNzRhNzYtY2M3Zi00ODJmLWExYmUtYzQ4MzU4YTEyOTE0IiwiYXVkIjoiNWYzazZjNHBndjE5aWN1cGoxbWswN3FidmgiLCJldmVudF9pZCI6IjlkYTA0ZGRkLTk0MGItNDgwZi04ZWY1LTVkZmRhYTNmMzA5ZSIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNjM3MDU0NDU1LCJleHAiOjE2MzcwNTgwNTUsImlhdCI6MTYzNzA1NDQ1NSwianRpIjoiMWQ4MTMzZDYtNDIxNS00ZjM1LTgzZjktNmVmZTgyYjlhNTY2IiwiZW1haWwiOiJBLXVzZXIwMUBleGFtcGxlLmNvbSJ9.gsoG_RkX_OAkiN_G6y7VdiDlErUO1zf-u2sOQP6nQBfuJ0MjL0k6CPD88z-G5YULUVumWXcICLYgp6ewQj89hJ629QpDSRtkytOgLbKqXgZVC6FsrZ-3Ehuz67ZdOYdwITPjB7Y5ACgtuqmtTK5trDs-JAux_WaHZbQbFj94ANiSNGrY1mTLtPngByn9IKSCYoeS7tD5wan-sm96NekKvZhUFlFmogr3thktsjZsmQkSPPfAEItVVFWQ77AZo7G3t4v2Oz872khAMEfx4w5mgz2PB5QeypjjEllnR3RcTmg2li8gg73fTUvsErvqnYuPTEZAcp7bBG8dXuIDwm03Aw",
        "CloudFront-Forwarded-Proto": "https",
        "CloudFront-Is-Desktop-Viewer": "true",
        "CloudFront-Is-Mobile-Viewer": "false",
        "CloudFront-Is-SmartTV-Viewer": "false",
        "CloudFront-Is-Tablet-Viewer": "false",
        "CloudFront-Viewer-Country": "JP",
        "Host": "xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com",
        "User-Agent": "curl/7.76.1",
        "Via": "2.0 0c5e099653d04db66768f10d36xxxxxx.cloudfront.net (CloudFront)",
        "X-Amz-Cf-Id": "XUo9V2akQO2oatfkeIauLGKBjVKbgOJ2O6_z8AL23Rg49-WGBN2siQ==",
        "X-Amzn-Trace-Id": "Root=1-619384d4-0b59980937a1bc905909e6e7",
        "X-Forwarded-For": "xx.xxx.xxx.xx, xx.xxx.xx.xxx",
        "X-Forwarded-Port": "443",
        "X-Forwarded-Proto": "https"
    },
    "multiValueHeaders": {
        "Accept": [
            "*/*"
        ],
        "Authorization": [
            "eyJraWQiOiJRd1RUTThyK1lvT1gyeVoxanRXbU8zcXZBRHBJanplYkRMYzZDMWhDaElFPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiIzMzFjM2M2Ny01MDZmLTQ1NmQtYWZlMy05MzlkM2VkYzViYjEiLCJjb2duaXRvOmdyb3VwcyI6WyJBIl0sImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1ub3J0aGVhc3QtMS5hbWF6b25hd3MuY29tXC9hcC1ub3J0aGVhc3QtMV94ZnVBWUlBM3AiLCJjb2duaXRvOnVzZXJuYW1lIjoiQS11c2VyMDEiLCJvcmlnaW5fanRpIjoiZmJhNzRhNzYtY2M3Zi00ODJmLWExYmUtYzQ4MzU4YTEyOTE0IiwiYXVkIjoiNWYzazZjNHBndjE5aWN1cGoxbWswN3FidmgiLCJldmVudF9pZCI6IjlkYTA0ZGRkLTk0MGItNDgwZi04ZWY1LTVkZmRhYTNmMzA5ZSIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNjM3MDU0NDU1LCJleHAiOjE2MzcwNTgwNTUsImlhdCI6MTYzNzA1NDQ1NSwianRpIjoiMWQ4MTMzZDYtNDIxNS00ZjM1LTgzZjktNmVmZTgyYjlhNTY2IiwiZW1haWwiOiJBLXVzZXIwMUBleGFtcGxlLmNvbSJ9.gsoG_RkX_OAkiN_G6y7VdiDlErUO1zf-u2sOQP6nQBfuJ0MjL0k6CPD88z-G5YULUVumWXcICLYgp6ewQj89hJ629QpDSRtkytOgLbKqXgZVC6FsrZ-3Ehuz67ZdOYdwITPjB7Y5ACgtuqmtTK5trDs-JAux_WaHZbQbFj94ANiSNGrY1mTLtPngByn9IKSCYoeS7tD5wan-sm96NekKvZhUFlFmogr3thktsjZsmQkSPPfAEItVVFWQ77AZo7G3t4v2Oz872khAMEfx4w5mgz2PB5QeypjjEllnR3RcTmg2li8gg73fTUvsErvqnYuPTEZAcp7bBG8dXuIDwm03Aw"
        ],
        "CloudFront-Forwarded-Proto": [
            "https"
        ],
        "CloudFront-Is-Desktop-Viewer": [
            "true"
        ],
        "CloudFront-Is-Mobile-Viewer": [
            "false"
        ],
        "CloudFront-Is-SmartTV-Viewer": [
            "false"
        ],
        "CloudFront-Is-Tablet-Viewer": [
            "false"
        ],
        "CloudFront-Viewer-Country": [
            "JP"
        ],
        "Host": [
            "xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com"
        ],
        "User-Agent": [
            "curl/7.76.1"
        ],
        "Via": [
            "2.0 0c5e099653d04db66768f10d36xxxxxx.cloudfront.net (CloudFront)"
        ],
        "X-Amz-Cf-Id": [
            "XUo9V2akQO2oatfkeIauLGKBjVKbgOJ2O6_z8AL23Rg49-WGBN2siQ=="
        ],
        "X-Amzn-Trace-Id": [
            "Root=1-619384d4-0b59180937a1bc905909e6e7"
        ],
        "X-Forwarded-For": [
            "xxx.xxx.xxx.xx, xxx.xxx.xxx.xx"
        ],
        "X-Forwarded-Port": [
            "443"
        ],
        "X-Forwarded-Proto": [
            "https"
        ]
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "4ctca0",
        "authorizer": {
            "claims": {
                "sub": "331c3c67-506f-456d-afe3-xxxxxxxxxxxx",
                "cognito:groups": "A",
                "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX",
                "cognito:username": "A-user01",
                "origin_jti": "fba74a76-cc7f-482f-a1be-xxxxxxxxxxxx",
                "aud": "5f3k6c4pgv19icxxxxxxxxxxxx",
                "event_id": "9da04ddd-940b-480f-8ef5-xxxxxxxxxxxx",
                "token_use": "id",
                "auth_time": "1637054455",
                "exp": "Tue Nov 16 10:20:55 UTC 2021",
                "iat": "Tue Nov 16 09:20:55 UTC 2021",
                "jti": "1d8133d6-4215-4f35-83f9-xxxxxxxxxxxx",
                "email": "A-user01@example.com"
            }
        },
        "resourcePath": "/check",
        "httpMethod": "GET",
        "extendedRequestId": "I5GxTHyCtjMFqXg=",
        "requestTime": "16/Nov/2021:10:15:48 +0000",
        "path": "/dev/check",
        "accountId": "012345678901",
        "protocol": "HTTP/1.1",
        "stage": "dev",
        "domainPrefix": "xxxxxxxxxx",
        "requestTimeEpoch": 1637057748920,
        "requestId": "8bba5346-e2a9-451f-a483-xxxxxxxxxxxx",
        "identity": {
            "cognitoIdentityPoolId": null,
            "accountId": null,
            "cognitoIdentityId": null,
            "caller": null,
            "sourceIp": "xxx.xxx.xxx.xx",
            "principalOrgId": null,
            "accessKey": null,
            "cognitoAuthenticationType": null,
            "cognitoAuthenticationProvider": null,
            "userArn": null,
            "userAgent": "curl/7.76.1",
            "user": null
        },
        "domainName": "xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com",
        "apiId": "xxxxxxxxxx"
    },
    "body": null,
    "isBase64Encoded": false
}

その内、今回使うのはここの部分です。

    ...
    "requestContext": {
        "resourceId": "4ctca0",
        "authorizer": {
            "claims": {
                "sub": "331c3c67-506f-456d-afe3-xxxxxxxxxxxx",
                "cognito:groups": "A",
                "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX",
                "cognito:username": "A-user01",
                "origin_jti": "fba74a76-cc7f-482f-a1be-xxxxxxxxxxxx",
                "aud": "5f3k6c4pgv19icxxxxxxxxxxxx",
                "event_id": "9da04ddd-940b-480f-8ef5-xxxxxxxxxxxx",
                "token_use": "id",
                "auth_time": "1637054455",
                "exp": "Tue Nov 16 10:20:55 UTC 2021",
                "iat": "Tue Nov 16 09:20:55 UTC 2021",
                "jti": "1d8133d6-4215-4f35-83f9-xxxxxxxxxxxx",
                "email": "A-user01@example.com"
            }
        },
    ...

次のようにすればグループ(テナント)名が取得できます。

tenant = event['requestContext']['authorizer']['claims']['cognito:groups']

このテナント名を用いて、 Users テーブルの「パーティションキーが特定の tenant であるアイテム」しか操作できないポリシーを作ります。大事なのは Condition 句です。

policy_document = {
    'Version': '2012-10-17',
    'Statement': [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:UpdateItem",
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:DeleteItem",
                "dynamodb:Query"
            ],
            "Resource": [
                f"arn:aws:dynamodb:{region}:{account_id}:table/{table_name}"
            ],
            "Condition": {
                "ForAllValues:StringEquals": {
                    "dynamodb:LeadingKeys": [
                        tenant
                    ]
                }
            }
        }
    ]
}

このポリシーを使って DynamoDB にアクセスしたいのですが、ここで セッションポリシー という概念が登場します。

ドキュメントを引用しますと、こういうことです。

AWS CLI または AWS API を使用して、ロールまたはフェデレーティッドユーザーを引き受ける場合に高度なセッションポリシーを渡します。セッションポリシーでは、ロールまたはユーザーのアイデンティティベースのポリシーでセッションに付与するアクセス許可を制限します。セッションポリシーでは、作成したセッションのアクセス許可が制限されますが、アクセス許可は付与されません。

ここのニュアンスにはじめピンと来なかったのですが、

セッションポリシーでは、作成したセッションのアクセス許可が制限されますが、アクセス許可は付与されません。

要するに「セッションポリシーは許可というより制限をかける」ためのものと理解できます。

よくある Lambda の実行権限(アイデンティティベースのポリシーと分類されます)は、あるアクション( s3:ListBuckets とか)が許可されると、今までアクセスできなかった人ができるようになります。これが「アクセス許可の付与」です。

それに対し、セッションポリシーではあるロールに対して同じように s3:ListBuckets を許可しても、そのロールに元々 s3:ListBuckets が許可されていなければ追加で許可されません。これが「アクセス許可を制限」します。

つまり、この処理において

sts = boto3.client('sts')
credential = sts.assume_role(
    RoleArn=f'arn:aws:iam::{account_id}:role/{role_name}', # コレ
    RoleSessionName='my-session', # セッション名を一意にするためランダムな文字列が良い
    Policy=json.dumps(policy_document), # アクセス制限を設ける
)['Credentials']

セッションポリシーを設定する前のロールは以下のようなポリシーを持っているのですが、

PolicyDocument: {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:*"
            ],
            "Resource": [
                !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/Users"
            ]
        }
    ]
}

これに対して先ほどの Conditon 句が入ったセッションポリシーを付与することで、アクセスできる範囲を「パーティションキーが特定の tenant であるアイテム」に制限している訳です。

寄り道

Lambda 実行ロールを二つ使っているのはなぜ?

ここの処理において、 RoleArn として元々のこの Lambda の実行ロールではなく、後から作ったロールを使うようにしています。

sts = boto3.client('sts')
credential = sts.assume_role(
    RoleArn=f'arn:aws:iam::{account_id}:role/{role_name}', # コレ
    RoleSessionName='my-session',
    Policy=json.dumps(policy_document), # アクセス制限を設ける
)['Credentials']

少し工夫すれば元々のロールを使うことも可能ですが、 Serverless Framework などでデプロイする際に面倒なので分けています。

ポイントはロールの信頼関係です。

iam-role.yml 内で AssumeRolePolicyDocument を定義している箇所をみて下さい。

TenantRole:
  Type: AWS::IAM::Role
  Properties:
    RoleName: tenant-role
    ManagedPolicyArns:
      - !Ref IamPolicy
    AssumeRolePolicyDocument:
      Version: 2012-10-17
      Statement:
        - Effect: Allow
          Principal:
            AWS: !Sub arn:aws:iam::${AWS::AccountId}:role/${TrustRoleName}
          Action: sts:AssumeRole

arn:aws:iam::${AWS::AccountId}:role/${TrustRoleName} と書いてあるのが Lambda にあらかじめ付与されたロールです。

要するにこの tenant-role は、元々のロールが引き受けられるように設定しています。

したがって、元々のロールの 信頼されたエンティティ に自分自身のロール ARN を書けば利用する IAM ロールは一つで済みます。

ただし、ここで注意しなければならないことがあります。 それは「存在しないエンティティ(ここでは IAM ロール)は信頼されたエンティティに指定できない」ということです。

ロールの Principal 要素に、特定の IAM ロールを指し示す ARN が含まれている場合、その ARN はポリシーを保存するときにロールの一意のプリンシパル ID に変換されます。これにより、ロールを削除して再作成することにより、誰かがそのユーザーの特権をエスカレートするリスクを緩和できます。通常、この ID はコンソールには表示されません。これは、信頼ポリシーが表示されるときに、ロールの ARN への逆変換が行われるためです。ただし、ロールを削除すると、関係が壊れます。ロールを再作成した場合でも、ポリシーは適用されません。これは、新しいロールは信頼ポリシーに保存されている ID と一致しない新しいプリンシパル ID を持っているためです。この場合、プリンシパル ID はコンソールに表示されます。これは、AWS が有効な ARN に ID をマッピングできなくなるためです。その結果、信頼ポリシーの Principal エレメントで参照されているロールを削除して再作成する場合は、ロールを編集して正しくなくなったプリンシパル ID を正しい ARN に置き換える必要があります。ポリシーを保存するときに、ARN は再びロールの新しいプリンシパル ID に変換されます。

IAM ロールプリンシパル

長くてすみませんが、

  • ロールに対する権限付与はロールの ARN から一意に決まる ID を使っている
    • ロールを再作成して同じ ARN のロールを作っても ID は別
    • 仮に削除済みのロールへの許可を取り消してなかった場合に、後から再作成されても許可はされないので安心
  • もちろんワイルドカードを使ったり、作成される前の存在しないロールの ARN を書いちゃうと ARN から一意な ID に変換できない
  • だからちゃんと存在するロールの ARN を書かないとダメだよ

ということです。

ですので、前述の「元々のロールの 信頼されたエンティティ に自分自身のロール ARN を書く」をやろうとすれば、まずロールを作成してから改めて 信頼されたエンティティ に自分自身のロール ARN を書くことになります。 少なくとも CloudFormation ではやりたくないですよね。

リクエストしてみる

ID_TOKEN を取得します。 参考: AWS CLIで動かして学ぶCognito IDプールを利用したAWSの一時クレデンシャルキー発行

コマンド

ID_TOKEN=$(aws cognito-idp admin-initiate-auth \
    --user-pool-id ap-northeast-1_XXXXXXXXX \
    --client-id XXXXXXXXXXXXXXXXXX \
    --auth-flow ADMIN_NO_SRP_AUTH \
    --auth-parameters "USERNAME=A-user01,PASSWORD=newP@ssw0rd" \
    --query "AuthenticationResult.IdToken" \
    --output text)

その ID_TOKEN をもって API を叩きます。

curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/check \
    -H "Authorization:${ID_TOKEN}"

レスポンスはこれです。自分のテナントのユーザー情報だけ取得出来ていますね。

[{"tenant": "A", "user_id": "A-user01", "email": "A-user01@example.com"}, {"tenant": "A", "user_id": "A-user02", "email": "A-user02@example.com"}]

中身の処理がただこれなので面白みがないですがね。

# 自分の所属するテナントのアイテムしか取得できない
res = table.query(
    KeyConditionExpression=Key('tenant').eq(tenant)
)['Items']

# サンプルとしてとりあえずそのまま返す
return {
    "statusCode": 200,
    "body": json.dumps(res, ensure_ascii=False)
}

さいごに

結構難しいことをしてしまった気がするのですが、もっと簡単な方法ってないですかね?