Sortir l’autorisation du code applicatif

Introduction

Nos applications décident en permanence qui a le droit de faire quoi, et presque toutes le font avec une logique ad hoc dispersée dans le code métier. Open Policy Agent propose l’inverse : un moteur de décision externe, dont les règles sont du code versionné et testable.

Problématique

L’autorisation se dégrade toujours de la même façon. Elle commence par un user.isAdmin(), puis vient une table de rôles, puis des exceptions pour certains clients, puis une contrainte de conformité qui impose un contrôle par région. Au bout de deux ans, la règle est répartie entre le contrôleur, le service et la clause WHERE, et dupliquée dans chaque application qui touche la même donnée.

Cette dispersion cause trois problèmes.

Personne n’a de vue claire sur l’existant. « Qui peut lire la table des transactions ? » exige de relire le code, de l’interpréter, et d’espérer n’avoir rien manqué. La réponse n’existe nulle part sous une forme lisible. Et quand l’audit réclame la trace des décisions prises, la réponse est simple : il n’y en a pas.

Changer une règle est un déploiement. Relever un seuil d’habilitation ou ajouter une région autorisée passe par une pull request, une revue, un pipeline et une fenêtre de mise en production. Le délai n’a aucun rapport avec la taille du changement.

La même règle existe en plusieurs exemplaires. Dès qu’une deuxième application touche la même donnée, la règle est réimplémentée, dans un autre langage, par quelqu’un d’autre, à partir d’une compréhension voisine mais pas identique. Les deux copies divergent, et personne ne sait laquelle est juste.

Le problème vient du couplage entre le code et les permissions. La politique d’accès vit dans le code qui l’applique, et hérite donc de son cycle de vie, de son langage et de son découpage.

Décider et appliquer sont deux métiers

Open Policy Agent part d’une séparation empruntée au vocabulaire du contrôle d’accès : le point d’application (PEP) et le point de décision (PDP).

Votre application reste le point d’application. Elle intercepte une demande, elle s’arrange pour la satisfaire et en assume les conséquences. Elle ne raisonne plus en termes de permissions. OPA est le point de décision : il reçoit une question relative aux permissions, y répond, et n’exécute rien.

Le contrat entre les deux est volontairement pauvre : un document JSON entre, un autre sort. OPA ne connaît ni votre métier, ni Kubernetes, ni Trino, ni le schéma de votre base de données. Il évalue des règles sur le contexte qu’on lui passe, et rien d’autre. Cette indigence assumée est ce qui lui permet de servir l’administration de pods aussi bien que le contrôle d’accès d’un moteur SQL: il n’y a aucun modèle de domaine préconçu.

Les règles s’écrivent en Rego, un langage déclaratif dérivé de Datalog. On n’y décrit pas une procédure de vérification mais un ensemble de faits et d’implications, que le moteur évalue.

Un exemple : le self-service BI

Prenons une API interne qui expose le catalogue de données aux analystes. Trois règles s’appliquent, et leur intérêt est qu’elles viennent de trois endroits différents de l’organisation :

  1. Un analyste n’accède qu’aux données de sa région.
  2. Son habilitation doit couvrir la classification de la table.
  3. Les colonnes identifiantes sont masquées, pas refusées : on peut exprimer des requêtes dessus, mais la valeur retournée est caviardée.

L’API n’implémente aucune de ces règles. Lorsqu’un analyste la contacte, elle demande à OPA de valider sa requête :

{
"action": "query",
"user": {
"id": "a.dupont",
"groups": ["analysts", "emea"],
"clearance": 2
},
"resource": {
"schema": "finance",
"table": "transactions",
"columns": ["txn_id", "amount",
"customer_iban"],
"classification": "confidential",
"region": "emea"
}
}

Deux choses méritent d’être notées sur ce document.

Il ne contient aucune donnée métier. OPA ne verra jamais une transaction, un montant ou un IBAN réel : seulement de quoi décider si une requête doit être accordée. Cela facilite son intégration dans un environnement régulé.

Il porte les attributs, pas la conclusion. L’application ne dit pas « cet utilisateur est analyste EMEA de niveau 2, donc… » ; elle dépose les faits. Toute la logique d’interprétation est définie dans OPA.

La politique

On décrit en Rego la politique qui valide une telle demande.

package bi.authz
default allow := false
allow if {
input.action == "query"
"analysts" in input.user.groups
input.resource.region in input.user.groups
input.user.clearance >= min_level
}
min_level := levels[input.resource.classification]
levels := {
"public": 0, "internal": 1,
"confidential": 2, "restricted": 3
}
pii := {
"customer_iban",
"customer_email"
}
masked contains col if {
some col in input.resource.columns
col in pii
input.user.clearance < 3
}

Trois points de ce fichier méritent un arrêt.

Les conditions sont une conjonction implicite. Dans le corps de allow, les quatre lignes sont des et logiques. Il n’y a pas de &&, pas d’imbrication, pas d’ordre d’évaluation à raisonner. On énumère ce qui doit être vrai. Une règle de dix conditions reste plate et lisible, là où l’équivalent impératif devient un escalier de if imbriqués.

L’indéfini n’est pas le faux. default allow := false pose le refus comme réponse par défaut. C’est plus fort qu’une simple valeur initiale, parce qu’en Rego une règle dont le corps échoue n’est pas fausse : elle est indéfinie. La valeur par défaut que nous ajoutons rend l’évaluation de la permission toujours définie. La conséquence est importante en pratique. Si la classification de la table est absente du catalogue, ou vaut une chaîne inconnue, levels[...] ne trouve rien, min_level est indéfini, la comparaison échoue, allow est indéfini, et la réponse est false. Un défaut de métadonnée produit un refus et ne constitue pas une ouverture vers une éventuelle acceptation.

Une décision n’est pas forcément un booléen. masked contains col if { ... } ne définit pas un booléen mais un ensemble : la liste des colonnes à masquer, construite par itération sur les colonnes demandées. Une décision OPA peut être un ensemble de colonnes, une clause SQL, un motif de refus lisible, ou un objet structuré. C’est précisément ce qui le rend utile dans de nombreux contextes.

La réponse

L’application interroge OPA avec une requête POST et reçoit une réponse en JSON.

{"result": {
"allow": true,
"masked": ["customer_iban"]
}}

Il revient à l’application d’exécuter la requête et de masquer les résultats de manière appropriée.

On peut aller plus loin. Par exemple, Trino peut lui déléguer son contrôle d’accès, et sait obtenir de lui des filtres de lignes et des masques de colonnes.

access-control.name=opa
opa.policy.uri=https://opa.internal/v1/data/trino/allow
opa.policy.row-filters-uri=https://opa.internal/v1/data/trino/rowFilters
opa.policy.column-masking-uri=https://opa.internal/v1/data/trino/columnMask

Trino envoie l’identité de l’appelant et l’action tentée ; la politique répond un booléen pour l’autorisation, ou une expression SQL que le moteur injecte dans le plan de requête. Un filtre de lignes retourné par OPA devient une clause WHERE que l’utilisateur ne peut pas contourner, parce qu’elle est ajoutée après l’analyse de sa requête.

C’est là que la séparation prend sa vraie valeur. La règle « un analyste ne voit que sa région » peut désormais être écrite une fois et appliquée à la fois par l’API de catalogue, par Trino quand l’analyste passe en SQL direct, et par le prochain outil qui touchera la même donnée. Les trois parlent le même protocole ; seule la forme de la réponse diffère. La duplication qui faisait diverger les règles disparaît, par construction.

La même mécanique existe chez Kubernetes (admission de ressources), Envoy (autorisation de requêtes au niveau du maillage), Kafka, Terraform et une bonne partie de l’écosystème CNCF.

Le testing, la botte secrète d’OPA

Sortir la politique du code ne servirait pas à grand-chose si elle devenait un fichier de configuration opaque. L’intérêt est qu’elle redevient un artefact de génie logiciel à part entière.

Les politiques sont testables

opa test exécute des tests unitaires écrits en Rego. Une règle d’autorisation devient vérifiable en CI sans démarrer l’application, sans base de données et sans jeu de données. Ce point, à lui seul, justifie qu’on s’embarrasse d’un langage de plus.

package bi.authz_test
import data.bi.authz
analyst := {
"action": "query",
"user": {
"id": "a.dupont",
"groups": ["analysts", "emea"],
"clearance": 2,
},
"resource": {
"schema": "finance",
"table": "transactions",
"columns": ["txn_id", "amount", "customer_iban"],
"classification": "confidential",
"region": "emea",
},
}
# le cas nominal
test_analyste_accede_a_sa_region if {
authz.allow with input as analyst
}
# hors de sa région : refus
test_refus_hors_region if {
not authz.allow with input as json.patch(analyst, [
{"op": "replace", "path": "/resource/region", "value": "apac"},
])
}
# habilitation insuffisante pour une table restricted
test_refus_clearance_insuffisante if {
not authz.allow with input as json.patch(analyst, [
{"op": "replace", "path": "/resource/classification", "value": "restricted"},
])
}
# métadonnée manquante ou inconnue => refus
test_classification_inconnue_refuse if {
not authz.allow with input as json.patch(analyst, [
{"op": "replace", "path": "/resource/classification", "value": "nouvelle_valeur"},
])
}
# le masquage, en dessous du niveau 3
test_iban_masque if {
authz.masked == {"customer_iban"} with input as analyst
}
# au niveau 3, plus de masquage
test_pas_de_masquage_niveau_3 if {
count(authz.masked) == 0 with input as json.patch(analyst, [
{"op": "replace", "path": "/user/clearance", "value": 3},
])
}

Il ne reste plus qu’à exécuter ces tests dans la CI:

opa test . --verbose
opa test . --coverage --format=json | jq '.coverage'

Les politiques se déploient sans redéployer l’application

Les politiques se distribuent en bundles : une archive, signable, qu’OPA télécharge périodiquement depuis un serveur HTTP, un bucket S3 ou un registre OCI. Changer un seuil d’habilitation devient une publication de bundle. Aucun redémarrage, aucune fenêtre de mise en production, et une signature vérifiable sur ce qui tourne réellement.

Les décisions sont traçables par défaut

Les decision logs exportent chaque décision (l’entrée, la sortie, la version de la politique, l’horodatage) vers un collecteur HTTP ou la sortie standard. C’est très pratique pour les audits et ça ne nécessite aucune instrumentation du code applicatif. Dans un environnement régulé, ce point peut être déterminant.

Limitations

Tout n’est pas rose pour autant, et plusieurs points méritent d’être considérés pour éviter toute déconvenue.

Rego ne ressemble à rien de connu. L’indéfini n’est pas le faux, l’itération est implicite, une variable ne se réaffecte pas, et une règle dont aucun corps n’est vrai disparaît silencieusement au lieu de lever une erreur. Un développeur expérimenté écrira du Rego qui marche en une journée et du Rego correct en une semaine. Entre les deux, il écrira surtout des règles qui ne se déclenchent jamais sans qu’il s’en aperçoive. Heureusement, regal sauve la mise et met en évidence la plupart des problèmes typiques. En complément, pratiquez le TDD et écrivez vos tests de politique avant d’écrire la politique.

OPA ne décide que sur ce qu’il a en mémoire. Ce qui est propre à la requête va dans l’input. Ce qui est partagé et petit va dans data, chargé avec le bundle. Synchroniser un référentiel de plusieurs centaines de milliers d’entrées ne tient pas : la mémoire et la fraîcheur deviennent problématique. Dans ce cas, c’est à l’application d’enrichir l’input avec ce que la décision exige, ce qui suppose de savoir à l’avance ce dont la politique a besoin. Cela réintroduit un couplage faible mais réel.

Ce n’est pas un remplacement universel. Pour du masquage de colonnes sur un seul entrepôt, le catalogue natif ou Ranger reste souvent plus simple, mieux intégré et plus facile à justifier devant une équipe d’exploitation. OPA gagne quand la même règle doit valoir dans plusieurs technologies, ou quand la politique change plus vite que le code qui l’applique.

Conclusion

OPA ne simplifie pas vos règles d’autorisation. Celles-ci restent aussi compliquées que votre organisation. Par contre, il modifie l’endroit où elles vivent, et ce déplacement peut être payant.

Une règle sortie du code devient un objet qu’on peut lire sans ouvrir l’application, tester sans la démarrer, déployer sans la redéployer, auditer sans l’instrumenter, et réutiliser dans le moteur d’à côté. Aucune de ces cinq propriétés n’est spectaculaire isolément. Ensemble, elles changent votre projet: la politique cesse d’être une dette qui s’accumule dans les recoins du code pour devenir un livrable avec un propriétaire, des tests et un historique.

Le coût d’entrée est réel. Il est presque entièrement dans Rego. Comptez une semaine par personne pour être à l’aise, et acceptez que les premières politiques doivent être réécrites.

Si vous voulez l’évaluer, la manière la moins coûteuse de procéder tient en trois étapes.

  1. Reproduisez l’exemple en local. Les deux fichiers de cet article, plus un input.json, suffisent : opa eval -d policy.rego -i input.json data.bi.authz. Ou passez par le Rego Playground, sans rien installer.
  2. Prenez une règle existante. Celle qui a changé trois fois en six mois, ou celle que deux applications implémentent différemment. Réécrivez-la en Rego avec ses tests, et comparez le résultat au code qu’elle remplacerait.
  3. Ne branchez rien en production au premier essai. Faites tourner OPA en mode observation : l’application journalise l’écart entre la décision d’OPA et la sienne, et continue d’appliquer la sienne. Au bout de quelques semaines, les journaux logs disent si la politique est juste.

Si la règle choisie ne vit que dans une application et ne change jamais, l’exercice conclura qu’OPA n’apporte rien. C’est un résultat utile, et il coûte une après-midi.

Votre commentaire