Open Policy Agentを始めてみよう
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_id
がmanager.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は、プログラミングを経験したことがある人ならなんとなーく読めるのではないでしょうか?長くなってしまうので詳しい説明についてはこの記事では割愛しますが、公式ドキュメントを一度は読んで見ることをおすすめします。
今のところ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」をやってみます。
まずは下図のようにServer, Network, Port情報があったとします。
自分が保有しているサーバーが上の通りだとして、
パブリックネットワークでは暗号化された通信しか許可しない(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.ports
をimport
してルール(public_servers
,violations
)で使えるようにするpublic_servers[s]
ルールを宣言servers
の中でports
で宣言しているport
、networks
で宣言しているnetwork
を使っていて、network
がpublic
のものをpublic_servers
とする
violations[s]
を宣言servers
の中で、protocol
がhttp
かつ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)を使用している app
と dev
サーバーが返ってきているのがわかります。ちなみに [_]
の部分は _
に値が無いので 全部
という意味になります。(なので該当するサーバーが全部出てます)
では、 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
として返ってきているのがわかります。
このように、宣言的にPolicyを書いていくことができるのがOPAの強みです!
余談:
data.opa.example.violations[_]
と書いた場合になぜかREPLではJSONが返ってきました。x
にすると表だったのですが、この違いがなぜ起きるのかはまだ原因がつかめていません。時間をみつけてOwnerに聞いてみます。
おわりに
今回はOPAの概要とPolicy言語Rego、そしてREPLについて紹介しました。まだ具体的にどう使うのかというのが見えづらい部分があると思いますがこの記事はいったんここまでとします!
- やっていいかどうかを判断してくれる
- 特定の条件を満たしているかどうかを教えてくれる
- PolicyをRegoという独自の言語で書くことができる
点についてはなんとなくイメージできたのではないでしょうか?次回はREPLではなく、実際に「やっていいかどうか」を判断してもらう使い方について紹介します!
参考
How Netflix Is Solving Authorization Across Their Cloud youtu.be
Intro: Open Policy Agent www.youtube.com