KeycloakのRoleを使ってHasuraのRBACを行ってみた。

## はじめに

GraphQLのバックエンドサービスであるHasuraはDBへのアクセス制御をRoleによって行っています。本記事ではKeycloakのRoleを使ってHasuraのRole Based Access Control(RBAC)を試してみたので、その内容を紹介します。

本記事は、あくまで執筆者の見解であり、日立製作所及びHasuraの公式なドキュメントではありません。

## Hasuraの概要

HasuraはDBにアクセスするためのGraphQL APIを生成するバックエンドサービスです。HasuraはクライアントとDBの間に入り，DBスキーマからGraphQL APIを構築したり、DBにアクセスするGraphQL APIをSQLに自動変換してくれます。これによりGraphQL APIの開発を迅速に進めることが可能になります。HasuraではDBとしては特に相性の良いPostgreSQLの他、Microsoft SQL Server、Amazon Aurora、Google BigQueryを用いることができます。その他、ORACLE、mongoDB、MySQL、Elasticsearchなどをサポート予定です。

## Hasuraでの認証・認可

Hasuraにおいてアクセス制御（認可）はRoleを用いて行います。Role，Table， CRUD毎にPermissionと呼ばれるアクセスルールを設定し、DBの行単位、列単位での制御ができます。またHausraでは権限のない行や列はアクセスできなくなるだけでなく、スキーマからも消失するという特徴があります。
一方でユーザ認証はHasura外で行う必要があり、認証方式としてwebhook認証とJWT認証の2種類が提供されています。いずれもSession Variablesと呼ばれる変数(X-Hasura-RoleやX-Hasura-User-Id等)に値をセットし、Hausraに渡すことでユーザの識別やアクセス制御を行います。webhook認証ではHasuraがwebhookにHTTPリクエストを送信しwebhook側でTokenの検証が行われた後、レスポンスとしてRoleやSession Variablesが返却されます。
JWT認証は認証やアクセス制御等の属性情報(claim)をJSON形式で記述したJSON Web Token(JWT)を用いる認証方式です。本手法では認可サーバーでJWTを生成する際にRoleやSession Variablesの情報をcustom claimとして含めます。クライアント側はこのJWTをリクエストヘッダに含めてHasuraにHTTPリクエストを送信し、HasuraでJWTの検証を行います。

## 検証内容概要

今回は認可サーバーとしてKeycloakを使用し、HasuraのJWT認証を試します。
HasuraがアクセスするDBにはPostgreSQLを使用し、以下の表のようなテーブルUser_Infoを作成します。

| user_num | name | department | password |
| :---: | :--- | :--- | :--- |
| 1 | 太郎 | 財務部 | aa1 |
| 2 | 次郎 | 法務部 | bki4 |
| 3 | 三郎 | 総務部 | csu27 |
| 4 | 四郎 | 法務部 | dte256 |
| 5 | 五郎 | 財務部 | eno3125 |
| 6 | 六郎 | 財務部 | fha46656 |

次にHasuraで3つのRole"manager"と"user"，"finance"を作成し、各Roleでテーブルにアクセスしたときの振る舞いを確認します。それぞれのRoleは以下のような権限を有します。

| Role名 | 説明 |
| :---: | :--- |
| manager | 全てのユーザ情報を取得できる。 |
| user | 自らのユーザ情報だけ取得できる。 |
| finance | 財務部に所属するユーザの情報の内password以外のデータを取得できる。 |

## 環境の構築

今回の検証はdocker-composeを用いて行います。docker-compose.ymlは以下の通りです。注意点としてJWTでの認証を有効にするために"HASURA_GRAPHQL_JWT_SECRET"でJWTを検証するjwks_uriを指定し、"HASURA_GRAPHQL_ADMIN_SECRET"でAdmin権限でアクセスする際のパスワードを設定してください。

<img width="500" alt="sample_environment.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2270745/b504538f-02ad-e49d-a3b2-9c6d594331ff.png">


```yaml:docker-compose.yml
version: '3.3'
services:

  # Hasura
  hasura:
    image: hasura/graphql-engine:v2.0.10
    ports:
    - "8080:8080"
    depends_on:
    - "pg_hasura"
    - "keycloak"
    restart: always
    environment:
      HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:postgrespassword@pg_hasura:5432/postgres
      HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console
      HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log
HASURA_GRAPHQL_JWT_SECRET: '{ "type": "RS256", "jwk_url":　"http://keycloak:8080/auth/realms/master/protocol/openid-connect/certs" }'
      HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey

  # PostgreSQL Server for Hasura
  pg_hasura:
    image: postgres:12
    restart: always
    volumes:
    - pg_hasura_data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgrespassword

  #Keycloak
  keycloak:
    image: jboss/keycloak:15.0.1
    ports:
    - "8081:8080"
    depends_on:
    - "pg_keycloak"
    restart: always
    environment:
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: admin
      DB_VENDOR: postgres
      DB_ADDR: pg_keycloak
      DB_USER: keycloak
      DB_PASSWORD: password

  # PostgreSQL Server for Keycloak
  pg_keycloak:
    image: postgres:12
    restart: always
    volumes:
    - pg_keycloak_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: password

volumes:
  pg_hasura_data:
  pg_keycloak_data:
```

## Keycloak側で行う設定

#### Roleの設定

まず[https://github.com/janhapke/hasura-keycloak](https://github.com/janhapke/hasura-keycloak)を参考にして[localhost:8081/auth/admin](localhost:8081/auth/admin)にアクセスします。ログインに必要なユーザ名とパスワード名はdocker-compose.yml内のKEYCLOAK_USERとKEYCLOAK_PASSWORDに設定した値になります。ログイン後"Client"として新たにhasuraを作成し、hasuraの"Roles"で上記の"manager","finace"，"user"を新たに生成します。

#### Mapperの設定

続いて"Mappers"で3つのMapper"x-hasura-default-role"と"x-hasura-allowed-roles"，"x-hasura-user-num"を作成します。各Mapperの説明は以下の通りです。

| Mapper名 | 説明 |
| :---: | :--- |
| x-hasura-allowed-roles | 認証したユーザに割り当てるRole一覧をセットします。この中で実際に割り当てたいRoleをX-Hasura-Roleヘッダで指定します。**必ずclaimに含めてください。** |
| x-hasura-default-role | X-Hasura-RoleヘッダでRoleを指定しない場合に割り当てるRoleを設定します。**必ずclaimに含めてください。** |
| x-hasura-user-num | Roleとは関係ないですがHasuraでSession Variablesとして使用します。claimに必ずしも含める必要はありません。 |

各Mapperは以下のように設定します。注意点としては公式のドキュメント[https://hasura.io/docs/latest/graphql/core/auth/authentication/jwt.html#](https://hasura.io/docs/latest/graphql/core/auth/authentication/jwt.html#)にも述べられているようにMapperのTypeは必ずStringにして下さい。

| 項目名 | 設定値 |
| :---: | :--- |
| Name | x-hasura-default-role |
| Mapper Type | Hardcoded claim |
| Token Claim Name | https\://hasura\\.io/jwt/claims.x-hasura-default-role |
| Claim value | user |
| Claim JSON Type | String |

| 項目名 | 設定値 |
| :---: | :--- |
| Name | x-hasura-allowed-roles |
| Mapper Type | User Client Role |
| Client Id | hasura |
| Multivalued | ON |
| Token Claim Name | https\://hasura\\.io/jwt/claims.x-hasura- allowed-roles |
| Claim JSON Type | String |

| 項目名 | 設定値 |
| :---: | :--- |
| Name | x-hasura-user-num |
| Mapper Type | User Attribute |
| User Attribute | num |
| Token Claim Name | https\://hasura\\.io/jwt/claims.x-hasura-user-num |
| Claim JSON Type | String |

#### Userの設定

最後に"Users"でアクセストークンを取得するユーザを編集します(ここではadmin)。そして"Attributes"でKeyにnum、Valueに3を追加します。続いて"Role Mappings"で"Client Roles"にhasuraを選択し、"Available Roles"にある"manager"と"user"，"finance"を"Assigned Roles"に追加してください。

## Hasura側で行う設定

HasuraではRoleに対してCRUDそれぞれにPermissionを作成できるのですが、今回は簡略化してRead(Select)操作に関するPermissionのみ設定します。まず"manager"Roleは全てのユーザ情報にアクセスできるようにしたいので、下図のように何も制限しません。

![manager.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2270745/8dba7fe9-b949-0742-13ef-80f54f6320ba.png)

次に"user"Roleは自らのユーザ情報に一致するデータのみアクセスしたいので、Row select permissionsでKeycloakから取得したJWTに含まれるX-Hasura-User-Numがuser_numと一致する行にのみアクセスできるようにします。そしてColumn select permissionsではすべて選択します。

![user.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2270745/beac8df7-ea33-1dda-55f8-63e4c5b1ff77.png)

最後に"finance"Roleは財務部に所属するユーザの情報の内passwordだけ取得できないので、下図のようにRow select permissionsではdepartmentが財務部に一致する行にのみアクセスするように、Column select permissionsではpassword以外の列を選択します。

![finance.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2270745/993d241b-aa9b-0cf4-6d0a-144a0164c3f9.png)


## 検証結果

まずは以下のコマンドを実行し、Keycloakからアクセストークンを取得します。取得したトークンはHasuraへのHTTPリクエストのAuthorizationヘッダに"Bearer 取得したアクセストークン"という形式で入れます。

```shell
curl -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=admin" \
  -d "password=admin" \
  -d "grant_type=password" \
  -d "client_id=hasura" \
  http://localhost:8081/auth/realms/master/protocol/openid-connect/token
```

それでは各Roleでのレスポンスを見てきます。まず"manager"Roleでのレスポンスを確認するためにX-Hasura-Roleヘッダにmanagerをセットし、全フィールドのデータを取得します。

```shell
curl -X POST -H "Content-Type: application/json" \
  -H "X-Hasura-Role: manager" \
  -H "Authorization: Bearer 取得したアクセストークン" \
  -d "{ \"query\": \"query { User_Info { user_num name department password }}\"}" \
  http://localhost:8080/v1/graphql | jq
```

レスポンスは以下のようになり、User_Infoテーブルの全てのデータが返ってきたことがわかります。

```json
{
  "data": {
    "User_Info": [
      {
        "user_num": "1",
        "name": "太郎",
        "department": "財務部",
        "password": "aa1"
      },
      {
        "user_num": "2",
        "name": "次郎",
        "department": "法務部",
        "password": "bki4"
      },
      {
        "user_num": "3",
        "name": "三郎",
        "department": "総務部",
        "password": "csu27"
      },
      {
        "user_num": "4",
        "name": "四郎",
        "department": "法務部",
        "password": "dte256"
      },
      {
        "user_num": "5",
        "name": "五郎",
        "department": "財務部",
        "password": "eno3125"
      },
      {
        "user_num": "6",
        "name": "六郎",
        "department": "財務部",
        "password": "fha46656"
      }
    ]
  }
}
```

次にx-hasura-allowed-rolesで指定したRoleしか割り当てられないことを確認するために、X-Hasura-RoleヘッダにtestをセットしてHTTPリクエストしてみます。その結果以下のようなエラーがレスポンスされ、x-hasura-allowed-rolesにセットしていないRoleは割り当てられないことが確認できます。

```json
{
  "errors": [
    {
      "extensions": {
        "path": "$",
        "code": "access-denied"
      },
      "message": "Your requested role is not in allowed roles"
    }
  ]
}
```

またアクセストークンがない場合にもリクエストが失敗することを確認するために、Authorizationヘッダを含めずにHTTPリクエストしてみます。すると以下のようなエラーレスポンスが返却され、リクエストに失敗が確認できます。

```json
{
  "errors": [
    {
      "extensions": {
        "path": "$",
        "code": "invalid-headers"
      },
      "message": "Missing Authorization header in JWT authentication mode"
    }
  ]
}
```

次にX-Hasura-RoleヘッダにuserをセットしてHTTPリクエストを送ります。Keycloakで認証するユーザ（ここではadmin）のnumを3に設定したので、レスポンスでは以下のようにuser_numが3のユーザ情報のみが返ってきていることが確認できます。また今回x-hasura-default-roleに"user"をセットしているので、HTTPリクエストにX-Hasura-Roleヘッダを含めなくても同様の結果が得られることが確認できると思います。

```json
{
  "data": {
    "User_Info": [
      {
        "user_num": "3",
        "name": "三郎",
        "department": "総務部",
        "password": "csu27"
      }
    ]
  }
}
```

最後にX-Hasura-RoleヘッダにfinanceをセットしてHTTPリクエストを送ると以下のように"password"fieldがUser_Infoテーブルには存在しないというエラーが返ってきます。

```json
{
  "errors": [
    {
      "extensions": {
        "path": "$.selectionSet.User_Info.selectionSet.password",
        "code": "validation-failed"
      },
      "message": "field \"password\" not found in type: 'User_Info'"
    }
  ]
}
```

そこで取得したいデータを"password"field以外にすると"department"が財務部のユーザ情報のみレスポンスとして返ってくることが確認できます。

```shell
curl -X POST -H "Content-Type: application/json" \
  -H "X-Hasura-Role: finance" \
  -H "Authorization: Bearer 取得したアクセストークン" \
  -d "{ \"query\": \"query { User_Info { user_num name department }}\"}" \
  http://localhost:8080/v1/graphql | jq
```

```json
{
  "data": {
    "User_Info": [
      {
        "user_num": "1",
        "name": "太郎",
        "department": "財務部"
      },
      {
        "user_num": "5",
        "name": "五郎",
        "department": "財務部"
      },
      {
        "user_num": "6",
        "name": "六郎",
        "department": "財務部"
      }
    ]
  }
}
```

## おわりに

本記事では、KeycloakのRoleでHasuraのRBACを行う方法をご紹介しました。X-Hasura-RoleヘッダにセットするRoleに応じて、取得するデータが変化したことが確認できたと思います。本手法はClient側のAppがKeycloakからアクセストークンを取得します。そのため、Appによって取得するRoleを切り替えるといったこともできると思います。