Skip to content

How-to : Gestion des membres de groupe

Référence FT : FT291 (NENE2-FT/grouplog) — Appartenance au groupe : enum MemberRole (owner/admin/member), UNIQUE(group_id, user_id), garde owner-ne-peut-pas-être-supprimé, prévention IDOR inter-groupe, hiérarchie de rôles canManageMembers()/canChangeRoles(), VULN-A à L tous SAFE, 38 tests / 101 assertions PASS.

Ce guide montre comment construire un système de gestion de groupe avec contrôle des membres basé sur les rôles — propriétaires, admins et membres avec des permissions graduées.

Schéma

sql
CREATE TABLE user_groups (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    owner_id   INTEGER NOT NULL,
    created_at TEXT    NOT NULL,
    FOREIGN KEY (owner_id) REFERENCES users(id)
);

CREATE TABLE memberships (
    id        INTEGER PRIMARY KEY AUTOINCREMENT,
    group_id  INTEGER NOT NULL,
    user_id   INTEGER NOT NULL,
    role      TEXT    NOT NULL DEFAULT 'member',
    joined_at TEXT    NOT NULL,
    UNIQUE (group_id, user_id),
    CHECK (role IN ('owner', 'admin', 'member')),
    FOREIGN KEY (group_id) REFERENCES user_groups(id),
    FOREIGN KEY (user_id)  REFERENCES users(id)
);

UNIQUE(group_id, user_id) empêche les appartenances en double. CHECK(role IN ...) bloque les rôles invalides au niveau DB.

Endpoints

MéthodeCheminAuthDescription
POST/groupsX-User-IdCréer un groupe (l'acteur devient owner)
GET/groups/{groupId}/membersX-User-Id (membre)Lister les membres
POST/groups/{groupId}/membersX-User-Id (owner/admin)Ajouter un membre
DELETE/groups/{groupId}/members/{userId}X-User-IdSupprimer un membre
PUT/groups/{groupId}/members/{userId}/roleX-User-Id (owner)Changer le rôle

Enum MemberRole

php
enum MemberRole: string
{
    case Owner  = 'owner';
    case Admin  = 'admin';
    case Member = 'member';

    public function canManageMembers(): bool
    {
        return $this === self::Owner || $this === self::Admin;
    }

    public function canChangeRoles(): bool
    {
        return $this === self::Owner;
    }
}

Capacités des rôles :

  • Owner : peut ajouter/supprimer des membres, changer les rôles, ne peut pas être supprimé
  • Admin : peut ajouter/supprimer des membres, ne peut pas changer les rôles
  • Member : peut seulement quitter (se supprimer lui-même)

Résolution de l'acteur

php
private function resolveActorId(ServerRequestInterface $request): int
{
    $header = $request->getHeaderLine('X-User-Id');
    return is_numeric($header) ? (int) $header : 0;
}

Les en-têtes non numériques retournent 0 (invalide). Chaque opération privilégiée valide l'acteur contre la DB avant de procéder.

Vérification d'appartenance avant toute opération

php
$actorMembership = $actorId > 0 ? $this->repo->findMembership($groupId, $actorId) : null;

if ($actorMembership === null) {
    return $this->responseFactory->create(['error' => 'not a member'], 403);
}

Les non-membres reçoivent 403 pour toutes les opérations de groupe — y compris la liste des membres (prévention IDOR).

Ajout de membres — Hiérarchie des rôles

php
$actorRole = MemberRole::tryFrom($actorMembership['role']) ?? MemberRole::Member;

if (!$actorRole->canManageMembers()) {
    return $this->responseFactory->create(['error' => 'only owner or admin can add members'], 403);
}

// Impossible d'assigner 'owner' via l'endpoint add-member
$role = MemberRole::tryFrom($roleValue);
if ($role === null || $role === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'role must be member or admin'], 422);
}

Le rôle owner ne peut pas être attribué via l'API — il est défini uniquement à la création du groupe.

Le propriétaire ne peut pas être supprimé

php
$targetRole = MemberRole::tryFrom($targetMembership['role']) ?? MemberRole::Member;

if ($targetRole === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'cannot remove the group owner'], 422);
}

Le propriétaire est protégé de la suppression. Le transfert de propriété nécessiterait un endpoint dédié.

Auto-départ vs. Suppression par admin

php
$isSelfLeave = $actorId === $userId;

if (!$isSelfLeave && !$actorRole->canManageMembers()) {
    return $this->responseFactory->create(['error' => 'only owner or admin can remove members'], 403);
}

Les membres peuvent se supprimer eux-mêmes (auto-départ) sans droits d'admin. Supprimer un autre utilisateur nécessite canManageMembers().

Changement de rôle — Propriétaire uniquement

php
if (!$actorRole->canChangeRoles()) {
    return $this->responseFactory->create(['error' => 'only owner can change roles'], 403);
}

$role = MemberRole::tryFrom($roleValue);
if ($role === null || $role === MemberRole::Owner) {
    return $this->responseFactory->create(['error' => 'role must be member or admin'], 422);
}

Seul le propriétaire peut promouvoir/rétrograder des membres. Le rôle owner ne peut pas être attribué (prévention du vol silencieux de propriété).


Évaluation des vulnérabilités

V-01 — IDOR : non-membre lit la liste des membres ✅ SAFE

Risque : Un non-membre appelle GET /groups/{id}/members pour énumérer les utilisateurs. Constatation : SAFE — findMembership(groupId, actorId) === null → 403 avant de retourner des données.


V-02 — IDOR : non-membre ajoute quelqu'un à un groupe ✅ SAFE

Risque : Un non-membre appelle POST /groups/{id}/members pour injecter des utilisateurs. Constatation : SAFE — même vérification d'appartenance ; non-membre → 403.


V-03 — Escalade de privilèges : membre ordinaire ajoute un autre membre ✅ SAFE

Risque : Un membre ordinaire (role = 'member') essaie d'ajouter un nouvel utilisateur. Constatation : SAFE — canManageMembers() retourne false pour Member → 403.


V-04 — Escalade de privilèges : admin se promeut en owner ✅ SAFE

Risque : Un admin essaie d'attribuer role = 'owner' via les endpoints add-member ou change-role. Constatation : SAFE — les deux endpoints rejettent MemberRole::Owner comme rôle assignable valide → 422.


V-05 — Escalade de privilèges : membre se promeut lui-même ✅ SAFE

Risque : Un membre ordinaire appelle PUT /groups/{id}/members/{self}/role. Constatation : SAFE — canChangeRoles() est owner-only → le membre reçoit 403.


V-06 — Suppression du propriétaire ✅ SAFE

Risque : Un admin essaie de supprimer le propriétaire du groupe. Constatation : SAFE — if ($targetRole === MemberRole::Owner) → 422.


V-07 — X-User-Id manquant à la création de groupe ✅ SAFE

Risque : Une requête sans X-User-Id crée un groupe sans propriétaire valide. Constatation : SAFE — resolveActorId() retourne 0 pour un en-tête manquant/invalide → findUserById(0) retourne null → 404.


V-08 — X-User-Id non numérique ✅ SAFE

Risque : L'en-tête X-User-Id: admin contourne la validation numérique de l'acteur. Constatation : SAFE — is_numeric($header) retourne false pour les chaînes non numériques → retourne 0 → rejeté.


V-09 — Injection SQL dans le nom de groupe ✅ SAFE

Risque : Le nom de groupe '; DROP TABLE user_groups; -- supprime des données. Constatation : SAFE — toutes les requêtes utilisent des instructions paramétrées. La chaîne d'injection est stockée verbatim comme nom de groupe sans exécution.


V-10 — Opération de membre inter-groupe (IDOR) ✅ SAFE

Risque : Le propriétaire du groupe A essaie de supprimer un membre du groupe B. Constatation : SAFE — findMembership(groupId, actorId) vérifie l'appartenance dans le groupe cible. Le propriétaire du groupe A n'a pas d'appartenance dans le groupe B → 403.


V-11 — ID de groupe négatif ✅ SAFE

Risque : GET /groups/-1/members cause une erreur DB ou un comportement inattendu. Constatation : SAFE — is_numeric($params['groupId']) ? (int)$params['groupId'] : 0 accepte -1 comme numérique, mais findGroupById(-1) retourne null → 404.


V-12 — L'admin ne peut pas changer les rôles ✅ SAFE

Risque : Un admin appelle PUT /groups/{id}/members/{userId}/role pour promouvoir des utilisateurs. Constatation : SAFE — canChangeRoles() est owner-only → l'admin reçoit 403.


Résumé VULN

IDVulnérabilitéConstatation
V-01IDOR : non-membre lit la liste des membres✅ SAFE
V-02IDOR : non-membre ajoute un membre✅ SAFE
V-03Escalade : membre ajoute un membre✅ SAFE
V-04Escalade : admin → owner✅ SAFE
V-05Escalade : membre se promeut lui-même✅ SAFE
V-06Suppression du propriétaire✅ SAFE
V-07X-User-Id manquant à la création✅ SAFE
V-08X-User-Id non numérique✅ SAFE
V-09Injection SQL dans le nom de groupe✅ SAFE
V-10IDOR inter-groupe (owner d'un autre groupe)✅ SAFE
V-11ID de groupe négatif✅ SAFE
V-12L'admin ne peut pas changer les rôles✅ SAFE

12 SAFE, 0 EXPOSED La vérification d'appartenance avant chaque opération, la hiérarchie de rôles canManageMembers()/canChangeRoles(), et la garde de suppression du propriétaire préviennent tous les vecteurs d'escalade de privilèges et d'IDOR.


À ne pas faire

Anti-patternRisque
Pas de vérification d'appartenance avant la liste des membresLes non-membres énumèrent tous les utilisateurs du groupe (IDOR)
Autoriser l'attribution du rôle owner via add-memberTout admin peut prendre silencieusement la propriété
Autoriser l'attribution du rôle owner via change-rolePareil — vol de propriété avec une seule requête
Ignorer la vérification canManageMembers()Les membres ordinaires ajoutent/suppriment n'importe qui
Autoriser la suppression du propriétaireLe groupe perd son utilisateur gestionnaire
Pas de UNIQUE(group_id, user_id)Le même utilisateur ajouté deux fois ; enregistrements d'appartenance dupliqués
Vérification is_numeric() uniquement pour X-User-Id"1.5" passe is_numeric ; utiliser le cast (int) + validation contre la DB
Vérifier l'appartenance dans le groupe de l'acteur (pas le groupe cible)IDOR inter-groupe : le propriétaire du groupe A modifie le groupe B
Permettre à l'admin de changer les rôlesL'admin se promeut lui-même en owner ; contournement de la hiérarchie des rôles

Publié sous licence MIT.