K8s 1.18でalphaになったServiceAccountIssuerDiscoveryについて


## 機能を有効にする

K8s 1.18でAlphaの機能として提供されたServiceAccountIssuerDiscoveryは、K8s APIサーバでOIDC DiscoveryやJWKsのエンドポイントを提供する機能です。

有効にするためにはFeatureGateで `ServiceAccountIssuerDiscovery=true` を設定する必要があります。そのうえで kube-apisever の `--service-account-issuer` を指定する必要があります。このとき、 `service-account-issuer` の値はエンドポイントのURLとして使われる(`${service-account-issuer}/.well-known/openid-configuration`)ため、scheme=httpsを含めた形でissueを指定しておく必要があります。

e.g., 


```
kube-apiserver \
  --feature-gates=ServiceAccountIssuerDiscovery=true      # <= 該当のFeatureGateを有効にする
  --service-account-issuer=https://kubernetes.default.svc # <= issueを https:// 付きで指定
  --service-account-signing-key-file= ...
  --service-account-api-audiences=api
  --service-account-key-file= ...
  ...
```

`ServiceAccountIssuerDiscovery` で提供されるエンドポイントはRBACによって制限されています。機能が有効になると `system:service-account-issuer-discovery` というClusterRoleが作られる(中身はconfigurationとjwksエンドポイントに対するgetの権限付与)ので、それを運用しているクラスタのセキュリティレベルに合わせてバインディングします。

```
$ kubectl get clusterrole system:service-account-issuer-discovery
NAME                                      CREATED AT
system:service-account-issuer-discovery   2020-04-10T00:42:18Z
```

認証済みのユーザに全てにアクセスを許可する場合には以下のようなClusterRoleBindingを作成します。

```yaml
$ cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: oidc-discovery
subjects:
- kind: Group
  name: system:authenticated
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: system:service-account-issuer-discovery
  apiGroup: rbac.authorization.k8s.io
EOF
```

権限まわりの準備ができたところで、適当にデバッグ用のPodを作って動作を確認してみます。

```yaml
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: debug
  namespace: default
---
apiVersion: v1
kind: Pod
metadata:
  name: curl
spec:
  containers:
  - name: main
    image: tutum/curl
    command: ["sleep", "9999999"]
    volumeMounts:
    - mountPath: /var/run/secrets/tokens
      name: debug-token
  serviceAccountName: debug
  volumes:
  - name: debug-token
    projected:
      sources:
      - serviceAccountToken:
          path: debug-token
          expirationSeconds: 7200
          audience: api
```

```
$ kubectl exec -it curl sh

# export TOKEN=`cat /var/run/secrets/tokens/debug-token`

# curl -k -H "Authorization: Bearer $TOKEN" https://kubernetes.default.svc/.well-known/openid-configuration

{
    "issuer": "https://kubernetes.default.svc",
    "jwks_uri": "https://172.17.0.3:6443/openid/v1/jwks",
    "response_types_supported": ["id_token"],
    "subject_types_supported": ["public"],
    "id_token_signing_alg_values_supported": ["RS256"]
}

# curl -k -H "Authorization: Bearer $TOKEN" https://172.17.0.3:6443/openid/v1/jwks

{
    "keys": [{
        "use": "sig",
        "kty": "RSA",
        "kid": "_FYGic7zVHrx.....8sXADzrDCFHZhr0eA",
        "alg": "RS256",
        "n": "vfA4JnnOHd1pMR.....Br6I6a_mxlzw5LFbi0g9zPIPLuwm7awe_jg_wbJbMiJDzZU_ln-Ge3HusCTQ",
        "e": "AQAB"
    }]
}
```

想定どおり、K8s APIサーバが提供するDiscoveryエンドポイントからOpenID Configurationの情報や、JWKsエンドポイントからSA Tokenの署名を検証するための鍵情報を取得できました。


## JWKsエンドポイントで提供されている鍵情報を使ってSA Tokenを検証してみる w/ OPA

こここからは応用編として、OpenID Connect Discoveryで取得できる情報を使って、OPAにSA Tokenを検証させてみたいと思います。ServiceAccountIssuerDiscoveryを有効にしたクラスタにOPA(とkube-mgmt)をデプロイします。

```yaml
$ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: opa
  name: opa
spec:
  replicas: 1
  selector:
    matchLabels:
      app: opa
  template:
    metadata:
      labels:
        app: opa
    spec:
      containers:
      - name: opa
        args:
        - run
        - --server
        - --log-level=debug
        image: openpolicyagent/opa:0.18.0
        imagePullPolicy: IfNotPresent
      - name: mgmt
        args:
        - --opa-url=http://127.0.0.1:8181/v1
        - --enable-policies=true
        - --policies=default
        - --replicate-path=kubernetes
        - --replicate=v1/pods
        image: openpolicyagent/kube-mgmt:0.10
        imagePullPolicy: IfNotPresent
        lifecycle:
          postStart:
            exec:
              # 今回の場合はOPAからDiscoveryのエンドポイントにアクセスする際の認証に必要。もうすこしいいやりかたあると思います。
              # system:unauthenticated に権限を与えるような場合にはこの処理は不要。
              command:
              command:
                - sh
                - -c
                - >
                  apk add curl &&
                  echo {\"token\"":" \"$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\"}  > /tmp/token.json &&
                  curl "localhost:8181/v1/data/credentials" -X PUT -d @/tmp/token.json
      serviceAccountName: opa
      volumes:
---
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app: opa
  name: opa

---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: opa
  name: opa
spec:
  selector:
    app: opa
  ports:
  - protocol: TCP
    port: 8181
    targetPort: 8181

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: for-kube-mgmt
rules:
- apiGroups:
  - ""
  resources:
  - pods
  verbs:
  - get
  - list
  - watch
- apiGroups:
  - ""
  resources:
  - configmaps
  verbs:
  - get
  - list
  - watch
  - patch
- apiGroups:
  - authentication.k8s.io
  resources:
  - tokenreviews
  verbs:
  - create

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: opa
subjects:
- kind: ServiceAccount
  name: opa
  namespace: default
roleRef:
  kind: ClusterRole
  name: for-kube-mgmt
  apiGroup: rbac.authorization.k8s.io
EOF
```

OPAに設定するPolicyはこんな感じです。RegoにはJWTを検証する関数が組み込まれているので、それをつかって簡単にPolicyを定義できます。

```yaml
$ cat <<EOF | kubectl apply -f -
kind: ConfigMap
apiVersion: v1
metadata:
  labels:
    openpolicyagent.org/policy: rego
  name: sa-token-validation.rego
data:
  system-main.rego: |
    package system

    import data.credentials

    main = response

    default response = {
       "valid": false,
        "status": {
            "reason": "Invalid SA Token",
        }       
    }

    response = {
         "valid": true
    } {
         is_valid
    }

    authz_header := {"Authorization": sprintf("Bearer %s", [credentials.token]) }

    discovery_req := {
        "method": "GET",
        "headers": authz_header,
        "tls_insecure_skip_verify": true, # 注意: 本来はCA証明書渡してセットすべき
        "url": "https://kubernetes.default.svc/.well-known/openid-configuration"
    }
    discovery_res := http.send(discovery_req)

    jwks_req := {
        "method": "GET",
        "headers": authz_header,    
        "tls_insecure_skip_verify": true,  # 注意: 本来はCA証明書渡してセットすべき
        "url": discovery_res.body.jwks_uri
    }
    jwks_res := http.send(jwks_req)

    constraints = {
        "iss": "https://kubernetes.default.svc",
        "aud": "api",
        "cert": jwks_res.raw_body
    }
    out := io.jwt.decode_verify(input.token, constraints)

    is_valid {
        out[0]
    }
EOF
```

ConfigMapで設定したPolicyはkube-mgmtがロードしてくれるように設定しています。正しく読み込まれた場合にはConfigMapのオブジェクトにアノテーションが付与されます。

```
kubectl get configmap sa-token-validation.rego -o yaml

kind: ConfigMap
metadata:
  annotations:
    openpolicyagent.org/policy-status: '{"status":"ok"}'  # <= 読み込まれるとこのようなアノテーションが付与される
  labels:
    openpolicyagent.org/policy: rego
  ....
```

OPAの準備ができたので、先程のデバッグ用のコンテナからOPAにリクエストしてみます。正しいSA Tokenを渡した場合にはvalid=trueの結果が返ってきました。

```
# export TOKEN=`cat /var/run/secrets/tokens/debug-token`
#  curl -X POST -d '{"token": "'"$TOKEN"'" }' http://opa.default.svc:8181/
{"valid":true}

#  curl -X POST -d '{"token": "'"$INVALID-TOKEN"'" }' http://opa.default.svc:8181/
{"status":{"reason":"Invalid SA Token"},"valid":false}
```

OPAがJWKの内容をキャッシュしたりする方法があるのかはわかりませんが、このままだとOPAにリクエストがくるたびにJWKsを取得しにいってしまうため、K8s APIサーバに毎回検証を依頼するよりは幾分かましなものの、実運用にはもう少し工夫がいりそうです。

このあたりのIssueの議論に期待したいですね。
https://github.com/open-policy-agent/opa/issues/2057


## まとめ

これまでSA Tokenを検証するとしても、検証用の鍵を動的に取得する方法がなかったため、TokenReview APIを使わざるを得ない状況でした。しかし、大規模環境のおいてSA Tokenの検証が同時に発生してしまうような場合※にはK8s APIサーバの負荷軽減を考える必要があり、そのような場合にはアプリケーション自身やその他OPAなどの仕組みでSA Tokenを検証できることが重要になってきます。 そのような場合には今回紹介した機能がとても重要になってきます。

現時点ではalpha版のため、仕様などが変わる可能性があります。必要になった場合には適切に使えるように引き続きウォッチしていきます。

※ たとえば、[SPIRE](<https://github.com/spiffe/spire>) の K8s NodeのNode Attestaion(k8s<sub>psat</sub>[](<https://github.com/spiffe/spire/blob/master/doc/plugin_agent_nodeattestor_k8s_psat.md>))なんかでもSA Tokenの正しさによってノードを認証しているのですが、Tokenの検証にはTokenReview APIを利用しているため、一度に大量のノードが起動してきた場合にはAPIサーバに負荷がかかってしまいます。