Une commande se garde comme un contrôleur : en déclarant la politique dessus. Ce qui diffère, c’est qui demande, et à quoi ressemble un refus quand il n’y a pas de réponse HTTP où le mettre.
#[AsCommand(name: 'app:import')]
#[AccessPolicy('ROLE_IMPORTER', message: 'This command is reserved to importers.')]
final class ImportCommand extends Command
{
// ...
}
L’écouteur lit l’attribut à ConsoleEvents::COMMAND et refuse avant que la commande ne tourne. Sur __invoke() plutôt que sur la classe, cela fonctionne pareil.
Qui demande, dans une console
Il n’y a pas de jeton dans une console, donc le composant interroge le même fournisseur de demandeur que partout ailleurs, et le défaut rend un demandeur fixe : un compte de service que l’application nomme. Un fournisseur, une identité, et les mêmes votants que sur le web.
Une condition sur l’invocation
When lit le contexte de console, si bien qu’une politique peut dépendre d’une option :
#[When(new Expression('input.getOption("force")'), [
new AccessPolicy('ROLE_ADMIN'),
])]
Seule l’exécution forcée demande ROLE_ADMIN ; l’exécution ordinaire s’efface. Sur le web, le même attribut lit request à la place, ce qui est la généralisation de ce qu’#[IsGranted] exprimait avec son paramètre methods.
Le code de sortie est 113
Un refus sort sous forme d’exception plutôt qu’en désactivant la commande, de sorte que sa raison atteint le terminal et que ConsoleEvents::ERROR est émis tout court.
L’exception porte ConsoleCommandEvent::RETURN_CODE_DISABLED, soit 113, ce que la console signifie déjà par « cette commande n’a pas eu le droit de tourner ». Un statut HTTP n’a pas sa place ici : un 403 ressortirait en 255, les états de sortie étant plafonnés à 255.
Il n’est délibérément pas configurable, parce qu’un état de sortie est lu par les scripts que l’application possède, pas par la politique qui a refusé. Quand vous en voulez un autre, la console offre déjà l’issue, et elle a le dernier mot :
public function onError(ConsoleErrorEvent $event): void
{
if ($event->getError() instanceof AccessDeniedExceptionInterface) {
$event->setExitCode(77);
}
}
setExitCode() écrit aussi la valeur sur l’exception, donc elle l’emporte, et elle peut se brancher sur la commande ou sur le demandeur, ce qu’aucun réglage de notre part n’aurait permis. Cette échappatoire existe parce que le refus est une exception : désactiver la commande à la place n’émettrait aucun événement d’erreur, et il n’y aurait rien où s’accrocher.
Le bogue qui mérite d’être raconté
La garde de console tourne aussi sous --profile. Il faut le dire, parce qu’un temps elle ne le faisait pas.
La console remet une commande enveloppée lors du profilage ou du chargement paresseux, et l’enveloppe ne porte aucun attribut de l’originale. Un écouteur qui réfléchit sur l’enveloppe ne trouve rien et laisse passer la commande, en silence, pendant que tous les tests unitaires passent sur une commande nue que la console ne fournit jamais en vrai.
C’est exactement la panne contre laquelle ce composant est bâti, rencontrée dans son propre code. C’est aussi pourquoi le guide de contribution demande des tests qui passent par le vrai chemin plutôt que des tests qui affirment que quelque chose a été appelé : une garde qui ne tourne pas est silencieuse, et le silence a l’apparence d’une suite verte.
Le corriger a fait remonter un bogue de Symfony en dessous, depuis corrigé en amont : le profilage de console ne survivait pas à une commande arrêtée à ConsoleEvents::COMMAND, et ce chemin n’avait aucune couverture de test.