Activer l'authentification par clé API demande de lire dist/ : prefixeCle absent du type, aucun outil pour créer une clé, et le non-rebornage des scopes n'est écrit nulle part #36
Closed
opened 2026-09-09 19:09:40 +00:00 by nicolas-fryder
·
3 comments
No Branch/Tag specified
main
dev
@eqs/tooling@0.24.3
@eqs/testing@0.24.3
@eqs/telemetry@0.24.3
@eqs/platform-next@0.24.3
@eqs/platform-nest@0.24.3
@eqs/http@0.24.3
@eqs/errors@0.24.3
@eqs/config@0.24.3
@eqs/cli@0.24.3
@eqs/auth@0.24.3
@eqs/tooling@0.24.2
@eqs/testing@0.24.2
@eqs/telemetry@0.24.2
@eqs/platform-next@0.24.2
@eqs/platform-nest@0.24.2
@eqs/http@0.24.2
@eqs/errors@0.24.2
@eqs/config@0.24.2
@eqs/cli@0.24.2
@eqs/auth@0.24.2
@eqs/tooling@0.24.1
@eqs/testing@0.24.1
@eqs/telemetry@0.24.1
@eqs/platform-next@0.24.1
@eqs/platform-nest@0.24.1
@eqs/http@0.24.1
@eqs/errors@0.24.1
@eqs/config@0.24.1
@eqs/cli@0.24.1
@eqs/auth@0.24.1
@eqs/tooling@0.24.0
@eqs/testing@0.24.0
@eqs/telemetry@0.24.0
@eqs/platform-next@0.24.0
@eqs/platform-nest@0.24.0
@eqs/http@0.24.0
@eqs/errors@0.24.0
@eqs/config@0.24.0
@eqs/cli@0.24.0
@eqs/auth@0.24.0
@eqs/tooling@0.23.0
@eqs/testing@0.23.0
@eqs/telemetry@0.23.0
@eqs/platform-next@0.23.0
@eqs/platform-nest@0.23.0
@eqs/http@0.23.0
@eqs/errors@0.23.0
@eqs/config@0.23.0
@eqs/cli@0.23.0
@eqs/auth@0.23.0
@eqs/tooling@0.22.0
@eqs/testing@0.22.0
@eqs/telemetry@0.22.0
@eqs/platform-next@0.22.0
@eqs/platform-nest@0.22.0
@eqs/http@0.22.0
@eqs/errors@0.22.0
@eqs/config@0.22.0
@eqs/cli@0.22.0
@eqs/auth@0.22.0
@eqs/tooling@0.21.0
@eqs/testing@0.21.0
@eqs/telemetry@0.21.0
@eqs/platform-next@0.21.0
@eqs/platform-nest@0.21.0
@eqs/http@0.21.0
@eqs/errors@0.21.0
@eqs/config@0.21.0
@eqs/cli@0.21.0
@eqs/auth@0.21.0
@eqs/tooling@0.20.0
@eqs/testing@0.20.0
@eqs/telemetry@0.20.0
@eqs/platform-next@0.20.0
@eqs/platform-nest@0.20.0
@eqs/http@0.20.0
@eqs/errors@0.20.0
@eqs/config@0.20.0
@eqs/cli@0.20.0
@eqs/auth@0.20.0
@eqs/tooling@0.19.0
@eqs/testing@0.19.0
@eqs/telemetry@0.19.0
@eqs/platform-next@0.19.0
@eqs/platform-nest@0.19.0
@eqs/http@0.19.0
@eqs/errors@0.19.0
@eqs/config@0.19.0
@eqs/cli@0.19.0
@eqs/auth@0.19.0
@eqs/tooling@0.17.1
@eqs/testing@0.17.1
@eqs/telemetry@0.17.1
@eqs/platform-next@0.17.1
@eqs/platform-nest@0.17.1
@eqs/http@0.17.1
@eqs/errors@0.17.1
@eqs/config@0.17.1
@eqs/cli@0.17.1
@eqs/auth@0.17.1
@eqs/tooling@0.17.0
@eqs/testing@0.17.0
@eqs/telemetry@0.17.0
@eqs/platform-next@0.17.0
@eqs/platform-nest@0.17.0
@eqs/http@0.17.0
@eqs/errors@0.17.0
@eqs/config@0.17.0
@eqs/cli@0.17.0
@eqs/auth@0.17.0
@eqs/tooling@0.16.0
@eqs/testing@0.16.0
@eqs/telemetry@0.16.0
@eqs/platform-next@0.16.0
@eqs/platform-nest@0.16.0
@eqs/http@0.16.0
@eqs/errors@0.16.0
@eqs/config@0.16.0
@eqs/cli@0.16.0
@eqs/auth@0.16.0
@eqs/tooling@0.15.0
@eqs/testing@0.15.0
@eqs/telemetry@0.15.0
@eqs/platform-next@0.15.0
@eqs/platform-nest@0.15.0
@eqs/http@0.15.0
@eqs/errors@0.15.0
@eqs/config@0.15.0
@eqs/cli@0.15.0
@eqs/auth@0.15.0
@eqs/tooling@0.14.0
@eqs/testing@0.14.0
@eqs/telemetry@0.14.0
@eqs/platform-next@0.14.0
@eqs/platform-nest@0.14.0
@eqs/http@0.14.0
@eqs/errors@0.14.0
@eqs/config@0.14.0
@eqs/cli@0.14.0
@eqs/auth@0.14.0
@eqs/tooling@0.13.0
@eqs/testing@0.13.0
@eqs/telemetry@0.13.0
@eqs/platform-next@0.13.0
@eqs/platform-nest@0.13.0
@eqs/http@0.13.0
@eqs/errors@0.13.0
@eqs/config@0.13.0
@eqs/cli@0.13.0
@eqs/auth@0.13.0
@eqs/tooling@0.12.2
@eqs/testing@0.12.2
@eqs/telemetry@0.12.2
@eqs/platform-next@0.12.2
@eqs/platform-nest@0.12.2
@eqs/http@0.12.2
@eqs/errors@0.12.2
@eqs/config@0.12.2
@eqs/cli@0.12.2
@eqs/auth@0.12.2
@eqs/tooling@0.12.1
@eqs/testing@0.12.1
@eqs/telemetry@0.12.1
@eqs/platform-next@0.12.1
@eqs/platform-nest@0.12.1
@eqs/http@0.12.1
@eqs/errors@0.12.1
@eqs/config@0.12.1
@eqs/cli@0.12.1
@eqs/auth@0.12.1
@eqs/tooling@0.12.0
@eqs/testing@0.12.0
@eqs/telemetry@0.12.0
@eqs/platform-next@0.12.0
@eqs/platform-nest@0.12.0
@eqs/http@0.12.0
@eqs/errors@0.12.0
@eqs/config@0.12.0
@eqs/cli@0.12.0
@eqs/auth@0.12.0
@eqs/tooling@0.11.2
@eqs/testing@0.11.2
@eqs/telemetry@0.11.2
@eqs/platform-next@0.11.2
@eqs/platform-nest@0.11.2
@eqs/http@0.11.2
@eqs/errors@0.11.2
@eqs/config@0.11.2
@eqs/cli@0.11.2
@eqs/auth@0.11.2
@eqs/tooling@0.11.1
@eqs/testing@0.11.1
@eqs/telemetry@0.11.1
@eqs/platform-next@0.11.1
@eqs/platform-nest@0.11.1
@eqs/http@0.11.1
@eqs/errors@0.11.1
@eqs/config@0.11.1
@eqs/cli@0.11.1
@eqs/auth@0.11.1
@eqs/tooling@0.11.0
@eqs/testing@0.11.0
@eqs/telemetry@0.11.0
@eqs/platform-next@0.11.0
@eqs/platform-nest@0.11.0
@eqs/http@0.11.0
@eqs/errors@0.11.0
@eqs/config@0.11.0
@eqs/cli@0.11.0
@eqs/auth@0.11.0
@eqs/tooling@0.10.1
@eqs/testing@0.10.1
@eqs/telemetry@0.10.1
@eqs/platform-next@0.10.1
@eqs/platform-nest@0.10.1
@eqs/http@0.10.1
@eqs/errors@0.10.1
@eqs/config@0.10.1
@eqs/cli@0.10.1
@eqs/auth@0.10.1
@eqs/tooling@0.10.0
@eqs/testing@0.10.0
@eqs/telemetry@0.10.0
@eqs/platform-next@0.10.0
@eqs/platform-nest@0.10.0
@eqs/http@0.10.0
@eqs/errors@0.10.0
@eqs/config@0.10.0
@eqs/cli@0.10.0
@eqs/auth@0.10.0
@eqs/tooling@0.9.0
@eqs/testing@0.9.0
@eqs/telemetry@0.9.0
@eqs/platform-next@0.9.0
@eqs/platform-nest@0.9.0
@eqs/http@0.9.0
@eqs/errors@0.9.0
@eqs/config@0.9.0
@eqs/cli@0.9.0
@eqs/auth@0.9.0
@eqs/tooling@0.8.1
@eqs/testing@0.8.1
@eqs/telemetry@0.8.1
@eqs/platform-next@0.8.1
@eqs/platform-nest@0.8.1
@eqs/http@0.8.1
@eqs/errors@0.8.1
@eqs/config@0.8.1
@eqs/cli@0.8.1
@eqs/auth@0.8.1
@eqs/tooling@0.8.0
@eqs/testing@0.8.0
@eqs/telemetry@0.8.0
@eqs/platform-next@0.8.0
@eqs/platform-nest@0.8.0
@eqs/http@0.8.0
@eqs/errors@0.8.0
@eqs/config@0.8.0
@eqs/cli@0.8.0
@eqs/auth@0.8.0
@eqs/tooling@0.7.0
@eqs/testing@0.7.0
@eqs/telemetry@0.7.0
@eqs/platform-next@0.7.0
@eqs/platform-nest@0.7.0
@eqs/http@0.7.0
@eqs/errors@0.7.0
@eqs/config@0.7.0
@eqs/cli@0.7.0
@eqs/auth@0.7.0
@eqs/tooling@0.6.0
@eqs/testing@0.6.0
@eqs/telemetry@0.6.0
@eqs/platform-next@0.6.0
@eqs/platform-nest@0.6.0
@eqs/http@0.6.0
@eqs/errors@0.6.0
@eqs/config@0.6.0
@eqs/cli@0.6.0
@eqs/auth@0.6.0
@eqs/tooling@0.5.1
@eqs/testing@0.5.1
@eqs/telemetry@0.5.1
@eqs/platform-next@0.5.1
@eqs/platform-nest@0.5.1
@eqs/http@0.5.1
@eqs/errors@0.5.1
@eqs/config@0.5.1
@eqs/cli@0.5.1
@eqs/auth@0.5.1
@eqs/tooling@0.5.0
@eqs/testing@0.5.0
@eqs/telemetry@0.5.0
@eqs/platform-next@0.5.0
@eqs/platform-nest@0.5.0
@eqs/http@0.5.0
@eqs/errors@0.5.0
@eqs/config@0.5.0
@eqs/cli@0.5.0
@eqs/auth@0.5.0
@eqs/tooling@0.4.0
@eqs/testing@0.4.0
@eqs/telemetry@0.4.0
@eqs/platform-next@0.4.0
@eqs/platform-nest@0.4.0
@eqs/http@0.4.0
@eqs/errors@0.4.0
@eqs/config@0.4.0
@eqs/cli@0.4.0
@eqs/auth@0.4.0
@eqs/tooling@0.2.0
@eqs/telemetry@0.2.0
@eqs/errors@0.2.0
@eqs/config@0.2.0
@eqs/tooling@0.1.0
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set.
Reference
Infrastructure/eqs-platform#36
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Constaté le 2026-09-09 sur le premier satellite réel monté depuis
eqs-satellite-template, en activant l'authentification par clé API pour qu'un agent non humain puisse écrire dans l'API.@eqs/*0.7.0.Le socle a tout ce qu'il faut :
DepotCles,genererCle,hacherCle,verifierCle,scopesBornes,principalDepuisCle, et jusqu'à la tableApiKeylivrée dansauth.prisma. Mais l'assembler demande d'aller liredist/, sur trois points distincts. Ça a marché, c'est en production chez nous, et les trois se corrigent probablement en une passe de documentation plus un champ de type.1.
prefixeClen'est pas une option, et rien ne le ditOptionsAuthPlateforme(@eqs/platform-nest,index.d.tsl. 21-37) exposedepotCles, mais aucun champ pour le préfixe. En réalitéassemblage.js(l. 149) litAPI_KEY_PREFIXdirectement dans l'environnement validé, sans jamais passer par les options que l'application fournit.Le résultat fonctionne —
API_KEY_PREFIXest dansbaseSchema— mais le chemin pour y arriver est une lecture dedist/. Ni le README de@eqs/auth, ni le type ne disent qu'il ne faut pas le passer soi-même. Une surface qui exposedepotClesmais tait son compagnon naturel invite à chercher là où il n'y a rien.Proposition : soit accepter
prefixeCledans les options en repliant surAPI_KEY_PREFIX, soit une ligne de documentation sur le type disant que le préfixe vient de l'environnement et pas des options. La seconde suffit.2. Aucun outil ne crée une clé
Le README documente
genererCle, mais ne montre nulle part l'insertion en base, et il n'existe pas de commande équivalente àeqs auth roles --init.Concrètement, créer une clé pour un agent demande d'écrire à la main l'insertion de la ligne
ApiKeyavec le bon hash — et de ne pas se tromper, puisque la clé brute ne se revoit jamais. C'est un geste qu'on refera à chaque agent, à chaque satellite.Proposition : une commande
eqs auth cle --creer --proprietaire <id> --scopes <…>, symétrique d'eqs auth roles --init, qui affiche la clé brute une seule fois et n'écrit que le hash. À défaut, un exemple complet dans le README de@eqs/auth— l'insertion en base, pas seulementgenererCle.3. Le non-rebornage des scopes n'est écrit nulle part — et c'est le point de sécurité
scopesBornes(demandes, permissionsProprietaire)existe et fait ce qu'il faut : une clé ne peut pas demander plus que ce que son propriétaire possède. Mais elle ne s'applique qu'à la création de la clé. À chaque requête,principalDepuisCleconstruitpermissions: new Set(cle.scopes)— les scopes figés dans la ligne, sans re-borner par les permissions actuelles du propriétaire.C'est un choix défendable (moins de requêtes, comportement prévisible), mais il a une conséquence qu'il faut connaître : si les droits d'une personne sont réduits, ses clés existantes gardent les leurs. Retirer un rôle à quelqu'un ne referme pas les clés qu'il a déjà émises ; il faut les révoquer explicitement.
Ce comportement n'est documenté que dans
dist/index.js. Sur une question d'autorisation, ça ne devrait pas être le cas — c'est le genre de détail qu'on découvre après un incident.Proposition : l'écrire dans le README de
@eqs/auth, à côté descopesBornes, avec la conséquence opérationnelle (« révoquer les clés en retirant un rôle »). Et si le rebornage à chaque requête est jugé souhaitable, c'est une décision de sécurité qui mérite son propre ticket.Ce que ça coûte
Rien de bloquant : tout se contourne en lisant
dist/. Le coût est en temps — nous avons passé un moment à assembler ce qui aurait dû être une recette — et en risque sur le point 3, qui touche à l'autorisation et se découvre au mauvais moment.Ce qui est en place chez nous
apps/api/src/auth/depot-cles.prisma.tsimplémenteDepotClessurApiKey, câblé dansplateforme.ts, avec un test d'intégration monté sans le simulacre d'authentification qui prouve qu'une vraie clé authentifie, qu'une clé révoquée ou expirée est refusée, et qu'un porteur de clé reçoit bien 403 sur une opération réservée aux personnes. Je peux coller le dépôt ici s'il peut servir de base à une implémentation de référence — il fait une vingtaine de lignes.Quatrième point, trouvé en implémentant :
scopesBornesest tout-ou-rien, et son nom dit le contraireEn écrivant l'outil de création de clé, nous avons découvert que
scopesBorneslève dès qu'un seul scope demandé dépasse les permissions du propriétaire — elle ne rend jamais le sous-ensemble autorisé.Le nom dit pourtant l'inverse. « Borner » un ensemble, c'est le réduire à ce qui est permis ; on s'attend à recevoir l'intersection. Nous avons écrit le code en supposant cette sémantique, et c'est l'exception qui nous a détrompés. Le comportement n'est documenté ni dans le README de
@eqs/auth, ni sur la signature — il se lit dansdist/.Pourquoi ça compte à l'usage
Le geste courant est : « donne à cet agent tout ce que je peux lui donner parmi ceci ». Avec un tout-ou-rien, la personne doit deviner d'avance l'intersection exacte, sous peine de se faire refuser l'appel entier — et elle n'a aucun moyen simple de la calculer, puisque c'est précisément le travail de la fonction.
Notre outil a donc dû reproduire le test de la fonction avec les primitives publiques (
estPermissionValide,permet) pour décider quoi lui soumettre, avant de la laisser rendre le résultat inséré. Autrement dit : on réimplémente la moitié descopesBornespour pouvoir l'appeler sans qu'elle lève. C'est le signe assez net qu'il manque quelque chose dans la surface publique.Ce que ça donne côté personne
Notre script affiche désormais :
C'est ce que nous voulions : quelqu'un qui croit accorder un droit apprend immédiatement que non. Mais obtenir ces trois lignes a demandé de contourner la fonction censée les produire.
Ce que je proposerais
Une des trois, par ordre de préférence :
scopesBornesrend l'intersection et ne lève plus. C'est ce que son nom promet, et ça rend l'appel utilisable directement. Ceux qui veulent un refus strict comparent la longueur avant/après.scopesAutorises(demandes, permissions)par exemple), pour qu'on n'ait pas à la réécrire.C'est le quatrième point de ce ticket ; les trois autres sont dans le corps ci-dessus. Ils se recoupent tous : la surface d'authentification par clé fonctionne bien, mais s'assemble en lisant
dist/.Les quatre points sont vrais, vérifiés dans le code, et corrigés.
1.
prefixeCle. Confirmé : le champ n'existe pas dansOptionsAuthPlateforme, etassemblage.tslitenv.API_KEY_PREFIX. J'ai retenu votre seconde proposition — la documentation — et pas la première, pour une raison qui mérite d'être écrite : deux sources se contrediraient en silence, et c'est celle qui n'a pas servi à créer les clés qui ferait échouer l'authentification. Le type le dit maintenant, avec le pourquoi.2. Aucun outil ne crée une clé. Vrai.
preparerClerend d'un coup la valeur à montrer une fois et la ligne à écrire, portées bornées comprises :Pas la commande CLI que vous proposiez, et je préfère l'argumenter :
eqsn'a ni client Prisma ni schéma de votre base, etDepotClesest en lecture seule par conception — l'écriture appartient à l'application. Une commande qui insérerait devrait deviner votre modèle. Le README porte la recette complète, insertion comprise.3. Le non-rebornage. Confirmé :
principalDepuisCleconstruitpermissions: new Set(cle.scopes), sans rien reborner. C'est écrit maintenant sur la signature descopesBorneset dans le README, avec la conséquence en clair :Vous avez raison que ça n'avait rien à faire dans
dist/. J'ajoute une chose que la relecture a rendue visible :auth.cle-revoqueevérifie qu'une clé révoquée est refusée ; aucun contrôle ne vérifie qu'on a pensé à la révoquer. C'est écrit à côté, pour que personne ne prenne le premier pour le second.Je n'ai pas changé le comportement — c'est bien une décision de sécurité, et elle mérite son propre ticket si vous voulez la rouvrir.
4.
scopesBornestout-ou-rien. Vrai, et votre analyse du coût est exacte : pour lui soumettre quelque chose qu'elle accepte, il fallait recalculer l'intersection soi-même, c'est-à-dire réécrire la moitié de la fonction.J'ai pris votre option 2 plutôt que la 1.
scopesAutorises(demandes, permissions)rend l'intersection sans lever.scopesBornesgarde son refus strict, et c'est délibéré pour la création d'une clé : accorder silencieusement moins que demandé produit une clé qui échouera plus tard, sur une route, sans que personne se souvienne pourquoi. Vos trois lignes d'affichage — demandé / accordé / retiré par le bornage — sont exactement le bon geste, et le README le recommande.Renommer ou changer le comportement de
scopesBornesaurait cassé silencieusement les appels existants, qui comptent sur le refus.pnpm checkvert, 49 tests dans@eqs/auth(deux de plus, dont un qui vérifie que la clé en clair n'apparaît nulle part dans la ligne à écrire).Votre offre de coller votre
DepotClesPrisma nous intéresse — une implémentation de référence dans le gabarit éviterait à chacun de la réécrire. Postez-la, je la reprendrai.Branche
fix/cles-api. Disponible à la prochaine version de@eqs/*.Le
DepotClesPrisma, comme demandéVoici l'implémentation en place chez nous. Elle tourne, elle est couverte par un test d'intégration qui monte l'application sans le simulacre d'authentification.
Le câblage, dans
optionsApp():Deux points qui méritent peut-être de voyager avec le code
1.
marquerUtiliseedoit avaler ses échecs, et ce n'est pas évident. C'est le seul endroit de notre dépôt où une écriture qui rate ne remonte pas — partout ailleurs on est fidèle à l'erreur réelle. La raison est que le socle l'appelle après avoir validé la clé et avant de rendre le principal : sans cetry/catch, une base momentanément indisponible fait échouer l'authentification d'une clé parfaitement valide. C'est notre relecture de sécurité qui l'a attrapé, pas nous.Si vous reprenez cette implémentation dans le gabarit, la garder avec son commentaire évitera qu'un lecteur bien intentionné « corrige » le
catchen le faisant remonter.Cela dit, ça pose une question qui vous appartient : la méthode étant optionnelle dans l'interface, faudrait-il que
principalDepuisCleprotège lui-même son appel ? Aujourd'hui, chaque implémentation doit y penser — et la nôtre n'y avait pas pensé du premier coup.2.
import type { PrismaClient }casse l'injection Nest. Nous nous sommes fait avoir ailleurs dans le même dépôt : unimport typeefface la métadonnée de constructeur que Nest lit à l'exécution, et le service ne démarre plus. Le message de Nest est explicite une fois qu'on l'a vu, mais on le voit tard. Ici la classe n'est pas@Injectable()— elle est instanciée à la main dansoptionsApp()— donc le piège ne joue pas. Si vous en faites un provider Nest dans le gabarit, l'import doit être de valeur.Ce qu'on fera de notre côté
preparerCleetscopesAutorisesremplaceront directement la partie la plus laide de notre script de création de clé — celle où l'on recalcule l'intersection avecestPermissionValideetpermetpour pouvoir soumettre àscopesBornesquelque chose qu'elle accepte. On reprendra ça dès que la version sera publiée, et je viendrai dire ici si la reprise se passe bien : c'est le meilleur test de la correction.Merci pour les réponses argumentées, en particulier sur
prefixeCle— l'argument des deux sources qui se contredisent en silence, avec celle qui n'a pas servi à créer les clés qui fait échouer l'authentification, est plus convaincant que ma proposition.