Retour aux textes

L'autorisation en quatre questions

Capacité, partition, relation et tenant. Chaque question avec son propre mécanisme, et aucun rôle qui essaie de répondre à toutes.

Presque tous les systèmes d'autorisation commencent par une seule question : quel est le rôle de l'utilisateur ? Et ça marche, tant que le domaine reste simple lui aussi. Le problème apparaît quand la même décision se met à dépendre de choses de nature différente et que quelqu'un essaie de tout résoudre en étirant la même hiérarchie de rôles.

Ce texte est un extrait d'une architecture que j'ai conçue pour l'autorisation humaine dans des systèmes multi-tenant. Ce n'est pas Zanzibar au complet, ce n'est pas un moteur de politiques générique et ça ne met pas toute la règle dans la base de données. C'est un choix délibéré : prendre dans chaque tradition de contrôle d'accès l'abstraction qui résout bien un problème précis, et seulement celle-là.

Quand un rôle ne suffit plus

Le début ressemble souvent à ça :

viewer < editor < admin < superadmin

if user.role.rank >= required_rank:
    allow()

Ça a l'air économique, mais ça mélange les concepts. Un administrateur peut avoir besoin d'enregistrer des personnes sans lire de contenu sensible. Un analyste peut exécuter une opération, mais seulement sur les données de sa propre unité. Quelqu'un d'un autre service peut recevoir un rapport précis sans pour autant faire partie de ce service. Et un opérateur de plateforme peut avoir besoin de traverser les tenants en urgence sans que le nom de son rôle devienne un mot de passe maître dans chaque handler.

Ce ne sont pas des niveaux qui manquent dans la hiérarchie. C'est la hiérarchie qui n'est pas la bonne abstraction pour toutes ces questions. Quand on insiste, les symptômes habituels apparaissent : rank magique, exception ad hoc, permission par département, if admin éparpillé dans le code, partage qui donne plus de pouvoir qu'il ne devrait et isolation entre clients qui dépend de la discipline de chaque endpoint.

Les quatre questions

Une décision d'autorisation mature se décompose en quatre dimensions indépendantes :

QuestionMécanismeRôle
Que peut faire le sujet ?RBAC avec permissionsCapacité
De quelles unités fait-il partie ?Membership organisationnelPartition
Cet objet a-t-il été partagé avec lui ?Relation typée, à la ReBACException ponctuelle
Cette ligne appartient-elle au tenant ?Row Level SecurityIsolation

Les quatre peuvent donner la même réponse finale, autoriser ou refuser, mais elles n'ont pas la même sémantique. Les tasser toutes dans une colonne role ou dans une table générique de grants, c'est le chemin le plus court vers une politique que personne n'arrive ni à expliquer ni à tester.

Une question d'autorisation, un mécanisme principal.

Il y a une façon simple de voir que les axes doivent rester séparés : regarder comment chacun change. La capacité change quand le poste de la personne change. La partition change quand l'organigramme change. Le partage change par une action ponctuelle de collaboration. Le tenant ne change presque jamais pendant la vie d'une identité. Ce sont des événements avec une fréquence, un responsable, une trace et une révocation différents, et tout mettre dans le même registre oblige le système à traiter comme identiques des choses qui ne le sont pas.

Capacité : du RBAC sans ranks magiques

Le noyau classique du RBAC reste : les utilisateurs reçoivent des rôles, les rôles regroupent des permissions et l'autorisation consulte l'ensemble effectif. Ce que je rejette, c'est le rôle comme nombre. Un rôle est un ensemble déclaré. Si l'une contient l'autre, c'est parce qu'elle a été déclarée ainsi, pas parce que l'une a eu le 3 et l'autre le 2.

VIEWER = { profile.manage, content.view }

ANALYST = VIEWER ∪ {
    analysis.run, data.query,
    content.manage, content.share
}

TENANT_ADMIN = ANALYST ∪ {
    users.read, users.write,
    authz.roles.read, authz.roles.assign_tenant
}

Comme ça, le pouvoir devient inspectable. On peut changer la composition d'un rôle sans reconstruire la sémantique de toute la hiérarchie, et l'ensemble effectif devient une simple union :

effective(u) =
      permissions(base_role)
    ∪ permissions(roles de u)
    ∪ grants directs valides(u)

La politique est monotone : il n'existe que des concessions, pas de deny explicite. C'est un choix, pas un oubli. Le deny change la précédence et l'analyse de tout le reste, et mérite d'être traité comme une évolution d'architecture, pas comme une colonne de plus.

Une permission est une capacité atomique, au format domaine.ressource.action. Avant d'entrer dans le catalogue, elle passe par trois questions :

  • Existe-t-il aujourd'hui un vrai point d'enforcement pour elle ?
  • Existe-t-il un rôle qui l'a et un autre qui ne l'a pas ?
  • Le backend et le frontend vont-ils utiliser exactement le même code ?

Si la première réponse est non, la permission est de la documentation sans effet. Si la deuxième est non, la granularité est du théâtre. Si la troisième est non, l'interface et l'API vont diverger. Le symptôme le plus courant d'un catalogue malhonnête, c'est une permission par étape d'écran, workflow.upload, workflow.preview, workflow.map, qu'aucun rôle ne différencie. Ça n'exprime pas du pouvoir, ça ne fait que copier la structure de l'UI.

Et toutes les permissions ne peuvent pas être accordées à l'intérieur d'un tenant. Des choses comme platform.tenant.bypass et authz.roles.assign_platform vivent de l'autre côté d'une frontière, et la CI vérifie qu'aucun rôle de tenant n'en contient une. Cette propriété compte plus que le nom de n'importe quel rôle.

Partition : l'organigramme n'est pas un catalogue

Si une entreprise a des dizaines d'unités, coder ça en permissions donne content.view.finance, content.view.hr, content.view.branch_001. L'organigramme devient du code, et chaque réorganisation devient un déploiement. La permission cesse de représenter une capacité et se met à représenter une instance de donnée.

La partition, c'est du membership, et le membership, c'est de la donnée :

org_unit(id, tenant_id, name)
user_org_unit(user_id, org_unit_id)
resource.org_unit_id

Une décision qui a l'air petite et ne l'est pas : la ressource appartient à l'unité, pas à la personne qui l'a créée. Si l'auteur change de service, il peut perdre l'accès à ce qu'il a produit. Ça semble contre-intuitif, mais ça préserve le principe selon lequel l'actif appartient à l'organisation.

Et attention à l'héritage. Un système en migration a toujours des données anciennes sans unité. Traiter org_unit_id IS NULL comme « legacy » pour toujours est dangereux, parce que chaque nouvel objet qui naît sans unité hérite de la même exception historique. Le legacy doit être marqué comme legacy, dans une colonne à lui, et un nouvel objet ne peut pas naître avec cette marque.

Relation : partager est une exception, pas un membership

Recevoir un rapport d'un autre service ne fait de personne un membre de ce service. C'est le cas parfait pour une relation typée, et c'est la partie de Zanzibar que j'utilise vraiment. Je n'ai pas besoin de namespace global, de zookies ni de graphe distribué. L'idée utile est plus petite :

Une exception d'accès sur un objet concret doit être une relation explicite, révocable et auditable.

resource_share(
    resource_id,
    grantee_user_id,
    granted_by_user_id,
    granted_at
)

Trois règles accompagnent la relation. Le share est intra-tenant par défaut : si partager entre clients est une exigence, ça mérite un modèle séparé, et ça ne peut pas apparaître comme effet de bord d'une table sans constraint. Celui qui reçoit ne gagne pas le pouvoir de re-partager, parce que recevoir viewer n'est pas une autorité de concession. Et partager exige une capacité et une autorité sur l'objet, exprimées en permission et en prédicat, jamais en if user.role == "admin".

can_share(u, r, grantee) :=
      has(u, content.share)
  AND same_tenant(u, r, grantee)
  AND can_view_resource(u, r)
  AND ( created_by(r) = u
     OR has(u, content.view_all_partitions) )

L'agrégat n'est pas la microdonnée

C'est la distinction la plus facile à perdre. Un rapport et le dataset dont il est issu sont des objets d'autorisation différents, et chacun a son propre prédicat :

can_view_resource(u, r) :=
      same_tenant(u, r)
  AND ( in_same_partition(u, r)
     OR shared_with(u, r)
     OR has_all_partitions_view(u)
     OR is_legacy_unpartitioned(r) )

can_access_data_context(u, c) :=
      same_tenant(u, c)
  AND ( in_same_partition(u, c)
     OR is_legacy_unpartitioned(c) )

L'absence de shared_with dans le second prédicat est délibérée. Partager la présentation ne peut pas devenir un accès à la donnée brute. Pour la même raison, content.view_all_partitions veut dire voir les agrégats de toutes les unités, et non consulter les microdonnées de toutes. Si le métier a besoin des deux, ce sont deux capacités. Le nom d'une permission doit refléter son vrai rayon d'action.

Et ne partitionner que le rapport, c'est une barrière cosmétique. Si la source n'a pas sa propre autorisation, il suffit que quelqu'un découvre l'identifiant du contexte et appelle l'endpoint directement.

Isolation : la base garantit le plancher

Row Level Security a ici une responsabilité étroite : empêcher l'accès cross-tenant. La policy est assez simple pour tenir en deux lignes et être auditée :

tenant_id = current_tenant()
OR current_platform_bypass() = true

La base ne sait pas qui est viewer, analyst ou propriétaire d'un share. Capacité, partition et relation restent dans l'application. Tout dupliquer dans le RLS et dans le code crée une double maintenance, et une correction faite d'un côté n'arrive pas de l'autre. Si un jour la partition descend dans la base, qu'elle soit générée à partir de la même spécification.

La base garantit le plancher. L'application garantit la politique.

Sauf que le RLS de tenant empêche le tenant A de voir le tenant B ; il n'empêche pas le département A de voir le département B. Une fois que les partitions existent, un repository qui fait get_by_id(id) et fait confiance au RLS n'est plus sûr. Ma réponse ici, c'est d'exiger un AccessContext dans toute lecture de contenu, de rendre les repositories sensibles privés au module d'autorisation et de faire échouer la CI quand un router en importe un directement. La review de PR aide, mais ne peut pas être la seule barrière.

Qui peut donner du pouvoir à qui

La règle « seul celui qui possède peut accorder » a l'air sûre et ne l'est pas. Si un admin a toutes les permissions d'admin, le subset check le laisse créer un autre admin. En même temps, elle oblige celui qui a seulement besoin d'accorder une capacité à la posséder. L'autorité de concession doit être modélisée directement, dans une matrice explicite :

role_grantable(grantor_role, target_role)

tenant_admin   -> viewer, analyst
platform_admin -> tenant_admin, viewer, analyst

Utiliser une capacité et l'attribuer à quelqu'un d'autre sont des opérations différentes. Enregistrer une identité et accorder une autorité aussi, donc ce sont des endpoints différents. J'en parle plus en détail dans Qui peut donner du pouvoir à qui.

Une session avec date de péremption

Presque tous les systèmes gardent l'ensemble effectif dans la session pour ne pas tout recalculer à chaque requête. Pas de problème, tant que le cache sait quand il a vieilli. Et il vieillit de trois façons : quand l'utilisateur change, quand le catalogue change et quand l'horloge dépasse l'heure d'un grant temporaire. Chacune a besoin de son propre mécanisme, et la dernière ne peut pas dépendre d'un cron. Le détail est dans Les permissions en cache vieillissent de trois façons.

Le frontend reflète, le backend décide

Le navigateur est hors de la frontière de confiance. Cacher un bouton n'est pas de l'autorisation, et un claim de rôle venu du client n'est pas une source de vérité. La permission a le même code aux deux bouts, require_permission("analysis.run") sur le serveur et hasPermission("analysis.run") dans l'interface, et seul le serveur décide.

FrontendBackendRésultat
cachebloquecorrect
cacheobserve seulementrollout, pas enforcement
affichebloquemauvaise UX
afficheautorise sans gatevulnérabilité

Pendant une migration, ça vaut la peine de faire tourner l'autorisation en shadow mode : le prédicat est évalué et la décision enregistrée, mais le refus ne bloque pas encore. Ça mesure l'impact réel avant le cutover. Sauf que le shadow n'est pas de la sécurité, et y rester indéfiniment est une dette mesurable. Chaque route authentifiée déclare une permission ou est classée explicitement comme sans gate, et la CI compare l'inventaire des routes avec le catalogue.

Ce qui est resté dehors exprès

  • Tout en ReBAC. On peut modéliser le membership et l'héritage comme des relations, mais, pour des partitions stables, le coût du graphe, de la cohérence et du tooling n'est pas rentable. Le rejet est pragmatique, pas théorique.
  • Un moteur ABAC générique. J'utilise des attributs comme tenant_id, org_unit_id et expires_at, mais dans des prédicats fermés et testables. Un langage de politique arbitraire est difficile à relire et à expliquer.
  • Tout dans le RLS. L'enforcement près de la donnée est excellent pour le plancher ; pour une règle produit, ça devient un versionnage difficile et de la duplication.
  • Le share comme nouveau rôle. Ça transforme une exception sur un objet en pouvoir global.

Pour conclure

Une autorisation mature n'est pas une suite de if role == ..., mais elle n'a pas non plus besoin d'être un graphe global distribué. Dans la plupart des systèmes applicatifs, le problème tient en quatre questions : ce que la personne peut faire, à quelle unité appartient la donnée, s'il existe une exception sur cet objet et si la ligne est du bon tenant. RBAC, membership, relation typée et RLS, chacun à sa place.

Le meilleur effet de bord, c'est la discipline d'évolution. Une nouvelle unité n'exige pas de nouvelle permission. Un nouveau share ne change le rôle de personne. Une nouvelle capacité ne touche pas au tenant. Chaque axe évolue à son rythme.

La capacité n'est pas la partition, la partition n'est pas le partage, le partage n'est pas le tenant, l'agrégat n'est pas la microdonnée et l'interface n'est pas l'enforcement.