L’autorisation, et rien d’autre

Un nouveau composant, et une seule question : ce demandeur a-t-il le droit d’effectuer cette action sur ce sujet ? Son vocabulaire ne connaît ni utilisateur, ni jeton, ni pare-feu, et c’est tout l’intérêt.

Access Control fait de l’autorisation, et rien d’autre. L’authentification est une question séparée qui appelle une réponse séparée, et ce composant ne la pose jamais. Il apporte des votants, des algorithmes de combinaison, des politiques d’accès déclarées en attributs, et des décisions qui disent pourquoi elles ont été prises.

Pourquoi il existe

Dans une application PHP, l’autorisation finit en général éparpillée en des endroits qui s’ignorent : des votants dans un composant, des règles d’URL dans un pare-feu, un attribut sur un contrôleur, une expression dans une garde de workflow, une fonction dans un gabarit. Chacun réimplémente un morceau de la même idée, et ils divergent.

Ce composant tient l’idée une seule fois. Tout le reste, l’attribut de contrôleur, la règle d’URL, la fonction de gabarit, la garde de console, la garde de workflow, n’est qu’un point d’entrée sur la même mécanique.

$decision = $accessControlManager->decide(new AccessRequest($requester, 'EDIT', $post));

if (! $decision->isGranted()) {
    throw new AccessDeniedException($decision->reason);
}

La même question, posée depuis le contrôleur d’une application Symfony :

#[AccessPolicy('EDIT', new Argument('post'))]
public function edit(Post $post): Response

Il n’a pas besoin d’une pile d’authentification

Le demandeur est typé mixed : un jeton Symfony quand vous en avez un, et sinon un compte de service, un acteur machine, une clé d’API, ou une chaîne. Un votant qui ne comprend pas un demandeur s’abstient, et c’est là tout le protocole.

symfony/security-core est facultatif de bout en bout, et les classes qui le connaissent vivent dans un espace de noms de passerelle qui leur est propre. Une application qui a Security le garde et ne change rien ; une application qui n’en a pas peut tout exprimer de la même façon.

Il dit pourquoi

Une décision n’est pas un booléen. Elle porte le verdict, la raison, et qui a voté dans quel sens. C’est ce qui rend un refus diagnosticable six mois plus tard, et c’est ce que lisent le panneau du profileur, le journal et les assertions de test.

Un refus qui atteint une réponse HTTP reste un 403 nu : le diagnostic est disponible pour le développeur et refusé au visiteur.

La panne contre laquelle il est bâti

Une garde qui laisse passer quelqu’un est la seule panne inacceptable, et elle est silencieuse. Rien ne passe au rouge dans une suite de tests, rien ne va de travers dans un journal ; les règles d’accès cessent simplement de s’appliquer.

Le composant est donc bâti pour tomber bruyamment à la place. Ce que la passerelle Symfony ne peut pas absorber lève à la compilation plutôt qu’à l’exécution : deux algorithmes de combinaison nommés à la fois, deux jeux de règles d’URL, deux hiérarchies de rôles, des réglages contradictoires. Ce qu’une reprise ne sait pas faire refuse à voix haute plutôt que de répondre false, parce qu’un refus qu’un gabarit ne peut pas distinguer d’un vrai relève de la même famille de faute.

Deux paquets, un dépôt

Les deux sont des extractions de sous-arbre en lecture seule d’access-control-framework, où se posent les tickets et les pull requests. La documentation vit sur acf.spomky-labs.com, et une application de démonstration met chacune de ces idées derrière une vraie page.

Installer le bundle dans une application qui a Symfony Security ne change rien de visible : vos votants continuent d’être consultés, #[IsGranted] est lu, is_granted() répond dans vos gabarits, et votre security.yaml reste octet pour octet ce qu’il était. Ce qui a changé, c’est qui décide.

Dans les semaines qui viennent, un article prendra une pièce à la fois : le vocabulaire, les votants, les algorithmes de combinaison, les points d’entrée, la migration, les tests, et la démonstration. Voilà la forme générale ; le reste est le détail.