Gatekeeperで意図しないapplyからk8sクラスタを守る

Livesense Advent Calendar 14日目の記事です。

# 概要

[CNCF](https://landscape.cncf.io/selected=open-policy-agent-opa)がホストしている[OPA(Open Policy Agent)](https://www.openpolicyagent.org/docs/latest/)プロジェクトの一部である[Gatekeeper](https://github.com/open-policy-agent/gatekeeper)で遊んでみたいと思います。

最初はCIでk8sのマニフェストをテストできる[conftest](https://github.com/open-policy-agent/conftest)の方を題材にしようと思いましたが、既に日本語記事がいくつか書かれてた(参考に記載)ため、情報が少なかったgatekeeperにしました。

この記事では、以下の危険なコマンド(stagingのリソースを本番にapply)を実行した際にGatekeeperでブロックする方法を模索します。

他の記事や公式サンプルと内容が被らないようにするため、若干強引な課題設定にしています。

```
$ kubectl apply --context production -k manifests/default/app1/overlays/staging
admission webhook "validation.gatekeeper.sh" denied the request: [denied by deny-deployment] expected: production, actual: staging

$ kubectl apply --context production -k manifests/default/app1/overlays/production
```

アイディアとして、ステージング環境のリソースには`env: staging`、本番環境のリソースには`env: production`をlabel(またはannotation)の値として付与しておき、適用しようとしている環境と異なるリソースのapplyをdenyする方法はどうかと考えました。

これらは、[kubebuilder](https://github.com/kubernetes-sigs/kubebuilder)等を使って自前でAdmission Webhooksを実装することも可能ですが、Gatekeeperを使うことにより、[rego言語](https://www.openpolicyagent.org/docs/latest/policy-language/)でポリシーを書くだけで、簡単に同様のことが実現できます。

#### 補足

このようなミスを防ぐためには、理想的には本番はプライベートクラスタにし、ローカルからは接続できないようにして踏み台等の専用の環境からのみ接続できるようにするのが良さそうです。

# Gatekeeperとは

> Open Policy Agent Gatekeeperプロジェクトは、Google、Microsoft、RedHat、Styraによるコラボレーションで、Kubernetes環境におけるポリシの適用とガバナンス強化を支援するように設計されている。Gatekeeper 3.0は、このOPA制約をさらに活用して、ユーザがポリシを宣言し、制約テンプレートを共有して、ポリシ違反のリソースを監査可能にする。

> Gatekeeperは、コードではなく、設定によってKubernetesアドミッションコントロールをカスタマイズする方法を提供するために考案された。厳しいリソース制限、グローバルに一意な入力名、すべてのイメージを指定されたリポジトリからのみプルすることなど、すべてのPodに対するコンプライアンス要件に対処することがその目的だ。

https://www.infoq.com/jp/news/2019/10/opa-gatekeeper-kubernetes/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/852413/efc785b4-5d36-d306-6628-609afd534321.png)
画像引用元: https://kubernetes.io/blog/2019/08/06/opa-gatekeeper-policy-and-governance-for-kubernetes/

# Gatekeeperを試す

まずは、公式が提供しているサンプルで動作をざっくり確認してみましょう。以下の例では[kind](https://github.com/kubernetes-sigs/kind)で新規にクラスタを作っていますが、[minikube](https://github.com/kubernetes/minikube)など何でも構いません。`gatekeeper`というラベルが付与されてないことにより、AdmissionWebhookによってnamespaceの作成がブロックされている様子がわかります。
https://github.com/open-policy-agent/gatekeeper

```bash
$ kind create cluster

$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/release-3.1/deploy/gatekeeper.yaml
$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/templates/k8srequiredlabels_template.yaml

$ kubectl create namespace test1
namespace/test created

$ kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/constraints/all_ns_must_have_gatekeeper.yaml

$ kubectl create namespace test2
Error from server ([denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}): admission webhook "validation.gatekeeper.sh" denied the request: [denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}
```

これらはどうやって実現しているのでしょうか。中身を覗いてみましょう。

```yaml:k8srequiredlabels_template.yaml
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        # Schema for the `parameters` field
        openAPIV3Schema:
          properties:
            labels:
              type: array
              items: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels

        violation[{"msg": msg, "details": {"missing_labels": missing}}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("you must provide labels: %v", [missing])
        }
```

```yaml:all_ns_must_have_gatekeeper.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: ns-must-have-gk
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Namespace"]
  parameters:
    labels: ["gatekeeper"]
```

ConstraintTemplateの`spec.targets.rego`の部分がrego言語で書かれたポリシーです。
regoには馴染み深くないですが、以下のように推測できます。

- ConstraintTemplateでテンプレートを定義し、labelsというパラメータを外部から受け取っている
    - input.reviewの内容(AdmissionReview)とパラメータの中身の差分が1つ以上あったら、違反(violation)として扱っている
    - エラー内容はmsgに突っ込んでいる
- K8sRequiredLabelsでlabelsというパラメータにgatekeeperを設定している
    - 制限の対象はNamespaceのみ

雰囲気はわかりましたが、やはりregoのことがよくわかってません。ローカルで色々試せるようにしたいですね。

## Regoを試す

regoを試すには、何通りか方法がありますが、ここではbrewで入れました。

- brew
- バイナリをインストール
    - https://www.openpolicyagent.org/docs/latest/#1-download-opa
- The Rego Playground
    - https://play.openpolicyagent.org/

```bash
$ brew install opa
$ opa run

> msg := sprintf("you must provide labels: %v", ["gatekeeper"])
Rule 'msg' defined in package repl. Type 'show' to see rules.
> msg
"you must provide labels: gatekeeper"
```

言語仕様はこちらに記載されています。
https://www.openpolicyagent.org/docs/latest/policy-language/

## 本題

公式サンプルの動作を確認し、regoを手元で試す手段も用意できました。
ようやく本題に着手しましょう。

```bash
kubectl apply --context production -k manifests/app1/overlays/staging
```

結果的に、このようになりました。
https://github.com/kashiwel/gatekeeper-example

以下は抜粋です。

```yaml
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
  name: config
  namespace: gatekeeper-system
spec:
  sync:
    syncOnly:
      - group: ""
        version: "v1"
        kind: "ConfigMap"
```

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: gatekeeper-config
  namespace: gatekeeper-system
data:
  env: production
```

```yaml:manifests/gatekeeper-system/constrainttemplate.yaml
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: envmatched
spec:
  crd:
    spec:
      names:
        kind: EnvMatched
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package envmatched
        
        violation[{"msg": msg}] {
          env := input.review.object.metadata.annotations[input.parameters.envName]
          config := data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]

          config.data.env != env

          msg := sprintf("expected: %v, actual: %v", [config.data.env, env])
        }
```

```yaml:manifests/gatekeeper-system/envmatched.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: EnvMatched
metadata:
  name: deny-deployment
spec:
  match:
    kinds:
      - apiGroups: ["apps"]
        kinds: ["Deployment"]
  parameters:
    envName: example.com/env
```

kindで構築したクラスタをproductionと見立てて、stagingのリソースをapplyしてみると想定どおりブロックされました。

```
$ cat manifests/default/app1/overlays/staging/kustomization.yaml
bases:
  - ../../base
patchesStrategicMerge:
  - deploy.yaml
commonAnnotations:
  example.com/env: staging

$ kubectl apply -k manifests/default/app1/overlays/staging/
"manifests/app1/overlays/staging/": admission webhook "validation.gatekeeper.sh" denied the request: [denied by deny-deployment] expected: production, actual: staging
```

本番のリソースは問題なくapplyできます。

```
$ cat manifests/default/app1/overlays/production/kustomization.yaml
bases:
  - ../../base
patchesStrategicMerge:
  - deploy.yaml
commonAnnotations:
  example.com/env: production

$ kubectl apply -k manifests/default/app1/overlays/production/
deployment.apps/echoserver configured
```

あまり実用的な例ではありませんでしたが、目的を達成できました。

## 補足

### opa.runtime()が空

`gatekeeper-config`というConfigMapと、以下の部分は補足しなければなりません。

```rego
config := data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]
```

当初は`opa.runtime()`で環境変数を含むランタイム情報を取得できると考え、GatekeeperのdeploymentのENV環境変数にstagingやproductionを設定しておき、比較する方法で考えていました。

```bash
$ opa run
> opa.runtime()
{
  "commit": "",
  "config": {},
  "env": {
    ...
    "LC_ALL": "en_US.UTF-8",
    "LC_CTYPE": "UTF-8",
    "SHELL": "/bin/bash",
    "SHLVL": "2",
    "STARSHIP_SHELL": "fish",
    "TERM": "xterm-256color",
  },
  "version": "0.25.2"
}
```

```rego
violation[{"msg": msg}] {
    opa.runtime().env.ENV != input.review.object.metadata.annotations[input.parameters.envName]

    msg := sprintf("expected: %v, actual: %v", [opa.runtime().env.ENV, input.review.object.metadata.annotations[input.parameters.envName]])
}
```

ただ、Gatekeeperで試してみると、`opa.runtime()`が空になっていました。Issueを探してみると、どうやら意図的に読み込んでないようです。

> We are not currently populating OPA with the contents of ENV
https://github.com/open-policy-agent/gatekeeper/issues/664#issuecomment-639245663

詰んだか…と思いましたが、どうやらsyncという機能を使えば、クラスタ内のリソースを`data.inventory`として読み出せるようでした。

https://github.com/open-policy-agent/gatekeeper#replicating-data

それが、この部分です。ドキュメントに記載されてますが、`metadata.name`は`config`である必要があります。

```yaml
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
  name: config
  namespace: gatekeeper-system
spec:
  sync:
    syncOnly:
      - group: ""
        version: "v1"
        kind: "ConfigMap"
```

この指定によって、以下のConfigMapの`env: production`の値が、`data.inventory.namespace["gatekeeper-system"]["v1"]["ConfigMap"]["gatekeeper-config"]`として読み込めるようになりました。

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: gatekeeper-config
  namespace: gatekeeper-system
data:
  env: production
```

ただ、正直なところ、このような使い方がセキュリティ的に問題ないかは検証できておりません。本番等で使う場合は、ご注意ください。

### デバッグ方法

上記のように、ローカルの`opa run`だと値が取得できるものが、gatekeeperだと挙動が変わったりします。

javascriptでいう`console.log`のように値をprintしたいと思って探したところ、`trace`というビルトイン関数が使えそうに見えました。ただ、仕込んでも何故かログに出力されません。
https://www.openpolicyagent.org/docs/latest/policy-reference/#debugging

READMEをみると、denyするようにしてmsgに突っ込むのが楽だよと書かれていました。これで、なんとかデバッグできそうです。

> A simple way to view the request object is to use a constraint/template that denies all requests and outputs the request object as its rejection message.
https://github.com/open-policy-agent/gatekeeper#viewing-the-request-object

```rego
apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: k8sdenyall
spec:
  crd:
    spec:
      names:
        kind: K8sDenyAll
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdenyall

        violation[{"msg": msg}] {
          msg := sprintf("REVIEW OBJECT: %v", [input.review])
        }
```

# その他のユースケース

今回紹介した例は、公式サンプルや日本語記事に存在しない(と思われる)もの、という縛りで作り出した恣意的なものです。

公式のサンプルに様々なユースケースの実装例がありますので、いくつか紹介します。
https://github.com/open-policy-agent/gatekeeper-library

### 指定したリポジトリのみ許可

最もイメージしやすい用途かと思います。この例では、リポジトリは`openpolicyagent`のみ許可されているため、imageに`openpolicyagent/opa:0.9.2`は指定できますが、`nginx`は指定できません。

```rego
violation[{"msg": msg}] {
    container := input.review.object.spec.containers[_]
    satisfied := [good | repo = input.parameters.repos[_] ; good = startswith(container.image, repo)]
    not any(satisfied)
    msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v", [container.name, container.image, input.parameters.repos])
}
violation[{"msg": msg}] {
    container := input.review.object.spec.initContainers[_]
    satisfied := [good | repo = input.parameters.repos[_] ; good = startswith(container.image, repo)]
    not any(satisfied)
    msg := sprintf("container <%v> has an invalid image repo <%v>, allowed repos are %v", [container.name, container.image, input.parameters.repos])
}
```
```yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: repo-is-openpolicyagent
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - "default"
  parameters:
    repos:
      - "openpolicyagent"
```

OK

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: opa-allowed
spec:
  containers:
    - name: opa
      image: openpolicyagent/opa:0.9.2
```

NG

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx-disallowed
spec:
  containers:
    - name: nginx
      image: nginx
```

https://github.com/open-policy-agent/gatekeeper-library/tree/master/library/general/allowedrepos

### ユニークなServiceセレクタのみ許可

以下のように、修正ミス等でServiceリソースのselectorが重複してしまった際に検出できるポリシーが紹介されています。

```yaml
apiVersion: v1
kind: Service
metadata:
  name: test1
spec:
  selector:
    app: test1
```

```yaml
apiVersion: v1
kind: Service
metadata:
  name: test2
spec:
  selector:
    app: test1
```

https://github.com/open-policy-agent/gatekeeper-library/tree/master/library/general/uniqueserviceselector

# まとめ

この記事では、以下のことを書きました。

- gatekeeperのREADMEに記載されているサンプルを試して挙動を確認
- 想定しているlabelの値ではない場合はブロック
    - 単純な方法で環境変数から取得できないことがわかったため、syncの機能を使い、ConfigMapから強引に取得
- 公式リポジトリに置かれているその他のユースケースの紹介

OPAの汎用性は非常に高く、非常に大きな可能性を秘めていると考えています。
本番環境の導入を見据えて、キャッチアップを続けたいと思います。

# 参考

- OPA
    - [「誰」が「何」をできるかをOPAのレスポンスで返す](https://kenfdev.hateblo.jp/entry/2019/05/09/222520)
        - OPAの可能性を感じることができる素晴らしい記事
        - [アプリケーションにおける権限設計の課題](https://kenfdev.hateblo.jp/entry/2020/01/13/115032)
- rego
    - [Policy Language](https://www.openpolicyagent.org/docs/latest/policy-language/)
        - 公式のregoリファレンス
- conftest
    - [Conftestを用いたCIでのポリシーチェックの紹介](https://engineering.mercari.com/blog/entry/introduce_conftest/)
        - メルカリ社におけるconftestの導入事例
        - `opa test`コマンドによる、ポリシーのテストについても触れられています
    - [Conftest で CI 時に Rego を用いたテストを行う](https://amsy810.hateblo.jp/entry/2020/04/03/124913)
        - Kubernetes完全ガイドでお馴染みの青山さんによるPLAID社にconftestを導入した事例
- Gatekeeper
    - [EKSにOPA GatekeeperをHelmでインストールしてポリシー強制をしてみる](https://yomon.hatenablog.com/entry/2020/11/eks_opa_eks_deny_invalid_registries)
        - この記事執筆時点で、EKSとGatekeeperに触れている唯一の記事かと思います
        - ECRレジストリからのみpullを許可する例が記載されています
    - [Using Gatekeeper as a drop-in Pod Security Policy replacement in Amazon EKS](https://aws.amazon.com/blogs/containers/using-gatekeeper-as-a-drop-in-pod-security-policy-replacement-in-amazon-eks/)
        - AWSブログの記事です
        - PSP(Pod Security Policy)の問題点を挙げ、Gatekeeperで`pod.spec.containers.securityContext.allowPrivilegeEscalation`がtrueのPodをブロックする例が紹介されています