Dexを利用したOIDCによるkubectlコマンドの管理

# はじめに

組織の認証管理に利用しているLDAPに登録されているID/Passwordを利用したいので、OpenID Providerのdex, https://github.com/dexidp/dex, を利用しています。

今回は数名で利用するKubernetesのmaster nodeにログインしたユーザーが/etc/kubernetes/admin.confを共有している状況がまずいかなと思ったので、利用者を個別に識別するためdexを利用したいと思います。

:::note
【2020/04/24追記】Kubespray v2.12.5 (kubernetes v1.16.8) でも稼動しています。
【2021/04/15追記】Dexを更新した際に遭遇した問題について記述しています。
【2024/08/28追記】Kubespray v2.25.0 (kubernetes v1.29.5) でも稼動しています。
:::

# 前提

* Kubespray: v2.8.5 → v2.12.5 → v2.15.0 → v2.25.0

dexが次のURLで稼動しているものとして説明します。

* URL: https://dex.example.com/dex/.well-known/openid-configuration
* client-id: example-app

# 参考資料

* [dex(OpenID Connect Provider)をLDAP認証で動かしてみる](https://qiita.com/YasuhiroABE/items/2effa6d68316b9dc3b10)
* [Kubernetes authentication via GitHub OAuth and Dex](https://medium.com/preply-engineering/k8s-auth-a81f59d4dff6) Mediumの記事。Tokenを発行する独自WebアプリとDex, k8sとの関係について説明している
* [Authenticating](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens) kubernetes.io の公式ガイド
* [Kubernetes authentication through dex](https://github.com/dexidp/dex/blob/master/Documentation/kubernetes.md) github上のdexガイド
* https://github.com/johnw188/dex-example
* https://qiita.com/shoichiimamura/items/91208a9b30e701d1e7f2
* https://banzaicloud.com/blog/k8s-rbac/
* https://int128.hatenablog.com/entry/2018/01/12/113745
* https://github.com/dexidp/dex/issues/1336

# 作業の流れ

参考資料のMediumの記事とは違い、dexに付属しているexample-appを利用していきます。
このため実際の利用には、手動で~/.kube/configを更新する手間がかかりますが、動きを理解する上では有用だと思います。

実際の利用ではexample-appアプリに相当する専用アプリを設計し、.kube/configファイルを更新するスクリプトを準備する方が利便性も向上すると思います。

まずは次の順序で設定を行なっていきます。

* example-appをWebアプリとして実行するdockerコンテナの作成とk8s環境へのデプロイ
* kubesprayで構築されているk8s環境へのOIDC設定
* 各ユーザー毎に必要なkubectlを実行するまでの流れ

## example-appを実行するdockerコンテナの作成とk8s環境へのデプロイ

現在利用しているイメージはalpineをベースにした上で、multi stage buildを利用して約40MBほどのサイズになっています。サーバーにもクライアントにも使えるように、examplesもビルドしていて、/dex/bin/dex, /dex/bin/example-app が利用できます。

```Dockerfile:Dockerfile
FROM docker.io/library/golang:1.22-alpine as dex
ARG DEX_TAGNAME=v2.39.1

RUN apk --no-cache add git make gcc libc-dev patch

RUN mkdir /work
WORKDIR /work
RUN git clone --depth 1 --branch $DEX_TAGNAME https://github.com/dexidp/dex.git

WORKDIR /work/dex
##COPY image/logo.png web/themes/dark/logo.png
##COPY image/logo.png web/themes/light/logo.png
COPY patch/20240523-v2.39.1.patch /v2.39.1.patch
RUN patch -p1 < /v2.39.1.patch
RUN make build && make examples

FROM docker.io/library/alpine:3.19

MAINTAINER YasuhiroABE <yasu-abe@u-aizu.ac.jp>

RUN apk update && apk add --no-cache bash ca-certificates

RUN mkdir -p /dex/bin
COPY --from=dex /work/dex/bin/. /dex/bin/.
COPY run.sh /run.sh
RUN chmod +x /run.sh

WORKDIR /dex

## Server Settings
EXPOSE 5556
ENV CONFIG_FILEPATH="/config/config-ldap.yaml"

## Client Settings
EXPOSE 5555
ENV DEXC_LISTENURL="http://0.0.0.0:5555"
ENV DEXC_REDIRECTURL="http://192.168.1.1:5555/callback"
ENV DEXC_ISSUERURL="http://192.168.1.2:5556/dex"
ENV DEXC_CLIENT_ID="example-app"
ENV DEXC_CLIENT_SECRET="ZXhhbXBsZS1hcHAtc2VjcmV0"

VOLUME ["/config", "/data"]

ENTRYPOINT ["/run.sh"]
```

次のようなrun.shファイルをENTRYPOINTに指定しています。

```bash:run.sh
#!/bin/bash -x

exec bin/example-app  \
    --client-id "${DEXC_CLIENT_ID}" \
    --client-secret "${DEXC_CLIENT_SECRET}" \
    --listen "${DEXC_LISTENURL}" \
    --redirect-uri "${DEXC_REDIRECTURL}" \
    --issuer "${DEXC_ISSUERURL}"
```

Dockerfileに記述しているpatch/20240523-v2.39.1.patchファイルの内容は次のようになっています。

これはMediawikiのように言語設定によってリダイレクトURLに日本語が含まれるような場合にデコードされたURLを適切に扱えないDexの問題を回避するためのものです。詳細は[DexのPR](https://github.com/dexidp/dex/pull/3492)を確認してください。

```diff:patch/20240523-v2.39.1.patch
diff --git a/server/handlers.go b/server/handlers.go
index ccd534d9..85335972 100644
--- a/server/handlers.go
+++ b/server/handlers.go
@@ -880,7 +880,11 @@ func (s *Server) calculateCodeChallenge(codeVerifier, codeChallengeMethod string
 func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client storage.Client) {
        ctx := r.Context()
        code := r.PostFormValue("code")
-       redirectURI := r.PostFormValue("redirect_uri")
+       redirectURI, err := url.QueryUnescape(r.PostFormValue("redirect_uri"))
+       if err != nil {
+               s.tokenErrHelper(w, errInvalidRequest, "No redirect_uri provided.", http.StatusBadRequest)
+               return
+       }
 
        if code == "" {
                s.tokenErrHelper(w, errInvalidRequest, `Required param: code.`, http.StatusBadRequest)
```

なおDexサーバーとして稼動しているコンテナのrun.shは次のようになっています。

```bash:サーバー側のrun.sh
#!/bin/bash -x
bin/dex serve "${CONFIG_FILEPATH}"
```

config-ldap.yamlの内容は環境によって違うため個別に設定が必要ですが、サーバー側は次のような設定にしています。

```yaml:サーバー側のconfig-ldap.yamlから抜粋
...
staticClients:
- id: example-app
  redirectURIs:
  - http://127.0.0.1:8000/callback
  name: 'Example App'
  secret: ZXhhbXBsZS1hcHAtc2VjcmV0
```

example-appが動作するalpineのイメージは DockerHubで yasuhiroabe/dex-webapp の名前で公開しています。(実際に使用しているDockerfileは少し違います)

* https://hub.docker.com/r/yasuhiroabe/dex-webapp

このイメージを使うためのコマンドラインは次のようなものです。
dexサーバー側で、client-id, client-secretを変更している場合には、適宜加えてください。

```bash:Dexクライアントを起動するdockerコマンド
$ podman run -it --rm \
		--env DEXC_LISTENURL="http://0.0.0.0:5555" \
		--env DEXC_REDIRECTURL="http://127.0.0.1:8000/callback" \
		--env DEXC_ISSUERURL="https://dex.example.com/dex" \
		-p 8000:5555 \
		docker.io/yasuhiroabe/dex-webapp
```

このサービスに http://127.0.0.1:8000/ などでログインして稼動確認を行います。

DEXC_REDIRECTURLのURLは、このコンテナ自身を指すものですが、k8sのような実際の環境ではサービス用のingressやloadBalancerで割り当てているホスト名やreverse-proxyのホスト名になると思います。あらかじめDexサーバー側の設定で、ここに指定するリダイレクトURLをexample-appのredirectURIsのリストに加えておく必要があります。

DEXC_ISSUERURLはDexサーバーのURLを指定します。あらかじめDexが稼動しているものとして、この説明ではサーバーの構築は省略しています。Dexサーバー環境の構築については下記の記事を参考にしてください。

* [dex(OpenID Connect Provider)をLDAP認証で動かしてみる](https://qiita.com/YasuhiroABE/items/2effa6d68316b9dc3b10)

## kubesprayで構築されたk8s環境へのOIDC設定

マニュアルどおに、inventory/sample/ を inventory/mycluster/ にコピーしているものとして進めます。

```diff:inventory/mycluster/group_vars/k8s-cluster/k8s-cluster.yml 
diff --git a/inventory/mycluster/group_vars/k8s-cluster/k8s-cluster.yml b/inventory/mycluster/group_vars/k8s-c
luster/k8s-cluster.yml
index 8338a0eb..97a9e718 100644
--- a/inventory/mycluster/group_vars/k8s-cluster/k8s-cluster.yml
+++ b/inventory/mycluster/group_vars/k8s-cluster/k8s-cluster.yml
@@ -36,7 +36,7 @@ kube_log_level: 2
 credentials_dir: "{{ inventory_dir }}/credentials"
 
 ## It is possible to activate / deactivate selected authentication methods (oidc, static token auth)
-# kube_oidc_auth: false
+kube_oidc_auth: true
 # kube_token_auth: false
 
 
@@ -44,12 +44,15 @@ credentials_dir: "{{ inventory_dir }}/credentials"
 ## To use OpenID you have to deploy additional an OpenID Provider (e.g Dex, Keycloak, ...)
 
 # kube_oidc_url: https:// ...
+kube_oidc_url: https://example.com/dex
 # kube_oidc_client_id: kubernetes
+kube_oidc_client_id: example-app
 ## Optional settings for OIDC
 # kube_oidc_ca_file: "{{ kube_cert_dir }}/ca.pem"
 # kube_oidc_username_claim: sub
+kube_oidc_username_claim: email
 # kube_oidc_username_prefix: oidc:
-# kube_oidc_groups_claim: groups
+kube_oidc_groups_claim: groups
 # kube_oidc_groups_prefix: oidc:
 
 ## Variables to control webhook authn/authz
```

v2.15.0では明示的にkube_oidc_groups_claimを指定しないと、グループによる制御ができませんでした。
この内容をupgrade-cluster.ymlで反映させておきます。

```bash
$ ansible-playbook upgrade-cluster.yml -b -i inventory/mycluster/hosts.ini 
```

反映が無事に終わると、masterノードの /etc/kubernetes/kubeadm-config.v1alpha3.yaml に内容が反映されます。

```bash
$ ansible kube-master -i inventory/mycluster/hosts.ini -b -m command -a 'grep oidc /etc/kubernetes/manifests/kube-apiserver.yaml'

node2 | CHANGED | rc=0 >>
  oidc-issuer-url: http://dex.example.com/dex
  oidc-client-id: example-app
  oidc-username-claim: email
  oidc-groups-claim: groups

node1 | CHANGED | rc=0 >>
  oidc-issuer-url: http://dex.example.com/dex
  oidc-client-id: example-app
  oidc-username-claim: email
  oidc-groups-claim: groups
```

oidc-client-id は、dexサーバー側の設定で staticClients: に id: として登録されている名称である必要があります。

# Dexで認証した権限でkubectlを実行する

クラスター全体で管理者になりたければcontrol-planeノードでrootになって /root/.kube/config を利用すれば良いので、ここでは個人の$USER名と同じnamespace(NS)があるとして、そのNS内で自由に活動できる権限のあるRoleBindingを生成することをゴールにしてみます。

## example-app から必要な情報を入手する

まず kubespray で oidc-issuer-url で指定したDexに接続しているexample-appにアクセスし、"ID Token" を取得します。



## ~/.kube/config の生成

続いて公式ガイドに従って ~/.kube/config ファイルを生成します。
次のようなスクリプトを準備しました。

```bash:gen-kubeconf.sh
#!/bin/bash

YA_NAME=user01@example.com
YA_ISSURL="https://dex.example.com/dex"
YA_CLIENT_ID="example-app"
YA_REF_TOKEN="ChlmNHZoM2s1....mhuZndt"
YA_ID_TOKEN="eyJ....Mxqg_ZQ"

kubectl config set-credentials "${YA_NAME}" \
   --auth-provider=oidc \
   --auth-provider-arg=idp-issuer-url="${YA_ISSURL}" \
   --auth-provider-arg=client-id="${YA_CLIENT_ID}" \
   --auth-provider-arg=refresh-token="${YA_REF_TOKEN}" \
   --auth-provider-arg=id-token="${YA_ID_TOKEN}"
```

この状態では ~/.kube/config が生成されますが、あらかじめ /root/.kube/config などをコピーしていると内容が重複します。
clusterやcontextの設定はそのまま流用して、kubernetes-adminの定義を削除するなどして、全体は次のような状態になっています。

```text:~/.kube/configファイル全体
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: BS0tBS...EDtKS0tBQo=
    server: https://192.168.1.65:6443
  name: cluster.local
contexts:
- context:
    cluster: cluster.local
    user: user01@example.com
  name: user01@example.com@cluster.local
current-context: user01@example.com@cluster.local
kind: Config
preferences: {}
users:
- name: user01@example.com
  user:
    auth-provider:
      config:
        client-id: example-app
        id-token: eyJ....Mxqg_ZQ
        idp-issuer-url: https://dex.example.com/dex
        refresh-token: ChlmNHZoM2s1....mhuZndt
      name: oidc
```

refresh-token: は実際には使われないと思うので省略しても良いかもしれません。

## kubectlの実行

デバッグ情報を出力させて、API Serverの反応を確認します。

```bash
$ kubectl -v=8 get node
```

最後の方のログに認証後の権限チェックに失敗している様子が分かります。

```text:標準エラー出力
...
I0703 00:29:57.111323    7807 helpers.go:201] server response object: [{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "nodes is forbidden: User \"user01@example.com\" cannot list resource \"nodes\" in API group \"\" at the cluster scope",
  "reason": "Forbidden",
  "details": {
    "kind": "nodes"
  },
  "code": 403
}]
...
```

API ServerがOIDCに対応するように設定されていない場合は、401 Unauthorizedエラーになります。

API ServerがOIDCに対応しているものの、~/.kube/config の内容が正しくない場合には、ID Tokenが検証できないというメッセージが表示されるはずです。

```text:~/.kube/configが正しくない場合のエラー
...
F0703 00:35:40.958201    9261 helpers.go:119] Unable to connect to the server: ID Token is not a valid JWT
```

単純に権限が不足しているようであれば、RoleBinding, ClusterRoleBinding の機能を利用していきます。

# 必要な権限の付与 (RBAC)

まずはユーザー毎にNamespace(NS)を分割するという想定で、特定のNSで操作可能にします。

## Role/RoleBindingの設定

rootになって、user01@example.com に対応するNS "user01"での操作を許可します。

```bash:rootで実行
$ sudo kubectl create ns user01
```

次に [k8s公式ガイド](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)の例を参考にNS "user01" で可能な操作を定義していきます。

```yaml:role-default.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: user01
  name: default
rules:
- apiGroups: [""] # "" indicates the core API group
  resources: ["pods","services"]
  verbs: ["create", "delete", "get", "watch", "list"]
```

これをapplyすると次のようになります。

```bash:rootで実行
# kubectl apply -f role-default.yaml
role.rbac.authorization.k8s.io/default created
```

次にこのRole定義を、user01@example.com と紐付けます。

```yaml:rolebinding-user01.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: default
  namespace: user01
subjects:
- kind: User
  name: user01@example.com
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: default
  apiGroup: rbac.authorization.k8s.io
```

これも同様にapplyで適用します。

```bash:rootで実行
$ sudo kubectl apply -f rolebinding-user01.yaml
rolebinding.rbac.authorization.k8s.io/default created
```

## kubectl による pod /svc の操作

ここからは一般ユーザーに戻って、RoleBindingで許可された操作が可能になっているか確認していきます。

この状態では、pods(pod), services(svc)の操作が可能になっていますが、他の操作は許可されていないので、とりあえず deployments(deploy) などは利用できません。

とりあえず nginx を動かしてみます。

```yaml:01.create-nginx.yaml
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  namespace: user01
  labels:
    app: nginx
spec:
  containers:
    - name: nginx
      image: nginx:latest
      ports:
      - containerPort: 80
```

対応する service を定義します。

```yaml:02.create-svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: user01
spec:
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  selector:
    app: nginx
```

これらをapplyで適用すると、pod,svcの定義が作成でき、とりあえずPodIP(10.233.78.161)経由でアクセスができます。

```bash
$ kubectl apply -f 01.create-nginx.yaml
$ kubectl apply -f 02.create-svc.yaml
$ kubectl -n user01 get svc
NAME    TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)        AGE
nginx   LoadBalancer   10.233.19.133   192.168.1.101   80:31936/TCP   4m38s

$ curl http://192.168.1.101:80/
<!DOCTYPE html>
...
```

しかし、pod,svc以外には許可していないため、get allのようなコマンドでは権限が不足している旨のエラーが出力されます。

```bash:権限不足の例
$ kubectl -n user01 get all
NAME        READY   STATUS    RESTARTS   AGE
pod/nginx   1/1     Running   0          43m

NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
nginx   LoadBalancer   10.233.19.133   192.168.1.101   80:31936/TCP   5m18s
Error from server (Forbidden): replicationcontrollers is forbidden: User "user01@example.com" cannot list resource "replicationcontrollers" in API group "" in the namespace "user01"
Error from server (Forbidden): daemonsets.apps is forbidden: User "user01@example.com" cannot list resource "daemonsets" in API group "apps" in the namespace "user01"
Error from server (Forbidden): deployments.apps is forbidden: User "user01@example.com" cannot list resource "deployments" in API group "apps" in the namespace "user01"
Error from server (Forbidden): replicasets.apps is forbidden: User "user01@example.com" cannot list resource "replicasets" in API group "apps" in the namespace "user01"
Error from server (Forbidden): statefulsets.apps is forbidden: User "user01@example.com" cannot list resource "statefulsets" in API group "apps" in the namespace "user01"
Error from server (Forbidden): horizontalpodautoscalers.autoscaling is forbidden: User "user01@example.com" cannot list resource "horizontalpodautoscalers" in API group "autoscaling" in the namespace "user01"
Error from server (Forbidden): jobs.batch is forbidden: User "user01@example.com" cannot list resource "jobs" in API group "batch" in the namespace "user01"
Error from server (Forbidden): cronjobs.batch is forbidden: User "user01@example.com" cannot list resource "cronjobs" in API group "batch" in the namespace "user01"
```

get nsにも失敗して、あまりにも不便なので、閲覧ぐらいは許可してみます。

## ClusterRoleBindingで閲覧許可(view)を付与してみる

ここから、またrootになって作業を進めていきます。

ClusterRoleは既に登録されているviewを利用します。

```yaml:clusterrolebinding4user01.yaml
apiVersion: rbac.authorization.k8s.io/v1
# This cluster role binding allows anyone in the "manager" group to read secrets in any namespace.
kind: ClusterRoleBinding
metadata:
  name: user:user01
subjects:
- kind: User
  name: user01@example.com
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view
  apiGroup: rbac.authorization.k8s.io
```

これをrootユーザーの権限で適用します。

```bash:root権限で実行
# kubectl apply -f clusterrolebinding4user01.yaml
```

この後で一般ユーザーで、先ほどエラーになった get all を実行してみます。

```bash:一般ユーザーで実行
$ kubectl -n user01 get all
NAME        READY   STATUS    RESTARTS   AGE
pod/nginx   1/1     Running   0          56m

NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
service/nginx   NodePort   10.233.12.39   <none>        80:31546/TCP   18m
```

登録されている ClusterRole の view では、node に対する処理が記述されていないため、get node はエラーになりますが、概ね期待する動作になりました。

# 実用上の問題

前述のRoleBinding, ClusterRoleBindingは実用上はまだ課題があります。

## cluster-adminとほぼ同じだけど閲覧のみ可能な権限を付与したい

k8s公式ガイドでは、デフォルトのClusterRole(system:basic-user,system:discovery,system:public-info-viewer,cluster-admin,admin,edit,view)について説明されています。

k8sクラスター全体の状況を把握させるために、閲覧権限は付けたいけれど、勝手にdeleteやcreateをしてくれると困る、といった場合には、cluster-adminとほぼ同様の権限で、"get","list","watch"などに限定したロールを付与する方が良い場合もあるかもしれません。

```yaml:ClusterRole::cluster-adminを元にした権限付与
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: dex-admin
rules:
- apiGroups:
  - '*'
  resources:
  - '*'
  verbs: ["get", "list", "watch"]
- nonResourceURLs:
  - '*'
  verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: admin:user01
subjects:
- kind: User
  name: user01@example.com
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: dex-admin
  apiGroup: rbac.authorization.k8s.io
```

セキュリティ上の問題など、いろいろ考慮しなければいけませんが、教育用のk8sクラスターでは便利な設定です。

## 特定のNamespace(NS)で利用可能な権限を付与したい

特定のNS内の権限を決定するRoleBindingを利用する場合には、Role内で'*'のようなワイルドカード指定はできません。

それでも先ほど説明したデフォルトのClusterRole(admin,edit,view)がRoleBindingで利用できます。

```yaml:"user01"ネームスペース内でほぼ全ての操作が許可される権限を付与する
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: admin
  namespace: user01
subjects:
- kind: User
  name: user01@example.com
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: admin
  apiGroup: rbac.authorization.k8s.io
```

オブジェクトによって、Clusterの管理下なのか、それ以外のNamespaceの管理下なのか違ってくるので、少し面倒ですが、[公式のAPI References](https://v1-14.docs.kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/)のCluster APIsのセクションを確認するのが確実だろうと思います。

# 遭遇した問題

## kubesprayが/etc/kubernetes/manifests/kube-apiserver.yamlを再生成してくれない

kubesprayの設定からOIDC関連のコメントを外して upgrade-cluster.yaml を実行しましたが、kube-apiserver.yamlは再生成してくれませんでした。

このため手動で必要なオプションを加えてapiserverのPODを削除することで設定を有効にしたapiserverのPODを再起動しています。

kubesprayを使った場合には、特定の設定を削除することが難しい場面があるため、手動でシステムを変更しなければいけない場面が時々あります。

以上