Kubernetes API-Server avec plusieurs IdP (et Github Actions)
Durant mon article sur Authentik, j’ai évoqué la possibilité d’avoir du SSO sur l’API-Server de Kubernetes.
Pour rappel, pour activer l’authentification par l’OIDC lorsqu’on passe par kubectl
, il faut dans un premier temps configurer l’API de Kubernetes pour qu’elle puisse accepter les tokens JWT émis par un fournisseur d’identité (IdP) compatible OIDC. Cela se fait en ajoutant des paramètres à la ligne de commande du serveur API Kubernetes:
cluster:
apiServer:
extraArgs:
oidc-issuer-url: "https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/"
oidc-client-id: mon-super-client-id
oidc-username-claim: email
oidc-groups-claim: groups
En installant oidc-login
(un plugin pour kubectl
), on peut avoir un workflow qui ouvre son navigateur pour se connecter à l’IdP et obtenir un token JWT, qui sera ensuite utilisé pour s’authentifier auprès de l’API Kubernetes.
C’est propre, efficace, et ça fonctionne très bien !
Mais si vous voulez authentifier plusieurs populations d’utilisateurs avec différents IdP… coup dur !
Typiquement, j’utilise Dex et Authentik sur mes clusters Kubernetes pour les utilisateurs internes, et j’ai le besoin de pouvoir faire du Machine2Machine en authentifiant des pipelines Github Actions !
À savoir que Github ne propose pas d’OIDC (donc si vous voulez faire du Social-login avec Github, il faudra passer par un intermédiaire) pour les utilisateurs … mais pour les pipelines, c’est possible !
On peut donc, techniquement, s’authentifier auprès de l’API-Server avec un token JWT émis par GitHub dans un workflow GitHub Actions en direct (l’usage d’un Dex ou autre aurait été un chtit peu plus complexe).
Mais il y a une grosse limitation : les arguments passés à l’API-Server sont globaux, ce qui signifie que si vous avez plusieurs IdP, vous ne pouvez pas les utiliser en même temps. Il faut donc choisir entre vos utilisateurs, ou vos pipelines !
Mais ça, c’était avant que je découvre la fonctionnalité game-changer : la configuration de l’APIServer via un fichier (et surtout qui permet de configurer plusieurs Issuers).
Au revoir les arguments --oidc-issuer-url
, --oidc-client-id
, etc. et bonjour le fichier de configuration, voici un peu ce que ça donne :
apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
- issuer:
url: https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ # équivalent de oidc-issuer-url
audiences:
- mon-client-id-sur-goauthentik # équivalent de oidc-client-id
audienceMatchPolicy: MatchAny
claimValidationRules:
- expression: "claims.email_verified == true"
message: "email must be verified"
claimMappings:
username:
expression: 'claims.email + ":authentik"' # équivalent de oidc-username-claim
groups:
expression: "claims.groups" # équivalent de oidc-groups-claim
uid:
expression: "claims.sub"
userValidationRules:
- expression: "!user.username.startsWith('system:')"
message: "username cannot used reserved system: prefix"
- expression: "user.groups.all(group, !group.startsWith('system:'))"
message: "groups cannot used reserved system: prefix"
En plus de pouvoir enfin configurer ça dans un fichier plutôt que via des arguments, on a aussi accès à plein de nouvelles fonctionnalités :
- Valider un champ spécifique du token JWT (par exemple, vérifier que l’email est vérifié comme dans l’exemple ci-dessus).
- Mapper des claims du JWT vers des champs spécifiques (en appliquant un traitement en CEL).
- Vérifier des champs (pour valider le JWT ou l’utilisateur) avec des règles CEL.
Pour configurer l’APIServer avec ce fichier, il faut ajouter l’argument --authentication-config-file
à la ligne de commande du serveur API Kubernetes. Dans un contexte Talos ou RKE2, il faut tenir compte du fait que le kubelet est conteneurisé, donc il faut ajouter le fichier sur le système et créer un point de montage pour le conteneur du kubelet.
Pour Talos (je n’allais pas faire un article sans en parler), voici un exemple de configuration :
cluster:
apiServer:
extraArgs:
authentication-config: /var/lib/apiserver/authentication.yaml
extraVolumes:
- hostPath: /var/lib/apiserver
mountPath: /var/lib/apiserver
readonly: true
machine:
files:
- content: |
apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
- issuer:
url: https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ # équivalent de oidc-issuer-url
audiences:
- mon-client-id-sur-goauthentik # équivalent de oidc-client-id
audienceMatchPolicy: MatchAny
claimValidationRules:
- expression: "claims.email_verified == true"
message: "email must be verified"
claimMappings:
username:
expression: 'claims.email + ":authentik"' # équivalent de oidc-username-claim (+ un suffixe)
groups:
expression: "claims.groups" # équivalent de oidc-groups-claim
uid:
expression: "claims.sub"
userValidationRules:
- expression: "!user.username.startsWith('system:')"
message: "username cannot used reserved system: prefix"
- expression: "user.groups.all(group, !group.startsWith('system:'))"
message: "groups cannot used reserved system: prefix"
permissions: 0o444
path: /var/lib/apiserver/authentication.yaml
op: create
Pour RKE2, je vous laisse vous débrouiller, je suis ambassadeur Talos, pas Rancher ! (après si rancher passe par ici, je suis open hein 😉)
Pour tester ça :
$ kubectl config set-credentials oidc \
--exec-api-version=client.authentication.k8s.io/v1beta1 \
--exec-command=kubectl \
--exec-arg=oidc-login \
--exec-arg=get-token \
--exec-arg=--oidc-issuer-url=https://goauthentik.une-tasse-de.cafe/application/o/k8s-lucca-poc/ \
--exec-arg=--oidc-client-id=mon-client-id-sur-goauthentik \
--exec-arg=--oidc-client-secret=mon-secret-id-sur-goauthentik \
--exec-arg=--oidc-extra-scope=profile \
--exec-arg=--oidc-extra-scope=email
$ kubectl auth whoami --user=oidc
ATTRIBUTE VALUE
Username goauthentik@une-pause-cafe.fr:second
UID 6d47d08157e7d71d1a3b18087dc068ae689b142934cbb3517562d9b74162edba
Groups [authentik Admins Tech Omni system:authenticated]
Maintenant qu’on sait comment appliquer notre configuration, on peut aller un peu plus loin en ajoutant notre deuxième IdP : celui de Github actions.
L’issuer GH est https://token.actions.githubusercontent.com
, on peut librement choisir le client ID (tant qu’il est identique sur l’API-Server et dans le workflow GitHub Actions) et on peut mapper les claims du JWT comme on le souhaite.
# ... après le premier issuer
- issuer:
url: https://token.actions.githubusercontent.com
audiences:
- coffee-lucca-poc
audienceMatchPolicy: MatchAny
claimMappings:
username:
expression: '"github-actions:" + claims.sub'
uid:
expression: "claims.sub"
extra:
- key: "github.com/repository"
valueExpression: "claims.repository"
- key: "github.com/repository_owner"
valueExpression: "claims.repository_owner"
- key: "github.com/ref"
valueExpression: "claims.ref"
C’est plus ou moins la même configuration que pour Authentik, mais avec des claims différents (et je rajoute des claims supplémentaires pour avoir des infos sur le repository et la branche dans mes logs d’audit).
Comment on teste ça ? Déjà on va créer un kubeconfig qu’on utilisera dans le pipeline:
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJpakNDQVRDZ0F3SUJBZ0lSQVBydklDT2xMMGNCZ05iLzI4QlNtaUl3Q2dZSUtvWkl6ajBFQXdJd0ZURVQKTUJFR0ExVUVDaE1LYTNWaVpYSnVaWFJsY3pBZUZ3MHlOVEEzTVRVd09EVXlOREJhRncwek5UQTNNVE13T0RVeQpOREJhTUJVeEV6QVJCZ05WQkFvVENtdDFZbVZ5Ym1WMFpYTXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CCkJ3TkNBQVJQcGpwWmM5blc4Sm5YQXV2VWVXdjlNeG1IeDRuUXVhbGdFVWtuT1VmMTZYRlU4S0N1M1NvY0tLRS8KUHNQY3ZYclVHQnV3V21Ib1lSamZWOE8yZVZ0a28yRXdYekFPQmdOVkhROEJBZjhFQkFNQ0FvUXdIUVlEVlIwbApCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQndNQ01BOEdBMVVkRXdFQi93UUZNQU1CQWY4d0hRWURWUjBPCkJCWUVGQ2Q0TnllTlppRFlQSHBSeWtUYXd4TVdIelJQTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSVFDY1lzTzgKOHUyUXZMKzN6UlY2UGJNMWF3Tk0zSUNPaHZwU2tTZElEVzEydXdJZ0ZrUjcyRFhRVTZhMFU0ZlREZ09pU1FVRQpZZEJFT0VxdzFFMHNrT0UreGlZPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
server: https://192.168.1.170:6443
name: lucca-oidc
contexts:
- context:
cluster: lucca-oidc
namespace: default
user: existe-pas
name: admin@lucca-oidc
current-context: admin@lucca-oidc
kind: Config
preferences: {}
Et non, vous ne rêvez pas : Il n’y a pas d’utilisateur défini dans le kubeconfig ! On injectera nous-même le JWT dans la commande kubectl
pour s’authentifier.
On va devoir créer un dépôt pour tester notre kubeconfig dans un workflow.
name: K8S OIDC Authentication Test
on:
push:
workflow_dispatch:
jobs:
get-nodes:
runs-on: ubuntu-latest
permissions:
id-token: write # Cette permission est nécessaire pour obtenir le token OIDC
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch kubeconfig secret
id: kubeconfig
run: |
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kubeconfig
env:
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
- name: Set KUBECONFIG env
run: echo "KUBECONFIG=$PWD/kubeconfig" >> $GITHUB_ENV
- name: Install kubectl
uses: azure/setup-kubectl@v3
- name: Get OIDC token from GitHub
id: get_token
run: |
token_json=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=coffee-lucca-poc")
oidc_token=$(echo "$token_json" | jq -r '.value')
echo "token=${oidc_token}" >> $GITHUB_OUTPUT
- name: Debug JWT token claims
run: |
echo "Decoding JWT token for debugging..."
echo "${{ steps.get_token.outputs.token }}" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq . || echo "Failed to decode JWT payload"
- name: Test authentication with kubectl
run: |
echo "Testing kubectl auth..."
kubectl auth whoami --token="${{ steps.get_token.outputs.token }}" || echo "Auth failed"
Les variables ACTIONS_ID_TOKEN_REQUEST_TOKEN
et ACTIONS_ID_TOKEN_REQUEST_URL
sont automatiquement définies par GitHub Actions pour obtenir le token OIDC.
Ce workflow va :
- Récupérer le kubeconfig encodé en base64 depuis les secrets du dépôt.
- Récupérer le token OIDC depuis GitHub avec une audience spécifique (celle qu’on a définie dans notre kubeconfig + API-Server).
- S’authentifier en injectant le token dans la commande
kubectl
(ce qui évite de devoir contacter l’issuer pour obtenir ce token quand on passe par Kubelogin).
Voyons ce qu’il décode dans le token JWT :
{
"actor": "qjoly",
"actor_id": "82603435",
"aud": "coffee-lucca-poc",
"base_ref": "",
"event_name": "workflow_dispatch",
"exp": 1752630402,
"head_ref": "",
"iat": 1752608802,
"iss": "https://token.actions.githubusercontent.com",
"job_workflow_ref": "qjoly/lucca-oidc-poc/.github/workflows/test.yaml@refs/heads/main",
"job_workflow_sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c",
"jti": "72b5e0be-16a1-46d2-930a-6a184407f2fc",
"nbf": 1752608502,
"ref": "refs/heads/main",
"ref_protected": "false",
"ref_type": "branch",
"repository": "qjoly/lucca-oidc-poc",
"repository_id": "1020257999",
"repository_owner": "qjoly",
"repository_owner_id": "82603435",
"repository_visibility": "private",
"run_attempt": "1",
"run_id": "16302845430",
"run_number": "2",
"runner_environment": "github-hosted",
"sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c",
"sub": "repo:qjoly/lucca-oidc-poc:ref:refs/heads/main",
"workflow": "K8S OIDC Get Nodes",
"workflow_ref": "qjoly/lucca-oidc-poc/.github/workflows/test.yaml@refs/heads/main",
"workflow_sha": "81ae75f767dd22d31f21ae4cff3460bcffbcea1c"
}
Et pour l’étape d’après :
ATTRIBUTE VALUE
Username github-actions:repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
UID repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
Groups [system:authenticated]
Extra: authentication.kubernetes.io/credential-id [JTI=72b5e0be-16a1-46d2-930a-6a184407f2fc]
Extra: github.com/ref [refs/heads/main]
Extra: github.com/repository [qjoly/lucca-oidc-poc]
Extra: github.com/repository_owner [qjoly]
C’est beau tout ça, mais dans l’état actuel : il ne faudra pas beaucoup de temps avant que l’on retrouve un mineur de bitcoin dans notre cluster puisque tous les dépôts GitHub ont accès à notre Kubernetes, même ceux qui ne nous appartiennent pas, oups ! (pour peu qu’ils aient le bon claim audience
).
Histoire d’être ceinture et bretelles, on doit procéder à une vérification en 2 étapes :
- Vérifier que le JWT provient bien de dépôts autorisés (via une
claimValidationRules
) et refuser ceux qui ne sont pas dans la liste. - Limiter les droits en fonction du dépôt et de la branche (via le RBAC Kubernetes).
Pour la première partie, on va ajouter une règle qui vérifie que le dépôt est bien dans la liste de ceux autorisés. On peut faire ça en utilisant claimValidationRules
dans notre configuration de l’APIServer :
- issuer:
url: https://token.actions.githubusercontent.com
audiences:
- coffee-lucca-poc
audienceMatchPolicy: MatchAny
claimMappings:
username:
expression: '"github-actions:" + claims.sub'
uid:
expression: "claims.sub"
extra:
- key: "github.com/repository"
valueExpression: "claims.repository"
- key: "github.com/repository_owner"
valueExpression: "claims.repository_owner"
- key: "github.com/ref"
valueExpression: "claims.ref"
+ claimValidationRules:
+ - expression: 'claims.repository in ["qjoly/lucca-oidc-poc", "qjoly/another-repo", "myorg/yet-another-repo"]'
+ message: "repository must be in the allowed list"
Puis coté RBAC Kubernetes, c’est un peu galère car on ne peut pas utiliser de wildcard dans les subjects
(donc pas de repo:qjoly/lucca-oidc-poc:ref:.*
), on ne peut pas non-plus se baser sur les claims “extra”… il va falloir être un peu plus créatif.
Je distingue 2 cas (complémentaires) où on peut faire du RBAC dans Kubernetes avec notre setup sur Github Actions :
- Donner des droits différents en fonction du dépôt.
- Donner des droits différents en fonction de la branche.
Dans un (Cluster)RoleBinding, on ne peut prendre en compte que deux paramètres dans le subjects
:
- un
User
- un
Group
# Exemple de RoleBinding pour un dépôt spécifique
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: read-secrets-global
subjects:
- kind: Group
name: les-sysadmins-de-la-mort
apiGroup: rbac.authorization.k8s.io
- kind: User
name: qjoly
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: can-destroy-everything
apiGroup: rbac.authorization.k8s.io
Les conditions dans les Subjects
sont des OR
, ce qui veut dire que même si on arrive à mettre la branche dans le Group
du JWT (via claimMappings
), on ne pourra pas faire de distinction entre les branches dans le RoleBinding
avec une règle Si le dépôt est qjoly/lucca-oidc-poc
et la branche est main
, alors on a les droits.
Ce qu’on peut faire à la place, toujours en rajoutant un claimMappings
, est de pouvoir stocker l’information du dépôt dans un groupe et conserver le dépôt+branche (repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
) dans le username
. Ainsi, on peut faire un RoleBinding
possédant des privilèges élevés pour une branche spécifique sur le dépôt et un autre RoleBinding
qui ciblera le groupe portant le nom du dépôt. De cette manière : on n’a pas à faire un RoleBinding
par branche (ce qui, logiquement, n’aurait pas été possible, car imprédictible).
- issuer:
url: https://token.actions.githubusercontent.com
audiences:
- coffee-lucca-poc
audienceMatchPolicy: MatchAny
claimMappings:
username:
expression: '"github-actions:" + claims.sub'
uid:
expression: "claims.sub"
+ groups:
+ expression: "claims.repository"
extra:
- key: "github.com/repository"
valueExpression: "claims.repository"
- key: "github.com/repository_owner"
valueExpression: "claims.repository_owner"
- key: "github.com/ref"
valueExpression: "claims.ref"
claimValidationRules:
- expression: 'claims.repository in ["qjoly/lucca-oidc-poc", "qjoly/another-repo", "myorg/yet-another-repo"]'
message: "repository must be in the allowed list"
Résultat, Kubernetes va maintenant reconnaître le groupe qjoly/lucca-oidc-poc
et on peut lui donner des permissions spécifiques dans un RoleBinding
:
ATTRIBUTE VALUE
Username repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
UID repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
Groups [qjoly/lucca-oidc-poc system:authenticated]
Extra: authentication.kubernetes.io/credential-id [JTI=133d3a4d-631c-48e8-ba3f-8d55315b4bfd]
Extra: github.com/ref [refs/heads/main]
Extra: github.com/repository [qjoly/lucca-oidc-poc]
Extra: github.com/repository_owner [qjoly]
Donc, voici nos deux RoleBinding
avec lesquels on différencie les droits en fonction du dépôt et de la branche :
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: godmode
subjects:
- kind: User
name: repo:qjoly/lucca-oidc-poc:ref:refs/heads/main
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: can-destroy-everything
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: list-pod-pods
subjects:
- kind: Group
name: qjoly/lucca-oidc-poc
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: can-list-pods # Safe ClusterRole
apiGroup: rbac.authorization.k8s.io
Les pipelines du dépôt qjoly/lucca-oidc-poc
peuvent juste lister les pods mais ceux sur la branche main
pourront avoir des accès bien plus permissifs via la ClusterRole can-destroy-everything
.
Si j’ai écrit cet article (qui est un mélange entre un expresso et un vrai blogpost), c’est parce que j’ai trouvé peu de ressources sur les AuthenticationConfiguration
et l’authentification Github Actions avec Kubernetes, c’était l’occasion de partager ma découverte et mes expérimentations.
Il doit existe de nombreux cas que je n’ai pas abordés (ou prévus), si vous avez des compléments d’information ou des suggestions, n’hésitez pas à les partager plus bas 😄