Open Policy Agentを始めてみよう

www.openpolicyagent.org

Open Policy Agent(以下OPA)を最近ちょっとずつ耳にすることが多くなってきた気がします。KubeCon + CloudNativeCon 2017 - Austinで「How Netflix Is Solving Authorization Across Their Cloud」を見たときに「こんな感じに認可を外に出せたらなー」と思った記憶があります。 この記事ではOPAの簡単な紹介と、公式TutorialのGetting Startedを実践した記録を共有します!

OPAとは

OPAは汎用的なPolicy Engineと言われています。Policy Engineとは、定義されたルールに従って 判断を下すことができる専門家 です。Policyの中でも「やっていいかどうか(見ていいかどうか)」に関するものが「Access Policy」と呼ばれ、馴染みがあるかと思います。

例えばWebサービスでユーザーAの情報をログイン中のユーザーが見ていいかどうか、というのをDBに見に行くときに次のようなコード(ここではJavaScript)に見覚えがあるはずです。

// ログイン中のユーザーがAさんのマネージャーだったら参照してOK
const canView = login.id === userA.manager_id;

if (canView) {
    // DBからAさんの情報を取得する
}

これはこれですぐに読めるコードですが、OPAを使った場合なら次のように書くことができます。

// Aさんの情報を参照していいかどうかチェックする
const canView = opa.checkCanView(userA);

if (canView) {
    // DBから取得する処理を記述する
}

見ていいかどうかの 判断 がOPAにおまかせ(委譲)になった、というのが大きな違いです。こうすることでメインのアプリケーションでは「やっていいかどうか」を判断する必要が無くなり、OPAに 聞けばOK となります。

  • 【注意その1】OPAは別のサービスとして立ち上がっているので、 opa.checkCanView の中では、OPAへのHTTPリクエストが実行されていると思ってください。
  • 【注意その2】上の例はOPAを使ってREST APIリクエストの認可を行う例です。OPAが利用できる場面はこれだけではなく、Docker, SSH, sudo, Kafka, Kubernetes Admission Controlなど幅広いです。詳しくは 公式のTutorial を参照ください。

また、 login.id == userA.manager_id というコードはとても繊細で、 id だったり manager_id が固定(ハードコード)されてしまっています。この条件が変わると、メインのアプリケーションのソースコードに影響が出てしまいます。ちょっと考えただけでも下の理由で壊れてしまいそうです。

  • manager_idmanager.id のように構造が変わった
  • 「ユーザーAのマネージャー」という観点ではなく、「ログイン中のユーザーの部下」という観点に変わった
    • 例: login.subordinates.include(userA.id)
  • 「特定の時期に入社していること」が条件に加わった
    • login.id == userA.manager_id && userA.joined_at.year >= 2019

OPAにおまかせしていれば、ユーザーAというコンテキストが変わらない限りは、メインのアプリケーションへの影響はありません。変更はOPA側で吸収できます。

「やっていいかどうかは専門家におまかせ」で、メインのアプリケーションは本来届けたいと思っている価値の部分に集中できます。

それでは、OPAの仕組みがどうなっているのかというのを見てみましょう!

OPAのPolicy言語Rego

「ログイン中のユーザーが、あるユーザーのマネージャーだったら○○を許可する」というのはPolicyの一例です。OPAは定義されたPolicyに従って、リクエストに応じた判断を返してくれます。そして、Policyを書く際にRegoと呼ばれるOPA用の言語を使うというのが大きな特徴です。

Regoは、プログラミングを経験したことがある人ならなんとなーく読めるのではないでしょうか?長くなってしまうので詳しい説明についてはこの記事では割愛しますが、公式ドキュメントを一度は読んで見ることをおすすめします。

www.openpolicyagent.org

今のところRegoを書く際に最大限気をつけないといけないと思っていることは = の挙動です。

x := 7 declares that x is a variable local to the rule. Furthermore, the compiler will stop you from writing two such assignments in the same rule, e.g. x := 7; x := 8 creates a compile-time error.

Neither of those are true with x = 7. The rule x = 7 will assign x to 7 if x is unbound, and it will compare x to 7 if x is already bound. Also, if there is a global variable named x, x = 7 will compare that global value of x to 7.

In short, use := if you want assignment inside a rule.

何を言っているのかと言うと、要は変数に値が入っているかどうかで = は挙動が変わるということです。変数に何も入っていなければ 代入 になり、入っていれば 比較 になります。

OPAはRego用のREPLを用意しているので実際にやってみましょう。

$ docker run -it --rm openpolicyagent/opa run

> x = 7
Rule 'x' defined in package repl. Type 'show' to see rules.
> x = 7
true
> x = 8
undefined

1回目は x に何も値が入っていないので 代入 になります。2回目は x に既に値が入っているので 比較 になって、結果が true になっているのがわかります。3回目は直感的には false になってほしいところですが、 undefined になります。これも= の特徴の様ですが、 true になるかどうかを判定しにいくため、 true と判定できないものに関しては undefined になるというのが仕様みたいです。詳しくは公式ドキュメントの The Basics に記載してます。

この挙動は 直感的じゃない ので、別途代入には := という構文が増えて、比較には見慣れた == が使えるようになりました。

$ docker run -it --rm openpolicyagent/opa run

> x := 7
Rule 'x' defined in package repl. Type 'show' to see rules.
> x == 7
true
> x == 8
false

こちらのほうがだいぶわかりやすいですね!

Get Startedチュートリアル

さて、Regoの構文の話をしてもPolicyが見えづらいと思うので、実際にPolicyを書きながら雰囲気をつかんでいきましょう。公式のチュートリアル「Get Started」をやってみます。

www.openpolicyagent.org

まずは下図のようにServer, Network, Port情報があったとします。

f:id:kenev:20190317232317p:plain

自分が保有しているサーバーが上の通りだとして、

パブリックネットワークでは暗号化された通信しか許可しない(HTTPはNG)

というPolicyを持ちたいとします。これをRegoで書いてみましょう。

まずは作業用のディレクトリを作ってその中に入ります。

mkdir opa
cd opa

上図の情報をJSONにしたものを data.json として用意します。

$ cat >data.json <<EOF
{
    "servers": [
        {"id": "s1", "name": "app", "protocols": ["https", "ssh"], "ports": ["p1", "p2", "p3"]},
        {"id": "s2", "name": "db", "protocols": ["mysql"], "ports": ["p3"]},
        {"id": "s3", "name": "cache", "protocols": ["memcache"], "ports": ["p3"]},
        {"id": "s4", "name": "dev", "protocols": ["http"], "ports": ["p1", "p2"]}
    ],
    "networks": [
        {"id": "n1", "public": false},
        {"id": "n2", "public": false},
        {"id": "n3", "public": true}
    ],
    "ports": [
        {"id": "p1", "networks": ["n1"]},
        {"id": "p2", "networks": ["n3"]},
        {"id": "p3", "networks": ["n2"]}
    ]
}
EOF

上のデータに対してRegoファイルを以下のようにして作ります。

cat >example.rego <<EOF
package opa.example

import data.servers
import data.networks
import data.ports

public_servers[s] {
    s := servers[_]
    s.ports[_] == ports[i].id
    ports[i].networks[_] == networks[j].id
    networks[j].public == true
}

violations[s] {
  s = servers[_]
  s.protocols[_] = "http"
  public_servers[s]
}
EOF

example.rego というRegoをこれで書いたことになります。意味としてはざっくり以下のとおり。

  • opa.example というパッケージを宣言
  • data として予め読み込んである data.servers, data.networks, data.portsimport してルール( public_servers, violations )で使えるようにする
  • public_servers[s] ルールを宣言
    • servers の中で ports で宣言している portnetworks で宣言している network を使っていて、 networkpublic のものを public_servers とする
  • violations[s] を宣言
    • servers の中で、 protocolhttp かつ public_servers に含まれる server であれば violations とする

これを実際 opa に読み込ませてREPLで使ってみましょう。

docker run -it --rm -v $(pwd):/tmp/workspace -w /tmp/workspace openpolicyagent/opa run data.json example.rego

まず、データがあることを確認してみます。

> data.servers[_]
+-------------------------------------------------------------------------------+
|                                data.servers[_]                                |
+-------------------------------------------------------------------------------+
| {"id":"s1","name":"app","ports":["p1","p2","p3"],"protocols":["https","ssh"]} |
| {"id":"s2","name":"db","ports":["p3"],"protocols":["mysql"]}                  |
| {"id":"s3","name":"cache","ports":["p3"],"protocols":["memcache"]}            |
| {"id":"s4","name":"dev","ports":["p1","p2"],"protocols":["http"]}             |
+-------------------------------------------------------------------------------+

先程用意したJSONの情報が読み込まれているのがわかりますね。では public_servers はどうでしょう?

> data.opa.example.public_servers[_]
+-------------------------------------------------------------------------------+
|                      data.opa.example.public_servers[_]                       |
+-------------------------------------------------------------------------------+
| {"id":"s1","name":"app","ports":["p1","p2","p3"],"protocols":["https","ssh"]} |
| {"id":"s4","name":"dev","ports":["p1","p2"],"protocols":["http"]}             |
+-------------------------------------------------------------------------------+

public == true なネットワーク(n3)の中のポート(p2)を使用している appdev サーバーが返ってきているのがわかります。ちなみに [_] の部分は _ に値が無いので 全部 という意味になります。(なので該当するサーバーが全部出てます)

では、 violations も同様に見てみましょう。

> data.opa.example.violations[x]
+-------------------------------------------------------------------+
|                  data.opa.example.violations[x]                   |
+-------------------------------------------------------------------+
| {"id":"s4","name":"dev","ports":["p1","p2"],"protocols":["http"]} |
+-------------------------------------------------------------------+

(結果はトリムしてますが、)先程の public_servers の結果の中で、プロトコルhttp を使用している dev サーバーが violation として返ってきているのがわかります。

f:id:kenev:20190318003839p:plain

このように、宣言的にPolicyを書いていくことができるのがOPAの強みです!

余談: data.opa.example.violations[_] と書いた場合になぜかREPLではJSONが返ってきました。 x にすると表だったのですが、この違いがなぜ起きるのかはまだ原因がつかめていません。時間をみつけてOwnerに聞いてみます。

おわりに

今回はOPAの概要とPolicy言語Rego、そしてREPLについて紹介しました。まだ具体的にどう使うのかというのが見えづらい部分があると思いますがこの記事はいったんここまでとします!

  • やっていいかどうかを判断してくれる
  • 特定の条件を満たしているかどうかを教えてくれる
  • PolicyをRegoという独自の言語で書くことができる

点についてはなんとなくイメージできたのではないでしょうか?次回はREPLではなく、実際に「やっていいかどうか」を判断してもらう使い方について紹介します!

参考