Amazon Cognito 認証の AWS AppSync で特定のユーザーグループのみに実行許可する Lambda リゾルバ

こんにちは、広野です。

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 グループによるリゾルバの実行制御を簡易に仕掛けることができています。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWSは好きですが、それよりもフロントエンド開発の方が好きでして、バックエンド構築を簡単にしてくれたAWSには感謝の気持ちの方が強いです。
取得資格:AWS 認定は13資格、ITサービスマネージャ、ITIL v3 Expert 等
2020 - 2024 Japan AWS Top Engineer 受賞
2022 - 2024 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSアプリケーション開発クラウドソリューション
シェアする
タイトルとURLをコピーしました