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の処理の全体の流れを説明します。
ユーザは、Webサービスのアプリケーションに対してリクエストを送付します。通信手段としては、https://<ホスト名>/<パス>
といったアドレスにJSONなどのデータを送信します。Webサービスは、このリクエストを実行してもよいか、OPAに認可の判断を委ねます。
OPAは、どのようなユーザがアクセスしようとしているのかに応じて、認可の判断を実施し、結果としてtrue
ないしfalse
を返答します。
Webサービスは、この返答を受け取り、元のユーザリクエストに対する返答を送信します。もし認可の結果がfalse
であれば、何も処理をせずに「許可されませんでした」という返答を送信します。また、認可の結果がtrue
であれば、リクエストで要求されたアプリケーションの処理を実行し、その結果を返答します。
1-2. OPAの働き
次に、OPAの視点で処理を説明します。OPAは、以下の図のように、データなどを読み込んで結果を返すことで処理を行うことができます。処理は大きく2つのフェーズがあります。
-
フェーズ(a) 初期化 (上図の右下)
- 事前に定義してある[A]ポリシーと、必要なら[B]JSONデータを参照し、フェーズ(b)の準備をします。
-
フェーズ(b) リクエストごとの処理 (上図の左)
- [1~4]ユーザからのリクエストに基づいて、アプリケーションからインプットをもらいます。OPAはインプットに応じて認可のアウトプット(
true
かfalse
)を返答します。
- [1~4]ユーザからのリクエストに基づいて、アプリケーションからインプットをもらいます。OPAはインプットに応じて認可のアウトプット(
2つのフェーズのうち、フェーズ(a)初期化は1度だけ実行し、その後、フェーズ(b)リクエストごとの処理をリクエストのたびに繰り返し実行します。
各フェーズをAWS上で実行する方法は、後ほど2-3にてソースコードに照らしながら説明します。
1-3. OPAで使用するポリシーファイル(サンプル)
前の「OPAの働き」で登場した、OPA実行に必要な[A]ポリシーファイルについて説明します。
以下のexample.rego
ファイルは、OPA公式ドキュメントの例に記載されたポリシーです。最後の検証でもこのポリシーファイルを使用します。ポリシーファイルの中身を追ってみます。
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の各コンポーネントと設定
AWS Lambdaを使った構成は上図のようになります。
AWS API GatewayはWebサービス向けのエンドポイントを持ち、そのAuthorizerとしてLambda Authorizerを設定します。Lambda AuthorizerとはAPI Gatewayの機能の一つで、API Gateway上にあるAPIへのアクセス制御をLambdaに委託するというものです。この機能のメリットは、自由にアクセス制御ロジックを変えられることです。
AWS Lambdaには、以下の2つのプログラムを動かします。
- i) Webサービス用
- ii) Go+OPA用 (前述のLambda Authorizerで使う)
AWS S3には、OPAが必要とする[A]ポリシーファイルを格納しておきます。
この構成を実現するには、Serverless Frameworkを使って、以下のような設定ファイル(serverless.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。
- そこから呼び出されるWebサービスの実態は
- パスが
- 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言語プログラム
では、OPAを実行するGo言語のプログラムbin/auth.go
の中身を見ていきます。
フェーズ(a) 初期化
Go言語のソースコードにおいて、以下の通りOPAを初期化していきます。実例を以下に示します。
メインポイント: S3からポリシーファイルを読み出し、rego.New
で初期化し、rego.Prepare
関数で準備処理をします。
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
関数でポリシーファイルを使った認可処理の結果を取得する
// 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がその役割を担ってくれるためです。ソースコードの内容は割愛させていただきます。
このフォルダ内で、以下のコマンドを実行します。
serverless deploy
これにより、AWS上のAPI Gateway/Lambda が自動で作成されます。
作成できたWebサービスのURLは以下のコマンドで確認することができます。
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がどのように機能するかを検証してみます。
3-1. リクエストが認可されるケース(OPAがtrue
を返す場合)
以下のコマンドを実行し、Webサービスに実際にアクセスすることで、OPAの認可処理を検証することができます。
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
を返す場合)
以下のコマンドを実行し、別のリクエストを送信してみます。
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プロダクツ統括本部 早川裕志さん
・株式会社日立製作所 研究開発グループ 肥村洋輔さん