【カスタムリゾルバー活用集 第2回】別テーブルの状態に依存する認可を、パイプラインリゾルバーで実装する

technologies

  • HOME
  • BLOG
  • technologies
  • 【カスタムリゾルバー活用集 第2回】別テーブルの状態に依存する認可を、パイプラインリゾルバーで実装する

本シリーズについて

Amplify Gen 2 で開発していると、a.model() で足りない処理はすべて Lambda に回したくなります。しかし実際には、カスタムリゾルバーで書けるものが少なくありません。本シリーズでは、実務で遭遇した要件を題材に、リゾルバーで完結できるケースを紹介します。

はじめに

こんにちは!マイリンです。

Amplify の認可ルールは強力です。所有者だけに編集を許可する、特定のグループにだけ読み取りを許可する、といった要件はほとんど宣言だけで済みます。

// これだけで、所有者以外は編集できなくなる
Todo: a.model({ content: a.string() })
  .authorization((allow) => [allow.owner()]),

しかし、ある時点で書けない要件に出会います。

「チャットルームのメンバーだけが、そのルームにメッセージを投稿できる」

一見すると単純です。しかし、.authorization() では表現できません。

本記事では、なぜ書けないのかを整理したうえで、パイプラインリゾルバーによる実装方法を説明します。

なお、第1回では「パイプラインリゾルバーは原子性を保証しない」と書きました。複数テーブルへの書き込みをまとめるには向いていない、という話です。

一方で今回のような 「読んで、判定して、書く」 処理は、パイプラインが最も素直にはまる形です。ロールバックが問題になるのは書き込みが複数あるときで、先頭が読み取りであれば失敗しても何も残りません。同じ仕組みでも、扱う操作によって向き不向きが変わります。

対象読者

  • Amplify Gen 2 で .authorization() の限界に突き当たった方
  • 「別のテーブルを見てから書き込む」処理を Lambda で書いている方
  • 第1回を読んで、カスタムリゾルバーをもう少し使ってみたい方

なぜ .authorization() では足りないのか

認可ルールは、リクエストされたレコードのフィールドと、リクエスト元の identity を突き合わせて判定します。

ルール比較するもの
allow.owner()owner フィールド と <sub>::<username>
allow.groups()トークンのグループ名
allow.authenticated()ログイン済みかどうか

つまり、判定に使えるのは 1 レコード分の情報と、トークンの中身だけです。

今回の要件では、Message を作成してよいかどうかが RoomMember テーブルの内容によって決まります。判定材料が別のレコードにあるため、宣言的なルールの範囲を超えています。

「メンバー一覧を Message に持たせればよいのでは」と考えることもできますが、メンバーが増減するたびに全メッセージを更新することになり、現実的ではありません。

ここで多くの人が a.handler.function() で Lambda を選びます。私もそうしていました。しかし、やっていることは「1 回読んで、条件を確認して、1 回書く」だけです。外部ライブラリも、非同期処理も必要ありません。 この程度であれば、リゾルバーで完結できます。

なぜ Lambda オーソライザーではないのか

ここで「認可なら Lambda オーソライザーを使えばよいのでは」と思われた方もいると思います。実際、私も最初はそう考えました。

Lambda オーソライザーは、リゾルバーが選ばれる前に実行されます。 イベントには queryString(クエリ本文)と variables(GraphQL 変数)が含まれるため、roomId 自体は取得できます。それでも、この判定をここに置くべきではありません。

理由はキャッシュです。公式ドキュメントには、戻り値が API ID と認証トークンをキーにキャッシュされうると記載されています。キーに引数は含まれません。

つまり、ルームごとに変わる判定をここで行うと、こうなります。

1. ユーザー X が ルーム A に投稿 → メンバーなので許可、結果がキャッシュされる
2. 同じユーザー X が ルーム B に投稿 → キャッシュヒット、Lambda は実行されない
   → メンバーでないルーム B への投稿が通る

デフォルトではキャッシュは無効ですが、API 設定や ttlOverride で有効化した瞬間に壊れます。そして、キャッシュを有効にした人は、自分が認可の穴を開けたことに気づけません。

Lambda オーソライザーパイプラインリゾルバー
実行タイミングリゾルバーの前(リクエスト単位)リゾルバーの中(フィールド単位)
参照できる情報トークン、API 情報、クエリ文字列、変数トークン + 検証済みの引数 + DB のデータ
判定結果のキャッシュAPI ID と認証トークンでキャッシュされうるされない
答えられる問いこの人は API を使ってよいかこの人はこのデータにこの操作をしてよいか

Lambda オーソライザーが向いているのは、外部の認証基盤を使う場合です。サードパーティの JWT を検証する、社内の API キーを使う、といった要件が該当します。判定結果が認証トークン単位でキャッシュされる設計であること自体が、「誰であるか」を判定する仕組みであることを示しています。

一方、今回必要なのは 「このデータに対して、この操作をしてよいか」 の判定です。データを見なければ答えられないため、リゾルバーの層で行う必要があります。

resolverContext という抜け道

なお、Lambda オーソライザーは resolverContext に値を返すことができ、リゾルバー側からは ctx.identity.resolverContext で参照できます。

これを使えば、所属ルームの一覧をあらかじめ渡しておくことも理屈の上では可能です。ただし、ルーム数が増えると破綻しますし、1 つのルームにしかアクセスしないリクエストでも毎回すべてを読み込むことになります。

素直にリゾルバーで引くほうが、単純で確実です。

パイプラインリゾルバーの構成

パイプラインリゾルバーは、複数の AppSync 関数を順番に実行します。関数ごとに異なるデータソースを指定できるのが重要な点です。

今回は 2 つの関数に分けます。

関数1: checkMembership  → RoomMember を読む → メンバーでなければ停止
関数2: createMessage    → Message に書く

1 つ目で条件を満たさなければ、そこで処理を止めます。2 つ目は実行されません。

実装 1:スキーマの定義

Gen 2 では、.handler()a.handler.custom の配列を渡すことでパイプラインを構成します。配列の順序がそのまま実行順序になります。

// amplify/data/resource.ts
const schema = a.schema({
  Message: a
    .model({
      messageId: a.id().required(),
      roomId: a.id().required(),
      senderId: a.id().required(),
      body: a.string().required(),
    })
    .identifier(['messageId'])
    .authorization((allow) => [allow.authenticated()])
    .disableOperations(['mutations']),

  RoomMember: a
    .model({
      roomId: a.id().required(),
      userId: a.id().required(),
    })
    .identifier(['roomId', 'userId'])
    .authorization((allow) => [allow.group('Admin')]),

  postMessage: a
    .mutation()
    .arguments({
      roomId: a.id().required(),
      body: a.string().required(),
    })
    .returns(a.ref('Message'))
    .authorization((allow) => [allow.authenticated()])
    .handler([
      a.handler.custom({
        dataSource: a.ref('RoomMember'),
        entry: './postMessage/checkMembership.js',
      }),
      a.handler.custom({
        dataSource: a.ref('Message'),
        entry: './postMessage/createMessage.js',
      }),
    ]),
});

ここで、認可が 2 段構えになっている点に注目してください。

  • .authorization(allow => [allow.authenticated()])ログインしていることを保証する
  • 関数 1 — メンバーであることを保証する

宣言で書ける部分は宣言に任せ、書けない部分だけをリゾルバーで補う。これが基本的な考え方です。

RoomMemberallow.group('Admin') にしているのは、メンバーの追加・削除が管理者の操作だからです。一般ユーザーがこのテーブルを直接書き換えられると、自分を任意のルームに追加できてしまいます。

なお、このルールは関数 1 の読み取りには影響しません。 リゾルバーはデータソース経由でテーブルを読むため、クライアントに与えた認可ルールとは別の経路です。一般ユーザーが RoomMember を GraphQL から読めなくても、メンバーシップの確認は問題なく動きます。

実装 2:認証方式の確認と sub の取得

ここで、先に一つ確認しておくべきことがあります。ctx.identity.sub は、いつでも存在するとは限りません。

.authorization(allow => [allow.authenticated()]) と書くと、「ログイン済みのユーザー」に限定されるように見えます。しかし、これが保証するのは「何らかの認証を通過していること」までです。

API に複数の認証モードを設定している場合、ctx.identity の中身は認証方式によって変わります。

認証方式sub の有無
Cognito User Poolあり
IAM(Identity Pool)期待する形では入っていない
OIDCプロバイダーの実装による
Lambda オーソライザーresolverContext の内容次第

subundefined のまま処理が進むと、どうなるでしょうか。

userId が空のままキーが組み立てられ、GetItem は何も返しません。結果として util.unauthorized() が呼ばれるため、今回のケースでは実害はありません。しかし、これはたまたま安全側に倒れているだけです。書き込み系の処理であれば、空の userId でレコードが作られてしまいます。

そのため、sub を使う前に明示的に確認します。

// amplify/data/postMessage/checkMembership.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  // 1. 認証方式を確認する
  if (util.authType() !== 'User Pool Authorization') {
    util.unauthorized();
  }

  // 2. sub が取得できることを確認する
  const sub = ctx.identity?.sub ?? '';
  if (!sub) {
    util.unauthorized();
  }

  // 後続の関数でも使うため、検証済みの sub をスタッシュに置く
  ctx.stash.sub = sub;

  return {
    operation: 'GetItem',
    key: util.dynamodb.toMapValues({ roomId: ctx.args.roomId, userId: sub }),
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }

  if (!ctx.result) {
    util.unauthorized();
  }

  return ctx.result;
}

util.authType() で認証方式を絞る

util.authType() は、そのリクエストがどの認証方式で通過したかを文字列で返します。User Pool 以外を想定していないのであれば、明示的に弾いておくのが安全です。

これは「今は User Pool しか設定していないから不要」と思える箇所です。しかし、後から IAM 認証を追加したり、ゲストアクセスを有効にしたりしたときに、この一行があるかないかで挙動が変わります。認証モードの追加は、既存のリゾルバーを壊しうる変更です。

返り値の文字列は認証方式ごとに決まっています。User Pool 以外も許可する場合は、実際の値をログで確認してから条件を書くのが確実です。

本記事のリゾルバーは .js で書いています。.ts の場合、ctx.identity は認証方式ごとのユニオン型のため sub に直接アクセスするとコンパイルエラーになります。その場合は (ctx.identity as { sub?: string } | undefined)?.sub ?? '' のように型を絞ってから取り出してください。

実装 3:値の受け渡し

何をスタッシュに置くべきか

ctx.stash は、パイプライン全体で共有される領域です。ここに入れた値は、後続のどの関数からも参照できます。

ただし、何でも置けばよいわけではありません。 ミューテーションの引数は ctx.args からどの関数でも直接参照できるため、スタッシュに詰め替える必要はありません。

// 不要:引数はそのまま ctx.args で読める
ctx.stash.roomId = ctx.args.roomId;

// 必要:関数1で検証して取り出した値
ctx.stash.sub = sub;

sub をスタッシュに置いているのは、util.authType() の確認を通ったうえで取り出した値だからです。これを置かないと、関数2 で同じ検証をもう一度書くことになります。スタッシュに置くのは「その関数が計算した結果」だけ、と考えると迷いません。

値の参照元は 3 つに整理できます。

何が欲しいかどこから取るか
ミューテーションの引数ctx.args(全関数から参照可)
前の関数が計算した値ctx.stash
直前の関数の実行結果ctx.prev.result

3 つ目の ctx.prev.result直前の関数の結果だけを指します。ここは混同しやすい部分です。

関数1 → 関数2 → 関数3
        ↑ 関数3 から見た ctx.prev.result は「関数2の結果」
          関数1の結果は ctx.prev では見えない

util.unauthorized() で止める

メンバーでなかった場合、util.unauthorized() を呼びます。これにより後続の関数は実行されず、クライアントには認可エラーが返ります。

util.error() でも処理は止まりますが、認可の失敗であることを明示するなら util.unauthorized() のほうが適切です。

認可の失敗は「捕まえて分岐」できない

ここで、第1回で触れた制約が効いてきます。APPSYNC_JS では try / catch が使えません。

つまり、「認可に失敗したら別の処理に切り替える」という書き方はできません。util.unauthorized() を呼んだ時点でパイプラインは終了し、後続の関数も、それを囲む処理も実行されません。

リゾルバーでの分岐は、例外を捕まえるのではなく、先にすべて確認してから進む形になります。

// できない:失敗を捕まえて別処理に切り替える
// try { ... } catch { fallback(); }

// できる:条件を確認して、満たさなければ止める
if (util.authType() !== 'User Pool Authorization') {
  util.unauthorized();
}
if (!ctx.result) {
  util.unauthorized();
}

一見すると不便ですが、認可処理においてはむしろ好都合です。「失敗したときのフォールバック」が書けないということは、権限がないまま処理が続く経路が存在しないということでもあります。

逆に、「認可に失敗したらゲスト用のデータを返す」といった要件が出てきたら、それはリゾルバーで書ける範囲を超えたサインです。

設計上の補足: 「ルームが存在しない」と「メンバーではない」を区別して返すと、ルームの存在自体が推測できてしまいます。今回はどちらも unauthorized に寄せています。エラーメッセージの粒度は、利便性とのトレードオフです。

実装 4:メッセージの作成

// amplify/data/postMessage/createMessage.js
import { util } from '@aws-appsync/utils';

export function request(ctx) {
  const sub = ctx.stash.sub;
  const now = util.time.nowISO8601();

  return {
    operation: 'PutItem',
    key: util.dynamodb.toMapValues({ messageId: util.autoId() }),
    attributeValues: util.dynamodb.toMapValues({
      roomId: ctx.args.roomId,
      senderId: sub,
      body: ctx.args.body,
      __typename: 'Message',
      createdAt: now,
      updatedAt: now,
    }),
  };
}

export function response(ctx) {
  if (ctx.error) {
    util.error(ctx.error.message, ctx.error.type);
  }
  return ctx.result;
}

senderId は引数ではなく ctx.stash.sub から取っています。関数 1 で検証済みの値であるため、ここで再度チェックする必要はありません。検証は入口で一度だけ行い、その後は stash の値を信頼するという形にしておくと、関数が増えても見通しが保てます。

__typename / createdAt / updatedAt は、Amplify が生成するモデルが前提としている属性です。必要な属性はモデル定義によって異なるため、自動生成のミューテーションで 1 件作成し、実際のテーブルの中身を確認してから記述するのが確実です。

権限について:今回は何もしなくてよい

第1回では grantWriteData を書きました。今回は書きません。違いはどこにあるのでしょうか。

a.handler.custom には dataSource を指定します。Amplify は、その指定に応じた権限を自動で付与します。今回は RoomMember を読む関数と Message に書く関数が、それぞれ自分のデータソースを宣言しているため、追加の設定は不要です。

つまり、「1 つの関数が、宣言したデータソースだけを触る」限り、権限を意識する必要はありません。

第1回で grantWriteData が必要だったのは、そこから外れていたからです。TransactWriteItems は 1 つの関数から複数のテーブルに書き込みます。データソースとして宣言していたテーブルは自動で権限が付きますが、それ以外のテーブルの分だけを自分で足す必要がありました。

これは例外であって、原則ではありません。「宣言したデータソースの外に出るときだけ、権限を書く」 と覚えておくとよいと思います。

ハマりどころ

① 関数を追加すると ctx.prev の中身が変わる

パイプラインの途中に関数を挿入すると、その後ろの関数から見た ctx.prev.result は別のものになります。stash を使っていれば影響を受けませんが、prev に依存していると静かに壊れます。

テストが通っているのに本番で挙動が変わる、という形で表面化するため、原因の特定に時間がかかります。複数人で触るコードでは、stash を基本にしておくほうが安全です。

② スタッシュのキー名がぶつかる

第1回で使った ctx.stash.awsAppsyncApiId のように、Amplify はいくつかの値をスタッシュに入れています。自分で使うキーには、プロジェクト独自の接頭辞を付けるなどの工夫をしておくと安全です。

③ 関数の数だけリクエストが増える、そして 10 個で頭打ちになる

パイプラインの関数は、それぞれがデータソースへのリクエストを発生させます。認可のために毎回 1 回読むということは、書き込み系のミューテーションが常に 2 回のアクセスになるということです。

さらに、増やせる数には上限があります。AWS の公式クォータでは、1 つのパイプラインリゾルバーに含められる関数は 10 個までと定められており、この値は調整不可です。

項目上限調整
パイプラインあたりの関数数10不可
リクエストの実行時間30 秒不可
ハンドラー・リゾルバー・関数のコードサイズ32 KB不可

「認可のために 3 テーブル読む」といった構成は、性能面で気になるだけでなく、そもそも伸ばせる余地が少ないということでもあります。判定に必要なテーブルが増えていくなら、リゾルバーで積み上げるのではなく Lambda を検討するタイミングです。

④ エラー時にロールバックされない

第1回の内容と重なりますが、重要なので繰り返します。パイプラインの 2 つ目で失敗しても、1 つ目の書き込みは取り消されません。

今回の構成は「読んでから書く」なので問題ありません。読み取りは何も変更しないためです。「書いてから書く」場合は、パイプラインではなく TransactWriteItems を検討してください。

自動生成されたミューテーションを塞ぐ

ここまでで認可の実装は終わりですが、もう一つやることがあります。

a.model() を書くと、createMessage / updateMessage / deleteMessage が自動的に生成されます。postMessage にどれだけ判定を書いても、この入口が開いていれば意味がありません。 非メンバーは createMessage を直接呼べば、メンバーシップの確認を通らずにメッセージを作成できます。

Amplify Gen 2 では、.disableOperations() で自動生成そのものを止められます。

Message: a
  .model({ ... })
  .identifier(['messageId'])
  .authorization((allow) => [allow.authenticated()])
  .disableOperations(['mutations']),

これでスキーマから createMessage などが消えます。権限で弾くのではなく、入口自体をなくすほうが確実です。読み取りは .authorization() で許可したまま、書き込みの入口だけを閉じています。

気になるのは「リゾルバー側も動かなくなるのでは」という点ですが、影響ありません。カスタムリゾルバーは DynamoDB に直接書き込むため、createMessage という GraphQL オペレーションを経由していないからです。塞ぐのは外から呼べる入口だけで、内部の書き込み経路はそのまま残ります。

冒頭で「宣言で書ける部分は宣言に任せ、書けない部分だけをリゾルバーで補う」と書きました。この「宣言に任せる部分」には、書き込みの入口を一本化することも含まれます。

副作用:リアルタイム購読が発火しなくなる

一点だけ注意が必要です。disableOperations(['mutations']) が止めるのは create / update / delete で、サブスクリプションは別カテゴリなので残ります。しかし onCreateMessage を発火させるのは createMessage ミューテーションそのものです。書き込みを postMessage に一本化すると、発火する経路がなくなります。

チャットのような題材では、ここでリアルタイム更新が動かなくなります。「内部の書き込み経路はそのまま残る」ことの、ちょうど裏返しです。

Amplify Gen 2 には、任意のミューテーションを購読対象にできるカスタムサブスクリプションがあります。

receiveMessage: a
  .subscription()
  .for(a.ref('postMessage'))
  .handler(a.handler.custom({ entry: './receiveMessage.js' }))
  .authorization((allow) => [allow.authenticated()]),

詳細はカスタムサブスクリプションのドキュメントを参照してください。

動作確認

次の 3 パターンで確認しました。

  1. メンバーとして投稿 → 成功し、Message に記録される
  2. 非メンバーとして投稿 → Unauthorized が返る
  3. 2 のあと、Message テーブルに 1 件も書き込まれていないことを確認する

3 が最も重要です。関数1で util.unauthorized() を呼んだ時点でパイプラインが止まり、関数2 が実行されていないことを、テーブルの中身で裏付けます。エラーが返っているのに書き込まれている、という状態がないことが確認したい点です。

なお、senderId.arguments() に含めていないため、クライアントから指定する余地がありません。引数を無視するのではなく、そもそも受け取らないという形にしています。

Lambda を選ぶべきライン

今回のような「読んで、判定して、書く」処理は、リゾルバーで完結します。Lambda を 1 つ増やす必要はありませんでした。

ただし、以下の場合は Lambda を検討します。

状況理由
判定に複数テーブルを横断する関数が増えすぎる(上限 10 個)
判定ロジックが複雑単体テストを厚く書きたい
外部の認可サービスに問い合わせるネットワークアクセスが必要
失敗を捕捉して代替処理に切り替えたいAPPSYNC_JS では try / catch が使えない

まとめ

  • .authorization() が見ているのは 1 レコード分の情報とトークンだけです
  • allow.authenticated() は User Pool を保証しないため、util.authType() で確認します
  • 別テーブルを参照する認可は、パイプラインリゾルバーで実装できます
  • Lambda オーソライザーの判定結果は認証トークン単位でキャッシュされうるため、データに依存する認可には使えません
  • Gen 2 では .handler()a.handler.custom の配列を渡します
  • 引数は ctx.args から全関数で読めます。スタッシュに置くのは関数が計算した値だけです
  • 認可の失敗は util.unauthorized() で止めます
  • try / catch が使えないため、認可は「捕まえて分岐」ではなく「確認して止める」形になります
  • クライアントから来た値ではなく、ctx.identity を信頼します
  • 宣言したデータソースの範囲内であれば、権限の設定は不要です
  • 自動生成されるミューテーションは .disableOperations(['mutations']) で塞ぎます

この処理にも Lambda は必要ありませんでした。

ここまで 2 回にわたって、カスタムリゾルバーで完結できるケースを見てきました。第1回は複数テーブルの原子的な更新、今回は別テーブルの状態に依存する認可です。どちらも、最初は Lambda を書こうとした処理でした。

a.model() で足りないと感じたとき、すぐに a.handler.function() に手を伸ばす前に、リゾルバーで書けないか一度考えてみる。それだけで、増やさずに済む Lambda がいくつかあるかもしれません。

誰かの参考になれば幸いです。

参考

Related posts