Keycloakの認可機能の使い方（RBAC編）

Keycloakの認可機能への理解を深めるため、実際に保護対象のリソースと認可ルール（ポリシー・パーミッション）を設定し、登録したクライアントとユーザーの組み合わせで取得したアクセストークンでの認可判定の動作を確認した。

今回はアクセス制御モデルとしてよく使われるロールベースアクセス制御（RBAC）を設定する。リソースの認可ルールとしてロールを条件（ロールベースポリシー）として登録し、ユーザーにロールを割り当てていない状態、割り当てた状態での認可判定を実施する。

なお、各種設定は管理コンソールを使わず、基本的に[Admin REST API](https://www.keycloak.org/docs-api/11.0/rest-api/index.html)で行う。

#### 検証環境

Docker（Docker Compose）で以下の構成の検証環境を立ち上げる。

* Keycloak 11.02
* Ubuntu 22.04（curlコマンドでAPIを呼び出すためのクライアントとして）

docker-compose.yml

```yaml
version: '3'
services:
  curl_jq:
    tty: true
    build:
      context: .
  keycloak:
    image: quay.io/keycloak/keycloak:11.0.2
    ports:
      - 8080:8080
    environment:
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: admin
```

※ 動作検証目的のため、Keycloakの管理者ユーザー名・パスワードを `admin` に設定している。

Dockerfile

```dockerfile
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y curl jq
```

#### 動作確認手順・結果

* 管理者用アクセストークンを取得する関数を用意する。

  ```bash
  BASE_URI=http://keycloak:8080/auth
  get-token() {
    ACCESS_TOKEN=$(curl -s -X POST \
      "${BASE_URI}/realms/master/protocol/openid-connect/token" \
      -d "client_id=admin-cli" \
      -d "username=admin" \
      -d "password=admin" \
      -d "grant_type=password" \
      | jq -r '.access_token')
    echo ACCESS_TOKEN = ${ACCESS_TOKEN}
  }
  ```

* ロール（role-1）を登録する。

  ```bash
  ROLE_NAME=role-1
  get-token
  ROLE_URI=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/roles" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=Location: ).+(?=\r)'
  {
    "name": "${ROLE_NAME}"
  }
  EOF
  )
  echo ROLE_URI = ${ROLE_URI}
  ROLE_ID=$(curl -s ${ROLE_URI} \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    | jq -r .id)
  echo ROLE_ID = ${ROLE_ID}
  ```

* リソースサーバー（resource-server-1）と保護対象となるリソース（resource-1）とrole-1を認可条件とするロールベースポリシー（policy-1）を登録する。   
※ KeycloakではリソースサーバーはOpenID ConnectのConfidentialクライアントとして登録する。

  ```bash
  RESOURCE_SERVER_CLIENT_ID=resource-server-1
  RESOURCE_NAME=resource-1
  POLICY_NAME=policy-1
  get-token
  RESOURCE_SERVER_ID=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=clients/).+(?=\r)'
  {
    "clientId": "${RESOURCE_SERVER_CLIENT_ID}",
    "protocol": "openid-connect",
    "publicClient": false,
    "serviceAccountsEnabled": true,
    "authorizationServicesEnabled": true,
    "authorizationSettings": {
      "resources": [{ "name": "${RESOURCE_NAME}", "displayName": "Resource 1" }],
      "policies": [{
        "type": "role",
        "logic": "POSITIVE",
        "decisionStrategy": "UNANIMOUS",
        "name": "${POLICY_NAME}",
        "config": { "roles": "[{\"id\":\"${ROLE_ID}\"}]" }
      }]
    }
  }
  EOF
  )
  echo RESOURCE_SERVER_ID = ${RESOURCE_SERVER_ID}
  ```

* 登録したリソース（resource-1）とポリシー（policy-1）を紐づけるパーミッション（permission-1）を設定する。

  ```bash
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients/${RESOURCE_SERVER_ID}/authz/resource-server/permission/resource" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | jq .
  {
    "type": "resource",
    "logic": "POSITIVE",
    "decisionStrategy": "UNANIMOUS",
    "name": "permission-1",
    "resources": ["${RESOURCE_NAME}"],
    "policies": ["${POLICY_NAME}"]
  }
  EOF
  ```

* リソースの利用者となるクライアント（client-1）を登録する。

  ```bash
  CLIENT_ID=client-1
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF
  {
    "clientId": "${CLIENT_ID}",
    "protocol": "openid-connect",
    "publicClient": true
  }
  EOF
  ```


* ユーザー（user-1）を登録してパスワードを設定する。

  ```bash
  USER_NAME=user-1
  USER_PASSWORD=password
  get-token
  USER_ID=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/users" \
    -D - \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | grep -oP '(?<=users/).+(?=\r)'
  {
    "username": "${USER_NAME}",
    "enabled": true
  }
  EOF
  )
  echo USER_ID = ${USER_ID}
  get-token
  curl -s -X PUT \
    "${BASE_URI}/admin/realms/master/users/${USER_ID}/reset-password" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF
  {
    "type": "password",
    "value": "${USER_PASSWORD}",
    "temporary": false
  }
  EOF
  ```

* ユーザー（user-1）にロール（role-1）を割り当てない状態で認可判定を行うとアクセス拒否となる。   
  ※ アクセストークン取得は簡易的にPassword Flowで行っている。

  ```bash
  CLIENT_ACCESS_TOKEN=$(curl -s -X POST \
      "${BASE_URI}/realms/master/protocol/openid-connect/token" \
      -d "grant_type=password" \
      -d "client_id=${CLIENT_ID}" \
      -d "username=${USER_NAME}" \
      -d "password=${USER_PASSWORD}" \
      | jq -r '.access_token'
  )
  echo CLIENT_ACCESS_TOKEN = ${CLIENT_ACCESS_TOKEN}
  curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token" \
    -H "Authorization: Bearer ${CLIENT_ACCESS_TOKEN}" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
    -d "response_mode=decision" \
    -d "audience=${RESOURCE_SERVER_CLIENT_ID}" \
    -d "permission=${RESOURCE_NAME}" \
    | jq .
  # => { "error": "access_denied", "error_description": "not_authorized" }
  ```

* ユーザー（user-1）にロール（role-1）を割り当てる。

  ```bash
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/users/${USER_ID}/role-mappings/realm" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | jq .
  [{
    "id": "${ROLE_ID}",
    "name": "${ROLE_NAME}"
  }]
  EOF
  ```

* 再度認可判定を行うとアクセス許可となる。

  ```bash
  CLIENT_ACCESS_TOKEN=$(curl -s -X POST \
      "${BASE_URI}/realms/master/protocol/openid-connect/token" \
      -d "grant_type=password" \
      -d "client_id=${CLIENT_ID}" \
      -d "username=${USER_NAME}" \
      -d "password=${USER_PASSWORD}" \
      | jq -r '.access_token'
  )
  echo CLIENT_ACCESS_TOKEN = ${CLIENT_ACCESS_TOKEN}
  curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token" \
    -H "Authorization: Bearer ${CLIENT_ACCESS_TOKEN}" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
    -d "response_mode=decision" \
    -d "audience=${RESOURCE_SERVER_CLIENT_ID}" \
    -d "permission=${RESOURCE_NAME}" \
    | jq .
  # => { "result": true }
  ```

#### 参考

* Keycloakドキュメント
  * [Server Administration](https://www.keycloak.org/docs/latest/server_admin/index.html)
  * [Server Container Image](https://github.com/keycloak/keycloak-containers/blob/11.0.2/server/README.md)
  * [Authorization Services](https://www.keycloak.org/docs/latest/authorization_services/index.html)
  * [Administration REST API](https://www.keycloak.org/docs-api/11.0/rest-api/index.html)