Les droits par les groupes

L’autorisation dans le projet : des groupes plutôt que des rôles, les niveaux VIEWER et EDITOR, l’héritage par la hiérarchie, le groupe Administrateurs et ses deux protections, les gardes qui s’appuient sur une seule source pour chaque règle, le layout du tableau de bord et les pages en construction.

Précédemment. Le navigateur reçoit la session dès le premier affichage ; le proxy filtre l’entrée des pages protégées, sans jamais être la protection qui fait foi.

Nous savons, à chaque requête, qui parle. Reste la seconde question du premier chapitre : a-t-il le droit ?

Les droits : des groupes, pas des rôles#

Les droits ne reposent pas sur un rôle attaché au membre, mais sur ses groupes. Un membre peut appartenir à plusieurs groupes, et chaque appartenance a un niveau :

Notion

Modèle

Signification

Groupe
MemberGroup
Un ensemble de membres. S’il est collaboratif (isCollaborative), il a un espace de fichiers.
Appartenance
MemberGroupMembership
Un membre dans un groupe, avec un niveau d’accès : VIEWER (consulter) ou EDITOR (consulter et déposer).
Hiérarchie
MemberGroup.parentGroupId
Un groupe peut avoir un groupe parent ; les membres du parent ont accès aux espaces des descendants.
Administrateurs
MemberGroup.isAdminGroup
Un groupe unique, créé au démarrage s’il n’existe pas (ensureAdminGroup). Ses membres sont les administrateurs.

Ce modèle répond aux besoins du club sans nouvelle notion : un groupe « Bureau » qui reçoit certains documents, un groupe par cours avec son espace partagé, des encadrants qui voient les espaces de tous les cours d’une discipline par l’héritage. La partie « Données » a raconté comment il a remplacé les rôles et les permissions du début du projet.


Être administrateur#

Être administrateur, c’est appartenir au groupe Administrateurs. Aucun champ de ne le dit. getSessionFromRequest le lit avec la session, en une requête (chapitre 4), et le pose dans SessionClient.user.isAdmin. Les services qui n’ont pas la session sous la main — une garde appelée depuis une route de fichiers, par exemple — appliquent la même règle :

isAdminByGroup : la même règle, sans la session
TS
packages/backend/src/modules/memberGroups/isAdminByGroup.service.ts
import type { PrismaClient } from "@prisma/client";/** * Source de vérité unique « admin » : l'utilisateur est-il membre du groupe * Administrateurs (isAdminGroup) ? Remplace l'ancien check `role.name === "ADMIN"` * dans tout le backend sans accès à la session (services, helpers). */export async function isAdminByGroup(  prisma: PrismaClient,  userId: string,): Promise<boolean> {  const membership = await prisma.memberGroupMembership.findFirst({    where: { userId, group: { isAdminGroup: true } },    select: { id: true },  });  return membership !== null;}

Le groupe lui-même est garanti au démarrage du site, par instrumentation.ts :

ensureAdminGroup : un groupe unique, garanti au démarrage
TS
packages/backend/src/modules/memberGroups/ensureAdminGroup.service.ts
import type { PrismaClient } from "@prisma/client";import { ensureGroupSpaceFolder } from "@backend/modules/memberGroups/ensureGroupSpaceFolder.service";const ADMIN_GROUP_NAME = "Administrateurs";/** * Garantit le groupe « Administrateurs » UNIQUE (collaboratif, isAdminGroup) et * matérialise son espace. Idempotent — appelé au boot. Ne touche pas aux adhésions : * ses membres sont les administrateurs du site (getSessionFromRequest), et s'y * ajoutent ou s'en retirent depuis le panneau de contrôle (jamais soi-même : memberGroup.removeMember). * * NB : le DÉPLACEMENT de l'ex-dossier « general » dans cet espace est une étape * SÉPARÉE (déplacement de données, avec sauvegarde) — pas ici. */export async function ensureAdminGroup(  prisma: PrismaClient,  appRoot: string,): Promise<{ groupId: string }> {  let group = await prisma.memberGroup.findFirst({    where: { isAdminGroup: true },    select: { id: true },  });  if (!group) {    group = await prisma.memberGroup.create({      data: {        name: ADMIN_GROUP_NAME,        isCollaborative: true,        isAdminGroup: true,      },      select: { id: true },    });  }  await ensureGroupSpaceFolder({ prisma, appRoot, groupId: group.id });  return { groupId: group.id };}

Deux règles protègent ce groupe contre une fausse manœuvre, toutes deux dans les procédures (partie « API ») : il ne peut pas être supprimé, et un administrateur ne peut pas s’en retirer lui-même. Sans la seconde, le dernier administrateur pouvait se retirer et laisser le club sans personne pour gérer le site.


L’accès à l’espace d’un groupe : l’héritage#

L’accès à l’espace d’un groupe se calcule en remontant la hiérarchie : on lit l’appartenance au groupe visé, puis à son parent, et ainsi de suite, et l’on garde le meilleur niveau.

resolveGroupAccessForUser : l’héritage
TS
packages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts
import type { PrismaClient } from "@prisma/client";export type GroupAccessLevel = "NONE" | "VIEWER" | "EDITOR";/** * Accès EFFECTIF d'un utilisateur à l'espace d'un groupe, HÉRITAGE compris. * * On remonte la chaîne des ancêtres (parentGroupId) depuis le groupe visé et on * prend le niveau MAXIMUM parmi l'appartenance directe et celles aux groupes * ancêtres (EDITOR > VIEWER > NONE). Un membre d'un groupe parent hérite ainsi * de l'accès (même niveau) aux espaces des groupes descendants. * * NB : ne traite PAS l'admin (les gardes court-circuitent l'admin en amont). */export async function resolveGroupAccessForUser(  prisma: PrismaClient,  userId: string,  groupId: string,): Promise<GroupAccessLevel> {  let best: GroupAccessLevel = "NONE";  let cursor: string | null = groupId;  const seen = new Set<string>();  while (cursor && !seen.has(cursor)) {    seen.add(cursor);    const membership = await prisma.memberGroupMembership.findUnique({      where: { groupId_userId: { groupId: cursor, userId } },      select: { access: true },    });    if (membership?.access === "EDITOR") return "EDITOR";    if (membership?.access === "VIEWER") best = "VIEWER";    const group: { parentGroupId: string | null } | null =      await prisma.memberGroup.findUnique({        where: { id: cursor },        select: { parentGroupId: true },      });    cursor = group?.parentGroupId ?? null;  }  return best;}

Lecture guidée du calcul d’accès#

Remonter la hiérarchie, garder le meilleur niveau

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Trois niveaux
TS
packages/backend/src/modules/memberGroups/resolveGroupAccessForUser.service.ts
guided extract: lines 1-43focus line 3
import type { PrismaClient } from "@prisma/client";export type GroupAccessLevel = "NONE" | "VIEWER" | "EDITOR";/** * Accès EFFECTIF d'un utilisateur à l'espace d'un groupe, HÉRITAGE compris. * * On remonte la chaîne des ancêtres (parentGroupId) depuis le groupe visé et on * prend le niveau MAXIMUM parmi l'appartenance directe et celles aux groupes * ancêtres (EDITOR > VIEWER > NONE). Un membre d'un groupe parent hérite ainsi * de l'accès (même niveau) aux espaces des groupes descendants. * * NB : ne traite PAS l'admin (les gardes court-circuitent l'admin en amont). */export async function resolveGroupAccessForUser(  prisma: PrismaClient,  userId: string,  groupId: string,): Promise<GroupAccessLevel> {  let best: GroupAccessLevel = "NONE";  let cursor: string | null = groupId;  const seen = new Set<string>();  while (cursor && !seen.has(cursor)) {    seen.add(cursor);    const membership = await prisma.memberGroupMembership.findUnique({      where: { groupId_userId: { groupId: cursor, userId } },      select: { access: true },    });    if (membership?.access === "EDITOR") return "EDITOR";    if (membership?.access === "VIEWER") best = "VIEWER";    const group: { parentGroupId: string | null } | null =      await prisma.memberGroup.findUnique({        where: { id: cursor },        select: { parentGroupId: true },      });    cursor = group?.parentGroupId ?? null;  }  return best;}

Les gardes#

Les gardes du projet s’appuient toutes sur ces deux règles — administrateur, accès à un espace :

Garde

Règle

assertCanReadPath
Lire un dossier du finder : administrateur, ou accès à l’espace du groupe visé.
assertCanWriteGroupSpace
Déposer dans l’espace d’un groupe : administrateur, ou EDITOR.
canReadStorageKey
Obtenir un fichier privé : administrateur, accès à l’espace du groupe, ou propriétaire de l’espace personnel.
assertCanTrashPaths
Jeter : administrateur partout, EDITOR dans l’espace de son groupe.
Layout serveur (admin)
Ouvrir le tableau de bord : administrateur, ou traitant d’au moins une catégorie de demandes.

Une seule source pour chaque règle : « administrateur » se lit toujours dans le groupe Administrateurs, « accès à un espace » toujours par resolveGroupAccessForUser. Aucune garde ne réécrit sa propre version de la règle.

Les gardes d’espace ne peuvent pas vivre dans la chaîne d’une procédure, comme isAdmin : pour savoir si un membre peut écrire dans un dossier, il faut connaître le dossier, c’est-à-dire l’entrée. Elles sont donc appelées en tête de la procédure, avec le chemin visé ; la partie « Stockage et médias » les détaille.


Le tableau de bord : une garde pour l’interface#

Les procédures d’administration se protègent elles-mêmes. Mais l’interface du tableau de bord mérite aussi sa garde : sans elle, un membre qui ouvre /dashboard verrait le squelette des écrans, avec des listes vides et des erreurs. Un layout serveur, placé au-dessus de tout le tableau de bord, s’exécute avant le moindre rendu :

Le layout serveur du tableau de bord
TSX
apps/web/src/app/(admin)/layout.tsx
// ⚠ Fichier introuvable : apps/web/src/app/(admin)/layout.tsx// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.

Le commentaire de tête dit l’essentiel : ce n’est pas la seule protection. Le layout garde l’interface ; les données restent gardées par leurs procédures. Retirer ce layout dégraderait l’expérience, pas la sécurité.


Les pages en construction#

Une dernière forme d’autorisation concerne les pages éditoriales (accueil, association, contacts). Tant qu’un administrateur ne les a pas publiées, elles affichent « Page en construction » aux visiteurs, et leur vrai contenu aux administrateurs, qui peuvent ainsi les préparer et les relire en conditions réelles :

isEditorialPageGated : afficher, ou « en construction »
TSX
apps/web/src/features/editorial/editorialGate.tsx
empty code block

La même règle — administrateur ou non — sert ici à choisir quoi afficher, et non à refuser une action. Elle passe par la même source : la session et son isAdmin.

À suivre#

Toutes les pièces sont sur la table : qui parle, et ce qu’il a le droit de faire. Il reste à les voir ensemble, d’un bout à l’autre.

Chapitre suivant : la vue d’ensemble. Le parcours complet d’un clic, les quatre parcours de la partie, la défense en profondeur, et les pièges — évités, ou corrigés.

Sur cette page