Skip to main content

5 conseils pour utiliser le langage Rego avec Open Policy Agent (OPA)

header cloud security

18 septembre 2020

0 minutes de lecture

Note de la rédaction

Cet article est paru à l’origine sur fugue.co. Fugue a rejoint Snyk en 2022 et constitue un élément clé de Snyk IaC.

Chez Fugue, nous apprécions beaucoup Open Policy Agent (OPA) et avons écrit beaucoup de code Rego pour sécuriser les ressources cloud. Nous avons donc rassemblé les enseignements les plus précieux que nous en avons tirés. Vous pouvez également utiliser OPA et le langage Rego pour mettre en place des politiques sous forme de code et appliquer automatiquement les règles définies.

1. Les lois de De Morgan sont vos meilleures alliées

S’il ne fallait retenir qu’un seul élément de cette liste, ce serait celui-ci. Les lois de De Morgan sont deux règles de transformation. Appliquées à Rego, elles permettent de transformer une règle tout en conservant exactement le même comportement.

Elles s’écrivent généralement ainsi :

¬(A ∧ B) ⇔ ¬A ∨ ¬B
¬(A ∨ B) ⇔ ¬A ∧ ¬B

Ou, en code :

!(a && b) <=> !a || !b
!(a || b) <=> !a && !b

Un exemple permettra peut-être de mieux comprendre. Les deux énoncés suivants sont équivalents et illustrent l’application de la première loi :

  • Cette pizza ne contient ni jambon ni champignons.

  • Cette pizza ne contient pas de jambon ou ne contient pas de champignons.

Elles permettent de transformer des clauses ET en clauses OU, et inversement, sans modifier le comportement du code. Un instant : vous vous demandez peut-être pourquoi ces transformations sont utiles si elles ne changent pas le comportement de mon code ?

L’idée essentielle, c’est que Rego, en tant que langage de requête, s’appuie largement sur les disjonctions (les énoncés OU). Par exemple, on peut écrire ainsi une vérification visant à déterminer si une personne du groupe est qualifiée pour couper une pizza :

default allow = false

allow {
  input.people[_].profession == "mathematician"
}

Mais comment vérifier si tout le monde dans le groupe est qualifié pour couper des pizzas ? On pourrait, euh, commencer par créer un groupe de personnes qualifiées, puis vérifier sa taille ?

qualified_pizza_cutters[name] = person {
  person = input.people[name]
  person.profession == "mathematician"
}

default allow = false

allow {
  count(qualified_pizza_cutters) == count(input.people)
}

Ce n’est pas idéal. En plus d’être difficile à lire, un énoncé de ce type est difficile à optimiser pour un planificateur de requêtes, puisqu’il faut compter les ensembles, alors qu’on pourrait déjà s’arrêter dès qu’on trouve un mineur dans le groupe.

C’est là que les lois de De Morgan entrent en jeu : elles nous indiquent qu’on peut toujours écrire les requêtes sous forme de disjonction en niant certaines parties. Il suffit d’introduire un énoncé auxiliaire qui nie l’énoncé d’origine :

cannot_cut_pizza {
  input.people[_].profession != "mathematician"
}

Il ne reste plus qu’à écrire la politique :

default allow = false

allow {
  not cannot_cut_pizza
}

Et voilà ! Efficace et facile à lire.

Conclusion : si une politique Rego semble difficile à écrire, demandez-vous toujours si la politique niée serait plus facile à écrire, puis niez-la à nouveau ! Vive la logique !

2. OR ou any ?

Comme nous venons de le voir, Rego est plus facile à lire et à écrire lorsque les énoncés OU figurent au début et que les énoncés ET sont regroupés dans le corps des règles. Mais en tant que programmeurs, nous finissons généralement par rencontrer un cas où cette approche idiomatique ne fonctionne pas très bien.

Avec Rego, cela arrive souvent lorsqu’un corps de règle contient déjà un bon nombre de requêtes, par exemple :

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Et si nous voulions également autoriser les parts de pizza ? Nous pourrions dupliquer tout le corps :

probably_a_pizza {
  input.shape == 'circle'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
} {
  input.shape == 'circular_sector'
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Ce n’est pas idéal. Dans la plupart des cas, nous pouvons introduire une règle auxiliaire :

has_a_pizza_shape {
  input.shape == 'circle'
} {
  input.shape == 'circular_sector'
}

probably_a_pizza {
  has_a_pizza_shape
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

Cela fonctionne bien ici. Mais il arrive que nous ayons tellement de règles auxiliaires qu’il devient difficile de leur trouver un nom pertinent ! Dans ces rares cas, nous pouvons faire comme s’il existait dans Rego un énoncé OU appelé any :

probably_a_pizza {
  any([input.shape == 'circle', input.shape == 'circular_sector'])
  input.ingredients[_] == 'cheese'
  input.ingredients[_] == 'tomato_sauce'
}

C’est une façon parfaitement lisible d’écrire cela. Utilisé avec modération, any peut vous simplifier considérablement la vie.

3. Les compréhensions any et all

Dans la même veine, any et all sont extrêmement utiles dans bien d’autres situations, en particulier avec les compréhensions de listes : ces deux fonctionnalités sont encore plus puissantes ensemble.

Pour reprendre le premier exemple, voici comment utiliser all avec une compréhension de liste pour exprimer la même politique :

allow {
  all([person.profession == "mathematician" | person = input.people[_]])
}

À mon avis, cette version est tout aussi lisible que celle que nous avons construite à partir des lois de De Morgan. Le choix de la formulation la plus idiomatique dépend généralement du contexte.

  1. Est-il plus facile de raisonner sur la politique niée ? Dans ce cas, utilisons la transformation de De Morgan.

  2. Ou est-il plus facile d’envisager les éléments que nous allons contrôler sous la forme d’une liste ou d’une collection ? Dans ce cas, une compréhension de liste convient.

4. Utilisez walk pour parcourir des arbres de documents entiers

Les requêtes Rego se terminent toujours, ce qui est très pratique, mais cela a un prix : en règle générale, il est impossible d’écrire des règles récursives.

Pourquoi les règles récursives sont-elles nécessaires ? Elles sont très utiles pour traiter des documents d’entrée récursifs, comme des arbres profondément imbriqués. Heureusement, OPA fournit des fonctions intégrées pour nous aider.

Par exemple, nous pourrions écrire une politique pour surveiller des fils de discussion :

[
  {
    "author": "Alice",
    "message": "I think New York is at least as good as Italy for pizza",
    "replies": [
      {
        "author": "Bob",
        "message": "It's because of the water!",
        "replies": [
          {
            "Author": "Alice",
            "message": "I've heard that before but I'm not convinced.",
            "replies": []
          }
        ]
      },
    ]
  },
  {
    "author": "Charlie",
    "message": "You are a horrible person.",
    "replies": []
  }
]

Comment récupérer les messages avec Rego ? Nous pourrions essayer quelque chose comme :

messages[message] {
  message = input[_].message
} {
  message = input[_].replies[_].message
}
  message = input[_].replies[_].replies[_].message
}

Cela fonctionne pour les niveaux d’imbrication finis, mais on ne peut pas toujours partir de ce principe et, en plus, le résultat n’est pas très lisible. Utilisez plutôt walk pour parcourir récursivement l’arbre et récupérer chaque champ .message :

messages[message] {
  [_, value] := walk(walk_input)
  message = value.message
}

Une fois ces messages disponibles dans une règle Rego classique, nous pouvons écrire une politique idiomatique qui bloque les fils de discussion contenant des jurons.

deny {
  swear_words := {"hawaii", "pineapple"}
  contains(lower(messages[_]), swear_words[_])
}

Un exemple concret où cela s’avère utile concerne les modules enfants Terraform : ici, nous utilisons walk dans Regula pour aplatir les modules enfants, d’une manière très semblable à l’aplatissement des messages d’un fil de discussion.

5. Charger dynamiquement des packages et des règles

L’un des aspects intéressants de la conception de Rego est que tout l’« univers » des règles et des données est imbriqué dans le même document. Qu’il s’agisse d’accéder aux entrées utilisateur, aux données de fichiers JSON ou YAML, ou aux règles de vos packages, tout se fait par des références :

input.people[0].order
data.recipes.pizza.pepperoni
data.policies.strict.allow

Comme Rego permet d’énumérer les références à l’aide de variables ou de caractères génériques, nous pouvons examiner dynamiquement l’ensemble des packages chargés. Cela permet de créer une architecture où il suffit d’ajouter de nouveaux fichiers de politique pour en faire la synthèse dans un rapport.

Placer les politiques dans des packages distincts présente également l’avantage de pouvoir les déboguer séparément et d’éviter tout conflit entre les noms de règles. Nous les placerons toutes dans l’espace de noms data.policies. La première utilise les lois de De Morgan pour vérifier la présence de fromage sur une pizza :

package policies.cheese

contains_cheese {
  input.toppings[_] == "cheese"
}

deny["must contain cheese"] {
  not contains_cheese
}

La deuxième politique vérifie que deux personnes ne se disputent pas la dernière part :

package policies.slices

deny["must be able to share with two people"] {
  input.slices % 2 != 0
}

Une fois que nous avons défini l’espace de noms dans lequel placer nos politiques, il est relativement simple d’en faire la synthèse : nous récupérons chaque deny dans data.policies et produisons un rapport lisible par un humain.

package summary

report = msg {
  denies := {m | m := data.policies[_].deny[_]}
  msg := sprintf("%d policies failed\n%s", [
    count(denies),
    concat("\n", [sprintf("- %s", [m]) | denies[m]]),
  ])
}

Avec un peu de jq pour extraire les valeurs proprement dites, nous pouvons générer un rapport bien présenté directement dans le terminal. Ici, nous utiliserons -I pour lire l’entrée depuis stdin.

$ opa eval -I 'data.summary.report' -d . --format json | \
    jq -r '.result | .[] | .expressions | .[] | .value'
{"toppings": ["crab", "tomato sauce"], "slices": 6}
1 policies failed
- must contain cheese

Il existe bien d’autres astuces avancées qui consistent à accéder aux packages comme à des données. On peut, par exemple, convertir l’entrée dans un format différent selon que le package déclare input_version = 1 ou input_version = 2. Tout cela, avec Rego !