Skip to content

Gestion de liste d'attente

Guide d'implémentation d'une liste d'attente basée sur la position. Couvre le calcul dynamique de position, la machine d'états, la prévention IDOR et les endpoints d'administration.

Vue d'ensemble

  • Les utilisateurs rejoignent la liste d'attente (avec une note optionnelle)
  • Calcul de position dynamique : la position n'est pas stockée en DB, elle est calculée à la demande avec COUNT(*)
  • Machine d'états : waitingapproved / declined (à sens unique, non annulable)
  • Seuls les utilisateurs en attente peuvent partir (approved/declined ne peuvent plus partir)
  • Les administrateurs peuvent lister, approuver et refuser tous les entrées (X-Admin-Key)
  • Ne pas inclure user_id dans les réponses côté utilisateur (prévention IDOR)

Endpoints

MéthodeCheminAuthDescription
POST/waitlistX-User-IdRejoindre la liste d'attente
GET/waitlist/meX-User-IdObtenir son entrée et sa position
DELETE/waitlist/meX-User-IdQuitter la liste d'attente
GET/waitlistX-Admin-KeyListe de toutes les entrées (admin)
POST/waitlist/{id}/approveX-Admin-KeyApprouver une entrée
POST/waitlist/{id}/declineX-Admin-KeyRefuser une entrée

Conception de la base de données

sql
CREATE TABLE IF NOT EXISTS waitlist_entries (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL UNIQUE,
    status     TEXT    NOT NULL DEFAULT 'waiting',
    note       TEXT,
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

La contrainte UNIQUE sur user_id garantit un seul enregistrement par utilisateur. Pas de colonne de position (calcul dynamique).

Calcul dynamique de position

Calculer la position d'une entrée en attente par ordre relatif d'id :

sql
SELECT COUNT(*) FROM waitlist_entries
WHERE status = 'waiting' AND id <= :id

Avantages :

  • Pas besoin d'UPDATE de toutes les entrées à chaque départ, approbation ou refus
  • Pas de conflits d'écriture
  • Retourne null pour les entrées dont status n'est pas waiting
php
public function positionOf(WaitlistEntry $entry): ?int
{
    if ($entry->status !== WaitlistStatus::Waiting) {
        return null;
    }

    $stmt = $this->pdo->prepare(
        "SELECT COUNT(*) FROM waitlist_entries
         WHERE status = 'waiting' AND id <= :id",
    );
    $stmt->execute(['id' => $entry->id]);

    return (int) $stmt->fetchColumn();
}

Machine d'états

waiting ──→ approved
        └─→ declined
  • waiting ne peut transitionner qu'en approved ou declined
  • Une fois en état terminal, aucun changement possible (déterminé par isTerminal())
  • Seuls les utilisateurs en attente peuvent partir avec DELETE /waitlist/me
php
enum WaitlistStatus: string
{
    case Waiting  = 'waiting';
    case Approved = 'approved';
    case Declined = 'declined';

    public function isTerminal(): bool
    {
        return $this !== self::Waiting;
    }
}

Prévention IDOR

L'endpoint utilisateur (/waitlist/me) récupère uniquement sa propre entrée via l'en-tête X-User-Id. Il n'y a pas de possibilité de passer l'user_id d'un autre utilisateur dans le chemin, et la réponse ne contient pas non plus user_id.

php
/** Réponse côté utilisateur (sans user_id) */
public function toPublicArray(): array
{
    return [
        'id'         => $this->id,
        'status'     => $this->status->value,
        'note'       => $this->note,
        'created_at' => $this->createdAt,
    ];
}

/** Réponse côté admin (avec user_id) */
public function toAdminArray(): array
{
    return [
        'id'         => $this->id,
        'user_id'    => $this->userId,
        'status'     => $this->status->value,
        'note'       => $this->note,
        'created_at' => $this->createdAt,
        'updated_at' => $this->updatedAt,
    ];
}

Authentification admin

Comparer l'en-tête X-Admin-Key en temps constant avec hash_equals(). Un adminKey vide retourne toujours false (fail-closed) :

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') {
        return false; // fail-closed
    }

    $provided = $request->getHeaderLine('X-Admin-Key');

    return $provided !== '' && hash_equals($this->adminKey, $provided);
}

Ordre des routes

Enregistrer GET /waitlist/me avant GET /waitlist. S'il est enregistré après, me risque d'être capturé comme {id} :

php
$this->router->post('/waitlist',            $this->handleJoin(...));
$this->router->get('/waitlist/me',          $this->handleMe(...));      // avant /waitlist
$this->router->delete('/waitlist/me',       $this->handleLeave(...));
$this->router->get('/waitlist',             $this->handleAdminList(...));
$this->router->post('/waitlist/{id}/approve', $this->handleApprove(...));
$this->router->post('/waitlist/{id}/decline', $this->handleDecline(...));

Validation X-User-Id

Prévenir les débordements d'entiers, les zéros, les nombres négatifs et les valeurs non numériques :

php
private function resolveUserId(ServerRequestInterface $request): ?int
{
    $raw = $request->getHeaderLine('X-User-Id');

    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) {
        return null;
    }

    $id = (int) $raw;

    return $id > 0 ? $id : null;
}

Points de sécurité

MenaceContre-mesure
IDOR/waitlist/me pour soi uniquement, ne pas inclure user_id dans la réponse
Écoute de la clé adminComparaison en temps constant avec hash_equals()
Débordement d'entierGarde strlen > 18
Double inscriptionContrainte UNIQUE(user_id) → 409
Transition d'état illiciteisTerminal() interdit les changements après l'état terminal
Injection SQLInstructions préparées PDO

Exemples de réponses

json
// POST /waitlist (201)
{
    "entry": { "id": 1, "status": "waiting", "note": "Demande VIP", "created_at": "..." },
    "position": 1
}

// GET /waitlist/me — statut approved (200)
{
    "entry": { "id": 1, "status": "approved", "note": "Demande VIP", "created_at": "..." },
    "position": null
}

// GET /waitlist (admin, 200)
{
    "data": [
        { "id": 1, "user_id": 101, "status": "approved", "note": "Demande VIP", ... },
        { "id": 2, "user_id": 102, "status": "waiting",  "note": null,          ... }
    ],
    "total": 2
}

Guides liés

Publié sous licence MIT.