In this article
Practical Regoの始め方
この記事の内容の多くは公式のRegoリファレンスと重複しますが、この「入門」ガイドでは、PythonやJavaなどの命令型言語に慣れたプログラマーに役立つ、プログラミングパラダイム(論理型と手続き型)を重視しています。
通常のリンク(例:Wikipedia)は詳しい情報を示し、角括弧内のリンク(例:[1])はこの記事の記述の出典を示します。
この記事のコードスニペットはすべて、OPA v0.54.0で実行しています。
このブログ記事の草稿を読んで改善点を提案してくれたJasper Van der Jeugtに感謝します。
Regoの概要
Open Policy Agent(「OPA」)プロジェクトは、CNCFの卒業プロジェクトとして承認されて以来、大きな注目を集めています [0]。OPAは、認可フレームワークの適用に使用される汎用ポリシーエンジンです。ポリシーはRegoというクエリ言語で定義されます。この記事ではRegoを取り上げます。
Regoは、宣言型の論理クエリ言語であるDatalogを基盤としています。パターンマッチング、フィルタリング、反復処理のための構文を備えています。どちらも汎用クエリ言語である点で、RegoはSQLに似ています。ただし、SQLが表形式のデータを扱うよう設計されているのに対し、RegoはJSON形式のデータを扱います。宣言型の論理クエリ言語であるRegoには、ほかのプログラミング言語とは異なる特性があります。Regoを理解するには、これらの特性が重要です。
宣言型プログラミング
宣言型プログラミングでは、問題を解決する手順を明示するのではなく、そのロジックやルールを表現します。重視するのはどのようにではなく、何をするかです。一方、より一般的なプログラミングパラダイムは「命令型」と呼ばれ、命令によってプログラムの状態を変化させ、結果を生成します。宣言型言語の一般的な例には、SQL([1]、p. 79)やHTML [2] があります。HCLなどのInfrastructure as Code言語も、命令型の要素と組み合わせることは多いものの、通常は宣言型のアプローチを採用します [3]。多くのプログラミング言語は両方のパラダイムを組み合わせられますが、Python [4]など、通常は命令型プログラミング寄りです。
命令型プログラミングの例(Python):
def is_even(x):
remainder = x % 2
if remainder == 0:
return True
return False宣言型論理プログラミングの例(Rego):
is_even {
remainder == 0
remainder = input % 2
}
[プレイグラウンド]
Regoでは、ルール内の文の順序は重要ではありません。
論理プログラミング
Datalogは、形式論理に基づく宣言型プログラミングの一種である論理プログラミングパラダイムを採用しています [5]、[6]。論理プログラムは、問題領域を記述する関係や制約を定義する、いわゆる事実とルールで構成されます。Regoでは、これらの事実やルールを、Datalogでも使われる論理式ホーン節で表現します [6]。
次に推論エンジンが実行順序を決定し、問題の解を導き出します。つまり、エンジンはコードを複数の論理式に変換し、それらを解きます。このアーキテクチャは副作用を大幅に抑え、堅牢性を高めるため、形式検証にも適しています。
Regoでは、ほかの役割に加えてOPAが推論エンジンとして機能します。Regoの停止性を保証するため、再帰の一部の形式を防ぐ取り組みなどが行われています [7]、[8]。さらに、前述の設計上の選択により、Regoは計算上重要な特性である決定可能性も備えています [9]。決定可能性により、ポリシー評価時の意思決定プロセスは必ず結果を出します。そのため、Regoのポリシーが処理を停止できなくなることはなく、時間計算量も最適化されます。この堅牢性は、OPAと組み合わせてRegoを使う主な理由の1つでしょう。ビジネスアプリケーションの重要な処理経路にも組み込め、確実に役割を果たせます。
前述のとおり、Regoは事実とルールで構成されます。コード内の各文は論理ルールを表します。論理ルールを解決できない場合、それ以上コードを評価する必要はありません。そのため、コードの各行はすべて「真(truthy)」でなければなりません。RegoはDatalogを基盤としているため、この動作を引き継いでいます。Regoに固有のものではありませんが、Regoのポリシー作成者にとって重要な点です。この動作を把握しておくべき理由については、「落とし穴」のセクションをお読みください。
存在量化
プログラミングにおける論理量化子は、集合内の項目に対する文の適用範囲を定義します。
Regoは存在 量化(FOR ANYと考えてください)を適用します。つまり、集合内の少なくとも1つの項目について条件が真かどうかを確認します。
命令型プログラミング言語では、論理プログラミングのような量化を本質的には適用しません。ただし、集合内のすべての項目について条件が真かどうかを確認できる構文があり、これは全称 量化(FOR ALLと考えてください)のように機能します。
どちらの場合も、ループは集合内を反復処理します。そして、条件に一致する項目を探す(存在量化)場合と、すべての項目が条件を満たすか検証する(全称量化)場合とで、その動作が異なります。
存在量化の考え方は、クエリの条件を満たす「すべての変数割り当て」を特定することで実現されます [10]。
抽象的に聞こえるかもしれませんが、次のセクションでは、より実践的な文脈で説明します。
存在量化は多くのプログラマーにとってなじみのない概念であり、「落とし穴」のセクションで説明するように、ロジックのバグにつながる可能性があります。
エントリーポイント
Regoには、実行を開始する「main」関数がありません。代わりに、OPAにエントリーポイントを設定します。エントリーポイントはRegoルールを指す文字列です。たとえば、data.mypolicies.denyです。このエントリーポイントによって、OPAは名前空間mypolicies内のすべてのdeny Regoルールをクエリし、ルールの結果をすべて統合して返します。複数のエントリーポイントを指定できます。ルールの条件が満たされない場合、そのルールは「解決不能」とみなされ、最終結果には含まれません。
エントリーポイントを指定するだけでは、OPAを実行するには不十分です。外部ソースから取得したすべてのデータを含み、Rego内でグローバル変数として機能するinput ドキュメントが必要です。
最適化
この段階でRegoの最適化について触れるのは早すぎるように思えるかもしれません。しかし、予期しない動作につながる可能性があるため、特に知っておくべき最適化があります。
この最適化は「ルール評価の早期終了」と呼ばれ、ロジックのバグの原因になることがあります。公式ドキュメントでは、この仕組みが詳しく説明されています。しかし、高度なトピックであるため、Regoを使い始めたばかりの人は見落とすかもしれません。「落とし穴」のセクションでは、このリスクと回避策を紹介します。
この最適化の注意すべき点は、次のように説明されています。
ルール(またはルールの集合)で「早期終了」が可能な場合、ルール本体に一致するバインディングが1つ見つかると、そのルール内の反復処理はキャンセルされます。
package earlyexit.iteration
p {
some p
data.projects[p] == "project-a"
}
変数のバインディングが条件を満たすと、data.earlyexit.iteration.pの結果を変える可能性はなくなるため、それ以上反復処理は行われません。– OPAドキュメント
つまり、OPAはdata.projectsをループ処理し、project-aという文字列に一致する項目が見つかると停止します。これは存在量化の直接的な結果です。これは理にかなった正しい動作ですが、把握しておくことが重要です。
等価性
Regoには、意味の異なる等価演算子が3種類あります。
等価演算子 | 意味 |
|---|---|
| 比較演算子。変数の値を比較するために使用 |
| 代入演算子。変数に値を代入するために使用 |
| 単一化演算子。代入と比較を組み合わせるために使用 |
=演算子は、まず代入を行い、その後に比較します。代入に失敗した場合は、OPAが値を比較します。
# input
[1, 2, 5]
# code
default allow := false
allow {
input[_] = 5 # assignment fails, comparison evaluates to `true`
# (`input[_] := 5` creates error "cannot assign to ref")
}
# output
{
"allow": true
}
[プレイグラウンド]
`=`演算子を使う理由がない限り、使用は避けることをお勧めします[13]。代入には`:=`演算子を、比較には`==`演算子を使用してください。
ポリシー、ルール、関数
このセクションに進む前に、Regoのデータ構造であるスカラー値、文字列、複合値、内包表記を知っておくと役立ちます。
ポリシー
ポリシーとは、システムの動作や制約を定義するルールの集合を指す、Regoの上位概念です。たとえば、次のドキュメントはallowとdenyのRegoルールを含むポリシーです。
package mypolicy
allow {
input.name == "bar"
}
deny {
input.name == "foo"
}
ルール
ルールは、実行時に計算されるデータ構造、いわゆる「仮想ドキュメント」を生成します。ルールについて詳しくはこちらをご覧ください。例:
# input
[
"world-a",
"universe-a",
"world-b",
"universe-b"
]
# code
worlds[world] {
item := input[_] # loop over `input`
startswith(item, "world")
world := item # var `world` is return value
}
universes[universe] {
item := input[_]
startswith(item, "universe")
universe := item
}
# output
{
"universes": [
"universe-a",
"universe-b"
],
"worlds": [
"world-a",
"world-b"
]
}
[プレイグラウンド]
戻り値が定義されていない場合、ドキュメントの説明どおり、Regoルールはtrueを返します。> valueが省略された場合、デフォルトでtrueになります。– ドキュメント
したがって、次の2つのルールは同じ動作をします。
deny := true if { # rule header
true # rule body
}
deny { # optimized rule header
true
}前述のとおり、ルールは本体内のすべての条件が満たされた場合にのみ実行を完了します。そのため、次のルールは何も返しません。
allow := true {
false
}set型またはobject型のデータを返すルールはインクリメンタルルールと呼ばれ、構文が少し異なります。
# input
[
"a",
"b",
"c",
"c"
]
# code
return_set[item] {
item := input[_]
}
return_object[key] := value {
value := input[key]
}
# output
{
"return_object": {
"0": "a",
"1": "b",
"2": "c",
"3": "c"
},
"return_set": [
"a",
"b",
"c"
]
}
[プレイグラウンド]
Regoルールはパラメーターをサポートしません。たとえば、return_set(param1は無効なルールコードです)。代わりに[関数][#functions]を使用できます。
存在量化
OPAのルール実行動作は次のように定義されています。ルール本体を評価する際、OPAはすべての式を真にする変数バインディングを検索します。– ドキュメント
命令型言語では全称量化はどのように機能するでしょうか。Pythonの例を見てみましょう。
for n in numbers: # think "FOR ALL" items `n` in numbers
if n % 2 == 0:
print(n)このコードスニペットは、配列numbersから偶数を見つけます。同じ処理はRegoでも実行できます。たとえば、次のようになります。
even_numbers[n] {
n := input[_]
n % 2 == 0 # think "FOR ANY" item `n` in inputs
}上の例では、キーワードsomeを使って変数nを宣言しています。OPAは配列inputから、偶数という条件を満たす数値nを探します。条件が真の場合、変数nのバインディングが返されます。
一致する変数バインディングをすべて特定することで、OPAは存在量化が満たされているかどうかを判断します。
必須ではありませんが、someを使って変数を明示的に宣言すると、明確さや読みやすさが向上します。
文字列1で始まるオブジェクト内のすべての値を見つける、別の例を見てみましょう。
# input
{
"a": "1a",
"b": "2b",
"c": "1c"
}
# code
prefixed[item] {
item := input[_]
startswith(item, "1")
}
# output
{
"prefixed": [
"1a",
"1c"
]
}[プレイグラウンド]
item := input[_]という行は、入力ドキュメントを反復処理します。2番目の入力項目bはルール条件startswith(l, "1")を満たしません。OPAはループの実行を中断せず、反復処理の結果を無視して次の項目cに進み、「すべての変数バインディングを検索」し続けます。
この動作は便利ですが、ロジックのバグにつながる可能性があります。
関数
Regoの関数はルールに似ていますが、動作は異なります。
greet(name) := msg {
msg := sprintf("Hello %s!", [name])
}
greeting := greet("Bob") # returns "Hello Bob!"パラメーターのない関数はサポートされていません。このような定義はルールとして解釈されるためです。
greet() := msg {
msg := "Hello"
}
greet() # creates error "rego_parse_error:
# rule argument list must take at least one argument"ルールは仮想ドキュメントを表し、関数は表さないため、それぞれ異なる方法でアクセスする必要があります。
# input
[
[1, 1],
[2, 2],
[3, 3]
]
# code
add_function(inp) := result { # define function
result := [ sum | # create array comprehension
arr := inp[_]
sum := arr[0] + arr[1]
]
}
output := add_function(input) # store result of function
dummy_rule_iterate_function_output[item] {
item := output[index] # work with function result
}
add_rule = result { # define rule
result := [ sum | # create array comprehension
arr := input[_]
sum := arr[0] + arr[1]
]
}
dummy_rule_iterate_rule[item] {
item := add_rule[index] # work with rule result
}
# output
"add_rule": [
2,
4,
6
],
"output": [
2,
4,
6
]
[プレイグラウンド]
ルールと関数はどちらも、ポリシー間で機能を共有するために使用できます。
制御フロー
基本
複数の条件を確認するロジックは、すべての条件を1行ずつ列挙して構成します。
# input
{
"name": "foo"
}
# code
my_rule {
startswith(input.name, "f") # first condition (AND..)
endswith(input.name, "o") # second condition
}
# output
{
"my_rule": true
}
[プレイグラウンド]
ルールがtrueと評価される複数のケースで制御フローを作るには、ルール(定義ごとにルールヘッダーは同じにする)を複数回定義します。いずれかのルールがtrueと評価されれば、戻り値はtrueになります。ルールがコレクションを返す場合、ルール条件を満たす項目がすべて統合されて返されます。
次の例では、my_ruleルールはブール値を返します。
# input
{
"name": "foo"
}
# code
my_rule {
count(input.name) == 3
}
my_rule {
input.name == "foobar" # not true; aborts execution
}
# output
{
"my_rule": true
}[プレイグラウンド]
elseキーワードを使うと、命令型言語と同様にif/else構文を実装できます。
# input
{
"name": "foo"
}
# code
my_rule := msg {
input.name == "foo"
msg := "Hello foo!"
} else := msg {
msg := "Hello anonymous"
}
# output
{
"my_rule": "Hello foo!"
}[プレイグラウンド]
否定ロジックはnotキーワードを使って実装します。
# input
{
"a": "1a",
"b": "2b",
"c": "1c"
}
# code
prefixed[item] {
item := input[_]
not startswith(item, "1")
}
# output
{
"prefixed": [
"2b"
]
}[プレイグラウンド]
not演算子は論理否定に使用し、!=演算子は値を比較して不等であることを確認するために使用します。
ループ
Regoではループを反復処理と呼び、詳しく解説されています。ただし、通常は存在量化を使うため、ループは必要ありません。
構文の曖昧さ
Regoでは、セット、オブジェクト、配列の値を反復処理する場合とアクセスする場合に、同じ構文を使います。そのため、使用するデータ構造を把握しておくことが重要です。同じ構文でも、データ構造によって制御フローの動作が変わります。
# input
{
"array": [0, 3, 5],
"object": {"foo": "bar"}
}
# code
iterate_array[msg] { # returns array of strings
value := input.array[index] # loop over array
msg := sprintf("%d: %d", [index, value])
}
access_key_in_object := v { # returns string
v := input.object["foo"] # get value by key "foo"
}
create_set := s {
s := { v |
v := input.array[_]
}
}
iterate_set[v] { # returns array of integers
create_set[v] # iterate over set
}
check_key_in_object { # returns `true`
input.object["foo"] # check if key "foo" exists
}[プレイグラウンド]
文脈なしにステートメントを読んでも、Regoコードの意味を必ずしも判断できるとは限りません。
例1:
var3 := var1[var2]
var1のデータ構造が…の場合
| その場合、 |
| その場合、 |
例2:
var1[var2]
var1のデータ構造が…の場合
| その場合、 |
| その場合、 |
| その場合、 |
落とし穴
あらゆるコードベースに欠陥は入り込みます。検出と防止を自動化することは効果的な対策であり、どのようなRego開発プロセスにも組み込むべきです。
これを実現する便利なツールを2つ紹介します。
OPAには、コンパイルエラーやコードの問題点の特定に役立つ
checkという組み込みコマンドが用意されています。
OPAを開発するStyraは、コードスタイルからバグまで、さまざまな問題をソースコードから分析するRego用リンター、
regalをリリースしました。
早期終了の最適化
「最適化」セクションで説明したように、Regoはコード全体を実行する前に最終結果を判断できる場合、処理を早期終了します。
ユーザーから会社へのすべての接続が、いずれかの支社から発信されている場合にのみアクセスを許可するポリシーを作成するとします。
# input
[
"on-prem",
"remote",
"on-prem",
"on-prem"
]
# code
default allow := false
allow {
input[_] == "on-prem"
}
# output
{
"allow": true
}[プレイグラウンド]
このポリシーでは、接続の1つが社外から発信されているにもかかわらず、結果はtrueになります。論理プログラミングに慣れていないプログラマーには、この動作が直感的でない場合があります。
たとえば、Pythonでは次のようなコードになり、意図どおりに動作します。
origins = ['on-prem', 'remote', 'on-prem', 'on-prem']
def allow():
for origin in origins:
if origin != 'on-prem':
return 1
return 0
if __name__ == '__main__':
raise SystemExit(allow())このロジックのバグはRegoの早期終了最適化に起因します。Regoはallowのルール条件がtrueと評価できた時点で処理を停止します。最初の反復で発信元がon-premに束縛されると、存在量化の条件が満たされるためです。これは、命令型プログラミングでoriginsの各要素の条件を評価してから判断する場合とは大きく異なります。
このバグを修正するには、全称量化(FOR ALL)を適用する必要があります。
修正方法の1つは、comprehensionを使うことです。
default allow := false
allow {
all_allows := [ allowed |
origin := input[_]
origin == "on-prem"
allowed := true
]
count(all_allows) == count(input)
}[プレイグラウンド]
もう1つの方法は、v0.38.0でOPAに導入されたeveryキーワードを使うことです。[14] [15]:
import future.keywords.every
default allow := false
allow {
every origin in input {
origin == "on-prem"
}
}[プレイグラウンド]
3つ目の方法は、否定を使ってルールのロジックを反転させることです。
# input
[
"remote",
"on-prem",
"on-prem"
]
# code
default deny := false
deny {
input[_] != "on-prem"
}
# output
{
"deny": true
}[プレイグラウンド]
このdenyルールは、ユーザーの接続のいずれかが会社のオフィス以外から発信されている場合に、期待どおりtrueを返します。
ただし、everyキーワードを使うのが最も適切です。全称量化を明示的に適用するため、ポリシー作成者の意図をより明確かつ意識的に表現できます。[14]
反復処理を含むルールを作成するときは、全称量化と存在量化のどちらが意図した動作を実現するかを検討しましょう。
未定義の値
上記の「概要」セクションで説明したように、コード行がtrueと評価されない場合、コードの実行は停止します。明確に言うと、条件を満たさず論理ルールを解決できない場合、命令型言語に慣れたプログラマーが予想するようにfalseを返すのではなく、コードの実行が単に停止します。これはundefinedの値や属性にも当てはまります。[17]
たとえば、存在しない名前空間やオブジェクト属性にアクセスすると、Regoコードはundefinedと評価されます。
意図しない動作につながり、デバッグが難しくなる可能性があります。
# input
{
"message": "world"
}
# code
deny {
input.mesage == "world"
}
# output
{}[プレイグラウンド]
属性input.mesageにはタイプミスがあり、存在しません。この属性が存在するかundefinedかをRegoが判断できるのは実行時のみのため、OPAはこの行の評価時に何も通知せずに停止します。
この種の問題を調査するには、コードベース内の複数の場所にprint()ステートメントを配置して、問題のあるコード行を絞り込むと効果的です。パッケージの名前空間も忘れずに確認してください。
問題のある行を見つける別の方法として、コードベースの大部分を無効化(コメントアウト)し、問題が明らかになるまで段階的に再度有効化する方法があります。
JSONの「true」
OPAへのinputドキュメントはJSON形式のデータで、ブール値と文字列の両方をサポートしています。[18] 実際には、ブール値が文字列(例: 「true」)で表されることがあり、意図しないポリシー判断につながる可能性があります。たとえば、次のようなケースです。
# input
{
"privileged": "true"
}
# code
deny {
input.privileged == true
}
# output
{}[プレイグラウンド]
修正方法として、文字列を考慮するヘルパー関数を用意できます。
1# input
2{
3 "privileged": "true"
4}
5
6# code
7is_true(b) := ret {
8 is_boolean(b)
9 b
10 ret := b
11} else := ret {
12 b == "true"
13 ret := true
14} else = false {
15 true
16}
17
18deny {
19 is_true(input.privileged)
20}
21
22# output
23{
24 "deny": true
25}[プレイグラウンド]
テスト
ポリシーが期待どおりに動作することを確認するには、テストが欠かせません。OPAには、テストを簡単に実行できるtestコマンドが用意されています。
次のポリシー(ファイルpolicy.rego)があるとします。
package mypolicy
deny {
input.privileged == true
}テスト(ファイルpolicy_tests.rego)は簡単に実装できます。
package mypolicy
test_deny_privileged {
deny with input as {"privileged": true}
}
test_deny_unprivileged {
not deny with input as {"privileged": false}
}コマンド./opa test policy.rego policy_tests.regoを実行すると、テストが実行され、結果PASS: 2/2が表示されます。
デバッグ
デバッグは、あらゆるコーディングプロジェクトで重要です。OPAにはREPLとevalコマンドが用意されており、デバッグ用に活用できます。しかし、コードをステップ実行してシンボルを調べられるgdbのような一般的なデバッガーは、まだありません。そのため、変数の値を出力する方法は、今も重要なデバッグ手法です。
この例では、前章の「テスト」で作成したテストに、入力「true」でポリシーを実行するテストケースを追加します。
test_deny_string {
deny with input as {"privileged": "true"}
}予想どおり、テストケースは失敗します。
$ ./opa test policy.rego policy_test.rego
policy_test.rego:
data.mypolicy.test_deny_string: FAIL (85.375µs) --------------------------------------------------------------------------------
PASS: 2/3
FAIL: 1/3
問題を調査するには、print()を使ってinput.privilegedの値を表示できます。
deny {
print(sprintf("value of `privileged`: %v", [input]))
input.privileged == true
}テストケースをもう一度実行すると、値が表示されます。
$ ./opa test policy.rego policy_test.rego
policy_test.rego:
data.mypolicy.test_deny_string: FAIL (88.875µs)
value of `privileged`: {"privileged": "true"} --------------------------------------------------------------------------------
PASS: 2/3
FAIL: 1/3
出力からinput.privilegedのデータ構造が明らかになりました。ブール値ではなく文字列であるため、修正できます。
OPAでは、リリースv0.34.0からprint()関数を利用できます。幸い、print()はundefinedの値があっても停止せず、代わりに<undefined>と表示します。
コードのデバッグやポリシーのテストなどを簡単に行える、便利なサードパーティ製ツールとしてfregotがあります。
よくあるエラー
Complete rules must not produce multiple outputs
同じ定義に属する1つ以上のルールが、同じ入力に対して異なる出力を生成すると、このエラーが発生します。例:
# input
-
# code
a_rule := res {
res := "b"
}
a_rule := res {
res := "c"
}
# outputpolicy.rego:7: eval_conflict_error: complete rules must not produce multiple outputs
[プレイグラウンド]
a_ruleの最初の定義は「b」を返し、2つ目の定義は「c」を返すため、動作が非決定的になります。
多くの場合、すべての戻り値が有効で、和集合が期待される出力です。その場合は、セットを返すことで実現できます。
# input
-
# code
a_rule[res] {
res := "b"
}
a_rule[res] {
res := "c"
}
# output
{
"a_rule": [
"b",
"c"
]
}[プレイグラウンド]
もう1つの一般的な方法は、2つのルールを統合してコレクションを返すことです。どちらの方法でも意図した解決策にならない場合は、ポリシーのロジックを見直す必要があるかもしれません。
重要な処理でのOPAの活用
RegoとOPAは、ポリシーの作成と評価に信頼性の高い手段を提供します。決定可能性と終了性が保証されるため、安心して強力な認可フレームワークを構築でき、ビジネスアプリケーションの重要な処理に組み込むことができます。
ただし、Regoを使うにはコストも伴います。「概要」セクションで説明した存在量化やOPAの早期終了最適化など、論理プログラミングの概念に慣れていないプログラマーも多いためです。
その結果、プログラマーは最初の急な学習曲線を乗り越える必要があり、導入の障壁となります。また、潜在的な落とし穴も考慮し、OPAの導入コストを適切に評価して、より成熟した開発者ツールエコシステムがあり、より多くのプログラマーが利用できる命令型プログラミング言語など、他の方法と比較する必要があります(もちろん、命令型言語にも落とし穴はあります)。
たとえば、副作用のリスクが許容され、市場投入までの時間が重要なソフトウェアを開発する場合は、Regoを選択したときの影響を考慮して判断すべきです。