0%

Assurer la conformité de son code Terraform avec Open Policy Agent

Dans le cadre d’une structure utilisant Terraform se pose toujours à un moment donné la question du contrôle de l’infrastructure. Est-ce que celle-ci est sécurisée ? Est-ce qu’elle est conforme aux réglementations applicables ? Suit-elle les bonnes pratique en vigueur ?

Ces questions deviennent de plus en plus pertinentes quand l’utilisation de Terraform tend à se généraliser et que ce n’est plus une équipe restreinte et particulièrement sensible à ces problématiques qui gère l’infrastructure. Comment s’assurer qu’un panel plus large d’utilisateurs, souvent moins expérimentés, puisse participer à l’évolution de l’infrastructure sans en compromettre la sécurité mais tout en gardant leur autonomie ?

C’est ici qu’un outil de Policy as Code peut s’avérer utile. Et nous abordons dans cet article Open Policy Agent en ce sens.

Avant-propos

Disclaimer

Il s’agit de mes premiers pas avec Open Policy Agent. Le but de cet article est de tracer ma réflexion et de ne pas repartir de zéro quand je reprendrai ce sujet dans quelques temps. Si cela peut, par la même occasion, donner un coup de main à d’autres ou si je peux récupérer quelques conseils ou remarques par ce biais : tant mieux.
Je ne détiens pas la vérité et ce que je présente ici est forcément améliorable, n’hésitez donc pas à me faire des retours.

Cas d’usage

Open Policy Agent ou OPA (prononcé oh-pa) est un outil générique qui peut s’appliquer dans de nombreux contextes. Notre but ici est de faire des vérifications sur le plan d’un manifeste Terraform avant que les modifications soient apportées à l’infrastructure.

Nos objectifs sont les suivants :

  • Décorréler les règles d’implémentations et les manifestes Terraform pour garder le contrôle de l’infrastructure.
  • Rendre les utilisateurs autonomes et leur apporter des retours clairs sur les points à améliorer en cas de non conformité.
  • Intégrer de la Policy as Code dans la chaîne d’intégration continue pour que les vérifications de conformité soient automatisées et les retours rapides.

Nous partirons d’un exemple pour détailler des cas concrets. Le manifeste terraform que nous utilisons crée les ressources suivantes :

  • deux buckets s3 : un à la racine du projet (root module) et un dans un module (child module)
  • deux lambdas : créées similairement, une à la racine et une dans un module
  • un rôle IAM pour les lambdas.

Tout le code utilisé dans cet article est disponible sur Gitlab.

Open Policy Agent

C’est quoi ?

Comme nous l’avons déjà mentionné Open Policy Agent est un outil trés générique. Il peut s’interfacer avec PAM pour gérer les autorisations d’accès sudo et ssh, ou avec le daemon docker pour vérifier les commandes envoyées à ce dernier. Il peut s’utiliser par le biais d’un binaire (en statique ou en interactif), par le biais d’un serveur HTTP avec des call API ou encore en tant que librairie GO. Je n’ai pas parcouru toutes ces solutions, mais j’aime beaucoup l’approche “couteau suisse” qui permet d’externaliser les prises de décisions dans beaucoup de contextes. Dans notre cas nous utilisons le binaire en statique, mais nous y reviendrons.

En pratique la logique reste similaire dans tous les cas : OPA prend en entrée du contenu JSON, prend des décisions en se basant sur des polices internes, puis renvoie ces décisions là encore sous format JSON. Ces décisions ne sont pas forcément des retours binaires vrai/faux mais peuvent générer des structures de données arbitraires en output.

Pour résumer :

  • OPA prend n’importe quel type de données en entrée, formatée en JSON.
  • L’utilisateur écrit ensuite des règles de traitement / validation qui sont cohérentes avec les données reçues.
  • Ces règles peuvent renvoyer en output n’importe quel type de données formatées en JSON.

Terraform JSON plan

Dans notre cas les données en entrée sont la transformation en JSON du plan Terraform. Donc la description de toutes les créations / modifications / suppressions de ressources prévues sur l’infrastructure. Nos règles viennent analyser ces données pour identifier les défauts de conformité. Et les données en sortie sont du feedback pour l’utilisateur : le détail de toutes les non conformitées identifiées.

Avant d’aller plus loin, précisons comment obtenir le fichier JSON qui nous sert d’input à partir d’un manifeste terraform. C’est simple :

1
2
terraform plan -out tfplan # générer le plan et écrire le résultat dans un fichier
terraform show -json tfplan > tfplan.json # transformer ledit fichier en JSON

Personnellement j’ajoute les fichiers tfplan et tfplan.json au git ignore de mon projet

Nous ne rentrons pas trop dans le detail de ce fichier dans cet article, mais il est important de bien le comprendre pour pouvoir écrire des polices pertinentes. Je vous conseille la lecture de la documentation Terraform sur la représentation JSON du plan.

Notons quand même deux sections que nous utilisons pour notre besoin :

La section configuration :

  • liste des options de configuration spécifique au manifeste lui-même : valeurs des variables fournies, détails sur les providers, sources et noms des modules …
  • elle présente une structure reflétant celle du manifeste : notamment hiérarchisée par modules

La section resource_changes :

  • liste toutes les ressources qui seront créées détruites ou modifiées indistinctement de l’endroit où elles sont définies (module principal ou sous-modules)
  • présente pour chaque ressource le détail des options de configuration avant et après l’application du manifeste, ainsi que celles “inconnues” tant que la ressource n’est pas créée (exemple : l’arn pour aws)

Voici un exemple partiel de représentation d’une ressource dans cette seconde section :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"address": "aws_iam_role.iam_for_lambda",
"mode": "managed",
"type": "aws_iam_role",
"name": "iam_for_lambda",
"provider_name": "aws",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"description": null,
"force_detach_policies": false,
"max_session_duration": 3600,
"name": "iam_for_lambda",
"name_prefix": null,
"path": "/",
"tags": null
},
"after_unknown": {
"arn": true,
"create_date": true,
"id": true,
"unique_id": true
}
}
}

Remarquons la section change qui présente les clefs actions, before, after et after_unknown. Dans notre cas la ressource n’existe pas encore sur AWS. L’action est donc create et la clef before est null. L’arn ne pouvant pas être déduit avant la création effective de la ressource il apparaît dans la clef after_unknown.
La clef "mode": "managed" est également importante : elle stipule que cette ressource est créée par Terraform. A l’inverse des Data Sources qui apparaissent dans le plan, mais que nous ne souhaitons pas analyser puisque nous ne les sollicitons qu’en lecture.

opa eval “fail-defined”

Nous savons désormais récupérer de la donnée depuis un manifeste Terraform. La partie “input” de OPA est traitée. Il reste à faire des choix pour l’écriture de nos polices et les outputs souhaités.

Comme évoqué en amont nous utilisons l’outil directement en ligne de commande avec opa eval.
Nous écrivons des polices qui n’ont aucun output si aucune anomalie n’est détectée et qui renvoient une liste d’erreurs quand il y en a. Nous souhaitons donc que le code retour de notre commande soit zero si nos polices sont undefined (pas d’output, donc pas d’erreurs) et non-zero dans le cas contraire.
Nous utilisons le flag --fail-defined qui fait exactement cela : Exit with a non-zero exit code if the query is not undefined.

Nous utilisons en plus les flags suivants :

  • --format pretty pour des output plus lisibles par l’homme
  • --data pour spécifier le répertoire qui contient nos fichiers de polices
  • --input pour spécifier le chemin vers le fichier de plan Terraform transformé en JSON
  • La commande eval prend un dernier paramètre : le nom d’une règle spécifique à évaluer

Voici un exemple complet dans notre cas avec une anomalie identifiée :

1
2
3
4
5
6
7
opa eval --fail-defined --format pretty --data rules/ --input tfplan.json "data.main.errors[list]"
+--------------------------------+--------------------------------+
| list | data.main.errors[list] |
+--------------------------------+--------------------------------+
| "resource.type(aws_iam_role) | "resource.type(aws_iam_role) |
| is blacklisted" | is blacklisted" |
+--------------------------------+--------------------------------+

L’affichage est redondant. Evaluer directement la règle data.main.errors sans le [list] est beaucoup plus lisible mais dans ce cas l’output est toujours défini (liste vide si pas d’erreur, mais pas de undefined) et nous perdons donc la valeur ajoutée d’un code retour explicite qui facilite grandement l’intégration dans une CI.
Voici tout de même un exemple :

1
2
3
4
opa eval --fail-defined --format pretty --data rules/ --input tfplan.json "data.main.errors"
[
"resource.type(aws_iam_role) is blacklisted"
]

Cet output est le résultat des polices de notre projet. Abordons maintenant la façon d’écrire celles-ci.

Rego

C’est le langage déclaratif utilisé pour écrire nos polices. Les exemples ci-après sont écrits en Rego. C’est le point qui m’a personnellement donné le plus de mal, mais ce n’est pas le but de cet article que de rentrer dans le détail. Je vous conseille ici encore de vous documenter sur ce point en espérant que les cas concrets que nous détaillons par la suite puissent aider.

Quelques ressources à parcourir selon moi : l’introduction à Rego de la documentation officielle, cet article de Varun Mathur sur Medium et la Policy Language, toujours sur le site officiel d’OPA.

Un outil particulièrement intéressant est le Rego Playground. Ce site web affiche trois sections (l’input, les règles et l’output) pour tester interactivement son code. Il permet de faire des évaluations partielles en sélectionnant des lignes spécifiques à tester ainsi que de publier des snippets pour partager facilement son code.
Je vous le conseille vivement : très utile pour le debug et l’expérimentation.

Exemple concret

Filtrer les données

Passons à la pratique avec la règle managed_buckets, qui permet de récupérer l’ensemble des ressources de type s3 bucket managées par Terraform :

1
2
3
4
5
managed_buckets[resources] {
resources := input.resource_changes[_]
resources.type == "aws_s3_bucket"
resources.mode == "managed"
}

L’objet input fait directement référence au fichier JSON qui a été passé en entrée via la commande opa eval. La donnée étant structurée, on sélectionne le contenu de la clef resource_changes du plan Terraform évoqué précédemment. La syntaxe [_] permet “d’itérer” sur l’ensemble des ressources définies sous resource_changes.

Avec la ligne resources := input.resource_changes[_], l’objet resources représente chaque ressource listée dans le manifeste Terraform. On y applique ensuite les deux lignes suivantes :

1
2
resources.type == "aws_s3_bucket"
resources.mode == "managed"

Chacune de ces déclarations permet de “filtrer” la donnée. La règle managed_buckets génère un set qui contient l’ensemble des ressources pour lesquelles toutes les déclarations contenues dans la règle sont vraies.

On récupère donc le détail de chaque ressource listée dans la section resource_changes du plan Terraform, pour lesquelles la valeur des clef type et mode sont respectivement “aws_s3_bucket” et “managed”. Le set généré par la règle peut se représenter comme suit :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"address": "aws_s3_bucket.root_bucket",
"change": "REMOVED",
"mode": "managed",
"name": "root_bucket",
"provider_name": "aws",
"type": "aws_s3_bucket"
},
{
"address": "module.s3.aws_s3_bucket.child_bucket",
"change": "REMOVED",
"mode": "managed",
"module_address": "module.s3",
"name": "child_bucket",
"provider_name": "aws",
"type": "aws_s3_bucket"
}

On y retrouve bien deux éléments : les deux buckets créés par Terraform.

Les détails des changements on été supprimés pour plus de lisibilité. Notons cependant que la seconde ressource présente la clef module_address qui indique qu’elle a été créée par le biais d’un module (tout comme son adresse d’ailleurs).

Dans la définition de la règle managed_buckets[resources], resources est à la fois un input et un output (oue.. j’ai rien compris non plus… 😅).

Ces règles de “filtrage” sont ensuite appelées dans les autres règles, au lieu d’interroger directement l’input, afin de limiter les données sur lesquelles des vérifications sont faites. Par exemple : une convention de nommage pour un bucket s3 ne doit pas être appliquée à d’autres types de ressources.

Renvoyer un message

Supposons que l’on souhaite vérifier qu’un bucket est privé et informer l’utilisateur de l’erreur levée si ce n’est pas le cas :

1
2
3
4
5
6
public_options_invalid[msg]{
bucket := managed_buckets[_].change.after
bucket.acl != "private"
bucket_name := bucket.bucket
msg := sprintf("aws_s3_bucket(%s) config is invalid : must have acl:private", [bucket_name])
}

On utilise ici la règle de filtre précédente : bucket := managed_buckets[_].change.after permet de récupérer un set de l’ensemble des buckets managés par Terraform. Chaque élément du set présente les informations d’état du bucket après application du manifeste (on ne garde que .change.after). Encore une fois bucket.acl != "private" permet d’exclure de ce set tous les buckets correctement configurés. On ne garde que les exceptions.

Cependant au lieu de renvoyer le set de bucket on renvoie cette fois-ci un set de messages. Pour chaque élément identifié en erreur on forge un message avec sprintf("aws_s3_bucket(%s) config is invalid : must have acl:private", [bucket_name]). msg étant défini comme output, nous avons en sortie de cette règle un message d’erreur explicite pour chaque bucket n’étant pas privé.

Nous avons notre première règle avec un retour explicite :

1
2
3
4
5
opa eval --fail-defined --format pretty --data rules/ --input tfplan.json "data.s3.public_options_invalid"
[
"aws_s3_bucket(memorandom.opa-first-steps.dev.root-bucket) config is invalid : must have acl:private",
"aws_s3_bucket(memorandom.opa-first-steps.dev.child-bucket) config is invalid : must have acl:private"
]

Le “ou” en Rego

En pratique un bucket peut être privé mais des droits peuvent être accordés au cas par cas. Nous souhaitons donc que notre police renvoie une exception si l’un OU l’autre des paramètres acl ou grant n’est pas correctement configuré. Jusqu’à présent les déclarations dans une règle doivent toutes être vraies pour qu’une erreur soit identifiée. Cela s’apparente à un ET. Le OU en Rego revient à définir plusieurs fois la même règle :

1
2
3
4
5
6
7
8
9
10
11
12
public_options_invalid[msg]{
bucket := managed_buckets[_].change.after
bucket.acl != "private" # Ici on vérifie le paramètre "acl"
bucket_name := bucket.bucket
msg := sprintf("aws_s3_bucket(%s) config is invalid : must have acl:private", [bucket_name])
}
public_options_invalid[msg]{
bucket := managed_buckets[_].change.after
bucket.grant != [] # Ici on vérifie le paramètre "grant"
bucket_name := bucket.bucket
msg := sprintf("aws_s3_bucket(%s) config is invalid : must have grant:[]", [bucket_name])
}

Si aucune problématique de configuration n’existe, alors la règle public_options_invalid ne renvoie rien (undefined). La règle est définie dès que l’un ou l’autre des paramètres est invalide. Par ailleurs, si les deux sont erronés le set de messages en output contiendra bien deux messages d’erreurs.

Mutualiser les messages

La commande opa eval ne permet d’évaluer qu’une seule règle. En plus de la règle public_options_invalid détaillée ci-dessus, supposons une nouvelle règle name_is_invalid. Pour éviter d’avoir à lancer plusieurs fois la commande eval, créons une 3ème règle qui appelle les deux précédentes, agrège leurs outputs respectifs et renvoie la liste combinée de tous les messages :

1
2
3
errors := list {
list := name_is_invalid | public_options_invalid
}

“a | b” renvoie l’union des deux sets a et b. voir policy reference : sets

Nous pouvons, sur ce principe, ajouter autant de règles que voulu, en utilisant toujours la même règle de référence pour la commande eval :

1
2
3
4
5
opa eval --fail-defined --format pretty --data rules/ --input tfplan.json "data.s3.errors"
[
"aws_s3_bucket(memorandom.opa-first-steps.dev.root-bucket) does not match naming convention",
"aws_s3_bucket(memorandom.opa-first-steps.dev.root-bucket) config is invalid : must have acl:private",
]

Structurer les règles

Depuis le début de l’article nous montrons des exemples d’appels à la commande opa qui nécessitent de spécifier une règle à évaluer. Nous avons jusqu’ici utilisé data.s3.public_options_invalid et data.s3.errors. A quoi correspondent ces références ?

Dans Open Policy Agent les règles s’organisent en modules, et les modules en packages. Un module est simplement un fichier qui contient une définition de package. Le package groupe les modules dans un namespace particulier et ces packages font partie d’un document racine nommé “data”.

Référencer une règle revient donc à la hiérarchie suivante : data.<package>.<rule>. Je n’ai pas creusé outre mesure ce point. Plus de détails sont disponibles ici.

En pratique toutes les règles que nous avons abordées jusqu’à présent doivent être écrites dans un fichier contenant à minima une définition de package. Dans notre cas, ces règles étant relatives à s3, elles sont dans un fichier s3.rego dont la première ligne est package s3. Le projet d’exemple disponible sur Gitlab présente des règles pour d’autres services AWS, chacune est groupée dans un fichier et dans un package portant le nom du service qu’elle valide.

Notons que les différents modules d’un package n’ont pas besoin d’être dans le même dossier et que le flag --data de la commande opa, qui permet de spécifier les fichiers de polices à charger, peut prendre un dossier en paramètre. OPA cherche alors récursivement tous les fichiers Rego dans le dossier donné.

Dans un package il est également possible d’importer des données depuis d’autres packages. Dans notre cas créons un main qui nous permet de mutualiser les messages des différents packages, similairement à la façon dont nous avons créé une règle errors pour mutualiser les messages des différentes règles :

1
2
3
4
5
6
7
8
package main # définition du package main

import data.s3 as s3 # import du package situé sous data.s3 avec le nom s3
import data.lambda as lambda # import du package situé sous data.lambda avec le nom lambda

errors := list { # une règle dans le namespace "main"
list := s3.errors | lambda.errors # appel aux règles "errors" des différents namespace
}

La règle list du package main agrège les messages des règles errors des différents packages, qui elles-mêmes agrègent les messages des règles de leur package respectif. Là encore l’avantage est de pouvoir utiliser une seule règle dans la commande opa eval, qui regroupe les messages d’erreurs issus de tous les packages :

1
opa eval --fail-defined --format pretty --data rules/ --input tfplan.json "data.main.errors[list]"

Exemples complémentaires

Dans cette partie nous listons simplement quelques exemples de polices.

Regex sur url de bucket

1
2
3
4
5
6
name_is_invalid[msg] {
bucket := managed_buckets[_].change.after
bucket_name := bucket.bucket
not re_match(`^memorandom\..+[^.]\.[a-z]{3}\.(root|child)-bucket$`, bucket_name)
msg := sprintf("bucket(%s) does not match naming convention", [bucket_name])
}

Rien de particulier ici. Nous utilisons re_match qui fait partie des Built-in Functions : regex.

Sources de modules

1
2
3
4
5
6
7
8
9
get_modules[module_calls] {
module_calls := input.configuration.root_module.module_calls[_]
}
module_source_is_invalid[msg] {
module := get_modules[_]
module_source := module.source
not re_match(`^\.\.\/\.\.\/modules\/.+$`, module_source)
msg := sprintf("module_calls(%s) source is not authorized", [module_source])
}

Similaire. Nous utilisons simplement la partie “configuration.root_module.module_calls” du plan Terraform au lieu de “resource_changes”.

Blacklist de ressources

1
2
3
4
5
6
7
8
9
10
get_all_managed_resources[resources] {
resources := input.resource_changes[_]
resources.mode == "managed"
}
resource_type_is_forbidden[msg] {
forbidden_types := ["aws_iam_role"]
resources_types := get_all_managed_resources[_].type
resources_types == forbidden_types[_]
msg := sprintf("resource.type(%s) is blacklisted", [resources_types])
}

Petite subtilité ici. Nous utilisons une liste “forbidden_types” qui est notre blacklist. Nous utilisons de nouveau la syntaxe [_] dans la ligne resources_types == forbidden_types[_]. Ceci nous permet de valider que l’élément resources_types considéré se trouve dans la liste noire. Si c’est effectivement le cas, la comparaison est vraie et donc la règle renvoie un message d’erreur pour cet élément.

Liste de tags obligatoire

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
get_all_managed_taggable_resources[resources] {
resources := input.resource_changes[_]
resources.mode == "managed"
resources.change.after.tags
}
mandatory_tags_missing[msg] {
mandatory_tags := {"project", "subproject", "owner", "env"}

resource := get_all_managed_taggable_resources[_]
existing_tags := {key | resource.change.after.tags[key]}
missing_tags := mandatory_tags - existing_tags

resource_address := resource.address
resource_type := resource.type
not count(missing_tags) == 0

msg := sprintf("%s(%s) is missing tags : %s", [resource_type, resource_address, missing_tags])
}

Plusieurs choses à souligner ici.

Pour la règle de filtre, resources.change.after.tags renvoie simplement faux si la clef tag n’existe pas sur la ressource. Une règle n’ajoutant à son output que les élèments pour lesquels l’ensemble des déclarations sont vraies, une ressource ne possédant pas de clef “tag” (et donc n’étant pas taggable) est exclue. Ceci nous permet d’exclure de la règle toutes les ressources qui ne sont pas taggables dans AWS.

La règle mandatory_tags_missing a pour but de vérifier l’existence de tags sur une ressource (et non leur valeur). Le premier point est donc de récupérer la liste des clefs dans la map des tags de la ressource. C’est ce que fait existing_tags := {key | resource.change.after.tags[key]} qui nous renvoie donc un set contenant les noms des tags appliqués.
Nous définissons ensuite un set des tags obligatoires avec la ligne mandatory_tags := {"project", "subproject", "owner", "env"}.

Nous terminons avec missing_tags := mandatory_tags - existing_tags une fonction built in qui fait la différence des deux sets. C’est-à-dire qui renvoie les éléments dans mandatory_tags qui ne sont pas présents dans existing_tags. Dès lors, si le compte des éléments du set missing_tags n’est pas nul, c’est qu’il y a une erreur. Ce dernier set contient d’ailleurs la liste des tags manquants que nous renvoyons dans le message.

Consolider son projet

Tester ses polices

Open Policy Agent met à disposition un framework pour gérer des tests pour nos polices. Techniquement il s’agit simplement d’écrire des règles Rego “classiques” à la différence que le nom des règles de tests doit être préfixé par test_. La commande opa test <directory> -v se charge ensuite de récupérer récursivement tout le code Rego dans le dossier spécifié et de jouer toutes les règles qui ont le bon préfixe :

1
2
3
4
5
6
7
opa test . -v
data.s3.test_rule_errors_pass: PASS (296.4µs)
data.s3.test_rule_errors_fail: PASS (698.1µs)
data.s3.test_rule_name_is_invalid_fail: PASS (485.1µs)
data.s3.test_rule_public_options_invalid_fail: PASS (2.6632ms)
------------------------------------------------------------------
PASS: 4/4

Remarquons que quatre règles sont trouvées et que tous les tests passent 🎉. Détaillons.

Data Mocking

Le premier test est le suivant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package s3

test_rule_errors_pass {
count(errors) == 0 with input as valid_input
}

valid_input = {
"resource_changes": [
{
"mode": "managed",
"type": "aws_s3_bucket",
"change": {
"after": {
"bucket": "memorandom.opa-first-steps.dev.root-bucket",
"acl": "private",
"grant": []
}
}
}
]
}

Notons les trois éléments présents :

  • La déclaration du package. La règle errors du namespace s3 est donc accessible telle quelle.
  • La règle de test en elle-même, qui se contente de vérifier que le nombre d’erreurs remontées est nul dans le cas d’un input valide.
  • Le mock de la donnée d’input. Les règles du namespace s3 attendent en input les données d’un Terraform plan. Ici nous simulons cet input par le biais de l’objet valid_input.

La syntaxe with input as valid_input permet de spécifier pour chaque règle de test une donnée d’entrée simulée sous notre contrôle. Nous pouvons donc y introduire des erreurs ou non, et vérifier dans notre test que le comportement des règles est bien celui attendu. Dans ce cas nous remplaçons l’input de Terraform par l’objet valid_input.

Exemple

Présentons un second exemple de test pour la règle public_options_invalid :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package s3

test_rule_public_options_invalid_fail {
count(public_options_invalid) == 4 with input as invalid_input

public_options_invalid[_] == "aws_s3_bucket(foo) config is invalid : must have acl:private" with input as invalid_input
public_options_invalid[_] == "aws_s3_bucket(foo) config is invalid : must have grant:[]" with input as invalid_input

public_options_invalid[_] == "aws_s3_bucket(bar) config is invalid : must have acl:private" with input as invalid_input
public_options_invalid[_] == "aws_s3_bucket(bar) config is invalid : must have grant:[]" with input as invalid_input
}

invalid_input = {
"resource_changes": [
{
"mode": "managed",
"type": "aws_s3_bucket",
"change": {
"after": {
"bucket": "foo",
"acl": "foo",
"grant": ["foo"]
}
}
},
{
"mode": "managed",
"type": "aws_s3_bucket",
"change": {
"after": {
"bucket": "bar",
"acl": "bar",
"grant": ["bar"]
}
}
}
]
}

Ici nous simulons un input de donnée non conforme. Deux buckets s3 présentent des anomalies de configuration au niveau des paramètres acl et grant. Nos polices doivent donc nous remonter un total de 4 erreurs : deux par bucket, une pour chaque option de configuration. Notre règle vérifie ces points. Elle compte que le nombre de retours est bien égal à 4, et que les quatre messages attendus font bien partie de l’output.

Structurer son code

Un autre point important pour assurer la maintenabilité de notre code est de structurer ce dernier. Je n’ai pas trouvé d’exemples précis dans le cadre d’OPA et je propose donc ici une approche personnelle :

1
2
3
4
5
6
7
8
9
10
rules
├── main.rego # Agrégation des outputs des autres packages
├── global # Package contenant les règles communes à tous les service AWS (tags par exemple)
│   ├── mocks.rego
│   ├── rules.rego
│   └── tests.rego
└── s3 # Package contenant les règles d'implémentations spécifiques au service s3
├── mocks.rego # Les données d'input simulées pour les tests
├── rules.rego # Les règles de validation
└── tests.rego # Les règles de tests

Je découpe mes règles par “sujet” (principalement un par service AWS), ici global et s3. Un sujet correspond à un package, tous les modules (fichiers) du package sont dans un même dossier portant le nom dudit package.
Pour les petits projets comme notre exemple, je divise le code avec un module par fonction : mocks, règles ou tests. Le code dans chaque fichier est suffisamment restreint pour rester lisible.

Pour les plus gros projets, avec une quantité de code plus importante, j’envisage d’identifier des “sous-groupes” au sein de chaque “sujet”. Ces sous-groupes devraient représenter des catégories distinctes (supposons “exposition/filtrage” et “noms/tags”, ou “règles corporates” et “spécifiques au projet”). Un découpage pourrait alors se faire avec un dossier par types (mocks, règles, tests) et un module par sous-groupes :

1
2
3
4
5
6
7
8
9
10
11
12
rules
├── main.rego
└── s3 # Tous les modules (fichiers) relatif au namespace s3
├── rules
│   ├── topic_1.rego # Sous-groupe de règles liées à un sujet particulier
│   └── topic_2.rego
├── mocks
│   ├── topic_1.rego # Mocks de données liés au règles du sous-groupe
│   └── topic_2.rego
└── tests
├── topic_1.rego # Tests associés au sous-groupe
└── topic_2.rego

Il s’agit plus d’idées que de recommandations. Je pense qu’il est important de définir en amont des conventions d’organisation pour faciliter l’évolution du projet mais je suis preneur de tout retour sur ce point.

Intégration CI

Comme nous l’avons mentionné précédemment, l’intégration dans une chaîne de déploiement ou d’intégration continue est simple puisqu’elle se résume à lancer la commande opa eval. Nous avons construit nos règles pour que la commande renvoie un non-zero si une non conformité est trouvée. Il nous faut donc juste lancer un script qui sera en erreur, induira un échec de la CI et renverra la liste des anomalies s’il y en a.

A titre d’exemple voici une intégration avec Gitlab CI :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
stages:
- init
- plan
- check

init:
stage: "init"
image:
name: "hashicorp/terraform:0.12.16"
entrypoint: [""]
script:
- "cd terraform/stacks/main"
- "terraform init -input=false"
artifacts:
name: "init-job:${CI_JOB_ID}"
when: "on_success"
expire_in: "6h"
paths:
- terraform/stacks/main/.terraform/

plan:
stage: "plan"
image:
name: "hashicorp/terraform:0.12.16"
entrypoint: [""]
script:
- "cd terraform/stacks/main"
- "terraform plan -out=tfplan -input=false"
- "terraform show -json tfplan > tfplan.json"
artifacts:
name: "plan-job:${CI_JOB_ID}"
when: "on_success"
expire_in: "6h"
paths:
- terraform/stacks/main/tfplan
- terraform/stacks/main/tfplan.json
dependencies:
- "init"

check:
stage: "check"
image:
name: "pbenefice/openpolicyagent:v0.16.1"
entrypoint: [""]
script:
- "opa eval --fail-defined --format pretty --data rules/ --input terraform/stacks/main/tfplan.json 'data.main.errors[list]'"
dependencies:
- "plan"

Nous ne détaillerons pas les deux premiers jobs qui se chargent simplement d’initialiser Terraform et de créer le fichier de plan. (Je vous renvoie à mon article précédent si vous souhaitez plus de détails sur ces points). Notons tout de même l’ajout de la commande terraform show -json tfplan > tfplan.json qui permet de transformer le fichier de plan en JSON.

Le job qui nous intéresse ici est le dernier. C’est effectivement simple : la définition d’un script qui appelle notre commande opa eval. Sans erreurs sur le script, la pipeline continue son exécution vers un job de déploiement potentiel. En cas d’anomalie, l’erreur sur le script stoppe la pipeline : tout manifeste non conforme est bloqué à cette étape.

Conclusion et liens utiles

J’espère que cet article pourra vous être utile. Je précise de nouveau ici que je ne suis pas expert sur le sujet et que je partage seulement ma première approche de l’outil. Tout retour est le bienvenu et si vous avez des questions ce sera peut-être pour moi aussi l’occasion d’apprendre quelque chose. 😉

Je colle ci-après quelques liens déjà mentionnés dans le corps de l’article qui me paraissent pertinents :

www.openpolicyagent.org - Introduction
play.openpolicyagent.org - The Rego Playground
terraform.io - JSON Output Format
medium.com - Getting Started With Rego

A la prochaine 👋