📙

Regoの記述例1 (OPAサーバの認可とテスト)

2021/12/12に公開

この記事はOPA/Regoアドベントカレンダーの12日目です。

今回は具体的なユースケースに基づいてRegoの記述例を紹介したいと思います。公式でも既存のインテグレーション事例がいくつか紹介されており、Kubernetes を題材に使用例も解説されています。

APIサーバへのアクセスを制限するポリシー

opa コマンドはCLIでポリシーを評価するだけでなく、サーバの機能も提供されています。そのサーバに対するアクセス制御もRegoで記述できるようになっています。詳しい仕様については公式ドキュメントに解説を譲り、今回は具体的な記述について説明していきたいと思います。

今回用意したポリシーは以下で、目的は「/v1/data/* に対して GETPOST のアクセスだけを許可する」になります。

package system.authz

default allow = false

allow {
    allowed_method
    allowed_path
}

# Check method
allowed_method {
    input.method == "GET"
}
allowed_method {
    input.method == "POST"
}

# Check path
allowed_path {
    print(input.path)
    input.path[0] == "v1"
    input.path[1] == "data"
}

それでは、上から順番に解説していきます。

package system.authz

まずはパッケージ名の宣言です。基本的にパッケージ名は利用者側で自由に利用できますが、system はOPA自身によって予約されています。(他にも datainput が予約語としてあり、勝手に利用できない)管理運用の面から考えても、評価したい対象をパッケージ名で表現・区別するというやりかたは比較的メジャーなやりかたと思われます。

opa コマンドは --authorization=basic というオプションが与えられた際、 system.authz パッケージをクエリして allow が真だった場合はアクセスを許可、偽だった場合に拒否するという動作をします。

default allow = false

今回は allow のデフォルトを false にして、条件に一致しないものはすべて偽 → アクセス拒否となるようにしました。

allow {
    allowed_method
    allowed_path
}

そしてこれが判定に用いられる allow 変数になります。この書き方は特定の値を代入していないため、{ ... } 内の式がすべて真だった場合(積条件)には allowtrue が代入されます。

allowed_method {
    input.method == "GET"
}
allowed_method {
    input.method == "POST"
}

allowed_method はその名の通り許可されているメソッドかどうかをチェックします。見てわかるかと思いますが、system.authz パッケージでリクエストの認可判定をするために、 input にリクエストの内容が詰め込まれて渡されます。どのような値が入るかは公式ドキュメントを参照していただきたいですが、基本的にHTTPリクエストに出現する情報は一通り引き渡されます。また今回は扱いませんがjwtの検証をする組み込み関数も用意されていたりするので、認証もやろうと思えば実装できます。

さて今回は先述したとおり GET もしくは POST のどちらかであれば許可するとしています。先程の allow { ... } のような書き方だと論理積扱いになってしまうため、このように2つに分けて記述しています。これは厳密にはOR条件ではなく、両方の allowed_method が評価されて結果が代入されます。ただし、内部の条件が偽の場合は「false を代入しようとする」のではなく「何もしない」になります。さらにOPAは二重に「別の値」を代入しようとするとエラーになりますが、同じ値が何度も代入される分にはエラーになりません。これらの特性から、このルールは二重代入とは扱われずに実質OR条件のように振る舞います。

allowed_path {
    input.path[0] == "v1"
    input.path[1] == "data"
}

最後はパスの確認です。パスはinput.path/ で分割されて格納されています。例えば /v1/data/foo/bar にアクセスした場合は ["v1", "data", "foo", "bar"] = input.path となります。今回はシンプルにprefixが /v1/data であることを確認したいため、

system.authz のテスト

ポリシーの記述例を紹介したので、併せてテストの実例も紹介したいと思います。

基本のテスト

まずはシンプルに1つずつ記述したテストになります。テストの解説でふれたとおり、withas キーワードを使用することで input の値を上書きし、methodpath に任意の値を入れてテストしています。

authz_test.rego
package system.authz

test_allow_get {
	allow with input as {
		"path": ["v1", "data", "foo"],
		"method": "GET",
	}
}

test_allow_post {
	allow with input as {
		"path": ["v1", "data", "foo"],
		"method": "POST",
	}
}

上記例は成功ケースで、失敗ケースも記述できます。以下の例では禁止されている DELETE メソッドや /v1/data 以外のパスにアクセスした場合に拒否されていることを確認しています。

test_disallow_method {
	not allow with input as {
		"path": ["v1", "data", "foo"],
		"method": "DELETE",
	}
}

test_disallow_path {
	not allow with input as {
		"path": ["v1", "policy"],
		"method": "GET",
	}
}

Table driven test

ここまでの例のように愚直に with キーワードで値を書き換えていくという書き方ももちろんできるのですが、テストの量が多くなってくると全体の見通しが悪くなったり、どのテストが失敗しているのかが若干わかりにくくなる[1]という問題があります。この課題を解決するために、Go言語などでよく見られるTable driven testで記述するというアプローチがあります。こちらのブログで事例として紹介されていたので、少しアレンジしたものを今回のケースに合わせて紹介します。

test_authz {
	count(authz_failed_results) == 0
}

authz_failed_results[failed] {
	tests := [
		{
			"title": "GET method is allowed",
			"input": {
				"path": ["v1", "data", "foo"],
				"method": "GET",
			},
			"exp": true,
		},
		{
			"title": "POST method is allowed",
			"input": {
				"path": ["v1", "data", "foo"],
				"method": "POST",
			},
			"exp": true,
		},
		{
			"title": "DELETE method is not allowed",
			"input": {
				"path": ["v1", "data", "foo"],
				"method": "DELETE",
			},
			"exp": false,
		},
		{
			"title": "other than /v1/data is not allowed",
			"input": {
				"path": ["v1", "policy"],
				"method": "GET",
			},
			"exp": false,
		},
	]

	t := tests[_]
	result := allow with input as t.input
	t.exp != result
	failed := sprintf("failed test '%s'. expected %v, but got %v", [t.title, t.exp, result])
	print(failed)
}

流れを説明すると、 tests に格納されたテストケースを t := tests[_] で1つずつ取り出し、検査していくという手法をとっています。Regoは基本的に状態を持たず、入力に対して一意の結果を返させるという使いかたが主なため、table driven testとも相性がいいと考えられます。

この記述方法のポイントは test_authz でテストをせず、 authz_failed_results 内で失敗するテストを探索し、失敗したテストがなかった( count(authz_failed_results) == 0 )場合にテスト成功と判定しているところです。これは test_authzt := tests[_] を使って検査した場合、1つでも一致するものがあると test_authz は成功したとみなされる、すなわちテストはPassしたとみなされてしまうためです。これを回避するために1つでも失敗するテストケースがあったら失敗する、というような検証をする必要があるわけです。

記述量の面でいうと普通にテストを書いた場合とそれほどかわりありませんが、テストの条件がまとまっていて見やすい点、およびテスト失敗時のメッセージを自由にカスタマイズできるというメリットがあります。例えば、上記の例だと以下のようなメッセージが出力されます。

failed test 'other than /v1/data is not allowed'. expected true, but got false

この他にもテストの入力値を表示させるなどにより、なぜテストが失敗したのかがわかりやすくすることができます。

脚注
  1. テスト失敗時にスタックトレース的なものは表示されるのですが、パッと見で何が失敗しているのかは判別しづらいです。 ↩︎

Discussion