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