Objectif
Le but de cet article est d'expliquer comment fonctionne Open Policy Agent à l'aide d'un exemple de gestion des autorisations pour une société fictive qui serait un éditeur de livres. Comme cet exemple va être utilisé dans un de mes prochains livres mais que ce n'est pas le sujet principal, la présente page permet de rentrer un peu plus dans les détails.
Contexte
Imaginez que vous devez mettre en place un mécanisme de gestion des autorisations pour une maison d'édition. En tant qu'intégrateur d'un progiciel ou éditeur d'une solution logicielle sur mesure pour cette entreprise, vous devez commencer par comprendre leur besoin. Frais et plein d'entrain, vous vous imaginez que quelques rôles de sécurité suffiront et que le paradigme RBAC va être mis en place en quelques paramétrages. Grossière erreur...
La société a bien une notion de rôles, qui plus est bien décorrélé de son organigramme, ce qui est une très bonne chose en termes de couplage. Mais votre interlocuteur vous prévient que les rôles héritent les uns des autres. Par exemple, quand vous êtes l'éditeur de plusieurs auteurs, vous recevez automatiquement les droits de tous ces auteurs, ainsi bien sûr que certains droits supplémentaires. Et il est essentiel que ceci soit dynamique : le seul fait qu'un nouvel auteur soit sous contrat implique qu'on lui affecte un éditeur référent et que l'un comme l'autre bénéficient des droits sur les livres (ou projets de livres) de cet auteur. Bien sûr, un auteur ne peut pas voir les livres des autres auteurs. Et un éditeur ne peut - par défaut - pas voir les livres d'auteurs qui ne sont pas coachés par lui.
La société étant de taille limitée, votre interlocuteur insiste fortement sur le fait que les rôles de chacun peuvent être multiples et s'ajuster en permanence, sans être cantonnés à l'organigramme. Par exemple, un des commerciaux (nom de code frank-velo
) est non seulement dans le groupe marketing en plus du groupe commerce, mais on lui a récemment donné un rôle d'éditeur (sans qu'il soit rattaché à cette direction).
Modélisation
Annuaire
Rapidement, vous modélisez donc deux arborescences bien séparées, une pour la gestion de l'identité des personnes (qui sera vraie quelle que soit l'application, et devrait être mise dans l'annuaire) et l'autre pour les autorisations, qui s'appliquera à l'application de gestion des livres principalement. La structuration JSON de l'organigramme donne ces résultats :
"org_chart": {
"farah-fossette": {
"frank-velu": {},
"celeste-moulinette": {},
"manu-franchouille": {
"00024": {},
"00025": {}
}
}
}
La cheffe farah-fossette
supervise trois personnes, deux commerciaux et une éditrice (nom de code manu-franchouille
) qui elle-même supervise deux auteurs. Pour des raisons de simplicité, il est décidé de ne pas représenter tout l'organigramme, mais une portion suffisante pour faire apparaître tous les cas et règles métiers.
L'appartenance des comptes aux groupes est ce qui reste fixe quelle que soit l'application concernée. Elle est modélisée ainsi :
"directory": {
"farah-fossette": {"groups": ["board"]},
"frank-velu": {"groups": ["commerce", "marketing"]},
"celeste-moulinette": {"groups": ["commerce"]},
"manu-franchouille": {"groups": ["editors", "quality"]},
"jp-gouigoux": {"groups": ["authors"]},
"nico-couseur": {"groups": ["authors"]}
}
Comme annoncé, l'utilisateur frank-velu
appartient au commerce, comme celeste-moulinette
, mais le premier dirige également le marketing, comme il s'agit d'une petite entreprise sans une personne dédiée à cette direction. Encore une fois, ceci est vrai pour l'organisation dans son ensemble : il s'agit de critères de pure identification des personnes, qui seront applicables indirectement sur les autorisations dans n'importe quel endroit du Système d'Information.
Rôles et affectations
Le reste de la discussion porte alors sur les rôles. A l'intérieur du référentiel de livres, qui est le principal asset de l'éditeur, il est nécessaire de suivre une organisation de type Role-Based Access Control pour facilement affecter des droits en masse, et ne pas avoir à le faire droit après droit. Comme expliqué dans le contexte, ces rôles ont une particularité, à savoir qu'ils héritent les uns des autres, comme cela a été décidé par la direction d'entreprise ; les éditeurs héritent automatiquement des droits des auteurs, mais la direction hérite également des rôles sales
et edition
. Pour chacun des rôles, les droits d'accès sont représentés de manière assez traditionnelle avec une liste de couples permission/entité. Seule petite particularité : il existe une opération all
qui regroupe toutes les opérations possibles sur une entité donnée. L'arborescence est ainsi établie :
"roles": {
"book-direction": { "access": []},
"book-sales": { "parent": "book-direction", "access": [{ "operation": "all", "type": "books.sales" }]},
"book-edition": { "parent": "book-direction", "access": [{ "operation": "all", "type": "books.editing" }]},
"book-writer": { "parent": "book-edition", "access": [{ "operation": "read", "type": "books.editing" }, { "operation": "all", "type": "books.content" }]}
}
Les auteurs ont ainsi tous les droits sur le contenu des livres, mais pas sur les attributs en lien avec l'édition du livre, comme l'ISBN, le titre, les catégories, les directives d'impression ou de vente uniquement en format électronique, etc., qu'ils peuvent toutefois lire. Leur éditeur ou éditrice a la main sur ces informations. Le rôle commercial peut gérer les informations de statistiques de vente, prix, réduction sur le transport et autres critères commerciaux sur les livres. Enfin, le rôle de direction reprend l'ensemble des droits par héritage. Encore une fois, par mesure de simplification, seules les permissions sur le référentiel de livres ont été modélisées, mais on considère que c'est l'essentiel... et le plus complexe en termes d'autorisations (nous reviendrons plus loin sur quelques cas particuliers un peu complexes à gérer).
Une fois ces rôles définis, il est nécessaire de les affecter, et cela se fait comme de bien entendu dans le mode RBAC en réalisant des mappings sur les groupes en priorité, puis en complétant par des mappings sur les personnes, pour les cas particuliers. Le mapping groupes/rôles est défini comme suit :
"group_mappings": {
"board": { "roles": ["book-direction"] },
"commerce": { "roles": ["book-sales"] },
"editors": { "roles": ["book-edition"] },
"authors": { "roles": ["book-writer"] }
}
Les liens sont assez logiques, et le seul cas un tant soit peu sophistiqué est sur l'affectation d'un rôle non porté par les groupes, comme expliqué précédemment sur le cas frank-velu
:
"user_mappings": {
"frank-velu": { "roles": ["book-edition"] }
}
Données des livres et auteurs
Une fois tout ceci en place, il nous faut également récupérer les données des livres et des auteurs. Ceci ne concerne pas directement la modélisation des autorisations, mais ces dernières se basent sur des informations métiers en provenance de ces deux référentiels de données maîtres, donc on ressort leur contenu (ou plutôt une version très simplifiée de ce contenu, qui ne contient par exemple pas toutes les décompositions en blocs de données des livres qui nous permettront de réaliser des accès granulaires).
Pour les livres, le contenu réduit est le suivant (c'est dans le bloc editing
qu'on retrouve le lien à l'auteur principal du livre) :
"books": {
"978-2409002205": { "id": "978-2409002205", "title": "Performance in .NET", "editing": { "author": "00024", "status": "published" }},
"978-0000123456": { "id": "978-0000123456", "title": ".NET 8 and Blazor", "editing": { "author": "00025", "status": "draft" }}
}
Pour les auteurs, le contenu réduit est comme suit :
"authors": {
"00024": { "id": "00024", "firstName": "Jean-Philippe", "lastName": "Gouigoux", "user": "jp-gouigoux", "restriction": "none" },
"00025": { "id": "00025", "firstName": "Nicolas", "lastName": "Couseur", "user": "nico-couseur" },
}
Tout est désormais prêt en termes de données d'IAM et métiers servant aux autorisations, le reste consiste à modéliser les règles expliquées plus haut... ainsi que certaines issues de la discussion sur des cas bien particuliers.
Traitement informatisé
Présentation d'Open Policy Agent
Vue la complexité des règles d'autorisation, il est clair qu'une simple implémentation de style RBAC ne suffit pas et qu'il faut mettre en place une approche Attribute-Based Access Control, gérant en plus l'héritage des rôles. De plus, comme notre interlocuteur explique que les règles changent rapidement car l'entreprise est en cours de structuration et des réglements comme le RGPD et la protection des données avec droits d'auteurs évoluent en continu, forçant à des contraintes de plus en plus fortes. La fraude est également un enjeu fort pour l'entreprise, avec une importance capitale que les auteurs ne voient surtout pas les travaux en cours des autres, et un point important - bien que moins capital - sur le fait que les profils d'édition et surtout commerciaux sont maintenus en concurrence, les affectations par catégories et zones de chalandise n'étant pas encore très claires.
Open Policy Agent est une solution assez simple et efficace pour traiter ce genre de problématique d'autorisation, avec un langage puissant (bien que parfois un peu complexe à appréhender, mais il existe heureusement beaucoup de documentation et d'exemples). Pour mieux comprendre ces écritures de règles, on anticipe un peu en montrant l'entrée qui sera envoyée à OPA pour lui demander de se prononcer sur un accès ou pas :
{
"input": {
"user": "manu-franchouille",
"operation": "all",
"resource": "books.content",
"book": "978-2409002205"
}
}
Dans cet exemple d'input
, on demande à OPA si l'utilisateur manu-franchouille
a les droits d'accès totaux au contenu du livre dont l'ISBN est indiqué. Pour ce cas simple, la réponse sera vraie, car manu-franchouille
est l'éditrice de l'auteur 00024
du livre concerné, et ce rôle lui donne le droit indirect sur le contenu.
Définition des règles
Un fichier est généré avec le contenu des règles d'autorisations en langage Rego
. La grammaire suivante permet par exemple de récupérer l'ensemble des rôles liés au compte réalisant la demande (input.user
), que ce soit par les mappings de groupes ou les mappings individuels :
user_groups contains group if {
some group in data.directory[input.user].groups
}
user_group_roles contains role if {
some group in user_groups
some role in data.group_mappings[group].roles
}
user_direct_roles contains role if {
some role in data.user_mappings[input.user].roles
}
user_roles := user_group_roles | user_direct_roles
Ces rôles sont ensuite utilisés pour trouver les accès associés et regarder s'ils correspondent à la demande réalisée :
permission_associated_to_role if {
some access in user_accesses_by_roles
access.type == input.resource
access.operation == input.operation
}
Il est nécessaire pour cela d'avoir une définition du concept user_accesses_by_roles
, ce qui est fourni par cette règle :
user_accesses_by_roles contains access if {
some role in user_roles
some access in permissions[role]
}
A son tour, cette règle nécessite que la liste complète des permissions soit réalisée, et c'est là qu'on retrouve la plus grande complexité dans la grammaire Rego
pour cet exemple, avec deux règles permettant de réaliser une recherche récursive dans l'arborescence des rôles :
roles_graph[entity_name] := edges {
data.roles[entity_name]
edges := {neighbor | data.roles[neighbor].parent == entity_name}
}
permissions[entity_name] := access {
data.roles[entity_name]
reachable := graph.reachable(roles_graph, {entity_name})
access := {item | reachable[k]; item := data.roles[k].access[_]}
}
Si vous ne comprene pas parfaitement cette grammaire, ne vous inquiétez pas outre mesure. Ce n'est le cas de personne et il faut voir pas mal d'exemples et les affiner pour trouver la bonne grammaire, en se servant de logs pour bien comprendre ce qui se passe dans les états intermédiaires (heureusement, OPA permet d'afficher pas par pas tous les contenus des règles lors de leur exécution, ce qui permet de mieux comprendre). Et comme cette grammaire reste complexe, nous verrons un peu plus loin que c'est l'endroit idéal pour mettre en place un bon solide harnais de tests.
Le but n'est pas ici de détailler toutes les règles, que vous pouvez retrouver sur https://github.com/PacktPublishing/.NET-Architecture-for-Enterprise-Applications/blob/main/opa-abac-authorization-main, mais de montrer le fonctionnement global. Il faut aussi que nous parlions de quelques cas particuliers qui n'avaient pas été évoqués avant. Par exemple, les commerciaux ne peuvent pas voir les livres tant qu'ils ne sont pas arrivés à un statut particulier qui rend possible d'en parler publiquement :
default no_status_blocking := false
no_status_blocking if {
some role in user_roles
role != "book-sales"
}
default readable_for_sales := false
readable_for_sales if {
book.status == "published"
}
readable_for_sales if {
book.status == "archived"
}
no_status_blocking if {
some role in user_roles
role == "book-sales"
readable_for_sales
}
Il existe aussi une règle permettant de bloquer certains auteurs qui n'ont pas joué le jeu sur la diffusion du livre, et voient leurs droits révoqués (totalement ou partiellement, OPA permet de gérer ceci) par un attribut spécifique porté sur leur auteur, et donc applicable à tous les livres sans avoir à gérer cela individuellement :
default no_author_blocking := false
no_author_blocking if {
some role in user_roles
role != "book-writer"
}
no_author_blocking if {
some role in user_roles
role == "book-writer"
some user in user_author
user.restriction == "none"
}
Enfin, une dernière règle intéressante à montrer est celle qui illustre le principe fondamental de la gestion d'autorisations :
default allow := false
allow if {
permission_associated_to_role
no_author_blocking
no_status_blocking
authors_on_books_they_write
editors_on_books_from_authors_they_manage
}
Comme on peut le voir, par défaut, tout est refusé. Seule la conjonction de l'acceptation de toutes les règles permet de donner un droit à la ressource demandée.
Mise en oeuvre avec Docker
Grâce à Docker, la mise en oeuvre d'OPA est simplissime :
docker run -d -p 8181:8181 --name opa openpolicyagent/opa run --server --addr :8181
Le serveur tourne et est prêt à accepter les appels d'API. Dans un premier temps, on charge les règles :
curl -X PUT http://localhost:8181/v1/policies/app/abac --data-binary @policy.rego
Ensuite, on dépose le fichier contenant les données (ce qu'on avait décrit en premier, avec l'organigramme, les affectations de groupes, etc.) :
curl -X PUT http://localhost:8181/v1/data --data-binary @data.json
Enfin, il suffit d'appeler le serveur OPA avec une requête comme montrée plus haut :
curl --no-progress-meter -X POST http://localhost:8181/v1/data/app/abac --data-binary @input.json | jq -r '.result | .allow'
Le serveur renvoit alors une arborescence complète avec le résultat de toutes les règles et comme ce qui nous intéresse est le résultat final que nous avions posé dans une règle nommée allow
(qui était, par sécurité, initialisée par défaut à false
), nous nous servons de l'outil de parsing JSON jq
pour aller chercher cet attribut et afficher sa valeur, ce qui nous donne, avec l'exemple d'input montré plus haut, le résultat true
.
Il ne reste alors plus qu'à intégrer cet appel dans la gestion d'exposition des données du référentiel des livres, ce qui est un sujet en soi mais dont on ne parlera pas ici. Si vous êtes intéressés et que vous vous posez des questions sur comment gérer la pagination, la performance en appels multiples, la mise en oeuvre en mode reverse-proxy, sidecar ou bien dans le processus, alors stay tuned pour mon prochain ouvrage !
Gestion de la qualité
Caractère indispensable des tests
Comme indiqué plus haut, dans des systèmes d'une telle complexité, il est bien sûr hors de question de se fier à quelques tests manuels et à sa compréhension parfaite des règles, sauf si vous êtes un expert patenté de la grammaire Rego
(et encore). Il est donc essentiel de se préparer un bon harnais de test pour couvrir le maximum de cas, en les préparant avec quelqu'un du métier.
Description des scénarios de test
La grammaire Gherkin permet de plus de décrire ces scénarios de manière très simple :
Scenario: An author has all rights on the content of their book
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user jp-gouigoux belongs to group authors
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"00024":{},"00025":{}}}}
And user jp-gouigoux is associated to author 00024 who has level of restriction none
When user jp-gouigoux request all access to the books.content petal of the book number 978-2409002205
Then access should be accepted
Scenario: An author has no rights on the content of the book from another author
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user jp-gouigoux belongs to group authors
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"00024":{},"00025":{}}}}
And user jp-gouigoux is associated to author 00024 who has level of restriction none
When user nico-couseur request read access to the books.content petal of the book number 978-2409002205
Then access should be refused
Scenario: Au author that has been blocked has no rights, even on their own books
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user jp-gouigoux belongs to group authors
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"00024":{},"00025":{}}}}
And user jp-gouigoux is associated to author 00024 who has level of restriction blocked
When user jp-gouigoux request all access to the books.content petal of the book number 978-2409002205
Then access should be refused
Scenario: An editor has all rights on the content of the books from the authors they manage
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user jp-gouigoux belongs to group authors
And user manu-franchouille belongs to group editors
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"00024":{},"00025":{}}}}
And user jp-gouigoux is associated to author 00024 who has level of restriction none
When user manu-franchouille request all access to the books.content petal of the book number 978-2409002205
Then access should be accepted
Scenario: An editor has no rights on the content of the books from the authors they do not manage
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user jp-gouigoux belongs to group authors
And user manu-franchouille belongs to group editors
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"nico-couseur":{}}}}
And user jp-gouigoux is associated to author 00024 who has level of restriction none
When user manu-franchouille request all access to the books.content petal of the book number 978-2409002205
Then access should be refused
Scenario: Refusing salesperson access to work-in-progress book
Given book number 978-2409002205 with author id 00024 is in workInProgress status
And user frank-velu belongs to group commerce
And organizational chart is {"farah-fossette":{"frank-velu":{},"celeste-moulinette":{},"manu-franchouille":{"00024":{},"00025":{}}}}
When user frank-velu request read access to the books.content petal of the book number 978-2409002205
Then access should be refused
Dans un cas réaliste, on viendrait bien sûr traiter tous les cas particuliers pour vérifier que l'ensemble est parfaitement fonctionnel, mais vous devez normalement bien voir l'idée avec ces quelques premiers scénarios.
Mise en oeuvre de SpecFlow
Le reste du travail est confié à SpecFlow, qui permet de transformer ces lignes de description de scénarios en lignes de code décrivant un test unitaire. Par exemple, la ligne comportant un Given
décrivant un livre sera mise automatiquement en correspondance avec la méthode C# ci-dessous :
[Given("book number (.*) with author id (.*) is in (.*) status")]
public void AddBookWithStatus(string number, string authorId, string status)
{
_books.Add(new Book() { Number = number, AuthorId = authorId, Status = status });
}
Un autre exemple montre comment les auteurs sont intégrés dans le test :
[Given("user (.*) is associated to author (.*) who has level of restriction (.*)")]
public void AddAuthor(string login, string authorId, string restrictionLevel)
{
_authors.Add(new Author() { Login = login, Id = authorId, Restriction = restrictionLevel });
}
Enfin, le When
va déclencher l'appel du serveur OPA (les parties moins importantes pour la compréhension sont enlevées pour faciliter la lecture) :
[When("user (.*) request (.*) access to the (.*) petal of the book number (.*)")]
public void ExecuteRequest(string login, string access, string perimeter, string bookNumber)
{
StringBuilder sb = new StringBuilder();
(...)
sb.AppendLine(" \"books\": {");
for (int i=0; i<_books.Count; i++)
{
Book b = _books[i];
sb.Append(" \"" + b.Number + "\": { \"id\": \"" + b.Number + "\", \"title\": \"***NORMALLY NO IMPACT ON RULES***\", \"editing\": { \"author\": \"" + b.AuthorId + "\", \"status\": \"" + b.Status + "\" }}");
if (i < _books.Count - 1) sb.AppendLine(","); else sb.AppendLine();
}
sb.AppendLine(" },");
(...)
var response = _client.PutAsync("data", new StringContent(sb.ToString(), Encoding.UTF8, "application/json")).Result;
string input = "{ \"input\": { \"user\": \"" + login + "\","
+ " \"operation\": \"" + access + "\","
+ " \"resource\": \"" + perimeter + "\","
+ " \"book\": \"" + bookNumber + "\" } }";
response = _client.PostAsync("data/app/abac", new StringContent(input, Encoding.UTF8, "application/json")).Result;
if (response != null)
{
_result = response.Content.ReadAsStringAsync().Result;
}
}
Ainsi, le reste du test unitaire généré par le scénario consiste simplement à récupérer le contenu du _result
et l'analyser pour regarder si c'est le comportement attendu, et passer le test en échec si ce n'est pas le cas :
[Then("access should be (.*)")]
public void ValidateExpectedResult(string expectedResult)
{
JsonTextReader reader = new JsonTextReader(new StringReader(_result));
reader.Read(); // Get first element
reader.Read(); // Read result attribute
reader.Read(); // Get element for result
reader.Read(); // Read allow attribute
bool? actual = reader.ReadAsBoolean(); // Get boolean value for allow attribute
if (actual is null)
throw new ApplicationException("Unable to find result");
bool? expected = null;
if (expectedResult == "refused") expected = false;
if (expectedResult == "accepted") expected = true;
if (expected is null)
throw new ApplicationException("Unable to find expected value");
Assert.Equal(expected, actual);
}
Lancement des tests
Ensuite, il suffit de lancer tous les tests unitaires avec les outils intégrés, et le résultat sera fourni comme suit (si on utilise une sortie graphique), avec en plus les indications de temps passés, ce qui permet de surveiller la performance, essentielle sur ce genre de fonctions (et qui explique en grande partie l'aspect complexe de la grammaire Rego
, qui suit des patterns précis permettant de limiter les boucles) :
Conclusion
Open Policy Agent est une façon très souple et sophistiquée de gérer les autorisations, à ne surtout pas utiliser dans des cas simples, bien sûr. Il existe d'autres approches, non seulement sur les outils mais aussi sur les paradigmes de gestion, avec en particulier un petit dernier nommé ReBAC et qui semble très prometteur. Si tout ceci vous intéresse et que vous souhaitez creuser le sujet, je me permets à nouveau un petit teasing pour mon prochain livre, à paraître en deuxième moitié d'année 2024 si tout va bien.