こんにちは、広野です。
AWS AppSync は Amazon API Gateway と同様に API を公開してくれるサービスですが、必ず何かしらの認証がないと動かないサービスです。私は Amazon Cognito 認証を使用することが多いのですが、Amazon Cognito で認証されたユーザーであれば誰でも実行できるクエリもあれば、例えば管理者のみに実行させたいクエリもあります。
そんなときに Amazon Cognito ユーザーの属性としてグループがあり、それを拠り所にして権限を分ける方法が最も簡易です。AWS IAM ロールを使用した方法も実装可能だと思いますが、IAM ロールをグループごとに用意しないといけないので面倒です。
今回は、AWS AppSync Lambda リゾルバ (JavaScript) の標準的な書き方説明も兼ねて、権限分けの書き方も紹介したいと思います。ちなみに本件は AWS 公式ドキュメントでは以下のページが関連します。
サンプルアーキテクチャ
以下の構成例で紹介します。主に緑色の線の部分にフォーカスします。
- AWS AppSync は Amazon Cognito 認証です。基本的に、Amazon Cognito 認証を受けたユーザーは AWS AppSync にアクセスできる構成です。
- AWS AppSync に Lambda リゾルバを作成し、AWS Lambda 関数を関連付けます。
- AWS Lambda 関数は、Amazon Cognito ユーザーのリストを取得します。
AWS AppSync では、データベースへの読み書きは AWS Lambda 関数抜きで基本的なことはできるので、AWS Lambda 関数を使用する機会が少ないです。そして、AWS Lambda 関数が無い方がレスポンスが速くなります。なので、AWS Lambda 関数を使用する例として Amazon Cognito ユーザーのリストを取得することを採用しています。
この例のように Amazon Cognito のユーザーリストを取得して良い人は、一般的には管理者に限られているはずです。そのため、この Lambda リゾルバは管理者用の Amazon Cognito グループに所属している人でないと実行できないようにしたいです。
実装するもの
権限関連設定について少しブレークダウンして説明します。
- まず、アプリ側では、管理者以外に見せたくない画面は表示させないようにします。アプリ内で Amazon Cognito ユーザーの属性は取得できるので、Cognito グループ属性をもとに表示する/しないを判別させます。
- ただし、それはあくまでもアプリによる表面的な表示制御だけです。ユーザーに無用な API アクセスをさせないためだけの。AWS AppSync に何らかの形で直接命令が来てしまったときは許可してしまうので、AWS AppSync 側でも Cognito グループによる実行制御を入れます。Lambda リゾルバ内で Amazon Cognito 認証に使用されたトークンの内容を取得できるので、それをもとに判別します。AWS Lambda 関数を呼び出す前にリゾルバ内で処理します。
アプリ側の画面表示制御
本記事の本題ではないですが、一応 React アプリを例としてコードを載せます。
前提として、以下の記事で紹介しているような React アプリから AWS AppSync に Amazon Cognito 認証でアクセスする設定が済んでいることとします。
まず、React アプリ内で Amazon Cognito の属性情報は以下のコードで取得できます。書き方はいろいろあるので、一例です。
//必要モジュールインポート import { fetchAuthSession } from 'aws-amplify/auth'; //state初期化 const [email, setEmail] = useState(); //ユーザーのメールアドレス const [authToken, setAuthToken] = useState(); //ユーザーのトークン const [groups, setGroups] = useState(); //ユーザーのCognitoグループ //セッション情報取得 const getSession = async () => { const { tokens } = await fetchAuthSession(); setEmail(tokens?.idToken?.payload.email); //メールアドレスはこれで取得 setGroups(tokens?.idToken?.payload["cognito:groups"]); //Cognitoグループはこれで取得 setAuthToken(tokens?.idToken?.toString()); //IDトークンはこれで取得 }; useEffect(() => { getSession(); }, []);
こうして取得した groups の中にユーザーが所属する Cognito グループが配列で格納されているので、(複数所属している可能性もあるので) この情報を活用して該当の画面を表示/非表示を制御できます。
例えば、管理者用のグループ名が ADMIN だったとすると、シンプルに以下のように書きます。メニュー画面のうち、管理者用画面へのリンクを表示制御する例です。groups に ADMIN が含まれているかどうかを条件にしています。
{/* ホーム画面ボタン */} <Button to="/" onClick={handleCloseNavMenu} component={Link} sx={{mr:2,color:"white",display:"block"}}>ホーム</Button> {/* 設定メニューボタン */} <Button to="/config" onClick={handleCloseNavMenu} component={Link} sx={{mr:2,color:"white",display:"block"}}>設定</Button> {/* Adminメニューボタン */} {(groups.includes("ADMIN")) && ( <Button to="/admin" onClick={handleCloseNavMenu} component={Link} sx={{mr:2,color:"white",display:"block"}}>Admin</Button> )} {/* サインアウトボタン */} <Button onClick={signOut} sx={{mr:2,color:"white",display:"block"}}>サインアウト</Button>
もちろん、リンク先のパスを知られていたら意味がないので、リンク先の方でも画面全体に同様の表示制御条件をかけます。
管理者用画面では、Amazon Cognito ユーザーリストを取得するためのクエリを書きます。
ここで、想定するリクエストとレスポンスの書式を整理します。
以降、このデータ授受をする前提で AWS AppSync の設定を書きます。
AWS AppSync の設定
スキーマ
必要な箇所を抜粋します。
schema { query: Query mutation: Mutation subscription: Subscription } type CognitoUsersItems { id: Int email: String createddate: String status: String } type CognitoUsers { items: [CognitoUsersItems] } type Query { queryListCognitoUsers(dummy: String!): CognitoUsers }
リクエストで dummy というパラメータを渡すので、Query の定義に入れています。
レスポンスは items というキーで配列を格納する階層構造になっているので、それを表現しています。
データソース
AWS AppSync の Lambda リゾルバを使用する場合、データソースのタイプを Lambda にする必要があります。データソースの設定で、関連付ける AWS Lambda 関数や、その AWS Lambda 関数を呼び出せる権限 (IAM ロール) を設定します。この詳細は割愛します。
Lambda リゾルバ
リゾルバの設定で、データソースを上述のものを指定します。これにより、そのリゾルバがデータソースで設定された AWS Lambda 関数と連携できるようになります。記事の本題の1つでもある、このリゾルバの書き方を説明します。
JavaScript リゾルバでは、マッピングテンプレートのリクエスト、レスポンスは 1 つの JavaScript コードの中で表現します。VTL ではそれぞれが分かれていましたが。
今回のリクエスト、レスポンス仕様を実現すると、以下のコードになります。
//リゾルバ内で使用可能なユーティリティ関数をインポート import { util } from '@aws-appsync/utils'; //リクエストマッピングテンプレート export function request(ctx) { //ctx の中にアプリから渡された引数(args)やヘッダー情報が格納される const groups = ctx.identity.claims['cognito:groups'] //ここで、トークンに格納された Cognito グループを取得 if (groups.indexOf('ADMIN') === -1) { //Cognitoグループに ADMIN が含まれているかチェック util.unauthorized(); //ADMIN が含まれていなければ、認証エラーとして返す } else { return { operation: 'Invoke', //Lambda関数を呼び出すオペレーション定義 payload: { ctx.args //Lambda関数に渡すペイロード、ここではアプリからもらった値(dummy)をパススルーする } }; } } //レスポンスマッピングテンプレート export function response(ctx) { //ctx の中にLambda関数のレスポンスが格納される if (ctx.error) { util.error(ctx.error.message, ctx.error.type); //レスポンスにエラーが含まれていればエラーで終了させる } return ctx.result; //Lambda関数から return で返した値は ctx.result に格納される、ここではそのまま AppSync にパススルーする }
ctx.identity.claims[‘cognito:groups’] に格納されているグループ名の配列に ADMIN が含まれているかチェックするのに includes を使用したいところですが、JavaScript リゾルバではサポートされていないので、代わりに indexOf を使用しています。JavaScript の機能は何でもリゾルバ内で使えるかと言うと全然そうではなくて、かなり限定されているので注意が必要です。
このリゾルバ構成は、結構標準的に使えるのではないかと考えています。
AWS Lambda 関数
リゾルバから呼び出された AWS Lambda 関数は、AWS AppSync 経由でアプリから渡された引数 (ここでは dummy の値) を取得できます。コード内では表現していませんが、本記事のリクエスト仕様では、event[‘dummy’] で “dummy” を取得できます。
また、レスポンスとして想定しているフォーマットでレスポンスを返すよう、書いたものが以下のコードになります。Python です。
Amazon Cognito ユーザープール ID は環境変数にしています。IAM ロールは割愛します。
import json import boto3 import os LIMIT = 60 # Amazon Cognito の list_users は最大60件しか一度にデータを取得できない USER_POOL_ID = os.environ['CognitoUserPoolID'] client = boto3.client('cognito-idp') def lambda_handler(event, context): try: response = client.list_users( UserPoolId=USER_POOL_ID, AttributesToGet=['email'], Limit=LIMIT ) user_records = response['Users'] while True: # PaginationToken が無くなるまで(データを全て取得するまで)データ取得処理を繰り返す if 'PaginationToken' not in response: break # PagenationToken がある限り、データを取得する response = client.list_users( UserPoolId=USER_POOL_ID, AttributesToGet=['email'], Limit=LIMIT, PaginationToken=response['PaginationToken'] ) user_records.extend(response['Users']) # user_records に最終的に全データが格納される except Exception as e: print(e) reserr = { "items": [ { "id": 0, "email": "Error", "createddate": None, "status": "Error" } ] } return reserr # エラー発生時はエラー用のレスポンスを AppSync に返す else: output = [] # Amazon Cognito から返ってきた生データを、必要なデータだけ抽出する for i, row in enumerate(user_records): transformed = { "id": i, "email": next(attr["Value"] for attr in row["Attributes"] if attr["Name"] == "email"), "createddate": str(row["UserCreateDate"]), "status": row["UserStatus"] } output.append(transformed) return { "items": output # 抽出したデータ(配列)を items に格納して AppSync に返す }
最後、return で items を返しています。これが、AWS AppSync をパススルーしてアプリに返ってきます。
アプリ側の AppSync クエリ実行
アプリ側では、上述のレスポンスを取得するためのクエリを実行します。本記事のシナリオでは、管理者用画面で実行するものです。
AWS AppSync スキーマの仕様に合わせて記述します。前述のスキーマ設定と照らし合わせるとわかりやすいと思います。記述箇所が離れててすみません。リクエスト、レスポンスのデータ定義がスキーマの仕様と少しでも合わないとエラーになります。厄介です。
//AppSync呼出用モジュールインポート import { generateClient } from 'aws-amplify/api'; import gql from 'graphql-tag'; //AppSync Client const client = generateClient(); //Cognitoユーザーリスト取得関数 const listCognitoUsers = async () => { const queryListCognitoUsers = gql` query queryListCognitoUsers($dummy: String!) { queryListCognitoUsers(dummy: $dummy) { items { id email createddate status } } } `; const res = await client.graphql({ query: queryListCognitoUsers, variables: { dummy: 'dummy' //dummyをAppSyncに渡す(本記事の説明用途で) } }); return res.data.queryListCognitoUsers.items; //AppSyncからのレスポンスは、data.Query名に格納される };
この listCognitoUsers 関数で、アプリから Amazon Cognito ユーザーのリストを配列で取得できます。フォーマットは必要情報を抽出したものになっていますが。
まとめ
いかがでしたでしょうか。
アプリ、AWS AppSync、AWS Lambda 関数の間でデータを授受する仕様が理解できるのではないかと思います。同時に、Amazon Cognito グループによるリゾルバの実行制御を簡易に仕掛けることができています。
本記事が皆様のお役に立てれば幸いです。