Protéger une procédure
Authentification, administrateur, accès à un espace : des niveaux distincts. protectedProcedure et isAdmin dans la chaîne plutôt qu’une garde recopiée dans le corps, le passage des rôles et permissions aux groupes, le piège des lectures publiques (les brouillons), et les règles « pas soi-même » garanties côté serveur.
Précédemment. Une procédure se compose dans un ordre précis : accès, entrée, intention, contexte, délégation. Le squelette tient ; reste à durcir chaque articulation.
Mouvement 3 — Durcir chaque étape#
Le squelette tient. Mais un squelette n’est pas encore un backend robuste. Chaque couche que nous avons posée doit maintenant être durcie — pas pour faire plus, mais pour faire mieux : plus précisément, plus lisiblement, plus fidèlement.
Voici les quatre étapes que nous allons traverser :
- Ce chapitre — Nous descendons dans la sécurité : distinguer authentification et autorisation, déclarer la garde d’administration dans la chaîne de la procédure, et comprendre pourquoi certaines vérifications ne sont pas de la sécurité mais des règles métier.
- Chapitre 7 — Nous plaçons Zod comme frontière du backend : valider une fois, au bon endroit, avec un contrat qui peut rester local à la procédure ou monter dans
packages/contracts. - Chapitre 8 — Nous apprenons à lever des
TRPCErroravec les bons codes, à laisser les erreurs remonter sans les transformer en résultats, et à décider de ce qu’un navigateur voit d’une erreur imprévue. - Chapitre 9 — Nous maîtrisons la sortie : filtrer avec
select, stabiliser avecsatisfies, et décider où vit la projection.
À la fin de ce mouvement, chaque articulation du flux sera explicite, contrôlée et documentée par le code lui-même.
Bien construite ne veut pas dire bien protégée#
Nous savons maintenant fabriquer une procédure.
Mais quelque chose résiste encore.
Une procédure peut être parfaitement construite — contexte injecté, validation en place, délégation propre — et rester fondamentalement poreuse.
Parce qu’une procédure bien construite n’est pas nécessairement une procédure bien protégée.
🧠 Là où une confusion s’installe facilement#
Si nous ne faisons pas attention, plusieurs notions commencent à se mélanger.
L’utilisateur est-il connecté ?
Est-il administrateur ?
A-t-il accès à cet espace ?
Peut-il exécuter cette intention précise ?
Tout finit par s’appeler “auth”.
Et à ce moment-là, une chose imperceptible se produit.
👉 Le système commence à perdre sa lisibilité.
Pas dans le code exécuté — dans le code lu. Parce que quand un mot unique désigne trois concepts distincts, le lecteur doit recharger le contexte à chaque ligne pour deviner duquel on parle.
🔥 Reposer les niveaux clairement#
Nous avons en réalité trois couches distinctes, qui ne répondent pas à la même question :
Trois niveaux à ne pas confondre
Niveau | Question posée | Ce que cela change |
|---|---|---|
Authentification | Qui agit ? | Le contexte est garanti non-nul |
Autorisation | A-t-il le droit ? | L’action est filtrée |
Intention | Que veut-il faire ? | La procédure s’exécute |
Showing entries1to3entries
Ces trois niveaux sont liés — mais ils ne se substituent pas l’un à l’autre.
Dans le code, ces niveaux deviennent trois façons de déclarer une procédure. Chacune hérite de la précédente :
Procédure | Garde | Exemples |
|---|---|---|
publicProcedure | aucune | contenus publics, connexion, mot de passe oublié |
protectedProcedure | session requise (UNAUTHORIZED sinon) | tout ce qui concerne le compte connecté : son profil, ses espaces, ses dépôts |
protectedProcedure.use(isAdmin) | administrateur requis (FORBIDDEN sinon) | la gestion du club : membres, groupes, contenus, réglages |
Showing entries1to3entries
La garde est déclarée sur la procédure : une procédure d’administration ne peut pas oublier de vérifier les droits, puisque la vérification fait partie de sa définition.
👉 Nous n’exécutons jamais une intention sans savoir qui agit et avec quel droit.
1) protectedProcedure : un premier seuil, pas une protection complète#
À ce stade, il serait tentant de croire que protectedProcedure suffit.
Lisons ce qu’il fait réellement dans core.ts :
const isAuthed = t.middleware(({ ctx, next }) => { const sessionClient = ctx.sessionClient; if (!sessionClient?.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Connexion requise." }); } return next({ ctx: { ...ctx, sessionClient: sessionClient as AuthenticatedSessionClient, } as AuthenticatedTRPCContext, });});export const protectedProcedure = t.procedure.use(isAuthed);Ce middleware fait deux choses précises.
Il vérifie que sessionClient.user existe.
Et s’il existe, il requalifie le contexte : ctx.sessionClient devient AuthenticatedSessionClient, un type plus fort dans lequel user n’est plus nullable.
👉 Ce que cela change est immédiat.
Dans toute procédure construite sur protectedProcedure, nous n’avons plus à écrire ceci :
Vérification redondante (anti-pattern)
if (!ctx.sessionClient?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' })}👉 TypeScript le sait déjà.
👉 Le contexte le garantit.
Mais nous ne savons toujours pas si cet utilisateur a le droit d’agir.
protectedProcedure répond à "qui agit ?"
Il ne répond pas encore à "a-t-il le droit d’agir ici ?"
2) L’erreur naturelle : disperser la sécurité dans les procédures#
Sans structure claire, voilà ce qui finit par s’écrire. Ce n’est pas un exemple inventé : c’est le code qu’avaient, jusqu’à récemment, trois routeurs du projet (les réglages du site, les partenaires, la visibilité des pages).
Sécurité dispersée — l’ancien code des réglages du site
/** Réserve l'action aux administrateurs (appartenance au groupe Administrateurs). */async function assertAdmin(ctx: { prisma: PrismaClient; user: { id: string } }): Promise<void> { if (!(await isAdminByGroup(ctx.prisma, ctx.user.id))) { throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs." }); }}saveContact: protectedProcedure.input(saveSiteContactSchema).mutation(async ({ ctx, input }) => { await assertAdmin(ctx); // …}),Ce code fonctionne.
Mais en le lisant, quelque chose dérange.
La vérification d’autorisation vit dans la logique d’exécution. On ne peut pas lire la chaîne de la procédure (protectedProcedure.input(…)) sans croire qu’elle est ouverte à tous les membres : il faut lire son corps pour découvrir qu’elle est réservée. Et si quinze procédures font la même chose, nous avons quinze endroits où la règle peut dériver silencieusement.
Ici, elle avait déjà dérivé, de plusieurs façons :
- une requête de plus par appel :
isAdminByGroupinterroge la base, alors que la session sait déjà si le membre est administrateur ; - un autre message (« Réservé aux administrateurs. ») que celui du reste de l’application ;
- un ordre inversé : l’entrée était validée avant la vérification des droits. Un membre qui n’avait rien à faire là recevait, selon ce qu’il envoyait, une erreur de validation détaillée plutôt qu’un refus ;
- un outil à tromper : la carte des procédures de cette documentation, qui lit l’accès de chaque procédure dans sa chaîne, avait dû apprendre à reconnaître cette ligne dans le corps.
Et surtout, le risque de fond : le jour où quelqu’un ajoute une procédure à l’un de ces routeurs en oubliant la première ligne, elle est ouverte à tous les membres, et rien ne le signale.
💥 La sécurité est devenue un bruit diffus.
Et le bruit diffus, en sécurité, est plus dangereux que l’absence totale. Parce qu’il donne l’illusion d’être protégé.
3) Le middleware : déplacer la sécurité au bon endroit#
Dans ce projet, la garde d’administration vit dans middleware.ts.
import { TRPCError } from "@trpc/server";import { t } from "@backend/trpc/core";export const isAdmin = t.middleware(({ ctx, next }) => { const user = ctx.sessionClient?.user; if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Connexion requise.", }); } if (!user.isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Action réservée aux administrateurs.", }); } return next();});Lecture guidée#
Lecture guidée de la garde d’administration
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
import { TRPCError } from "@trpc/server";import { t } from "@backend/trpc/core";export const isAdmin = t.middleware(({ ctx, next }) => { const user = ctx.sessionClient?.user; if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Connexion requise.", }); } if (!user.isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "Action réservée aux administrateurs.", }); } return next();});/** * Plusieurs procédures du stockage reçoivent encore `appRoot` dans leur entrée (héritage). La racine * de l'application n'est pas un choix du client : elle doit être celle du serveur. Sinon, les gardes * qui protègent la racine, la corbeille ou un préfixe (`${appRoot}/bin/.trash/…`) seraient calculées * sur une racine inventée. Sans effet pour une entrée qui ne porte pas `appRoot`. */export const sameAppRoot = t.middleware(async ({ ctx, getRawInput, next }) => { const raw = await getRawInput(); if (raw && typeof raw === "object" && "appRoot" in raw && raw.appRoot !== ctx.appRoot) { throw new TRPCError({ code: "BAD_REQUEST", message: "Racine d'application inconnue." }); } return next();});🔭 Pour aller plus loin — des rôles et permissions aux groupes#
La première version de ce tutoriel montrait deux gardes : requirePermission("manage_users"), qui vérifiait une permission précise, et isAdmin, qui vérifiait le nom d’un rôle. Le schéma avait alors des modèles Role, Permission et une table RolePermissions. C’est le modèle RBAC (role-based access control) classique : un rôle regroupe des permissions, un membre a un rôle.
Ce modèle a disparu, et la raison vaut d’être comprise. Dans un club, les permissions configurables n’étaient configurées par personne : il y avait des administrateurs, et des membres. La finesse dont le club avait besoin n’était pas « qui peut appeler quelle procédure », mais « qui peut lire ou écrire dans quel espace partagé ». Elle s’est donc déplacée vers les groupes : un membre appartient à des groupes, avec un niveau (lecteur ou éditeur) sur l’espace de chacun, et être administrateur, c’est appartenir au groupe marqué isAdminGroup. La partie « Données » raconte cette évolution du schéma.
Il en reste, côté procédures, une garde unique et lisible (isAdmin), et des gardes d’espace qui travaillent sur l’entrée. Un backend n’a pas à offrir plus de finesse que son domaine n’en demande : chaque permission configurable est une règle de plus à tester, à documenter, et à ne pas oublier de configurer.
4) Ce que cela change dans le router#
Voyons maintenant comment cette garde s’applique dans des routeurs réels. Elle s’écrit de deux façons, équivalentes. Chaînée sur chaque procédure, comme dans le routeur des membres :
export const userRouter = router({ getAll: protectedProcedure .use(isAdmin) .query(async ({ ctx }) => { // Jamais l'empreinte du mot de passe vers le navigateur. const users = await ctx.prisma.user.findMany({ orderBy: { id: "asc" },Ou nommée une fois, en tête du fichier, quand tout le routeur est réservé, comme celui des groupes :
const adminProcedure = protectedProcedure.use(isAdmin);/** Nom affichable d'un membre (prénom/nom, sinon email). */function memberName(u: { firstName: string | null; lastName: string | null; email: string;}): string { return [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email;}/** * Groupes de membres (ex. « Bureau »), gérés dans le panneau de contrôle. * Servent de listes de diffusion : l'appartenance est dynamique (utilisée à la * volée pour la visibilité des documents, cf. router memberDocument). */export const memberGroupRouter = router({ /** Groupes + nombre de membres. */ list: adminProcedure.query(async ({ ctx }) => {Ce que nous lisons ici est très instructif.
Dans les deux cas, la garde est dans la chaîne de la procédure. Il suffit de lire sa première ligne pour savoir qui peut l’appeler : protectedProcedure.use(isAdmin) ou adminProcedure, c’est réservé ; protectedProcedure seul, c’est ouvert aux membres connectés ; publicProcedure, à tout le monde.
protectedProcedure → qui agit (une session)
.use(isAdmin) → avec quel droit (le groupe Administrateurs)
La première ligne d’une procédure dit qui peut l’appeler. Le reste du code n’a plus à le deviner.
5) Le piège des lectures publiques : l’histoire des brouillons#
Un même routeur mélange souvent les niveaux. Celui des disciplines expose ses lectures publiquement (le site public en a besoin) et réserve ses écritures aux administrateurs. C’est là que le projet a appris qu’une publicProcedure demande une attention particulière.
Une discipline n’est publique qu’à partir de sa date de publication : sans date, c’est un brouillon ; avec une date future, une publication programmée. Les administrateurs préparent ainsi une nouvelle discipline à leur rythme, et la page publique /disciplines/[slug] répond 404 tant qu’elle n’est pas publiée.
Pendant longtemps, pourtant, les brouillons n’étaient pas secrets. Les lectures discipline.getAll et discipline.getById étaient des publicProcedure sans filtre : le tableau de bord en avait besoin pour lister et éditer toutes les disciplines, et seuls les écrans d’administration les appelaient. La page publique, elle, filtrait correctement. Tout semblait juste.
Mais une procédure publique n’est pas publique « pour les écrans qui l’utilisent » : elle l’est pour quiconque connaît son nom. N’importe quel visiteur pouvait ouvrir /api/trpc/discipline.getAll dans son navigateur et lire la liste complète, brouillons compris, avec leur nom, leur description et leur date de publication prévue. Aucune page n’affichait ces informations ; l’API les donnait.
La règle de visibilité vit désormais dans les procédures, en un seul endroit :
});/* -------------------------------------------------------------------------- *//* ROUTER *//* -------------------------------------------------------------------------- *//** * Disciplines visibles par l'appelant : toutes pour un administrateur, sinon les seules PUBLIÉES * (date de publication renseignée et passée). Une discipline sans date est un brouillon, une date * future une publication programmée : ni l'un ni l'autre ne doit sortir de l'API pour le public. * Toutes les lectures du routeur passent par ce filtre — y compris celles qui servent surtout le * tableau de bord, puisqu'une procédure publique peut être appelée par n'importe qui. */function visibleTo(ctx: { sessionClient: { user?: { isAdmin?: boolean } | null } | null }): Prisma.DisciplineWhereInput { if (ctx.sessionClient?.user?.isAdmin) return {}; return { publicationDate: { not: null, lte: new Date() } };}export const disciplineRouter = router({ /** * Liste toutes les disciplines d'une catégorie donnée. * Usage typique : formulaire d'upload (sélecteur de discipline après choix * de catégorie). */ getAllByCategory: publicProcedure .input(z.object({ categoryId: z.number().int().positive() })) .query(async ({ ctx, input }) => { return ctx.prisma.discipline.findMany({ where: { categoryId: input.categoryId, ...visibleTo(ctx) }, orderBy: { name: "asc" }, }); }), /** * Liste des disciplines toutes catégories confondues : TOUTES pour un * administrateur (vues d'administration, brouillons compris), les seules * PUBLIÉES pour les autres (sélecteurs des formulaires membres). */Toutes les lectures du routeur (getAll, getAllByCategory, getById, getBySlug) appliquent ce filtre. Un administrateur voit tout ; un membre ou un visiteur ne voit que ce qui est publié, et un brouillon demandé par son identifiant répond « introuvable », exactement comme la page publique.
Une seule lecture fait exception : getAllForMenu, qui alimente le menu « Nos disciplines », ne renvoie que les disciplines publiées, même à un administrateur. Elle suivait d’abord la règle commune, et un administrateur voyait ses brouillons dans le menu ; mais la page d’un brouillon répond 404 à tout le monde, et ces entrées menaient à des liens morts. Le menu est la navigation publique : il montre ce que voit le public.
Les stages et les événements résolvent le même problème autrement : une lecture publique filtrée (seminar.getAll) et une lecture séparée réservée aux administrateurs (seminar.getAllAdmin). Pour les disciplines, le filtre selon l’appelant a été préféré parce que la même liste sert aussi aux formulaires des membres (le dépôt de fichiers propose les disciplines) : ils reçoivent désormais les seules disciplines publiées, sans qu’aucun écran ait eu à changer.
Une publicProcedure est une URL publique. La question à se poser n’est pas « quelle page l’utilise ? » mais « que verrait un inconnu qui l’appelle directement ? ». Ce qui ne doit pas sortir est filtré dans la procédure ; masquer une information dans l’interface ne la protège pas.
6) Une règle métier qui ne ressemble pas à de la sécurité#
Il existe un cas particulièrement révélateur dans le routeur des membres.
user.delete est réservée aux administrateurs. Mais elle contient aussi ceci :
delete: protectedProcedure .use(isAdmin) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Règle métier, pas question de droit : l'administrateur a le droit de supprimer un compte, // mais pas le sien (il perdrait l'accès, et le club pourrait se retrouver sans administrateur). // L'interface désactive déjà le bouton ; c'est ici que la règle est garantie. if (input.id === ctx.sessionClient.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas supprimer votre propre compte." }); } const avatar = (await ctx.prisma.user.findUnique({ where: { id: input.id }, select: { avatar: true } }))?.avatar; const deleted = await ctx.prisma.$transaction(async (tx) => { // Formulaires de droit à l'image signés : gardés 5 ans comme preuve (purge nocturne). await tx.imageConsentForm.updateMany({ where: { userId: input.id }, data: { detachedAt: new Date() } }); // `pageId` n'a pas de clé étrangère : sans ce nettoyage, les liens de // la bio bloqueraient pour toujours la suppression de ses images. await syncPageMediaReferences(tx, { pageType: "INSTRUCTOR_BIO", pageId: input.id, newContent: null, }); // Seul l'identifiant repart vers le navigateur : la ligne complète contient l'empreinte // du mot de passe. return tx.user.delete({ where: { id: input.id }, select: { id: true }, }); }); // Son portrait part avec le compte : un avatar n'est rattaché qu'à lui (aucune ligne // MediaAsset ne le retient), il resterait sinon dans le stockage sans que rien ne l'atteigne. // Après la transaction : un fichier ne se restaure pas si la base annule. if (avatar) await removeAvatarFile(avatar); return deleted; }),Un administrateur a le droit de supprimer le compte d’un membre.
Mais il ne peut pas supprimer le sien.
👉 Ce n’est pas une question de droit : il est administrateur.
👉 Ce n’est pas une question d’authentification.
C’est une règle métier — une contrainte propre au domaine. Un administrateur qui supprimerait son compte perdrait l’accès, et si c’était le dernier, le club n’aurait plus personne pour gérer le site : il faudrait intervenir directement dans la base.
Et elle vit ici, dans la procédure, parce que c’est exactement sa place. Le middleware ne peut pas la porter : il ne connaît pas l’entrée. C’est seulement quand la procédure reçoit input.id qu’elle peut le comparer avec ctx.sessionClient.user.id et détecter qu’il s’agit de soi.
Cette règle a une histoire qui résume tout ce chapitre. Le tableau des membres désactivait déjà le bouton « Supprimer » sur la ligne de l’administrateur connecté, avec une explication au survol. L’interface était correcte. Mais la procédure, elle, acceptait : un appel direct à /api/trpc/user.delete avec son propre identifiant passait. La règle vivait dans l’écran, pas dans le serveur. Elle a été ajoutée à la procédure, et l’écran garde son bouton désactivé, pour le confort : il évite de proposer une action vouée à l’échec.
Le même raisonnement a fait apparaître une seconde porte : retirer quelqu’un du groupe Administrateurs. Un administrateur pouvait s’en retirer lui-même, avec les mêmes conséquences. Le routeur des groupes refuse désormais ce cas précis, et seulement lui :
/** * Retire un membre d'un groupe. Un administrateur ne peut pas se retirer lui-même du groupe * Administrateurs : il perdrait l'accès au panneau, et le club pourrait n'avoir plus aucun * administrateur. Un autre administrateur peut le faire. */ removeMember: adminProcedure .input(z.object({ groupId: z.string(), userId: z.string() })) .mutation(async ({ ctx, input }) => { if (input.userId === ctx.sessionClient.user.id) { const group = await ctx.prisma.memberGroup.findUnique({ where: { id: input.groupId }, select: { isAdminGroup: true }, }); if (group?.isAdminGroup) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez pas vous retirer vous-même du groupe Administrateurs.", }); } } await ctx.prisma.memberGroupMembership.deleteMany({ where: { groupId: input.groupId, userId: input.userId }, }); return { success: true }; }),Un autre administrateur peut toujours le retirer : la règle protège contre la perte d’accès par erreur, pas contre une décision collective.
La sécurité filtre les accès.
Les règles métier filtrent les cas limites.
Ces deux niveaux sont complémentaires — mais ils ne doivent pas être confondus. Et ni l’un ni l’autre ne peut vivre seulement dans l’interface.
🔥 Le vrai bénéfice#
Le gain n’est pas seulement la sécurité.
C’est la lisibilité du contrat.
Quand nous lisons une procédure protégée correctement :
Procédure protégée, lisible d’un coup d’œil
getAll: protectedProcedure .use(isAdmin) .query(async ({ ctx }) => { return ctx.prisma.user.findMany({ omit: { password: true }, /* … */ }) })Nous savons immédiatement :
- qui peut entrer →
protectedProcedure - avec quel droit →
.use(isAdmin) - ce qui est fait →
findMany, sans l’empreinte des mots de passe
👉 Trois lignes. Trois questions répondues.
Et c’est cette lisibilité qui fait la différence quand un développeur arrive sur le projet. Il n’a pas besoin de chercher la logique de sécurité ailleurs. Elle est là, compactée en une ligne, au sommet de la procédure.
🧠 Le flux devient stable#
Scroller horizontalement si nécessaire
🚀 Conclusion#
Nous ne dispersons plus la sécurité dans les procédures.
Nous la composons en amont, là où elle devient visible.
Une procédure bien protégée dit en une lecture :
— qui peut entrer
— avec quel droit
— et ce qu’elle fait
Le reste du code n’a plus à le deviner.
La suite est naturelle.
Nous savons construire une procédure.
Nous savons la protéger.
👉 Il reste à garantir que ce qui entre est toujours valide.
👉 Où nous en sommes : la sécurité a trouvé sa place — dans des middlewares (protectedProcedure, isAdmin) qui vérifient une fois, au bon endroit, avec un type requalifié pour la suite. Et nous avons distingué ce qui est de la sécurité de ce qui est une règle métier. Reste à verrouiller l’autre frontière : l’entrée elle-même, avant que quoi que ce soit ne la touche.
À suivre#
Nous savons qui peut appeler une procédure, et à quelles conditions. Reste la seconde frontière : ce qui entre.
Chapitre suivant : valider l’entrée. Zod comme frontière formelle, le schéma inline et le contrat partagé avec le front, et ce qui se passe quand la frontière est laissée ouverte.