IAM PolicyをOPAで書く

OPAシリーズです!今回は公式ドキュメントの「Comparison to Other Systems」について見てみます。 世の中に存在しているPolicyのパターンをOPAで実装した場合、どうなるかというのを教えてくれている項目です。

www.openpolicyagent.org

この中でも「Amazon Web Services IAM」をOPAで実装した場合について本記事では見ていきます。

IAM PolicyをOPAで書くとどうなるか

AWSのIAMではユーザーやロール、グループに加えてリソースに対してPolicyをアタッチできます。Policyは「 誰が何に何を して 良いか 、あるいは ダメか 」を定義することができます。

次のIAMのPolicyを想定してみます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "FirstStatement",
      "Effect": "Allow",
      "Action": ["iam:ChangePassword"],
      "Resource": "*"
    },
    {
      "Sid": "SecondStatement",
      "Effect": "Allow",
      "Action": "s3:ListAllMyBuckets",
      "Resource": "*"
    },
    {
      "Sid": "ThirdStatement",
      "Effect": "Allow",
      "Action": [
        "s3:List*",
        "s3:Get*"
      ],
      "Resource": [
        "arn:aws:s3:::confidential-data",
        "arn:aws:s3:::confidential-data/*"
      ]
    }
  ]
}

IAMに馴染みの無い人(僕もその一人)からするとわかりにくいと思うので、要点だけあげると

  • iam:ChangePassword というアクションだったら許可
  • s3:ListAllMyBuckets というアクションだったら許可
  • arn:aws:s3:::confidential-data というリソース、あるいはその配下のリソースに対して、 s3:List から始まるアクション、 s3:Get から始まるアクションを許可

という内容になります。これをOPAのRegoで書くと次のようになります!

注意:一部、公式のコメントを日本語に差し替えています

package aws

# FirstStatement
allow {
    input.action == "iam:ChangePassword"
}

# SecondStatement
allow {
    input.action == "s3:ListAllMyBuckets"
}

# ThirdStatement
# ここではヘルパールールを定義することで見やすくしています
# actions_matchとresources_matchがtrueの場合にallowがtrueになります
allow {
    actions_match
    resources_match
}

# actions_matchはinput.actionがactionsに含まれていればtrueになる
actions_match {
    actions := ["s3:List.*","s3:Get.*"]
    # actionはactionsを順番にイテレートする、という意味があります
    action := actions[_]
    # check if input.action matches an action
    regex.globs_match(input.action, action)
}

# resources_matchはinput.resourceがresourcesに含まれていればtrueになる
resources_match {
    resources := ["arn:aws:s3:::confidential-data","arn:aws:s3:::confidential-data/.*"]
    # resourceはresourcesを順番にイテレートする、という意味があります
    resource := resources[_]
    # check if input.resource matches a resource
    regex.globs_match(input.resource, resource)
}

Regoに対する慣れは必要ですが、上から読んでいってそれなりに意味を読み取れるのではないかと思います。 ただし、トリッキーな部分が少なくとも2個あります。

  • action := actions[_] のようにイテレーションを宣言してる部分
  • regex.globs_match(input.action, action) でビルトインな関数を使っている部分

actions_match のルールをもう少し噛み砕いてみます。 まず、このルールですけど action := actions[_] と代入してる場所を無くしてみましょう。 個人的には action が単数形なのがわかりにくくしている気がします。 関係のある箇所だけ見ると下のようになります。

actions_match {
    actions := ["s3:List.*","s3:Get.*"]
    regex.globs_match(input.action, actions[_])
}

だいぶすっきりしましたね!このルールを言葉で言うと

インプットで渡ってきた actionactions 内の正規表現s3:List.*, s3:Get.* ) とマッチすれば許可する

という意味になります。 regex.globs_match は公式ドキュメントにも記載がありますが、最初から使えるビルトインな関数です。

www.openpolicyagent.org

true if the intersection of regex-style globs glob1 and glob2 matches a non-empty set of non-empty strings.

ドキュメントにも書いてあるように、 第1引数と第2引数を総当たりして、正規表現がマッチするものがあれば true になるという関数です。 試してみたところ、引数は string でも stringの配列 でも正常に動作する様です。

Playgroundで検証

OPAにはPlaygroundがあるので、下のPlaygroundを使って検証してみましょう!

play.openpolicyagent.org

次のようなInputを渡してみましょう。

{
  "action": "s3:ListBucket",
  "resource": "arn:aws:s3:::confidential-data/12345678"
}

arn:aws:s3:::confidential-data 配下のリソースに対して s3:List から始まるアクションを行っているので許可されるはずです。 Outputをみてみましょう。

{
  "result": {
    "actions_match": true,
    "allow": true,
    "resources_match": true
  }
}

true の評価結果が全部返ってくるので actions_matchresources_match も返ってきますが、本命の allowtrue になっているのがわかりますね!

では s3:ListBucket を下のように s3:CreateBucket にしたらどうなるでしょう?

{
  "action": "s3:CreateBucket",
  "resource": "arn:aws:s3:::confidential-data/12345678"
}

結果は下の通り。

{
  "result": {
    "resources_match": true
  }
}

リソースのルールは許可されているけど、アクションが拒否されたので allow は返ってきませんでした! 想定した動きになってそうですね。 では、ちょっと戻りますけど、最初に登場する2つの allow ルールについても検証してみましょう。

パスワード変更を想定して次のようなInputを渡します。

{
  "action": "iam:ChangePassword",
  "resource": "arn:aws:iam::123456789012:user/JohnDoe"
}

結果は下の通り。

{
  "result": {
    "allow": true
  }
}

iam:ChangePassword であればなんでも許可なので allowtrue になってます。

最後に s3:ListAllMyBuckets についても見てみましょう。

{
  "action": "s3:ListAllMyBuckets",
  "resource": "arn:aws:s3:::*"
}

結果は下の通り。

{
  "result": {
    "actions_match": true,
    "allow": true
  }
}

allowtrue で返ってきますね!また、 s3:List から始まるアクションなので actions_matchtrue で返ってきているのがわかります。

おわりに

OPAではこのようにAWSのIAMに似たPolicy管理もしていくことができそうですね。ただし、シンプルなパターンを見ているだけなので、複雑なパターンでも検証してみたいところです。 今後のネタとしてその点についてはストックしておきます!

おまけ

regex.globs_match(glob1, glob2) は第1引数( glob1 )と第2引数( glob2 )を総当たりして true があれば true になるみたいなので、Inputを下のようにすると

{
  "action": ".*",
  "resource": ".*"
}

正規表現としては なんでもOK という意味になるので結果は下の通り。

{
  "result": {
    "actions_match": true,
    "allow": true,
    "resources_match": true
  }
}

この動きはどうなんだろう、と思いながらも使う側の問題なので制御自体はできそうです。 あるいは、ヘルパー関数は自分でも作れるので、例えば 第1引数の文字列が、第2引数の正規表現のいずれかにマッチする かチェックする関数を作れば良さそうです。 この点についても今後深掘りしていけたらと思います!

参考

kenfdev.hateblo.jp

kenfdev.hateblo.jp

OPAでSSHとsudoの認可をする

前回 に引き続きOPAの勉強をしています。今回の記事では公式チュートリアルの「SSH and sudo Authorization」を見ます。

www.openpolicyagent.org

ゴール

このチュートリアルではLinux-PAM(Pluggable Authentication Modules)とOPAを連動させています。

en.wikipedia.org

Linux-PAM(以下PAM)について僕は今回初めて知ったのですが、Linuxに認証の仕組みをプラグインとして追加できるものという理解です。ここでOPAと連動できるPAMを使うことで、認証に加えてPolicyに沿った 認可 も行えるようにします。OPA公式のリポジトリにこのチュートリアル用のOPAのPAMがあります。

github.com

これを使って次のことを実現します!

  • Adminであればどのホストにもsshできるし、そこでsudoもできる
  • 通常ユーザーであれば、ホスト内のコードにコントリビュートしている場合にsshができる

さらに、次の登場人物がいると仮定します。

  • frontend-devfrontend ホストで動いているコードにコントリビュートしている開発者
  • backend-devbackend ホストで動いているコードにコントリビュートしている開発者
  • ops は組織内のAdmin

図示すると次のようになります。

f:id:kenev:20190323134623p:plain

準備

まずは作業場所を用意します。

$ mkdir ssh_sudo_auth
$ cd ssh_sudo_auth

上の図の状況を作り上げるために frontend, backend ホストとOPAを docker-compose で立ち上げます。

# 好きなエディタで作ります
$ vim tutorial-docker-compose.yaml

yaml の中身は次の通り。

version: '2'
services:
  opa:
    image: openpolicyagent/opa:0.10.5
    ports:
      - 8181:8181
    # WARNING: OPA is NOT running with an authorization policy configured. This
    # means that clients can read and write policies in OPA. If you are
    # deploying OPA in an insecure environment, be sure to configure
    # authentication and authorization on the daemon. See the Security page for
    # details: https://www.openpolicyagent.org/docs/security.html.
    command:
      - "run"
      - "--server"
      - "--log-level=debug"
  frontend:
    image: openpolicyagent/demo-pam
    ports:
      - "2222:22"
    volumes:
      - ./frontend_host_id.json:/etc/host_identity.json
  backend:
    image: openpolicyagent/demo-pam
    ports:
      - "2223:22"
    volumes:
      - ./backend_host_id.json:/etc/host_identity.json

WARNING の部分が何を言っているかというと、 OPA自身をPolicyで守っていないのでセキュアじゃないということです。 例えばREST APIは誰でも叩けてしまうので、OPAに対してリクエストを投げられる人がいれば、誰でもPolicyに変更を加えられちゃいます。 本来どうOPA自身を守るべきかという点については以下の公式ドキュメントにまとめられています。

www.openpolicyagent.org

それでは、このチュートリアル用のPAMがホストを識別するための host_id 情報を含んだ json を作ります。 PAMはこのJSONを必要に応じて読み込んで、自分が frontend なのか backend なのか判別しています。

$ echo '{"host_id": "frontend"}' > frontend_host_id.json
$ echo '{"host_id": "backend"}' > backend_host_id.json

準備が整ったので docker-compose で立ち上げます!

$ docker-compose -f tutorial-docker-compose.yaml up

OPA連携するPAMの概要

このチュートリアルで用意されているPAMとOPAの関係は下図のようだと理解しました。

f:id:kenev:20190323153551p:plain
PAMとOPA

  1. PAMがOPAの pull エンドポイントに、システムから取得するファイル一覧を聞く
  2. PAMがOPAの sshd/authz エンドポイントに、SSHしていいか聞く
  3. PAMがOPAの sudo/authz エンドポイントに、sudoしていいか聞く

PAMの設定を実際に見てみましょう。 frontend コンテナに入ります。

$ docker-compose -f tutorial-docker-compose.yaml exec frontend bash

sudo のPAM設定を覗いてみます。

$ cat /etc/pam.d/sudo
# Enable the Authz PAM module
# At image build time, the value of HOST_UUID is replaced with the role of the image (for example, webapp)
auth required /lib/security/pam_authz.so url=http://opa:8181 authz_endpoint=/v1/data/sudo/authz display_endpoint=/v1/data/display pull_endpoint=/v1/data/pull log_level=debug
  • auth required/lib/security/pam_authz.so を指定している
  • OPAのエンドポイントをurlhttp://opa:8181 と指定している
  • pull_endpoint/v1/data/pull を指定している
  • authz_endpoint/v1/data/sudo/authz を指定している

のがわかります。 sudo するときOPAに認可を問い合わせしている雰囲気がなんとなくですけど伝わってきますね!

注意: display_endpoint は本記事では扱わないので説明は割愛します。

Policyの作成

pull Policy作成

それでは、まずは pull エンドポイント用のPolicyを作ります。PolicyはRegoで書きます。

$ cat >pull.rego <<EOF
package pull

# Which files should be loaded into the context?
files = ["/etc/host_identity.json"]

# Which environment variables should be loaded into the context?
env_vars = []

EOF

上記を用意しておくことで、PAMが pull エンドポイントに問い合わせをしたときに、どんなファイルを読み込む必要があるかを知ることができます。(この場合は /etc/host_identity.json を読み込みます)

では、このPolicyをOPAにREST API経由で入れます。

$ curl -X PUT --data-binary @pull.rego \
  localhost:8181/v1/policies/pull

sshdの認可Policy作成

では、SSHするときに適用する認可Policyを作りましょう。

$ cat >sshd_authz.rego <<EOF
package sshd.authz

import input.pull_responses
import input.sysinfo

import data.hosts

# By default, users are not authorized.
default allow = false

# Allow access to any user that has the "admin" role.
allow {
    data.roles["admin"][_] = input.sysinfo.pam_username
}

# Allow access to any user who contributed to the code running on the host.
#
# This rule gets the `host_id` value from the file `/etc/host_identity.json`.
# It is available in the input under `pull_responses` because we
# asked for it in our pull policy above.
#
# It then compares all the contributors for that host against the username
# that is asking for authorization.
allow {
    hosts[pull_responses.files["/etc/host_identity.json"].host_id].contributors[_] = sysinfo.pam_username
}


# If the user is not authorized, then include an error message in the response.
errors["Request denied by administrative policy"] {
    not allow
}
EOF

ポイントとしては以下のとおり。

  • 問い合わせしてきたユーザー( sysinfo.pam_username )が rolesadmin に含まれていれば許可
  • 問い合わせしてきたユーザー( sysinfo.pam_username )が pull で指定されたファイル /etc/host_identity.json のホストにコントリビュートした人( contributors )であれば許可
  • 上に該当しなければエラーとする

input に関連する情報はPAMがOPAにリクエストするときに渡ってくるデータです。次のようなJSONがリクエストに含まれてくるとイメージしてください。

{
    "input": {
        "display_responses": {
            "last_name": "<ユーザーが入力した情報。本記事では扱いません。>",
            "secret":    "<ユーザーが入力した情報。本記事では扱いません。>"
        },
        "pull_responses": {
            "files": {
                "/etc/host_identity.json": {
                    "host_id": "<JSON内に指定されたhost_id>"
                }
            },
            "env_vars": {}
        },
        "sysinfo": {
            "pam_username":     "<PAMセッション内の値>",
            "pam_service":      "<PAMセッション内の値>",
            "pam_req_username": "<PAMセッション内の値>",
            "pam_req_hostname": "<PAMセッション内の値>"
        }
    }
}

また、Policy内に登場する rolescontributors のデータはこの後OPAに投入します!

まずは上のPolicyをOPAに投入しましょう。

$ curl -X PUT --data-binary @sshd_authz.rego \
  localhost:8181/v1/policies/sshd/authz

sudoの認可Policy作成

sudoするときのPolicyも同じ要領で作ります。

$ cat >sudo_authz.rego <<EOF
package sudo.authz

# By default, users are not authorized.
default allow = false

# Allow access to any user that has the "admin" role.
allow {
    data.roles["admin"][_] = input.sysinfo.pam_username
}

# If the user is not authorized, then include an error message in the response.
errors["Request denied by administrative policy"] {
    not allow
}
EOF

これはシンプルで、

  • 問い合わせしてきたユーザー( sysinfo.pam_username )が rolesadmin に含まれていれば許可
  • 上に該当しなければエラーとする

というPolicyになります。 これもOPAに投入します。

$ curl -X PUT --data-binary @sudo_authz.rego \
  localhost:8181/v1/policies/sudo/authz

その他必要なデータの投入

roles

roles のデータを投入します。これで ops ユーザーだけが admin となります。

$ curl -X PUT localhost:8181/v1/data/roles -d \
'{
    "admin": ["ops"]
}'

hosts

ホストの情報と、そのホストに対するコントリビューターの情報を投入します。

frontend のコントリビューターとして frontend-dev を入れて、 backend のコントリビューターとして backend-dev を定義します。

$ curl -X PUT localhost:8181/v1/data/hosts -d \
'{
  "frontend": {
    "contributors": [
      "frontend-dev"
    ]
  },
  "backend": {
    "contributors": [
      "backend-dev"
    ]
  }
}'

実践!

準備ができました!では、Policyがちゃんと動作するか確認していきましょう。

opsユーザーとしてSSHとsudo

まずは ops ユーザーでSSHとsudoをしてみましょう。 opsadmin なので成功するはずです。 ローカルのポート2222が frontend のホストにつながるはずなので試してみます。

$ ssh -p 2222 ops@localhost \
   -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

ops@f55968aabd47:~$

SSHはできました!では、sudoはどうでしょう?

$ sudo ls /
OPA-PAM[49]: Session log level is set to debug
OPA-PAM[49]: Commencing display cycle.
OPA-PAM[49]: Initializing HTTP request GET /v1/data/display
OPA-PAM[49]: HTTP request body: (null)
OPA-PAM[49]: HTTP request complete, libcURL returned with 0.
OPA-PAM[49]: HTTP response body: {}
OPA-PAM[49]: Value of field 'result' does not have type object in JSON response. Please ensure that your endpoint flag '/v1/data/display' matches your package path.
OPA-PAM[49]: Commencing pull cycle.
OPA-PAM[49]: Initializing HTTP request GET /v1/data/pull
OPA-PAM[49]: HTTP request body: (null)
OPA-PAM[49]: HTTP request complete, libcURL returned with 0.
OPA-PAM[49]: HTTP response body: {"result":{"env_vars":[],"files":["/etc/host_identity.json"]}}
OPA-PAM[49]: Loaded JSON from file :
{
  "host_id": "frontend"
}
OPA-PAM[49]: Collecting system information.
OPA-PAM[49]: Loaded sysinfo pam_username: ops
OPA-PAM[49]: Loaded sysinfo pam_service: sudo
OPA-PAM[49]: Loaded sysinfo pam_req_username: ops
OPA-PAM[49]: Loaded sysinfo pam_req_hostname:
OPA-PAM[49]: Commencing authz cycle.
OPA-PAM[49]: Initializing HTTP request POST /v1/data/sudo/authz
OPA-PAM[49]: HTTP request body: {"input":{"display_responses":{},"pull_responses":{"files":{"/etc/host_identity.json":{"host_id":"frontend"}},"env_vars":{}},"sysinfo":{"pam_username":"ops","pam_service":"sudo","pam_req_username":"ops","pam_req_hostname":""}}}
OPA-PAM[49]: HTTP request complete, libcURL returned with 0.
OPA-PAM[49]: HTTP response body: {"result":{"allow":true,"errors":[]}}
OPA-PAM[49]: Freeing allocated data.
OPA-PAM[49]: Application invoked pam_sm_setcred.
bd_build  bin  boot  create_user.sh  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
OPA-PAM[49]: Application invoked pam_sm_setcred.

sudo ls / してみると、ドカッとログがいっぱい出てきました!これはPAMが debug モードで走ってるのでログがたくさん出るようになっているからです。

1行1行見ていくとなんとなく何をしているかというのがわかります。ポイントとしては次の行です。

  • Initializing HTTP request GET /v1/data/pull
  • Loaded sysinfo pam_username: ops
  • Initializing HTTP request POST /v1/data/sudo/authz
  • HTTP response body: {"result":{"allow":true,"errors":[]}}

上で用意したPolicyが動作しているのがわかりますね!最終的に allow: true になっているので、 ls の内容も出力されています。

bd_build  bin  boot  create_user.sh  dev  etc  home  lib  lib64  media mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

SSHから抜けておきましょう。

$ exit

adminじゃないユーザーでsudo

それでは、adminじゃないユーザーでsudoをしてみましょう。

frontend-dev ユーザーとして frontend ホストにSSHします。

$ ssh -p 2222 frontend-dev@localhost \
  -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

frontend-dev@f55968aabd47:~$

SSHは成功します。ではsudoしたらどうなるでしょう?

$ sudo ls /
OPA-PAM[81]: Session log level is set to debug
OPA-PAM[81]: Commencing display cycle.
OPA-PAM[81]: Initializing HTTP request GET /v1/data/display
OPA-PAM[81]: HTTP request body: (null)
OPA-PAM[81]: HTTP request complete, libcURL returned with 0.
OPA-PAM[81]: HTTP response body: {}
OPA-PAM[81]: Value of field 'result' does not have type object in JSON response. Please ensure that your endpoint flag '/v1/data/display' matches your package path.
OPA-PAM[81]: Commencing pull cycle.
OPA-PAM[81]: Initializing HTTP request GET /v1/data/pull
OPA-PAM[81]: HTTP request body: (null)
OPA-PAM[81]: HTTP request complete, libcURL returned with 0.
OPA-PAM[81]: HTTP response body: {"result":{"env_vars":[],"files":["/etc/host_identity.json"]}}
OPA-PAM[81]: Loaded JSON from file /etc/host_identity.json:
{
  "host_id": "frontend"
}
OPA-PAM[81]: Collecting system information.
OPA-PAM[81]: Loaded sysinfo pam_username: frontend-dev
OPA-PAM[81]: Loaded sysinfo pam_service: sudo
OPA-PAM[81]: Loaded sysinfo pam_req_username: frontend-dev
OPA-PAM[81]: Loaded sysinfo pam_req_hostname:
OPA-PAM[81]: Commencing authz cycle.
OPA-PAM[81]: Initializing HTTP request POST /v1/data/sudo/authz
OPA-PAM[81]: HTTP request body: {"input":{"display_responses":{},"pull_responses":{"files":{"/etc/host_identity.json":{"host_id":"frontend"}},"env_vars":{}},"sysinfo":{"pam_username":"frontend-dev","pam_service":"sudo","pam_req_username":"frontend-dev","pam_req_hostname":""}}}
OPA-PAM[81]: HTTP request complete, libcURL returned with 0.
OPA-PAM[81]: HTTP response body: {"result":{"allow":false,"errors":["Request denied by administrative policy"]}}
OPA-PAM[81]: Received authz error log from OPA: Request denied by administrative policy
OPA-PAM[81]: Freeing allocated data.
Sorry, try again.
OPA-PAM[81]: Application invoked pam_sm_authenticate.
OPA-PAM[81]: Session log level is set to debug
...
OPA-PAM[81]: HTTP response body: {"result":{"allow":false,"errors":["Request denied by administrative policy"]}}
OPA-PAM[81]: Received authz error log from OPA: Request denied by administrative policy
...
Sorry, try again.
...
OPA-PAM[81]: Session log level is set to debug
...
OPA-PAM[81]: HTTP response body: {"result":{"allow":false,"errors":["Request denied by administrative policy"]}}
OPA-PAM[81]: Received authz error log from OPA: Request denied by administrative policy
...
sudo: 3 incorrect password attempts

失敗しているのがわかります。エラー文言もPolicyで設定していた Request denied by administrative policy になっています。 3回挑戦した結果、 sudo: 3 incorrect password attempts と言ってあきらめています。

SSHから抜けておきます。

$ exit

コントリビューターじゃないホストへSSH

では、最後にコントリビュートしていないホストへのSSHがちゃんと拒否されるか確認しましょう。 frontend-dev ユーザーとして backend ホストにSSHしてみます。( backend はローカルのポート2223で待ち受けています)

$ ssh -p 2223 frontend-dev@localhost \
   -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null

frontend-dev@localhost: Permission denied (keyboard-interactive).

ちゃんと拒否されます! docker-compose のログをみると下のようにエラーを返しているのも確認できます。

opa_1       | time="2019-03-23T07:45:53Z" level=info msg="Sent response." client_addr="172.18.0.3:48434" req_id=39 req_method=POST req_path=/v1/data/sshd/authz resp_body="{\"result\":{\"allow\":false,\"errors\":[\"Request denied by administrative policy\"]}}" resp_bytes=79 resp_duration=2.7604 resp_status=200

準備したPolicyに対して、想定した動きになっているのが確認できました!

おわりに

本記事ではOPAとLinux-PAMを連携することで、SSHとsudoにOPAの認可Policyを適用する方法について見ました! 公式チュートリアルでは最後に 動的にユーザーの権限を昇格させる方法 について述べています。この記事では割愛していますが、ぜひそちらもチャレンジしてみてください。

「どんな人」が「何を」していいかというのをPolicyで制御できる

OPAの可能性の1つを学ぶことができました。

参考

kenfdev.hateblo.jp

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ではなく、実際に「やっていいかどうか」を判断してもらう使い方について紹介します!

参考

コンテナの疲れをk3sとRemoで癒やした話

f:id:kenev:20190316002715p:plain

Cloud Native Kansai #02 に参加してきました!

cnjp.connpass.com

そしてこの記事の題名にもあるように「コンテナの疲れをk3sとRemoで癒やした話」という題名でLTもやらせていただきました。

発表資料

ストーリー

コンテナに限らずですが技術の進化が早すぎ&自分の守備範囲が比較的広いため、様々な分野にキャッチアップしていくのが年々大変になっていってる気がします。「手を出しすぎなだけでしょ?」と言われてしまえばそうなのかもしれないですが、無知であることが怖いと思うところもあってついつい手を出してしまうのです。

いろいろあってなんだか最近「追われながら学習している」ことが多い気がするので、純粋に技術を楽しもうと思って家にある「ラズパイ」、「Nature Remo」を使ってお家ハックしてみました!「開発の疲れは開発で癒やす」と昔先輩に教えてもらったので、「コンテナの疲れはコンテナで癒す」を実践しました(笑)

出来上がるとラズパイ1台で、 k3s で作ったk8s上のPrometheus, Grafanaを使って次の画面が作れるようになります!

注意:Grafanaのグラフは自分で設定する必要があります

f:id:kenev:20190313220955p:plain
GrafanaでRemoのデータを可視化

家のラズパイで試してみたい人は以下リポジトリの手順に従ってぜひ構築してみてください!

github.com

まとめ

  • コンテナの疲れはコンテナで癒やす
  • 純粋にテクノロジーを楽しむのも大事
  • アプリエンジニアと言えど守備範囲は広めよう
  • 「パパすごい」は最高の癒やし(褒め)言葉

余談

Clean Architectureに気づいてくれる人がいた

Cloud Native Meetupだけど、スライドに入っている図から以下を共感してもらえたことが実はすごくうれしかった。

armとamdで同じイメージパスを指定

おまけ情報ですが、今回のお家ハックを通してarmとamdアーキテクチャが違っていても、Docker HubのURLを同じものとして使える方法について知ることができました。参考になるのは以下のGrafanaのPR。これで、ラズパイ(arm)だろうがmacbookamd)だろうが、同じ grafana/grafana:latest イメージを指定することができます! kenfdev/remo-exporter も真似をして、同一のイメージパスを指定できます!

github.com

参考

  • 基盤には前回の記事でも紹介したk3sを使っています

kenfdev.hateblo.jp

  • ダッシュボードの雰囲気は以下記事を参考にしています

qiita.com

k3sをラズパイで起動するまでにやったこと

LightweightなKubernetesとして最近話題に上がっているk3s。僕もミーハーなのでさっそく家のラズパイに入れてみました。

k3s.io

k3sがなんなのか、という点についてはQiitaの以下の記事が簡潔によくまとまっています。

qiita.com

我が家には半年ほど前にkubeadmで構築したラズパイが放置されていたので、初期化してk3sを入れてみました。なんだかんだでいつも同じようなページをググりながらセットアップするので以下に手順を残しておきます。

【注意】この記事ではPodを動かしたりしません。あくまでラズパイをセットアップしてk3sでノード情報を取得(kubectl get nodes)するところまでをゴールとしています

MicroSDの準備

Raspbian Stretch Liteを使うので公式サイトからダウンロードします。

www.raspberrypi.org

これをMicroSDに焼きたいのでbalenaEtcherというソフトを使います。以下からダウンロードしておきます。

balenaEtcher - Home

これを使ってMicroSDにRaspbian Stretch Liteが入れられます。

ラズパイ用のディスプレイは持っていないのでsshできるようにしておきます。これはMicroSDのルートに ssh というファイルを置いておけばOK。おそらく boot というボリュームでマウントされているのでほとんどの場合次のコマンドでOKです。

$ sudo touch /Volumes/boot/ssh

あとはMicroSDをラズパイに差し込んで完了!

ラズパイの下準備

ラズパイを起動したらIPアドレスルーターなりで調べて ssh で繋ぎます。デフォルトユーザーは pi でパスワードは raspberry です。どのみち家でしか使わないのと、またすぐ破壊する環境なのでここらへんは特に変更しません。

$ ssh pi@192.168.11.4

ホスト名を変更

この手順は任意ですが、わかりやすいようにホスト名を変更しておきます。これには raspi-config を使用。

$ sudo raspi-config

次の画面が出てくるので 2 Network Options を選択。

f:id:kenev:20190311090918p:plain
raspi-configトップメニュー

続いて N1 Hostname を選択します。

f:id:kenev:20190311091018p:plain
raspi-config ホスト名変更

k3s-master とでも設定しておきます。

f:id:kenev:20190311091108p:plain

決定すると再起動するか聞かれるので再起動します。

cgroupの有効化

再起動後に再度 ssh して、次はRaspbianで cgroup を有効にしておきます。 この手順を忘れるとk3sの起動時にエラーとなる ので要注意です。

# 好きなエディタで開きます
$ sudo vi /boot/cmdline.txt

次の値を 行の末尾に追加します

 cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory

新しい行に追加しちゃダメです。 必ず 末尾 に追加してください。 追加したあとは↓のようになります。(わかりにくいですが1行です)

dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=9cdfde31-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory

保存したら再起動しましょう。ラズパイの下準備はこれでOKです。

$ sudo reboot

k3sのインストールと起動

ラズパイに再度 ssh して次のコマンドを叩くだけでk3sの

  • 最新版のダウンロード
  • systemdへのサービス登録
  • 起動

まですべて完了します!

$ curl -sfL https://get.k3s.io | sh -

シェルの中身が気になる人は以下にアクセスすれば中身チェックできます。

https://get.k3s.io

関数の名前からも何をやっているかはなんとなくわかります。

# --- run the install process --
{
    verify_systemd
    setup_env ${INSTALL_K3S_EXEC} $@
    download_and_verify
    create_symlinks
    create_uninstall
    systemd_disable
    create_env_file
    create_service_file
    systemd_enable_and_start
}

kubectl get nodes をk3sで実行してみましょう。通常の kubectl の先頭に k3s をつければOKです。

$ k3s kubectl get nodes
NAME         STATUS   ROLES    AGE   VERSION
k3s-master   Ready    <none>   8d    v1.13.4-k3s.1

正常に実行できているのが確認できます。

k3s kubectl 毎回入力するので kalias 貼っておきます。

$ vi ~/.bashrc

末尾に以下を追加しておきました。

alias k='k3s kubectl'

これで k get nodes のようにもっと省略してコマンド打てるようになるのですっきり!

おわりに

以上でラズパイ上での k3s のセットアップは完了です!ほとんど同じような手順でクラスタにノードを追加していけるのですが、その話はまたの機会にします。

k3sで「お家ハック」もしていけると、モチベーション維持しながらkubernetesの経験も積んでいけるので引き続き共有していきたいと思います!

AWS SAA再認定のために5日間頑張ったこと

昨日AWSソリューションアーキテクト・アソシエイト(以下SAA)に再認定されました。3月中に取得しないといけないというミッションがあったので慌てて先週申し込みました。試験会場の関係で試験日まで5日しか無かったのですが、合格できたので備忘録を残しておきます。

TL;DR(まとめ)

  • 背景
    • 昨年12月にSAAの有効期限が切れました
    • 半年ほどAWSは触っていません(ほとんどコード書いてました)
    • 3月中に取得しないといけないのに、試験日は一番後ろにしても5日しか猶予がなかったです
    • 確保できた勉強時間は13時間くらいです
  • 『最短突破 AWS認定ソリューションアーキテクト アソシエイト 合格教本』を読みました
  • Whizlabsの模擬試験をやりました(7個中4個しかやれる時間はありませんでした)
  • Whizlabsのセクションテストをやりましたが、以下しかやれる時間はありませんでした
  • 模擬試験を英語で受けたので本番も英語で受けました
  • 自信をもって答えられたのは75%くらいです。結果をみたら905/1000点だったので、迷った選択肢は良い方向に転んでくれていた様です

背景

まず僕が何者なのか、というところですが毎日AWSを触るような業務は最近になるまでやってませんでした。普段はコードを書いている(JavaScript, PHP, Goなど)ことが多く、インフラに関連することをやるのはコンテナを触るときくらいです。

SAAをとったのは2年以上前で、そのときは結構ガッツリ勉強しています。ただし合格してからもそれほどAWSを使う機会はなく、使ってもELBとEC2くらいでした。ここ1年ほどはAWSのホットな情報にはほとんどついていけてないです。

ここまでは割と意識低い系に捉えられそうですが、割と重要なポイントとして僕は「インフラが好き」です!得意ではないのですが、好きです。なのでインフラに関連することを勉強するのは苦ではないタイプです。

試験申し込み

試験申し込みにはAWS認定アカウントが必要で、このサイトから試験会場が選べます。同僚からは「Webカメラでリモート監視員に監視されながらテスト受けた」と聞いていたため、この2年の間で試験の方法が変わったのかと思っていました。ところがそうではなく、「キオスク」タイプとそうでないタイプに分かれているようです。試験会場に「K」や「キオスク」と書いてあるものが、リモート監視員による監視のもと、専用端末で試験を受けるタイプの様です。僕は2年前同様、普通の端末でWebカメラでの監視もなく試験を受けました。

もう1つ再認定の特に重要なのは、「特典」を使用することです。AWS認定アカウントの「特典」のページに行けば、クーポンが発行できるので、試験申し込み時にこのクーポンを使うことで半額(執筆時点では16200円/2=8100円!)で試験を受けられます。 また、通常であればRecertificationという試験も受けられるはずです。この試験は試験時間が90分と書かれていましたが、試験の期限が2019年3月3日に設定されていて、2月28日に申し込んだ僕としては準備期間が少なすぎるので断念しました(たぶん昨年SAAが刷新されたことと関連している)。 よって、通常の試験を申し込んでいます。半額のクーポンは使えるのでご安心ください。

試験勉強でやったこと

とはいえ、日常でさほど触っていないAWSの試験を受けるので、僕も結構プレッシャーを感じました。試験までは「土、日、月、火、水」と5日しかなくて、確保できた勉強時間は13時間ほど。何をしたかというのは以下の通り。

  • 土(3時間)
  • 日(3時間)
  • 月(2時間)
    • WhizlabsのセクションテストでVPC, API Gateway, ECS, EBSを実施しました。全部不合格(正答率50%くらい)!
    • WhizlabsのPractice Test 1(本番同様65問の模擬試験)を実施しました。合格(正答率81.54%)
    • 解説を読んで復習しました。Whizlabsの解説はAWSへのドキュメントのリンクがあったりしてかなり丁寧に書いてあるのでわかりやすいです。
  • 火(2時間)
    • WhizlabsのセクションテストでSTS, SNS, SQS, ELBを実施しました。全部不合格!(正答率40%くらい)
    • WhizlabsのPractice Test 2を実施しました。合格(正答率92.31%)
    • 解説で復習しました。
  • 水(3時間)
    • WhizlabsのPractice Test 3を実施しました。合格(正答率87.69%)
    • WhizlabsのPractice Test 4を実施しました。合格(正答率80%)
    • 解説で復習しました。
    • 試験当日

アドバイス

  • 試験の長さ(65問・130分)に慣れるためにも模擬試験を数こなしたほうが良いです。集中力の持続力が合否を分けるような気がします。
  • WhizlabsのセクションテストとPractice Testの難易度が違いすぎる気が。。。(セクションテストは1個も合格できなかった。。。)
  • WhizlabsのPractice Testは全部合格だったので、解説を読むのを怠りそうになったのですが、間違えたところちゃんと復習しましょう。復習しなかったところが問題に出ると超後悔します。
  • ↑にも関連するのですが、思ったより見慣れた問題がなくてちょっと焦りました。
  • 英語がある程度抵抗なければ英語で受験するのもありです。Whizlabsで英語に慣れている場合、当日日本語の問題にあたったときに妙な違和感があったりします(僕にとって1回目のSAAがそうでした)

試験当日

  • 僕は運転免許証とクレジットカードで身分証明しました(コピーじゃなくて本物を見せます)。
  • 試験実施する部屋には何も持っていかなくてOKでした(身分証明書も不要)。時計も持ち込めなかったです。ロッカーに全部しまいます。
  • 試験会場でボールペンと紙を5枚ほどメモ用にもらいました。試験が終わると回収されます。
  • WhizlabsのPractice Testは130分ある中、いつも70分ほど余ってたのですが、本番では見直しもしてたら残り3分になっちゃいました。。。やっぱり本番は何かしら心理的にやられてる気がします。落ち着きましょう。

おわりに

合格できたからよかったですが、当日は問題にフラグがつきっぱなしのものが15問ほどあったので、「もしかしたらこれは落ちるかも」と思いました。選択問題を2問までは絞り込めるものの、最後の1つに絞り込む決定的な知識が不足していると痛感しました。運良くそれらの問題が正解に転んでくれたんだとますが、精神衛生上よろしくないのと、そもそも「AWSの実力」的にその状態はどうなんだ、という疑問が残りますね。。。可能であれば普段からAWSに触れておくことが一番大事でしょう。そうすることで自然と「あの画面にあのパラメータがあったな」みたいなのがインプットされていくはずです。

この記事が、次に受けるときの自分へのメッセージにすることと、僕のように準備期間が少なくとも再認定してもらいたい人の参考になれば幸いです!