電通国際情報サービス TechBlog

ISIDが運営する技術ブログ

Policy as Codeを実現する Open Policy Agent / Rego の紹介

こんにちは、Xイノベーション本部の柴田です。

このポストは 電通国際情報サービス Advent Calendar 2021 の5日目のポストです。 4日目のポストは加納さんの「リアルタイムレンダラーP3Dのご紹介」でした。

さて、このポストではOpen Policy Agentとポリシー言語Regoの紹介をしたいと思います。 前半ではRegoの文法を簡単に説明します。 後半では私がOpen Policy AgentとRegoを実際に使っていてハマった点をいくつかご紹介します。 このポストを読んでくださる方の役に立てば幸いです。

Open Policy Agentとは

Open Policy Agent(OPA)は汎用ポリシーエンジンです。 与えられた構造化データがRegoと呼ばれるポリシー言語で記述されたポリシーを満たしているか判定します。 OPAを使ってサービス本体とポリシーエンジンを疎結合にすることでポリシーの更新、デプロイ、バージョン管理などをサービス本体から分離できます。

※図は Open Policy Agent | Documentation から引用しています。

OPAには様々なユースケースがあります。いくつか例を挙げます。

  • Infrastructure as Code: Infrastructure as Codeではインフラの理想的な状態をyamlやHCL2などの構造化データとして宣言的に記述します。 そしてそれをTerraformなどのツールへ渡すことで宣言に従って実際のインフラを構築します。 OPAを使ってyamlやHCL2が満たすべきルール(=インフラの設定)をポリシーとして定義することでInfrastructure as Codeの品質を担保できます。 これは特に複数のチームが独自にサービスを構築・運用している場合にチーム横断で品質を向上する方法として有効です。
  • ログ分析: OPAを使ってセキュリティや監査の構造化ログを分析し、指定したポリシーを満たさない異常なログが出力されていないかチェックできます。 たとえばメルカリではGCPのセキュリティログをOPAを使って分析しています。 参考:Achieving Security Compliance Monitoring with Open Policy Agent and Rego - Speaker Deck
  • データパイプライン上のバリデーション: OPAを使ってデータパイプライン上を流れる構造化データが指定したポリシーを満たしているかチェックできます。 たとえば日本経済新聞社ではデータパイプライン上のトラッキングデータが正常に加工されているかOPAでチェックしています。 参考:【開催報告】新聞社による AWS を活用した DX 事例セミナー | Amazon Web Services ブログ

OPAのソースコードApache License 2.0 で公開されています。

Regoとは

RegoはOPAが使用する宣言型クエリ言語です。 Datalog(Prologのサブセットである宣言型論理プログラミング言語)にインスパイアされています。

まずは動かしてみる

公式の Example に掲載されている例題を手元で実行してみます。

設問

与えられた構造化データが以下のポリシーを満たしているか検証します。

  • インターネットから到達可能なサーバのうち、安全でない http プロトコルを使用しているものはないか。
  • telnet プロトコルを使用しているサーバはないか。

構造化データ(input.json

{
    "servers": [
        {"id": "app", "protocols": ["https", "ssh"], "ports": ["p1", "p2", "p3"]},
        {"id": "db", "protocols": ["mysql"], "ports": ["p3"]},
        {"id": "cache", "protocols": ["memcache"], "ports": ["p3"]},
        {"id": "ci", "protocols": ["http"], "ports": ["p1", "p2"]},
        {"id": "busybox", "protocols": ["telnet"], "ports": ["p1"]}
    ],
    "networks": [
        {"id": "net1", "public": "false"},
        {"id": "net2", "public": "false"},
        {"id": "net3", "public": "true"},
        {"id": "net4", "public": "true"}
    ],
    "ports": [
        {"id": "p1", "network": "net1"},
        {"id": "p2", "network": "net3"},
        {"id": "p3", "network": "net2"}
    ]
}

ポリシー(example.rego

上述の設問を検証するためにRegoで記述されたポリシーを使用します。

※Regoの文法は後ほど説明します。今はイメージだけ把握していただければ結構です。

package example

# allow のデフォルト値を false に設定します
default allow = false

# violation の要素数が 0 の場合、 allow を true に設定します
allow = true {
    count(violation) == 0
}

# 生成した集合 public_server の要素 server のうち protocols が "http" である要素の id を集合 violation の要素に追加します
violation[server.id] {
    some server
    public_server[server]
    server.protocols[_] == "http"
}

# 配列 input.servers の要素 server のうち protocols が "telnet" である要素の id を集合 violation の要素に追加します
violation[server.id] {
    server := input.servers[_]
    server.protocols[_] == "telnet"
}

# 配列 input.servers の要素 server のうち以下の条件を満たす要素を集合 public_server の要素に追加します
# 1. 配列 input.ports のうち server.ports のいずれかと id が合致する要素の添字を i とします
# 2. 配列 input.networks のうち input.ports[i].network と id が合致する要素の添字を j とします
# 3. input.networks[j].public が true である場合、 server を集合 public_server の要素に追加します
public_server[server] {
    some i, j
    server := input.servers[_]
    server.ports[_] == input.ports[i].id
    input.ports[i].network == input.networks[j].id
    input.networks[j].public
}

検証

ローカル環境で実行する場合は以下を行ってください。

  1. open-policy-agent/opa から最新のリリースバイナリをダウンロードして、実行パスの通ったディレクトリに配置します。
  2. opa eval コマンドでポリシーを評価します。
# 設問を満たしているか検証します。結果は "false" (満たしていない)でした。
$ opa eval -i input.json -d policy.rego --format pretty data.example.allow
false

# 設問のポリシーを満たしていないサーバは "ci","busybox" であることが確認できます。
$ opa eval -i input.json -d policy.rego --format pretty data.example.violation
[
  "ci",
  "busybox"
]

または The Rego Playground からも実行できます。

Regoの文法

Regoの文法を簡単に説明します。

本章で扱う構造化データ

本章( Regoの文法 )では以下の構造化データをサンプルデータとして使用します。

{
    "persons": [
        {
            "name": "alice",
            "age": 20,
            "height": 170,
            "weight": 60
        },
        {
            "name": "bob",
            "age": 22,
            "height": 180,
            "weight": 80
        },
        {
            "name": "carol",
            "age": 17,
            "height": 175,
            "weight": 66
        },
        {
            "name": "dave",
            "age": 18,
            "height": 155,
            "weight": 50
        }
    ]
}

変数

変数の束縛

Regoの変数は「指定された条件を満たすよう束縛された値」です。

例えば以下の場合、変数 i

  • 配列 persons のうち「20才以下かつ身長170cm以上の人」を指す任意の添字

の値、つまり 02 に束縛されます。

some i
persons[i].age <= 20
persons[i].height >= 170

手続き型プログラミング言語のように値を再び代入することはできません。

i := 1
i := 2  # i は既に 1 に束縛にされているためエラーになる

また特殊な変数として _ があります。 _ は登場する度に他の変数と競合しない一意な変数へ変換されます。再び参照する必要のない変数は _ を使うことが推奨されています。

_ を使って先程の例を書き換えると以下のようになります。

person := persons[_]
person.age <= 20
person.height >= 160

配列、集合、オブジェクトへのアクセス

Regoには以下のような型があります。

  • 数値
  • 文字列
  • ブール値
  • 配列
  • 集合
  • オブジェクト(辞書)

配列 arr から要素 val を取りだす際は以下のように書きます。

# 変数を使った書き方。iは配列の添字、valは配列の値に束縛される。
some i
val := arr[i]

# または `_` を使った書き方。
val := arr[_]

集合 a_set から要素 val を取りだす際は以下のように書きます。

# valは集合の各要素に束縛される
a_set[val]

オブジェクト obj のキー key にアクセスする際は以下のように書きます。

# `.` を使った書き方
value := obj.key

# または `[]` を使った書き方
value := obj["key"]

ルール

OPAでは、渡された構造化データ(ベースドキュメントとも言います)を元に、新しい構造化データ(仮想ドキュメントとも言います)を算出します。 この仮想ドキュメントの定義を ルール と言います。OPAでは様々なルールを定義することでポリシーを実装します。

集合を生成する

集合を生成するルールは以下のように記述します。

<name>[<value>] {
    <body>
}

このときルール <name> は「 <body> の式が全て真になるときの <value> 」を要素として持つ集合になります。 <body> の各式は論理積(AND)で結合されます。

以下は

  • 「年齢20才以下、身長170cm以上の人」の名前の集合

を生成するルールの例です。

young_and_tall_persons[person.name] {
    person := input.persons[_]
    person.age <= 20
    person.height >= 170
}

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

func youngAndTallPersons(input Input) []string {
    result := []string{}
    for _, person := range input.persons {
        if person.age <= 20 && person.height >= 170 {
            result = append(result, person.name)
        }
    }
    return result
}

オブジェクト(辞書)を生成する

オブジェクトを生成するルールは以下のように記述します。

<name>[<key>] = <value> {
    <body>
}

このときルール <name> は「 <body> の式が全て真になるときの <key><value> のペア」を要素として持つオブジェクトになります。 <body> の各式は論理積(AND)で結合されます。

以下は

  • 「年齢20才以下、身長170cm以上の人」について、名前と体重のペアを要素として持つのオブジェクト

を生成するルールの例です。

young_and_tall_persons_weight[person.name] = person.weight {
    person := input.persons[_]
    person.age <= 20
    person.height >= 170
}

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

func youngAndTallPersonsWeight(input Input) map[string]int {
    result := map[string]int{}
    for _, person := range input.persons {
        if person.age <= 20 && person.height >= 170 {
            result[person.name] = person.weight
        }
    }
    return result
}

値を生成する

値を生成するルールは以下のように記述します。

<name> = <value> {
    <body>
}

このときルール <name> は「 <body> の式が全て真になるときの <value> の値」になります。 <body> の各式は論理積(AND)で結合されます。

以下は「Aliceの身長」を返すルールの例です。

alice_height = person.height {
    person := input.persons[_]
    person.name == "alice"
}

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

func aliceHeight(input Input) int {
    result := 0
    for _, person := range input.persons {
        if person.name == "alice" {
            result = person.height
        }
    }
    return result
}

<vaule> には数値、文字列、真偽値だけでなく、集合、配列、オブジェクトなども指定できます。

= <value> 部を省略すると = true として扱われます。 例えば以下のルール alice_existsname キーの値が alice な要素が存在する場合のみ true になります。

alice_exists {
    person := input.persons[_]
    person.name == "alice"
}

{ <body> } 部を省略すると { true } として扱われます。定数はこの書き方を使って定義します。

pi = 3.14

複数の定義を書く

ルールは複数に分割して定義できます。 分割して記述されたルールは論理和(OR)のように評価されます。

以下は

  • BMIが18.5未満または25以上の人」の名前の集合

を返すルールの例です。

unhealthy_persons[person.name] {
    person := input.persons[_]
    bmi := (person.weight / (person.height * person.height)) * 10000
    bmi < 18.5
}

unhealthy_persons[person.name] {
    person := input.persons[_]
    bmi := (person.weight / (person.height * person.height)) * 10000
    bmi >= 25
}

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

func unhealthyPersons(input Input) []string {
    result := []string{}
    for _, person := range input.persons {
        bmi := float64(person.weight) / (float64(person.height) * float64(person.height)) * 10000.0
        if bmi < 18.5 || bmi >= 25 {
            result = append(result, person.name)
        }
    }
    return result
}

関数

関数は以下のように記述します。引数を取ること以外は 値を生成するルール と概ね同じです。

<name> (<args>) = <value> {
    <body>
}

以下はBMIを計算する関数と、BMIの分類を返す関数の例です。

bmi(height, weight) = (weight / (height * height)) * 10000

bmi_class(person) = "underweight" {
    bmi(person.height, person.weight) < 18.5
}

bmi_class(person) = "normal range" {
    bmi(person.height, person.weight) >= 18.5
    bmi(person.height, person.weight) < 25
}

bmi_class(person) = "overweight" {
    bmi(person.height, person.weight) >= 25
}

またOPA/Regoには組込み関数があります。よく使う組込み関数をいくつか紹介します。

# 書式付き文字列を評価して結果の文字列を返す
sprintf("%s's weight is %d", [person.name, person.weight])

# 配列、集合、オブジェクトの要素数を返す
count(young_and_tall_persons) == 0

# オブジェクトを文字列(json形式)に変換したものを返す
json.marshal(obj)

# --explainオプションをつけて実行した際に文字列を出力する(詳細は後述)
trace("...")

上で紹介した以外にも様々な組込み関数があります。詳細は Built-in Functions を参照してください。

内包表記

Regoでは内包表記を使用できます。

リスト内包表記

以下は

  • 「年齢20才以下、身長170cm以上の人」の名前の配列

を生成する内包表記の例です。

array := [person.name | person := input.persons[_]; person.age <= 20; person.height >= 170]

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

array := func(input Input) []string {
    result := []string{}
    for _, person := range input.persons {
        if person.age <= 20 && person.height >= 170 {
            result = append(result, person.name)
        }
    }
    return result
}(input)

集合内包表記

以下は 集合を生成する で例示した

  • 「年齢20才以下、身長170cm以上の人」の名前の集合

を内包表記で記述した例です。

a_set := {person.name | person := input.persons[_]; person.age <= 20; person.height >= 170}

これをGoで書いた場合のコードは リスト内包表記 と同じため割愛します(※あくまでイメージです)。

オブジェクト内包表記

以下は オブジェクト(辞書)を生成する で例示した

  • 「年齢20才以下、身長170cm以上の人」について、名前と体重のペアを要素として持つのオブジェクト

を内包表記で記述した例です。

obj := {person.name: person.weight | person := input.persons[_]; person.age <= 20; person.height >= 170}

これをGoのコードで書くと以下のようになります(※あくまでイメージです)。

obj := func(input Input) map[string]int {
    result := map[string]int{}
    for _, person := range input.persons {
        if person.age <= 20 && person.height >= 170 {
            result[person.name] = person.weight
        }
    }
    return result
}(input)

キーワードと演算子

not

式を否定するには not を使用します。

not person.age <= 20
  • ルールが仮想ドキュメントを生成したか
  • 配列や集合やオブジェクトの中に指定した要素が存在するか

を判定する際にも使用します。

not input.persons[4]
not person.birthday

some

some を使うことでルール内のローカル変数を明示的に宣言できます。

young_and_tall_persons[person.name] {
    some i
    person := input.persons[i]
    person.age <= 20
    person.height >= 170
}

ローカル変数の宣言を必ず行う必要はありませんが、変数名と同じルールが存在した場合でも期待通りローカル変数として扱われるよう、明示的に宣言することが推奨されています。

例えば以下のルール young_and_tall_personssome i の有無で結果が変わります。

i = 1

young_and_tall_persons[person.name] {
    some i
    person := input.persons[i]
    person.age <= 20
    person.height >= 170
}

default

default を使うことで 値を生成するルール のデフォルト値を指定できます。

以下の例では、ルール alice_existsinput.persons の中にAliceの要素が存在すれば true 、そうでなければ false になります。 default キーワードを省略すると、ルール alice_existsinput.persons の中にAliceの要素が存在しない場合 false ではなく未定義になります。

default alice_exists = false

alice_exists {
    person := input.persons[_]
    person.name == "alice"
}

in

in はOPAの v0.34.0 で導入されたキーワードです。

  • 配列、集合、オブジェクトがある値を要素に含んでいるか(または含んでいないか)判定する
  • 配列、集合、オブジェクトの要素に変数を束縛する

といったことができます。

OPA v0.34.2 時点では、 in キーワードを使うには import future.keywords.in を宣言する必要があります。

以下は配列、集合、オブジェクトがある値を要素に含んでいるか判定するルールの例です。

import future.keywords.in

# 名前の集合。結果は ["alice", "bob", "carol", "dave"] 。
person_names[person.name] {
    person := input.persons[_]
}

# 名前と体重のペアからなるオブジェクト。結果は {"alice": 60, "bob": 80, "carol": 66, "dave": 50} 。
person_weights[person.name] = person.weight {
    person := input.persons[_]
}

# 集合 person_names の中に "alice" が存在するか。結果は true 。
alice_exists {
    "alice" in person_names
}

# オブジェクト person_weights の中に値が 60 である要素(例: {"alice": 60} )が存在するか。結果は true 。
sixty_kg_person_exists {
    60 in person_weights
}

not と組み合わせて、配列、集合、オブジェクトがある値を要素に含んでいないかを判定できます。

# 集合 person_names の中に "ellen" が存在しないか。結果は true 。
ellen_does_not_exist {
    not "ellen" in person_names
}

# オブジェクト person_weights の中に値が 70 である要素(例: {"alice": 70} )が存在しないか。結果は true 。
seventy_kg_person_does_not_exist {
    not 70 in person_weights
}

左辺に引数を2つ渡すことで、配列、オブジェクトが「添字、キー」と「値」のペアを含んでいるかを判定できます。

# オブジェクト person_weights の中に要素 {"alice": 60} が存在するか。結果は true 。
is_alice_sixty_kg {
    "alice", 60 in person_weights
}
  • 配列、集合の定義
  • 関数の引数

などで in キーワードの左辺に引数を2つ渡す場合は、正しく評価されるよう () で囲む必要があります。

# 誤った書き方。「`0`と `2 in [2]` の2つの引数を受け取る関数f」として解釈される。
f(0, 2 in [2])

# 正しい書き方。「`0, 2 in [2]` の結果を1つの引数として受け取る関数f」として解釈される。
f((0, 2 in [2]))

some と組み合わせて、配列、集合、オブジェクトの要素に変数を束縛できます。

# 体重が60kgの人の名前の集合を返す。結果は {"alice"} 。
sixty_kg_persons[name] {
    some name, 60 in person_weights
}

# 結果は ["a", "r", "y"]
unique[x] {
    some x in ["a", "r", "r", "a", "y"]
}

with

ルールの評価時に with を使って

  • input
  • data.<path>

の構造化データを指定した値に置き換えることができます。

allow with input as {"user": "charlie", "method": "GET"} with data.roles as {"dev": ["charlie"]}

これは主に単体テストを書く際に使用します。テストの書き方は テストを書く で説明します。

モジュール、パッケージ

0個以上のルールの集合をモジュールといい、それを特定の名前空間にグループ化したものをパッケージといいます。 同じパッケージのポリシーを同じディレクトリに配置する必要はありません。

パッケージ名は package で指定します。

package example

他のパッケージのルールや関数を参照する場合は import data.<パッケージ名> のように宣言します。

import data.other_example

example_rule[value] {
    value := other_example.other_rule
}

テストを書く

単体テストを書くことでポリシーが期待通り動作するか確認できます。

テストコードはファイル名の末尾を _test.rego としてください。 例えば example.rego のテストコードのファイル名は example_test.rego です。

テストケースは名前が test_ から始まるルールとして記述します。ルールのbody部の式が全て真ならば成功、そうでなければ失敗です。

package example

test_young_and_tall_persons {
    input := {"persons": [
        {"name": "alice", "age": 20, "height": 170, "weight": 60},
        {"name": "bob", "age": 22, "height": 180, "weight": 80},
        {"name": "carol", "age": 17, "height": 175, "weight": 66},
        {"name": "dave", "age": 18, "height": 155, "weight": 50},
    ]}

    young_and_tall_persons == {"alice", "carol"} with input as input
}

test_no_young_and_tall_persons {
    input := {"persons": [
        {"name": "alice", "age": 30, "height": 170, "weight": 60},
        {"name": "bob", "age": 22, "height": 180, "weight": 80},
        {"name": "carol", "age": 27, "height": 175, "weight": 66},
        {"name": "dave", "age": 18, "height": 155, "weight": 50},
    ]}

    count(young_and_tall_persons) == 0 with input as input
}

テストは以下のように実行します。

$ opa test .
PASS: 2/2

試しにテストケース test_young_and_tall_personsアサーションの右辺を {"alice", "carol"} から {"alice", "bob"} へ書き換えてテストを失敗させます。 テストが失敗した場合は次のような結果になります。

$ opa test .
data.example.test_young_and_tall_persons: FAIL (1.466812ms)
--------------------------------------------------------------------------------
PASS: 1/2
FAIL: 1/2

opa test コマンドには以下のようなオプションもあります。

  • -v, --verbose :テスト結果の詳細を表示します。
  • -r, --run :指定したテストケースのみを実行できます。
  • -c, --coverate :テストカバレッジを出力できます。

OPA/Regoを利用するツール

OPA/Regoを利用するツールを簡単にご紹介します。

Conftest

ConftestKubernetesマニフェストファイルやTerraformコードなどの構成ファイルがRegoで記述されたポリシーに従っているか検証するためのCLIツールです。

以下はKubernetesのPodに Readiness Probe が設定されているか検証するポリシーの例です(※このポリシーは説明のために簡略化しており実運用には適していません)。

package deny_container_without_readiness_probe

violation[msg] {
    input.kind == "Pod"
    container := input.spec.containers[_]
    not container.readinessProbe

    msg := "Readiness Probe must be set"
}

先程のポリシーを用いて以下のマニフェストファイルを検証してみます。

apiVersion: v1
kind: Pod
metadata:
  name: myapp
spec:
  containers:
  - name: myapp
    image: myapp:1.0.0
    ports:
    - containerPort: 8080
$ conftest test --policy . --all-namespaces manifests.yaml
FAIL - manifests.yaml - deny_container_without_readiness_probe - Readiness Probe must be set

1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

マニフェストがポリシーに違反していることを検出できました。

ConftestはKubernetesマニフェストファイル以外の構成ファイルに対しても利用できます。公式ドキュメントの Examples には以下の構成ファイルを検証するサンプルポリシーが掲載されています。

  • AWS SAM Framework
  • CUE
  • Docker compose
  • Dockerfile
  • EDN
  • Ignore
  • HCL
  • HCL 2
  • HOCON
  • INI
  • Jsonnet
  • Kubernetes
  • Kustomize
  • Serverless Framework
  • Traefik
  • Typescript
  • VCL
  • XML

Gatekeeper

GatekeeperKubernetesValidating Admission Webhook として動作します。 kube-apiserverに対するリソース作成の要求を検証し、ポリシーに違反するリソースの作成を禁止できます。

KubernetesにおけるConftestとGatekeeperの使い分け

Conftestは主に開発者のローカル環境やCIサーバなどで実行されます。 gitリポジトリ上のマニフェストファイルが変更された際に、その変更がポリシーに従っているか検証し、即座に開発者へフィードバックできます。 ただし以下のようなケースはConftestで検知できません。

GatekeeperKubernetesのValidating Admission Webhookとして動作します。 kube-apiserverに対する全てのリソース作成の要求を検証し、ポリシーに違反するリソースの作成を禁止できます。 ただし kubectl createkubectl apply を実行して実際にkube-apiserverへリクエストを送信するまで、ポリシーに違反しているか検証できません。

ConftestとGatekeeperはどちらか片方だけ使うのではなく、両方組み合わせて使うのがよいでしょう。

主な実行場所 主な実行タイミング 検証対象
Conftest 開発者のローカル環境、CIサーバ マニフェストファイルの更新時 マニフェストファイル
Gatekeeper KubernetesのValidating Admission Webhook kube-apiserverへのリソース作成の要求時 kube-apiserverに対する全てのリソース作成の要求

ConftestもGatekeeperもRegoで記述されたポリシーを使用します。 しかし構造化データ(=マニフェスト)の渡し方などに差異があります。 ConftestとGatekeeperで同じポリシーを使用する場合、何らかの方法でこの差分を吸収する必要があります。

マニフェストの格納場所
Conftest( --combine オプションなし)(*1) input
Conftest( --combine オプションあり)(*1) input[_].contents
Gatekeeper input.review.object

(*1):Conftestは通常マニフェストファイルに含まれるリソースを1つずつ個別に評価します。 そのため、例えばDeploymentに対してPodDisruptionBudgetが定義されているかといったような、複数のリソースにまたがったポリシーを評価できません。 --combine オプションを設定することで、マニフェストファイルに含まれる全てのリソースを同時にRegoへ渡し、複数のリソースにまたがったポリシーを評価できます。

またConftestが使用する *.rego ファイルからGatekeeperが必要とするリソース( ConstraintTemplate など)を生成する必要があります。 これを行うツールに konstraint があります。

それ以外のツール

これまで紹介したツールの他に、以下のツールもRegoで記述されたポリシーを利用しています。

ハマったところ

私が実際にOPA/Regoを使っていてハマった点をいくつかご紹介します。

矛盾したルールを定義しない

ルールの定義が矛盾してはいけません。そのようなルールに矛盾を発生させる構造化データを渡すと実行時エラーが発生します。

いくつか悪い例を紹介します。

  • 以下は値を生成するルールです。ただし値が1つに定まりません。
height = person.height {
    person = input.persons[_]
}
  • 以下の例ではルールの定義が複数に分かれています。 1つ目の定義では「 nameweight のペアを要素として持つオブジェクト」を生成します。 一方、2つ目の定義では「 nameheight のペアを要素として持つオブジェクト」を生成します。 結果、生成されたオブジェクトの name キーの値は1つに定まりません。
personal_health_info[person.name] = person.height {
    person := input.persons[_]
}

personal_health_info[person.name] = person.weight {
    person := input.persons[_]
}

配列や集合がある条件を満たす値を含んでいないか検証するルールの書き方

配列 array や集合 a_set が関数 f を満たす値を含まないか判定するルールは以下のように記述できます。

# 配列が関数fを満たす値を含んでいないか
none_in_array_match {
    count({x | x := array[_]; f(x)}) == 0
}

# 集合が関数fを満たす値を含んでいないか
none_in_set_match {
    count({x | a_set[x]; f(x)}) == 0
}

特に配列 array や集合 a_set がある値 x を要素に含んでいないか判定したい場合は in を使って以下のように記述できます。

※OPA v0.34.2 時点では、 in キーワードを使うには import future.keywords.in を宣言する必要があります。

import future.keywords.in

# 配列がある値を含んでいないか
none_in_array_match {
    not x in array
}

# 集合がある値を含んでいないか
none_in_set_match {
    not x in a_set
}

以下の書き方は誤りです。 以下のルールは配列や集合に関数 f を満たさない値が1つでも存在すれば true になります。 配列や集合の全ての要素が関数 f を満たさないことは検証できません。

# 配列が関数fを満たす値を含んでいないか(間違った書き方)
none_in_array_match {
    x := array[_]
    not f(x)
}

# 集合が関数fを満たす値を含んでいないか(間違った書き方)
none_in_set_match {
    a_set[x]
    not f(x)
}

オブジェクトのフィールドが特定の値であるか検証するルールの書き方

オブジェクト obj のキー key の値が value であることを判定するルールは以下のように記述できます。

violation[msg] {
    not has_specific_value(obj)
    msg := "'obj.key' must be 'value'"
}

has_specific_value(obj) {
    obj.key == value
}

以下の書き方は誤りです。 「オブジェクト obj のキー key の値が value である場合」に加えて「オブジェクト obj にキー key が存在しない場合」にもルールのbody部が false になるためです。

violation[msg] {
    obj.key != value
    msg := "'obj.key' must be 'value'"
}

異常系テストのアサーションの書き方

以下は入力データの値が偶数かどうか判定するポリシーの例です。

violation[msg] {
    input.value % 2 != 0
    msg := "Value must be even"
}

上述のポリシーに対する異常系テストとして、最初私は以下のようなテストケースを記述していました。

test_odd {
    input := {"value": 1}
    violation with input as input
}

ですが上述のテストケースは下表の通り常に成功してしまいます。 これは {}空集合)が真として扱われるためです。 よってこのテストケースの書き方は誤りです。

value の値 violation の結果 test_odd の結果
奇数(例:1, 3, ...) {"Value must be even"} true
偶数(例:2, 4, ...) {}空集合 true

参考:open policy agent - Rego testing: how to test "not deny"? - Stack Overflow

正しくは以下のいずれかのように記述するとよいでしょう。上の方がより厳密に検証を行います。

# 例1:violationが指定した値に完全に一致するか確認する。
test_odd {
    input := {"value": 1}
    violation == {"Value must be even"} with input as input
}

# 例2:violationに指定した要素が含まれているか確認する。他の要素が含まれている可能性もある。
test_odd {
    input := {"value": 1}
    violation["Value must be even"] with input as input
}

# 例3:violationが空でないことを確認する。具体的な要素までは確認しない。
test_odd {
    input := {"value": 1}
    count(violation) > 0 with input as input
}

オブジェクトの存在しないキーを参照した際の挙動

オブジェクトの存在しないキーを参照するとルールが意図しない結果を返すため注意が必要です。

先程の偶数かどうか判定するポリシーを誤って以下のように記述したとします。

violation[msg] {
    input.number % 2 != 0  # 値が格納されているのは input.value だが誤って input.number を参照している
    msg := "Value must be even"
}

このとき input.value がどのような値でも violation空集合を返します。 これはルール violation のbody部における1番目の式が、 input.value の値にかかわらず、常に未定義(つまり真ではない)になるためです。

$ cat input_odd.json
{
    "value": 1
}

$ opa eval -i input_odd.json -d policy.rego --format pretty data.example.violation
[]

$ cat input_even.json
{
    "value": 2
}


$ opa eval -i input_even.json -d policy.rego --format pretty data.example.violation
[]

ここで問題なのは未定義が false と同様に扱われていることです。 ルールの中でオブジェクトの存在しないキーを参照して未定義が発生しても特に例外などは発生しません。 そのため正しくポリシーが評価されてルールのbody部が false になったのか、オブジェクトの存在しないキーを参照して未定義になったのか、区別するのは困難です。

現時点では、単体テストカバレッジを上げる以外に、この問題を解決するよい方法は見つけられていません。

またこれは「OPAのポリシーエンジン」と「それを呼びだす外部サービス」のインターフェース部分の仕様が変更された場合も問題になります。 例えばConftestでは v0.22.0--combine オプションをつけた際にRegoへ渡すデータの構造が変更されました(#388)。 結果、いくつかのルールがオブジェクトの存在しないキーを参照して必ず未定義になり、マニフェストがポリシーに違反していてもConftestの検証が常に成功し続けるという問題が起こりました。

これを防ぐ方法として、Regoの単体テストに加えて「OPAのポリシーエンジン」と「それを呼びだす外部サービス」の間の結合テストを実装することが挙げられます。 例えば私はConftestのテスト結果を conftest test -o json で出力しておき、Conftestをバージョンアップする際はテスト結果に意図しない変更が発生していないか確認するスナップショットテストを行うようにしています。

ポリシーのデバッグ方法

ポリシーの結果が期待通りでない場合、原因を調査する必要があります。ですがRegoの処理の流れは複雑で追うのが困難です。

ポリシーの挙動を把握する方法として --explain オプションを活用できます。

例として以下のポリシーと構造化データを --explain オプションをつけて評価します。

violation[msg] {
    trace(sprintf("input.value is %d", [input.value]))
    input.value % 2 != 0
    msg := "Value must be even"
}
{
    "value": 2
}

--explain オプションの値とそれに対する出力は以下のとおりです。

  • --explain=full :ルールがどのように評価されたか全て表示する。
$ opa eval -i input.json -d policy.rego --explain full --format pretty data.example.violation
query:1           Enter data.example.violation = _
query:1           | Eval data.example.violation = _
query:1           | Index data.example.violation (matched 1 rule)
policy.rego:3     | Enter data.example.violation
policy.rego:4     | | Eval __local3__ = input.value
policy.rego:4     | | Eval sprintf("input.value is %d", [__local3__], __local1__)
policy.rego:4     | | Eval trace(__local1__)
policy.rego:4     | | Note "input.value is 2"
policy.rego:5     | | Eval __local4__ = input.value
policy.rego:5     | | Eval rem(__local4__, 2, __local2__)
policy.rego:5     | | Eval neq(__local2__, 0)
policy.rego:5     | | Fail neq(__local2__, 0)
policy.rego:5     | | Redo rem(__local4__, 2, __local2__)
policy.rego:5     | | Redo __local4__ = input.value
policy.rego:4     | | Redo trace(__local1__)
policy.rego:4     | | Redo sprintf("input.value is %d", [__local3__], __local1__)
policy.rego:4     | | Redo __local3__ = input.value
query:1           | Exit data.example.violation = _
query:1           Redo data.example.violation = _
query:1           | Redo data.example.violation = _
[]
  • --explain=fails :式が false になった部分のみ表示する。
$ opa eval -i input.json -d policy.rego --explain fails --format pretty data.example.violation
query:1           Enter data.example.violation = _
policy.rego:3     | Enter data.example.violation
policy.rego:5     | | Fail neq(__local2__, 0)
[]
  • --explain=notestrace 関数(後述)の内容のみ表示する。
$ opa eval -i input.json -d policy.rego --explain notes --format pretty data.example.violation
query:1           Enter data.example.violation = _
policy.rego:3     | Enter data.example.violation
policy.rego:4     | | Note "input.value is 2"
[]

trace 関数を使うと --explain オプションをつけて実行した際に文字列を出力できます。 trace 関数の引数は文字列ですが sprintf 関数や json.marshal 関数と組み合わせることで文字列以外の値(数値やオブジェクトなど)も出力できます。 これを使ってprintデバッグできます。

--explain=full の出力を読み解くのはなかなか大変なので、最初は trace 関数と --explain=notes オプションを組み合わせてprintデバッグするのがよいでしょう。

Conftestでも --trace オプションをつけることでルールがどのように評価されたか表示できます。これは opa コマンドの --explain=full に相当します。

=:=== の違い

Regoには =:=== という似たような演算子があります。それぞれの違いは以下のとおりです。

記法 記述できる場所 コンパイルエラー ユースケース
:= ルール内 変数が既に定義されている場合に発生 ローカル変数を定義する
== ルール内 変数がまだ定義されていない場合に発生 値を比較する
= どこでも 変数が正しく参照できない場合に発生 クエリを表現する

:=== のかわりに = を使うことができます。 ただし :===コンパイル時に追加のチェックが働くため、使える場面ではなるべく = よりも :=== を使うとよいでしょう。

参考: Equality: Assignment, Comparison, and Unification

フォーマッタを使う

opa fmt --write . で現在のディレクトリ配下にあるポリシーのフォーマットを整えることができます。 複数人でポリシーを更新する際はこのコマンドを使ってインデントや改行の有無などを統一するとよいでしょう。

また opa fmt --fail . でポリシーのフォーマットが整っていなかった場合に0以外の終了コードを返すことができます。 CIに組み込んでポリシーのフォーマットが整っているか検証するとよいでしょう。

おわりに

このポストではOpen Policy Agentとポリシー言語Regoの紹介をしました。 前半ではRegoの文法を簡単に説明しました。 後半では私がOpen Policy AgentとRegoを実際に使っていてハマった点をいくつかご紹介しました。 このポストが読んでくださった方の役に立てば幸いです。

さて、明日は橋詰さんの「今年社内の技術書読書会で読んだ本!」です。 どんな技術書が紹介されるのか楽しみです!

最後までお読みいただきありがとうございました。

参考

執筆:@shibata.takao、レビュー:@ueba.yukiShodoで執筆されました