Open Policy Agentの活用事例 (AWS上での稼働)

Hitachi America, Ltd. R&D プラットフォームラボラトリの 大崎 裕之です。現在サンタクララで活動しております。

オープンソースのポリシーエンジンであるOpen Policy Agent (以下OPA) は、近年普及が進み、Cloud Native Computing Foundation (CNCF) でGraduationとなりました。OPAは、Kubernetesなどのインフラのアクセス制御に活用できるだけでなく、Webサービスの認可にも活用できます。本記事では、**Webサービス向けのOPAの活用事例**について紹介します。

## 本事例の特徴

OPAをAmazon Web Services (以下AWS) 上で稼働し、Webサービスの認可処理を行わせるという活用事例を紹介します。
本事例では、AWS Lambda/API Gatewayを使って稼働しているWebサービスを題材とします。

- 事前にOPAをAWS Lambdaで稼働させておきLambda Authorizerに設定しておく
- Webサービスは、ユーザからリクエストを受信。Webサービスへのリクエストをフックし、OPAで認可の処理を開始
- OPAを稼働させるために、Lambda内のGo言語のプログラムで`rego.New`や`rego.PrepareForEval`などのOPAのAPIを使用

上記の構成を用いて、Webサービスへのリクエストに応じOPAの認可処理が実現できました。

## 1. OPAを用いたWebサービスの認可

AWS上の構成について説明する前に、Webサービスの認可とOPAの働きについて触れます。

### 1-1. WebサービスとOPAの処理の流れ

まず、Webサービスへのリクエストの処理とその間に呼び出されるOPAの処理の全体の流れを説明します。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/fd613755-5eae-6dcd-cb33-8a62b0a71853.png' width=400px>

ユーザは、Webサービスのアプリケーションに対してリクエストを送付します。通信手段としては、`https://<ホスト名>/<パス>`といったアドレスにJSONなどのデータを送信します。Webサービスは、このリクエストを実行してもよいか、OPAに認可の判断を委ねます。

OPAは、どのようなユーザがアクセスしようとしているのかに応じて、認可の判断を実施し、結果として`true`ないし`false`を返答します。

Webサービスは、この返答を受け取り、元のユーザリクエストに対する返答を送信します。もし認可の結果が`false`であれば、何も処理をせずに「許可されませんでした」という返答を送信します。また、認可の結果が`true`であれば、リクエストで要求されたアプリケーションの処理を実行し、その結果を返答します。

## 1-2. OPAの働き

次に、OPAの視点で処理を説明します。OPAは、以下の図のように、データなどを読み込んで結果を返すことで処理を行うことができます。処理は大きく２つのフェーズがあります。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/9d7158ec-ef5d-a689-6274-440409fefa82.png' width=600px>


- **フェーズ(a) 初期化** (上図の右下)
  - 事前に定義してある[A]ポリシーと、必要なら[B]JSONデータを参照し、フェーズ(b)の準備をします。
- **フェーズ(b) リクエストごとの処理** (上図の左)
  - [1~4]ユーザからのリクエストに基づいて、アプリケーションからインプットをもらいます。OPAはインプットに応じて認可のアウトプット(`true` か `false`)を返答します。

2つのフェーズのうち、フェーズ(a)初期化は１度だけ実行し、その後、フェーズ(b)リクエストごとの処理をリクエストのたびに繰り返し実行します。
各フェーズをAWS上で実行する方法は、後ほど2-3にてソースコードに照らしながら説明します。

### 1-3. OPAで使用するポリシーファイル(サンプル)

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/2fe4cbeb-d107-c090-25a9-a428d51001b3.png' width=100px>

前の「OPAの働き」で登場した、OPA実行に必要な[A]ポリシーファイルについて説明します。

以下の`example.rego`ファイルは、[OPA公式ドキュメントの例](https://www.openpolicyagent.org/docs/latest/policy-testing/)に記載されたポリシーです。最後の検証でもこのポリシーファイルを使用します。ポリシーファイルの中身を追ってみます。

```rego
package authz

# This was orignally published on https://www.openpolicyagent.org/docs/latest/policy-testing/

allow {
    input.path == ["users"]
    input.method == "POST"
}

allow {
    some profile_id
    input.path = ["users", profile_id]
    input.method == "GET"
    profile_id == input.user_id
}
```

上記のポリシーには`allow {}`が複数あることがわかります。これが、OPAで **「ルール」** と呼ばれるもので、関数のように内部で定義された処理に基づいて`true`もしくは`false`を返します。ルールは、`true`/`false`以外の値、配列やオブジェクトを返すこともできます。
OPAは**同一名称の複数のルールを定義可能** です。一般的なプログラムの関数では同一の名称で複数定義を記述できないので、この点はOPAの重要な特徴です。`allow {}`を定義した複数のルールのうち **いずれかが`true`を返すと、`allow`の返り値は`true`** になります。
この特性により、OPAは後からどんどんルールを足すことができるようになっています。

各`allow`の中身は、`input`で表される入力JSONオブジェクトから`path`, `method`, `user_id`を取り出し、条件判定します。2つ目の`allow`の中では、以下2つの条件をアンドで判定しています。

- `input.method == "GET"`により`method`の値が`"GET"`であるか確認
- `input.path = ["users", profile_id]`で`profile_id`を取り出し、`profile_id == input.user_id`によりそれが`user_id`と一致するかを確認

このポリシーファイルを後でLambdaから使用できるよう、S3に格納します。例として、バケット名を`"opa-lambda-authorizer-test"`として説明します。

## 2. AWS上の構成

ここからは、OPAをAWSで稼働するのに必要なコンポーネントの構成について説明します。

### 2-1. OPAの実行方法のバリエーション

AWS上でOPAを稼働するには、以下のような方法が考えられます。

- EC2上でVMを稼働する
- EKSなどのコンテナサービス上でコンテナを稼働する
- Lambdaなどのサーバレスサービス上でプログラムを起動する

このうちOPAをAWS Lambda上で稼働する方法今回は採用します。また、プログラム言語にはGo言語を用います。

### 2-2. 使用するAWSの各コンポーネントと設定

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/7abb750c-d81d-25a9-4733-2537ab8a66a3.png' width=300px>

AWS Lambdaを使った構成は上図のようになります。

AWS API GatewayはWebサービス向けのエンドポイントを持ち、そのAuthorizerとしてLambda Authorizerを設定します。[Lambda Authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html)とはAPI Gatewayの機能の一つで、API Gateway上にあるAPIへのアクセス制御をLambdaに委託するというものです。この機能のメリットは、自由にアクセス制御ロジックを変えられることです。

AWS Lambdaには、以下の2つのプログラムを動かします。

- i) Webサービス用
- ii) Go+OPA用 (前述のLambda Authorizerで使う)

AWS S3には、OPAが必要とする[A]ポリシーファイルを格納しておきます。
この構成を実現するには、Serverless Frameworkを使って、以下のような設定ファイル(`serverless.yaml`)をデプロイすることができます。

```yaml
service: opa-lambda-authorizer

provider:
  name: aws
  runtime: go1.x
  stage: dev
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - 's3:GetObject'
      Resource: "arn:aws:s3:::*/*"

package:
  exclude:
    - ./**
  include:
    - ./bin/**

functions:
  getuser:
    handler: bin/getuser
    events:
    - http:
        path: users/{user_id}
        method: get
        authorizer:
          type: request
          name: auth
          resultTtlInSeconds: 0
          identitySource: method.request.header.Authorization
  auth:
    handler: bin/auth
```

この例では、以下の2つを構築する設定が含まれています。

- Webサービス向け `getuser`
  - パスが`/users/{user_id}`であるエンドポイントがAPI Gateway上に作成される
	- そこから呼び出されるWebサービスの実態は`bin/getuser`ファイルを実行したLambda。
- OPA向け `auth`
  - `auth`というLambda関数が作成される
	- 内部では`bin/auth`が実行される。
	- これはエンドポイントが無くユーザからは直接呼び出せない。代わりに、先ほど説明したLambda Authorizerの機能が呼び出す。

この`getuser`の設定の中で、`authorizer`の部分でLambda関数`auth`が前述のLambda Authorizerとして設定されています。さらに、Authorizerの結果のキャッシングを無効化するための`resultTtlInSeconds: 0`や、アイデンティティ情報としてLambda Authorizerが使用するヘッダを指定する`identitySource`が設定されています。これによって、Webサービス`getuser`がユーザから呼び出されるたびに、`auth`に認可の問い合わせを行います。

### 2-3. OPAを呼び出すGo言語プログラム

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/8fc7e701-47c4-b398-363c-2ca567242206.png' width=150px>

では、OPAを実行するGo言語のプログラム`bin/auth.go`の中身を見ていきます。

#### フェーズ(a) 初期化

Go言語のソースコードにおいて、以下の通りOPAを初期化していきます。実例を以下に示します。

メインポイント: _S3からポリシーファイルを読み出し、**`rego.New`で初期化し、`rego.Prepare`関数で準備処理**をします_。


```go
package main

import (
	"strings"
	"context"
	"log"
	"io/ioutil"
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/open-policy-agent/opa/rego" // STEP 1 **********
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/aws"
)

var query rego.PreparedEvalQuery

func main() {
	prepare() 
	lambda.Start(handleRequest)
}

func prepare() {
	ctx := context.Background()
	// step 2 **********
	policyfile := fetchs3("opa-lambda-authorizer-test", "example.rego")

  // step 3 **********
	var err error
	query, err = rego.New( // <-- MAIN POINT ****************
    rego.Query("x = data.authz.allow"),
    rego.Module("example.rego", policyfile),
    ).PrepareForEval(ctx) // <-- MAIN POINT ****************

	if err != nil {
		panic(err)
	}
}

func fetchs3(bucketName string, objectName string) (string) {
	cfg, err := config.LoadDefaultConfig(context.TODO())
	svc := s3.NewFromConfig(cfg)
	ctx := context.Background()
	resp, err := svc.GetObject(ctx, &s3.GetObjectInput{
			Bucket: aws.String(bucketName),
			Key:    aws.String(objectName),
	})
	if err != nil {
		panic(err)
}
	s3objectBytes, err := ioutil.ReadAll(resp.Body)
	if err != nil {
			panic(err)
	}
	return string(s3objectBytes)
}
```

まずは、`"github.com/open-policy-agent/opa/rego"`をimportします (ステップ1)。これによって、このソースコード内でOPAの各APIが呼び出せます。

次に、Lambdaの初期化時に、`prepare()`を実行し、その中で前節で説明したポリシーファイル`example.rego`をS3からダウンロードします (ステップ2)。上の例では、この処理を`fetchs3`という名で関数化してあります。
最後に、ポリシーファイルを使いOPAを初期化していきます (ステップ3)。

- `rego.New`によりOPAのインスタンスを作成する
- `rego.Module`でポリシーファイル`example.rego`をOPAに読み込ませる
- `PrepareForEval`を呼ぶことで、ポリシーファイルを内部でパースして実行準備ができた状態の`rego.PreparedEvalQuery`インスタンスを作成する

#### フェーズ(b) リクエスト処理時

リクエスト処理時のGo言語アプリケーションの実装は、以下のようになります。

メインポイント: _**`Eval`関数でポリシーファイルを使った認可処理の結果を取得する**_

```go
// STEP 4 **********
func handleRequest(event events.APIGatewayCustomAuthorizerRequestTypeRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
	user_id := event.Headers["Authorization"]
	path := event.Path
	method := event.RequestContext.HTTPMethod
	log.Println("UserID: "+user_id)
	log.Println("Path: "+path)
	log.Println("Method: "+method)

	effect := "Deny" // Default value
	if checkOpaExample(path, method, user_id) {
		effect = "Allow"
	}
	return generateIAMPolicy(user_id, effect), nil

}

// STEP 5 **********
func checkOpaExample(path string, method string, user_id string) (result bool) {
	ctx := context.Background()
	paths := strings.Split(path, "/")[1:] // /users/bob -> ["users", "bob"]
	input := map[string]interface{}{
		"method": method,
		"path": paths,
		"user_id": user_id,
	}
	results, err := query.Eval(ctx, rego.EvalInput(input)) // <-- MAIN POINT ****************

	if err != nil {
		log.Println("Error:", err)
	} else if len(results) == 0 {
		log.Println("Error: results are empty")
	} else if result, ok := results[0].Bindings["x"].(bool); !ok {
		log.Println("Error: binding result is not bool")
	} else {
		log.Println("Result:", result)
		return result
	}
	return false
}

// STEP 6 **********
func generateIAMPolicy(profileId string, effect string) events.APIGatewayCustomAuthorizerResponse {
	rtn := events.APIGatewayCustomAuthorizerResponse{
		PrincipalID: profileId, 
		Context: map[string]interface{}{},
	}

	if effect != "" {
		rtn.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
			Version: "2012-10-17",
			Statement: []events.IAMPolicyStatement{
				{
					Action:   []string{"*"},
					Effect:   effect,
					Resource: []string{"*"},
				},
			},
		}
	}
	return rtn
}
```

上記ソースコードでは以下を順に実行します。

- Webサービスがユーザから呼び出されるたびに、`bin/auth`内ののハンドラ`handlerRequest()`が呼び出されます。この中で、OPAの認可判定処理を呼び出します(ステップ4)。具体的には、初期化時に作成した`query`の`Eval`関数を呼び出します。その時の引数に、OPAの認可処理のインプットを入れる必要があります。上記ソースコードでは、`handleRequest`の引数で渡される`events.APIGatewayCustomAuthorizerREquestTypeRequest`からインプットに必要な情報を取り出します。たとえば、`Authorization`ヘッダを取り出して`user_id`に格納しています。また、`Path`フィールドを取り出し`path`に格納しています。同様に`method`も格納します。
- これらを関数`checkOpaExample`内で`input`というJSONに変換し、これを引数に指定し **`Eval`関数** を呼び出します(ステップ5)。
- OPAからの結果は`result[0].Binding["x"]`に格納されているので、これが`true`か`false`かに応じて`generateIAMPolicy`(ステップ6)にてLambda Authorizerのフォーマットで出力を返答し、このハンドラ`handlerRequest()`を終了します。

以上のファイルを`auth.go`として保存し、`go build`を用いてコンパイルし、`bin/auth`を生成します。

### 2-4. AWS上へのデプロイメント

これまで説明したファイル群を以下のように同じフォルダに格納します。

```
├── bin
│   ├── auth (バイナリ)
│   └── getuser (バイナリ、割愛)
├── policy
│   └── example.rego (1-3で説明。これはS3にアップロード済み)
├── serverless.yml (2-2で説明)
└── src
    ├── auth.go (2-3で説明)
    └── getuser.go (割愛)
```

これまで説明していなかった`src/getuser.go`はWebサービスのソースコードであり、それをコンパイルして`bin/getuser`を生成します。今回このサンプルのWebサービスの処理は非常にシンプルで、`{"id": <ユーザのID>}`というJSONを返すだけとなっております。この**Webサービス自体のソースコードには認可処理は含まれません**。OPAとLambda Authorizerがその役割を担ってくれるためです。ソースコードの内容は割愛させていただきます。

このフォルダ内で、以下のコマンドを実行します。

```sh
serverless deploy
```

これにより、AWS上のAPI Gateway/Lambda が自動で作成されます。

作成できたWebサービスのURLは以下のコマンドで確認することができます。

```sh
serverless info
(中略)
endpoints:
  GET - https://<xxxxxxxx>.execute-api.<region>.amazonaws.com/dev/users/{user_id}
```

WebサービスのURLは、`<xxxxxxxx>.execute-api.<region>.amazonaws.com/dev`のような形となります。

## 3. 検証: Webサービスの認可処理の確認

ここまで説明したAWSの構成により、ユーザから見てOPAがどのように機能するかを検証してみます。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96277/96613218-0f7a-d9dd-61a3-09a142d478ef.png' width=400px>

### 3-1. リクエストが認可されるケース(OPAが`true`を返す場合)

以下のコマンドを実行し、Webサービスに実際にアクセスすることで、OPAの認可処理を検証することができます。

```sh
curl -s -X GET -H "Authorization:bob" https://<xxxxxxxx>.execute-api.<region>.amazonaws.com/dev/users/bob
{"id":"bob"}
curl -s -X GET -H "Authorization:abcde" https://<xxxxxxxx>.execute-api.<region>.amazonaws.com/dev/users/abcde
{"id":"abcde"}
```

一回目のリクエストは、`/users/bob`というパスに"GET"リクエストを送信しており、そのときのヘッダにユーザの情報`Authorization:bob`を同封しています。このリクエストの結果、正しくアプリケーションの出力`{"id":"bob"}`を取得できている事がわかります。これは、OPAのポリシーファイルが`true`を返答したためです。二回目のリクエストも同様です。共通点は、`/users/`より後のIDが`Authorization:`に付記したユーザ名と同一であることです。この条件を満たせば、1-3で説明した`example.rego`ポリシーファイルの最後の行`profile_id == input.user_id`の判定結果が`true`となります。

### 3-2. リクエストが拒否されるケース(OPAが`false`を返す場合)

以下のコマンドを実行し、別のリクエストを送信してみます。

```sh
curl -s -X GET -H "Authorization:abcde" <xxxxxxxx>.execute-api.<region>.amazonaws.com/dev/users/bob
{"Message":"User is not authorized to access this resource with an explicit deny"}
```

今度は返事が変わり、アプリケーションの出力の代わりにエラーメッセージが返ってきています。これは、OPAのポリシーファイルが`false`を返したことを意味します。`/users/bob`なのに対し、ヘッダは`Authorization:abcde`となっており、`example.rego`内の`profile_id == input.user_id`の条件に合致しないためです。このように、正しいヘッダやパスをリクエストしなければ、上記のエラーメッセージが出力されるようになります。

## まとめ

本記事では、Webサービスを題材とし、AWS上で稼働するOPAをLambda Authorizerに設定し、Webサービスへのリクエストをフックし認可処理する事例を紹介しました。
OPAのポリシーファイルで判定した認可処理結果に基づいてWebサービスへのリクエストが続行ないし拒否されました。

今回のAWS上の構成のメリットは、Webサービスのアプリケーション開発者が認可処理を実装しなくても、そのAPI Gatewayの層でその認可機能を後から付け足せばよく、**アプリケーション開発者がコアロジック実装に集中できる**ということだと考えます。また、**同じ認可処理を複数のアプリケーションから利用することも容易**になります。

また、OPAを使った効果については、1-3で説明した`allow{}`のようなポリシーのロジックを柔軟に拡張できる特徴から、利用シーンに合わせてポリシーを適宜追加したり差し替えたりでき、**認可処理側の運用も柔軟性が向上する**ことが考えられます。
今後も、OPAなどのオープンソースのツールを活用したデジタルソリューションの構築方法を発信して参りたいと考えております。

## 参考文献

[1] Open Policy Agent, https://www.openpolicyagent.org/
[2] OPAのGo言語向けAPI, https://pkg.go.dev/github.com/open-policy-agent/opa/rego
[3] OPA公式ドキュメント "Integrating with the Go API", https://www.openpolicyagent.org/docs/latest/integration/#integrating-with-the-go-api
[4] OPA公式ドキュメント "Policy Testing", https://www.openpolicyagent.org/docs/latest/policy-testing/
[5] AWS, "Use API Gateway Lambda authorizers", https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html

## 謝辞

本記事執筆にあたっては、以下の皆様をはじめとしたくさんの方のご協力をいただきました。この場を借りて御礼申し上げます。

・株式会社日立製作所　サービス＆プラットフォームビジネスユニット ITプロダクツ統括本部　富田隆彦さん
・株式会社日立製作所　サービス＆プラットフォームビジネスユニット ITプロダクツ統括本部　早川裕志さん
・株式会社日立製作所　研究開発グループ　肥村洋輔さん