HasuraでIBM Cloud Databases for PostgreSQLをGraphQL化しApp IDで保護する

# 目的

Hasuraはデータベースを簡単にGraphQL API対応することができるソフトウェアです。

https://hasura.io/

今回はIBM CloudのDatabases for PostgreSQLをGraphQL化しつつ、App IDでAPIの保護をしてみます。

# 環境

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/8319c257-056d-e4e3-b2f4-309647b70a41.png)

HasuraはRed Hat OpenShift on IBM Cloud（ROKS）上にデプロイします。HasuraはDatabases for PostgreSQLを利用します。アクターは2種類を想定し、管理者はブラウザで直接Hasuraのコンソールを操作します。ユーザーはAPIコールをする人またはアプリを想定し、App IDで取得したトークンを使ってAPIの認可を行います。

# 手順

## PostgreSQL接続情報の確認

インスタンスはすでに作成済とします。クレデンシャルを確認し、次の値を探します。

```
      "composed": [
        "postgres://********:********@********.databases.appdomain.cloud:32497/ibmclouddb?sslmode=verify-full"
      ],
```

PostgreSQLでプライベートエンドポイントが有効化されている場合は、ホスト名部分のdatabasesの手前に``private``を付けます。また、このPostgreSQLはクラウドサービスですので、予期せぬネットワークエラーに多少なりとも対応できるよう、``connect_timeout``を30秒で設定しておきます。今回は次の値を使用します。

```
postgres://********:********@********.private.databases.appdomain.cloud:32497/ibmclouddb?sslmode=verify-full&connect_timeout=30
```

## PostgreSQL SSL通信用CA証明書の取得

同じくクレデンシャルから次の値を探します。

```
        "certificate_base64": "********"
```

これはSSL通信時のCA証明書です。この値を複合化して保存します。

```
$ echo -n "********" | base64 -d >root.crt
```

## Hasuraのデプロイ

Hasuraは公式のコンテナ版があるので、それを利用します。

```
$ oc project qiita 2>/dev/null || oc new-project qiita
$ oc create deploy graphql-engine --image hasura/graphql-engine:v2.0.9 --replicas 0
$ oc set env deploy/graphql-engine HASURA_GRAPHQL_ADMIN_SECRET=P@ssw0rd	
$ oc set env deploy/graphql-engine HASURA_GRAPHQL_ENABLE_CONSOLE=true
$ oc set env deploy/graphql-engine HASURA_GRAPHQL_DATABASE_URL=postgres://********:********@********.private.databases.appdomain.cloud:32497/ibmclouddb?sslmode=verify-full\&connect_timeout=30
$ oc create secret generic postgresql-ca --from-file root.crt
$ oc set volume deploy/graphql-engine --add --mount-path /.postgresql/ --secret-name postgresql-ca
$ oc scale deploy/graphql-engine --replicas 1
$ oc get pods | grep graphql-engine
$ oc expose deploy/graphql-engine --port 8080
$ oc create route edge --service graphql-engine
```

いくつか要点を解説します。

環境変数``HASURA_GRAPHQL_ADMIN_SECRET``を設定することで、コンソール使用時に認証が発生します。逆に言うとこれを付けないと認証なしでコンソールが使えてしまいますので、注意ください。

CA証明書を/.postgresql/root.crtという名前でマウントしておくと、HasuraのPostgreSQLドライバは自動的に使ってくれるようになります。

## コンソールへのアクセス

経路を確認します。

```
$ oc get route graphql-engine
NAME             HOST/PORT                                                                                                      PATH   SERVICES         PORT    TERMINATION   WILDCARD
graphql-engine   graphql-engine-qiita.roks-public-tok-********************************-0000.jp-tok.containers.appdomain.cloud          graphql-engine   <all>   edge          None
```

ブラウザで上記URLにアクセスします（https://）。先ほど環境変数``HASURA_GRAPHQL_ADMIN_SECRET``で指定した値でログインすることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/432f2243-b8fe-58e5-bf1f-591327304870.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/a6db3fc3-2342-a7ef-02af-81173d4ee108.png)

## チュートリアル

公式の教材コースがありますので、初めて触れる方は非常に参考になります。

https://hasura.io/learn/ja/graphql/hasura/introduction/

今回はそれに沿って進める想定とします。現在の状態で、「基本的なデータモデル」から「承認」まで実施可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/997bc366-9451-3e4f-f9a4-2a445ec25605.png)

## App IDの設定

チュートリアルではAuth0を使っていましたが、今回はApp IDを使用します。App IDで認証し発行されたJWTを使ってHasuraの認可を行う仕組みとなります。

HasuraのJWTについては公式ガイドを参照ください。

https://hasura.io/docs/latest/graphql/core/auth/authentication/jwt.html

### アプリケーションの登録

App IDの管理画面からアプリケーションを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/95766a14-12f2-8364-512d-abd8961d6d1e.png)

タイプは``通常のWebアプリケーション``とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/7c56cf68-19d8-c0d9-6b46-5d6e6746aa29.png)

``clientId``、``tenantId``、``secret``を控えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/a09e19b8-fd50-9789-b982-09cf3d9b10cb.png)

### APIコール用ユーザーの登録

次にユーザーを追加します。今回はクラウドディレクトリーを使用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/eb73f3ae-0b34-f135-3e9b-fdda0de7ba31.png)

ユーザー名（E-メール）、パスワードを控えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/489cd9b8-c309-09b6-e819-9f858657abfe.png)

先ほど登録したユーザーのユーザープロファイルを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/d05836e5-e16e-c2ed-9728-c6e242c7dc76.png)

カスタム属性を編集します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/ad431ef0-d73b-c27e-2361-c08c2aa27eb4.png)

下記のとおり設定します。少し解説しますと、``https://hasura.io/jwt/claims``はHasuraがJWTを検証する際のネームスペース名です。``x-hasura-allowed-roles``と``x-hasura-default-role``は必須情報で、このユーザーのHasuraでのロールを指定しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/cc95778a-623d-ff84-4ccf-b60a16fc4c9f.png)

### トークンのカスタマイズ

これらカスタム属性をJWTに含めるよう、トークンのカスタマイズを行います。``api_key``はApp IDに更新権をもつIAMのAPI KEYです。``tenant_id``は先ほど控えた値です。これを実行すると、JWTに先ほど追加したカスタム属性が含まれるようになります。

```
$ api_key=********
$ tenant_id=********
$ token=$(curl -s https://iam.cloud.ibm.com/identity/token -X POST -H "Content-Type: application/x-www-form-urlencoded" -H "Accept: application/json" -d "grant_type=urn:ibm:params:oauth:grant-type:apikey" -d "apikey=${api_key}")
$ iam_token=$(echo ${token} | jq -r .access_token)
$ curl https://jp-tok.appid.cloud.ibm.com/management/v4/${tenant_id}/config/tokens -X PUT -H "Content-Type: application/json" -H "Authorization: Bearer ${iam_token}" -d '{
  "accessTokenClaims": [
    {
      "source": "attributes",
      "sourceClaim": "https://hasura.io/jwt/claims"
    }
  ]
}'
```

手動で認証してJWTを取得します。

```
$ tenant_id=********
$ client_id=********
$ secret=********
$ oauth_url=https://jp-tok.appid.cloud.ibm.com/oauth/v4/${tenant_id}
$ basic=$(echo -n "${client_id}:${secret}" | base64 -w 0)
$ token=$(curl -s ${oauth_url}/token -X POST -H "Content-Type: application/x-www-form-urlencoded" -H "Authorization: Basic ${basic}" -d 'grant_type=password&username=hasura-user@example.com&password=P@ssw0rd')
$ access_token=$(echo ${token} | jq -r .access_token)
$ echo access_token=${access_token}
access_token=eyJhbGciOiJSUz********
```

得られたアクセストークンを次のサイトでデコードします。

https://jwt.io/

カスタム属性が追加されたことが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/7f751e9d-5b56-22cf-9395-24ea7d546335.png)

画面下方の公開鍵を控えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241424/32fd52cb-0fc8-f7f9-de6b-6d0b01ab1d0c.png)

### App ID公開鍵のHasuraへの登録

Hasuraに下記のとおり環境変数``HASURA_GRAPHQL_JWT_SECRET``を追加します。改行はすべて\nで置換してください。

```
oc set env deploy graphql-engine HASURA_GRAPHQL_JWT_SECRET='{"type": "RS256", "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBC
gKCAQEAmEwHPzp87mN6MtaYL3AC\nQKl45dq1tYfxxsvjo5R87hRWY2c/wErFCfeImj6ovaVr0BK5uvS9QdR6XIDjLCR7\nvf+PJalTXiJXi/OZBZRH2t7VlljzNObTkXfARozliwFNdjpApNpQtBE3
I2xDOzel\nfSMmB4t3cl1tTJwEpiL6S81Gk1cpPNRO0XRa+RVxzt8gSS+RkkZu8zMBWCRgwSCH\nTRcshCdPFpLpn1pB3hKLhlOcdcbMZLR5o6W+/RlCMze2uIsSeOs/WzW9MVhj2yD9\nAyCtQvq7a
km30fe4q2wfKZFZjA6hJU9yiPKF17lt69L74g7n363evHPNrp3mEs2W\niQIDAQAB\n-----END PUBLIC KEY-----"}'
```

## 認可の動作確認

クエリ用のJSONを作成します。

```query.json
{ "query": "query { users { id name } }" }
```

まずは認証ヘッダなしでコールします。当然エラーになります。

```
$ curl https://graphql-engine-qiita.roks-public-tok-********-0000.jp-tok.containers.appdomain.cloud/v1/graphql -X POST -d @query.json
{"errors":[{"extensions":{"path":"$","code":"invalid-headers"},"message":"Missing Authorization header in JWT authentication mode"}]}
```

認証ヘッダにアクセストークンを指定します。無事結果を取得することができました。

```
$ curl https://graphql-engine-qiita.roks-public-tok-9dfbcfe481bb6d6d7afea007d1a8cafd-0000.jp-tok.containers.appdomain.cloud/v1/graphql -X POST -H "Authorization: Bearer ${access_token}" -d @query.json
{"data":{"users":[{"id":"2","name":"Fuga"}, {"id":"1","name":"Hoge"}]}}
```

ちなみに、もし公開鍵の登録がうまくいっていないと、トークンの検証に失敗してエラーとなります。

```
{"errors":[{"extensions":{"path":"$","code":"invalid-jwt"},"message":"Could not verify JWT: JWSError JWSInvalidSignature"}]}
```

さらに、もしアクセストークンがExpireしている場合は次のエラーとなります。HTTP的には200で応答されるので、アプリを実装する際はHTTPステータスではなくこの出力をチェックして再認証する仕組みが必要です。

```
{"errors":[{"extensions":{"path":"$","code":"invalid-jwt"},"message":"Could not verify JWT: JWTExpired"}]}
```

これで設定は完了です。