Durcissez votre Kube avec OpenPolicyAgent

Nous l’avons vu dans cet article précédent, Kubernetes dispose de très nombreux moyens d’extensions. Regardons en détail l’implémentation d’OpenPolicyAgent (OPA) pour K8s. Quoique facultatif, nous pensons que sa mise en place a beaucoup de sens, notamment dans un contexte multi-tenant : une équipe d’infra qui opère un cluster Kubernetes pour plusieurs équipes (produits, applications) en tirera de grands bénéfices. À la clé : une meilleure capacité à vérifier que les ressources Kubernetes sont utilisées correctement.

Objectifs

Avant de se lancer avidement dans la mise en place, voyons l’objectif d’OPA. L’intention affichée sur le wiki du projet est simple : ”Kubernetes clusters governance made simple”. On parle en fait d’un moteur de règles génériques. OPA est une solution générique qui peut s’appliquer à de nombreuses situations et technologies. C’est dans un contexte Kubernetes que nous allons nous y intéresser en particulier.

Le positionnement d’OPA se situe à la croisée des chemins entre l’autorisation, les quotas et la conformité, le respect de bonnes pratiques et les règles métiers propres à une organisation.

Deux types de règles peuvent être mises en place :

  • Validation : des règles qui conduisent à interdire une opération de création ou de modification d’un objet s’il ne répond pas à certains critères.
  • Mutation : des règles qui conduisent à modifier les objets que l’utilisateur souhaite créer ou  modifier.

Le moteur de règles OPA propose un format pour écrire des règles : Rego. Le format n’est pas le plus intuitif à première vue, mais il permet de modéliser des règles très puissantes.

Cas d’utilisation

Nous allons nous focaliser sur la partie validation uniquement. En effet, la partie mutation est actuellement bien moins documentée et est délicate à utiliser.

Les cas d’utilisation qui peuvent être implémentés spécifiquement dans Kubernetes sont déjà très nombreux. Voici quelques exemples :

  • Interdire l’utilisation d’image Docker avec un tag latest ou pas de tag du tout, ce qui revient au même
  • Garantir l’utilisation d’images Docker provenant exclusivement d’une registry privée
  • Éviter les conflits de vhosts dans les Ingress entre namespaces
  • Interdire l’utilisation de tolerations permettant de placer des pods sur les nœuds maîtres
  • Limiter l’utilisation d’annotations, ce qui peut être très important sur des services de type LoadBalancer (pour interdire la création de LB cloud externes) ou des Ingress (pour interdire les ingress de type pass-through)
  • N’autoriser le contenu de certains champs uniquement s’ils appartiennent à une liste blanche
  • S’assurer de la présence de labels obligatoires et/ou respectant une nomenclature précise (organisation, code de facturation, responsable applicatif…)
  • Permettre la création d’objets (comme des namespaces) uniquement suivant des règles (nommage, dimensionnement…)

Mise en place

Techniquement, la mise en œuvre d’OPA consiste à déployer un pod qui va venir intercepter les appels à l’APIserver :

Mauvaise nouvelle : il existe plusieurs solutions pour atteindre cet objectif, ce qui est passablement déstabilisant pour y voir clair :

  • Un projet nommé GateKeeper de chez replicatedhq, basé sur des règles écrites sous forme d’une CRD admissionpolicy.policies.replicated.com
  • Un autre projet du nom de Gatekeeper, initialement écrit côté Azure, désormais déplacé sous l’organisation open-policy-agent. Les règles sont simplement fournies sous forme d’une CRD configs.config.gatekeeper.sh et leur paramétrage par une autre CRD  constrainttemplates.templates.gatekeeper.sh.
  • L’implémentation documentée ici, reposant sur une installation moins bien packagée que les deux précédentes et utilisant un pod constitué de deux conteneurs : le moteur opa générique et un sidecar spécifique à Kubernetes. Utilisant des ConfigMap pour stocker les configurations, cette implémentation a l’avantage d’avoir la documentation la plus à jour. C’est sur cette implémentation que se basent les exemples ci-dessous.

Bonne nouvelle cependant, le format des règles utilisées dans ces implémentations reste identique.

Seul un administrateur parfaitement conscient de ce qu’il fait doit le mettre en place, car le pod a potentiellement des droits très avancés sur un cluster K8s.

Nous n’allons pas décrire ici la procédure d’installation d’OPA, car elle est détaillée ici et finalement, ce sont, à date, des exemples d’usage et de tests qui nous paraissent les plus pertinents à présenter.

Un mot sur Rego

Nous l’avons dit, Rego est un formalisme pour exprimer des règles. On a tendance dans un premier temps à lire du Rego en pensant que c’est un langage procédural, mais il n’en est rien : Rego est déclaratif. Avant de se lancer dans la lecture des exemples suivant écrits en Rego, nous recommandons de suivre la documentation. Celle-ci explique le fonctionnement des règles, pourquoi l’ordre des instructions n’est pas discriminant, pourquoi plusieurs règles peuvent avoir le même nom, comment les itérations dans les tableaux et les dictionnaire s’effectuent sans voir l’ombre d’une boucle...

Quelques exemples de règles

Voici quelques exemples de règles de validation qui peuvent être implémentées avec OPA.

Interdire les images latest

Ce premier exemple, qui apparaît dans tous les tutoriels est à la fois très simple à comprendre et très utile. Il permet d’implémenter le fameux adage : “latest is not a version”. Il n’est en effet absolument pas recommandé de déployer des images sans en maîtriser les versions. Cet exemple permet en outre de se familiariser avec Rego :

# image.rego

package kubernetes.admission

deny[msg] {
        input.request.kind.kind = "Pod"
        endswith(input.request.object.spec.containers[_].image, ":latest")
        msg = "Latest tags are forbidden"
}

deny[msg] {
        input.request.kind.kind = "Pod"
        container = input.request.object.spec.containers[_]
        not contains(container.image, ":")
        msg = "Untagged images are forbidden"
}

Notez la syntaxe Rego, notamment la valeur magique "_" qui permet de provoquer l’opération de blocage (deny) dès qu’une des images du pod est en version latest. "_" va en effet déclencher l’évaluation de la règle pour tous les éléments du tableau spec.containers.

Notez également que dans cet exemple, deux règles peuvent s’appliquer et produire le même résultat (deny), avec des messages d’erreur différents (msg) si l’image utilise le tag latest ou pas de tag du tout.

La variable input contient la structure de donnée représentant le type d’action à effectuer (CREATE, UPDATE...) et l’objet sur lequel s’effectue l’action avec tout son contenu. En se basant uniquement sur cette variable, il est possible de faire des vérifications simples de validité de champs comme c’est le cas dans cet exemple.

Dernier point sur cet exemple : dans cette méthode d’installation, toutes les règles doivent être écrites dans le package kubernetes.admission pour être traitées par le moteur de règle, d’où la première ligne de code.

Se protéger contre les conflits dans les Ingress entre namespaces

Cet exemple est également très souvent présenté car il expose un cas de figure malheureux contre lequel on souhaite se prémunir. Il y a en effet un problème inhérent au fonctionnement des ingress : il est possible de créer dans deux namespaces différents des ingress avec des vhosts identiques.

# ingress.rego

package kubernetes.admission

import data.kubernetes.ingresses

deny[msg] {
        input.request.kind.kind = "Ingress"
        host = input.request.object.spec.rules[_].host
        ingress = ingresses[other_ns][other_ingress]
        other_ns != input.request.namespace
        ingress.spec.rules[_].host = host
        msg = sprintf("invalid ingress host %q (conflicts with %v/%v)", [host, other_ns, other_ingress])
}

Dans cet exemple, comme l’objectif est de détecter les conflits avec d’autres ingress déjà présentes dans le cluster, en plus d’utiliser input, qui décrit l’objet en cours de création ou de modification, il est nécessaire d’utiliser également la variable data.kubernetes. Elle donne accès à n’importe quel autre objet connu de l’API kubernetes. Dans notre cas, ce sont toutes les autres ingress déjà présentes dans le cluster qui nous intéressent. Elles sont simplement accessibles via le dictionnaire data.kubernetes.ingresses. Les variables other_ns et other_ingress vont servir d’itérateurs sur toutes les valeurs possibles pour les ingress présentes.

Interdire les tolerations

L’objectif d’une telle règle est de s’assurer que les utilisateurs n’utilisent pas de tolerations car elles peuvent permettre de placer des pods sur des nœuds maître, ce qui est rarement une bonne idée. Dans cet exemple, nous allons limiter cette interdiction aux pods dans des namespaces applicatifs, c’est à dire ceux qui ont un labelapp-namespace qui vaut true.

# toleration.rego

package kubernetes.admission

import data.kubernetes.namespaces

deny[msg] {
        input.request.kind.kind = "Pod"
        namespaces[input.request.namespace].metadata.labels["app-namespace"] = "true"
        count(input.request.object.spec.tolerations) > 0
        msg = "Tolerations are not permitted"
}

Interdire les ImagePullPolicy autres que Always

Si vous avez compris le principe, la suite ne devrait pas trop vous étonner :

# imagepullpolicy.rego

package kubernetes.admission

deny[msg] {
        input.request.kind.kind = "Pod"
        container = input.request.object.spec.containers[_]
        container.imagePullPolicy != "Always"
        msg = sprintf("Forbidden imagePullPolicy value \"%v\"", [container.imagePullPolicy])
}

Comment tester ses règles ?

En bons TDDistes, nous aurions dû commencer par ce point bien entendu. Écrire des règles est compliqué il est donc primordial de pouvoir les tester, et unitairement qui plus est. C’est là que toute la magie opère. La documentation est claire et ne traite pas de ce sujet à la légère. Un guide de bonnes pratiques traite spécifiquement de la question des règles dans un monde Kubernetes.

Pour l’exemple, nous allons tester le mécanisme de détection de l’utilisation d’images non taggées ou taggées latest. En se basant sur le fichier Rego vu dans le chapitre précédent, une batterie de tests pourrait ressembler à cela :

# image_test.rego

package kubernetes.image_test

import data.kubernetes.admission

gen_pod_creation(image) = res {
        res = {
                "kind": "AdmissionReview",
                "apiVersion": "admission.k8s.io/v1beta1",
                "request": {
                        "kind": {
                                "group": "",
                                "version": "v1",
                                "kind": "Pod",
                        },
                        "resource": {
                                "group": "",
                                "version": "v1",
                                "resource": "pods",
                        },
                        "name": "apod",
                        "operation": "CREATE",
                        "namespace": "ns1",
                        "object": {
                                "apiVersion": "v1",
                                "kind": "Pod",
                                "metadata": {
                                        "name": "apod",
                                        "namespace": "ns1",
                                },
                                "spec": {"containers": [{"image": image}]},
                        },
                },
        }
}

test_valid_pod {
        violations := admission.deny with input as gen_pod_creation("nginx:1.15")
        count(violations) == 0
}

test_invalid_pod_no_tag {
        violations := admission.deny with input as gen_pod_creation("nginx")
        count(violations) == 1
        violations[reason]
        contains(reason, "Untagged images are forbidden")
}

test_invalid_pod_latest {
        violations := admission.deny with input as gen_pod_creation("nginx:latest")
        count(violations) == 1
        violations[reason]
        contains(reason, "Latest tags are forbidden")
}

Comme souvent, les tests sont plus longs que le code à tester, mais rien d’anormal à ça. Nous devons en effet créer artificiellement des structures représentant des pseudos objets que l’on souhaite soumettre à notre moteur de règles. Dans l’exemple ci-dessus, nous simulons une demande de création de pod. Un fichier image_test.rego est déposé à côté de notre fichier image.rego.

Le lancement du test, qui peut être automatisé dans <insérer ici le nom de votre outil de CI/CD préféré> est trivial.  Il faut uniquement installer l’utilitaire en ligne de commande opa. Inutile d’avoir un cluster K8s sous la main pour mettre au point ses règles, la boucle de feedback est extrêmement courte (largement en dessous de la seconde) et c’est un très bon point.

$ opa test -v image*.rego
data.kubernetes.image_test.test_valid_pod: PASS (747ns)
data.kubernetes.image_test.test_invalid_pod_no_tag: PASS (783ns)
data.kubernetes.image_test.test_invalid_pod_latest: PASS (723ns)
--------------------------------------------------------------------------------
PASS: 3/3

Tips : n’hésitez pas à utiliser également opa check et opa fmt pour vous assurer de la validité et du bon formatage de vos fichiers Rego !!

Notons pour finir que tous les exemples écrits dans cet article ont été écrits en TDD, ce qui est très rassurant pour vérifier que nos règles ne laissent pas de trous béants.

Conclusion

Même si nous ne l’avons pas encore mis en place en production, nous sommes convaincus que l’usage d’OPA dans un contexte Kubernetes multi-tenant va s’avérer très intéressant, même si la solution est encore assez jeune. La grande force de cette solution, outre son côté générique, est sa capacité à écrire des règles au travers d’une approche TDD. Cela vient compenser une syntaxe de règles assez désarçonnante au premier abord.

Nous ne disposons pas encore de recul nous permettant d’identifier à coup sûr le projet Open source qui va survivre parmi les 3 identifiés.

Nous ne disposons pas non plus de retour sur la stabilité et les performances du produit, notamment le nombre de règles qu’il est capable de traiter.

La documentation, même si elle est fournie, contient des exemples parfois obsolètes et traite très peu de la question des règles de mutation, ce qui nous laisse un peu sur notre faim.

Nous prévoyons prochainement mettre en place OPA sur des clusters de développement pour obtenir de premiers retours du terrain. Si vous avez déjà des expériences sur le sujet, n’hésitez pas à partager !!