Amplify Gen1の構築方法(その3)

前回、Amplify Gen1 で認証モードを「IAM」「public」にし、テーブルがReadOnly属性となっていることを確認した。今回は、IAM認証でログインユーザーに対して書き込み権限の付与や、Cognito UserPools 認証で、オーナー権限、グループ権限の付与を行う。

その際、前回まではSSRのサンプルソースで検証してきたが、今回はCSRでの検証とする。理由は、SSRの実装の難易度がそれなりに高いため(認証状態を取得した上でクエリを実行する必要があるなど)、今回は簡単に検証できるCSRでの実装・検証を行う。

戦略:CMS管理者(ログインユーザーは書き込み可)

よくあるケースであるCMSの管理画面をイメージする。管理画面にログインできるユーザーは、記事の作成・編集ができるが、管理画面にログインできない一般のエンドユーザーは記事の変更はできず、ReadOnlyとなる。つまり、ログイン済み=DBの書き込み権限あり、となる制御を行う。

以下の赤枠部分の認証設定を行い、プログラム(CSR)で検証を行う。

schema.graphqlの編集

まずは、schma.graphql の編集から。private = ログインユーザー に対する権限の付与、という意味になる。operations を指定しなければ全権限(read, create, update, delete)が暗黙的に割り当たる。

publicは未ログインユーザーに対する権限となる。これらの権限設定は、Amazon Cognito ID プールの「認証されたロール」「ゲストロール」に自動的に反映される。

/next-blog4/amplify/backend/api/nextblog4/schema.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
#input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo 
  @model 
  @auth(rules: [
    { allow: private, provider: iam }, # Login = all operations ok
    { allow: public, provider: iam, operations: [read] }, # Non-logged in user cannot write.
  ]) {
  id: ID!
  name: String!
  description: String
}

ちなみに、operations を明示的に指定する場合、以下のように指定する。

    { allow: private, provider: iam, operations: [read, create, update, delete] }, # Login = all operations ok

amplify push を実行して、3分ほどじっくり待つ。

ログイン画面の作成

新たにログインページを作る。src/app/login(※フォルダを新規作成)/page.tsx ファイルを作成し、前回記事のログインページのソース(以下)を以下を貼り付ける。

src/app/login/page.tsx

'use client'

import { Amplify } from 'aws-amplify'
import type { WithAuthenticatorProps } from '@aws-amplify/ui-react'
import { withAuthenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css'
import config from '@/amplifyconfiguration.json'
Amplify.configure(config)

export function App({ signOut, user }: WithAuthenticatorProps) {
  return (
    <>
      <h1>Hello {user?.username}</h1>
      <button onClick={signOut}>Sign out</button>
    </>
  );
}

export default withAuthenticator(App)

npm run dev を実行、http://localhost:3000/login にアクセスし、Create Account(ユーザー登録)し、Sign in(ログイン)できるようにしておく。

ログイン・ユーザー登録画面

Todoテーブル読み込み・書き込み

CSRでTodoの追加、表示を行う処理を追記する(太字)。かなり適当な作りであるが、検証用のためご容赦いただきたい。Todoをcreate・queryする処理は以下のリファレンスを参考にする。

■Connect your app code to the API – Next.js – AWS Amplify Documentation
https://docs.amplify.aws/nextjs/build-a-backend/graphqlapi/connect-to-api/#use-generated-graphql-queries-mutations-and-subscriptions

src/app/login/page.tsx

'use client'

import { Amplify } from 'aws-amplify'
import type { WithAuthenticatorProps } from '@aws-amplify/ui-react'
import { withAuthenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css'

import { useState, useEffect } from 'react'
import { generateClient } from 'aws-amplify/api'
import { createTodo } from '@/graphql/mutations'
import { listTodos } from '@/graphql/queries'

import config from '@/amplifyconfiguration.json'
Amplify.configure(config)

export function App({ signOut, user }: WithAuthenticatorProps) {
    const [todoName, setTodoName] = useState("")
    const [todos, setTodos] = useState<any[]>([])
    useEffect(()=> {
        const fetchData = async () => {
            const client = generateClient()
            const ret = await client.graphql({ query: listTodos })
            setTodos(ret.data.listTodos.items)
        }
        fetchData()
    }, [])

    // Todo入力時のChangeイベント
    const todoNameChange = (e: any) => {
        setTodoName(e.target.value)
    }

    // Todo追加ボタンクリック時
    async function addTodo() {
        const client = generateClient()
        /* create a todo */
        const newData = await client.graphql({
            query: createTodo,
            variables: {
                input: {
                    "name": todoName,
                    "description": ''
                }
            }
        })
        console.log('Created Todo: ', newData)
    }

    return (
        <>
            <div
                style={{
                    maxWidth: '500px',
                    margin: '0 auto',
                    textAlign: 'center',
                    marginTop: '100px'
                }}
            >
                <div style={{ marginBottom: "30px" }}>
                    <h1>Hello {user?.username}</h1>
                    <button onClick={signOut}>Sign out</button>
                </div>
                <div>
                    <input name="todoName" placeholder="Add a todo" onChange={(e) => todoNameChange(e)} />
                    <button onClick={addTodo}>Add</button>
                </div>
                {(!todos || todos.length === 0) && (
                    <div>
                        <p>No todos, please add one.</p>
                    </div>
                )}
               <ul>
                    {todos?.map((todo) => {
                        return <li key={todo.id} style={{ listStyle: 'none' }}>{todo.name}</li>
                    })}
                </ul>
            </div>
        </>
    );
}

export default withAuthenticator(App)

http://localhost:3000/login でログイン後、以下の画面が表示されればひとまず成功である。一瞬、「No todos, please add one.」が表示され不恰好ではあるが、そのあたりは目を瞑ることにする。

ログイン後の画面

ログイン済みのため、以下のようにTodoを追加することができた。追加直後に自動表示されないためリロードが必要だが、こちらも目を瞑ることにする。

追加できた

ログインしていない場合の画面の作成

上記画面ではログイン必須となるため、ログインしていない状態ではTodoを追加できない(エラーになる)ことを確認できるページも作る。まずは忘れずに「Sign Out」ボタンをクリックしてサインアウト(ログアウト)しておく。

そして、先ほどのソースをコピーし、src/app/notlogin(※フォルダを新規作成)/page.tsx ファイルを作成し、認証系の処理をカット・編集した以下のソースを貼り付ける。

src/app/notlogin/page.tsx

'use client'

import { Amplify } from 'aws-amplify'
//import type { WithAuthenticatorProps } from '@aws-amplify/ui-react'
//import { withAuthenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css'

import { useState, useEffect } from 'react'
import { generateClient } from 'aws-amplify/api'
import { createTodo } from '@/graphql/mutations'
import { listTodos } from '@/graphql/queries'

import config from '@/amplifyconfiguration.json'
Amplify.configure(config)

export function App() {
    const [todoName, setTodoName] = useState("")
    const [todos, setTodos] = useState<any[]>([])
    useEffect(()=> {
        const fetchData = async () => {
            const client = generateClient()
            const ret = await client.graphql({ query: listTodos })
            setTodos(ret.data.listTodos.items)
        }
        fetchData()
    }, [])

    // Todo入力時のChangeイベント
    const todoNameChange = (e: any) => {
        setTodoName(e.target.value)
    }

    // Todo追加ボタンクリック時
    async function addTodo() {
        const client = generateClient()
        /* create a todo */
        const newData = await client.graphql({
            query: createTodo,
            variables: {
                input: {
                    "name": todoName,
                    "description": ''
                }
            }
        })
        console.log('Created Todo: ', newData)
    }

    return (
        <>
            <div
                style={{
                    maxWidth: '500px',
                    margin: '0 auto',
                    textAlign: 'center',
                    marginTop: '100px'
                }}
            >
                {/* <div style={{ marginBottom: "30px" }}>
                    <h1>Hello {user?.username}</h1>
                    <button onClick={signOut}>Sign out</button>
                </div> */}
                <div>
                    <input name="todoName" placeholder="Add a todo" onChange={(e) => todoNameChange(e)} />
                    <button onClick={addTodo}>Add</button>
                </div>
                {(!todos || todos.length === 0) && (
                    <div>
                        <p>No todos, please add one.</p>
                    </div>
                )}
               <ul>
                    {todos?.map((todo) => {
                        return <li key={todo.id} style={{ listStyle: 'none' }}>{todo.name}</li>
                    })}
                </ul>
            </div>
        </>
    );
}

export default App

http://localhost:3000/notlogin にアクセスし、Todo追加時にコンソールにて Unauthorized エラーが発生していることを確認する。今回Read権限しか付与していないので正しい動きとなる。本来であればtry-catchでエラーハンドリングすべきだが検証用なので目を瞑る。

ログインしていないと追加エラーになる。

戦略:CMSの記事投稿者 or 自マイページ(ログインユーザーは自分のデータのみ書き込み可)

今まで「IAM」認証を扱ってきたが、今回は「userPools」認証、Cognito User Poolsを使った認証を行う。「IAM」ではログイン済みか、そうでないかでシンプルに分ける形だったが、今回はオーナーの場合のみ書き込み可とする。例えばCMS管理画面におけるブログの投稿者権限のようなもので、自分の記事以外は触れないようにする。もしくはエンドユーザーがユーザー登録後、自分のメールアドレス変更を行う場合、自分のメールアドレスのみ変更できる(他のユーザーのメールアドレスは変更できない)よう制限をかけ、セキュリティを高めることができる。

CMSの記事投稿者 or 自マイページの編集 戦略

schema.graphqlの編集

schma.graphql の編集を行う。ログインした場合、owner = 自分のデータのみ全権限が付与されるようにする。注意点として、この設定でログインした場合、自分以外のTodoはReadさえできなくなってしまうという点。マイページで自分の情報のみ閲覧・書き込みできるのでセキュリティ上の安全度は高い。ログインしていなかった場合は今まで通りReadOnlyとなる。

今回の検証の場合、一つのテーブルに対して権限を付与しているため、一見おかしな仕様に思える(ログインしていないのに他のTodoが見える、は矛盾する)。本来であればテーブルごと(例えば記事テーブル、マイページテーブルなど)に権限の付与方法を変えるべきであるが、瑣末な点は無視して進める。amplify push していつものように3分間待つ。

/next-blog4/amplify/backend/api/nextblog4/schema.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
#input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo 
  @model 
  @auth(rules: [
    { allow: owner, provider: userPools }, # Login = own data operations ok
    { allow: public, provider: iam, operations: [read] }, # Non-logged in user cannot write.
  ]) {
  id: ID!
  name: String!
  description: String
}

Todoテーブルに「owner」というカラムが自動的に追加され、データ追加時に一意の識別子が自動で格納されるようになる。amplify push時、以下のワーニングが出るが気にしない。

⚠️ WARNING: owners may reassign ownership for the following model(s) and role(s): Todo: [owner]. If this is not intentional, you may want to apply field-level authorization rules to these fields. To read more: https://docs.amplify.aws/cli/graphql/authorization-rules/#per-user--owner-based-data-access.

上記のワーニングの意味は、この「owner」カラムの値を各ユーザーに自由に書き換えられてしまうと意図せぬ動きになるということ。それを防止するためには「owner」カラムを書き換えできないよう schema.graphql にカラム単位で権限制御を追記する必要がある。今回は割愛する。

ログアウト(サインアウト)して、http://localhost:3000/notlogin にアクセスすると、先ほどと同様、Todoが表示される。

userPools用にプログラムを修正

http://localhost:3000/login にアクセスしてログインすると、コンソール上で Unauthorized エラーが発生するが、動きとしては正しい。

この時の内部の動きとして、query実行時、ログインしているため「public」方式は無視され「owner」方式が適用される。その「owner」の認証方式は「userPools」と定義しているにも関わらず、authModeの指定がない=デフォルトの「iam」でアクセスしようとしているため、そのような権限はない、というエラーになる。

そのため、authMode: ‘userPool’ (userPoolsではない)と明示的に指定することで動作するようになる。もしくは、amplify update api でデフォルトの認証モードをuserPoolsにすれば引数は不要だが、設定によらずコード上で認証モードを明記しておいた方が可読性は高い。

src/app/login/page.tsx

(割愛)

export function App({ signOut, user }: WithAuthenticatorProps) {
    const [todoName, setTodoName] = useState("")
    const [todos, setTodos] = useState<any[]>([])
    useEffect(()=> {
        const fetchData = async () => {
            const client = generateClient()
            const ret = await client.graphql({ 
                query: listTodos,
                authMode: 'userPool'
            })
            setTodos(ret.data.listTodos.items)
        }
        fetchData()
    }, [])

    // Todo入力時のChangeイベント
    const todoNameChange = (e: any) => {
        setTodoName(e.target.value)
    }

    // Todo追加ボタンクリック時
    async function addTodo() {
        const client = generateClient()
        /* create a todo */
        const newData = await client.graphql({
            query: createTodo,
            variables: {
                input: {
                    "name": todoName,
                    "description": ''
                }
            },
            authMode: 'userPool'
        })
        console.log('Created Todo: ', newData)
    }

(割愛)

}

export default withAuthenticator(App)

http://localhost:3000/login にアクセスすると、最初はTodoが空っぽだが(今までのデータには所有者情報がないため自データとみなされないため)、Todoを追加すると自分が追加したTodoが表示されるようになる。

自分が追加したTodoのみ表示される。

DynamoDBを確認すると、「owner」フィールドに先ほど追加したデータのみ、何らかの値が入っていることがわかる。

DynamoDBのTodoテーブルの「owner」フィールドに値が入っている。

戦略:CMSの管理者(特定ユーザーに限定)

前述の「owner」では自分のTodoしか変更できない。そのため、管理者権限、つまり全データを編集できる権限をもちメンテナンスできる管理者権限が必要になるのでその設定方法を検証する。

CMSの管理者(特定ユーザーに限定)

schema.graphqlの編集

以下のように「groups」を追記する。これは、”Admin”グループのみ全権限を付与するというもの。amplify pushして3分待つ。

/next-blog4/amplify/backend/api/nextblog4/schema.graphql

# This "input" configures a global authorization rule to enable public access to
# all models in this schema. Learn more about authorization rules here: https://docs.amplify.aws/cli/graphql/authorization-rules
#input AMPLIFY { globalAuthRule: AuthRule = { allow: public } } # FOR TESTING ONLY!

type Todo 
  @model 
  @auth(rules: [
    { allow: owner, provider: userPools }, # Login = own data operations ok
    { allow: groups, groups: ["Admin"] } # Admin all operations ok
    { allow: public, provider: iam, operations: [read] }, # Non-logged in user cannot write.
  ]) {
  id: ID!
  name: String!
  description: String
}

Cognito ユーザープールにてグループ作成・紐付け

Cognito のユーザープールにて、グループを作成する。

Cognito ユーザープール

グループ名を入力して「グループを作成」ボタンをクリック。

グループ情報

”Admin” グループが作成された。リンクをクリック。

グループ

「ユーザーをグループに追加」ボタンをクリック。

グループ:Admin

追加したいユーザーを選択して「追加」ボタンをクリック。これでユーザーに”Admin”グループの紐付けが完了する。

ユーザーをグループに追加: Admin

別のユーザーでTodoを追加しておく。

別ユーザーで1件Todoを追加しておく。

“Admin”グループに紐づくユーザーでログインすると、過去のTodoと別ユーザーのTodoも閲覧できるようになっている。

全データを見ることができた。

以上でおおよそのAmplify Gen1 の構築方法のイメージは掴めたと思う。次回は、今回のAmplify Gen1の課題を元に作られたAmplify Gen2 で今回と同じことをする場合、どのような方法になるのか調べてみる。