Développeurs et intégrateurs

Parqueo — documentation technique

Architecture, modèle de données, API et sécurité. Public visé : l'équipe technique qui intègre, exploite ou audite Parqueo.


1. Vue d'ensemble#

Parqueo est un logiciel ITSM auto-hébergé : ticketing, inventaire de parc, catalogue de demandes, base de connaissances et automatisation par workflows visuels. Il se déploie en deux processus servis sur une même origine :

Navigateur
    │  HTTP
    ▼
Reverse proxy ──┬── /      → client/dist (SPA React statique)
                └── /api   → API Express (Node, port 4000)
                                 │
                    ┌────────────┼──────────────┐
                    ▼            ▼               ▼
               PostgreSQL   server/uploads/   SMTP / IMAP
                (Prisma)    (pièces jointes)

L'API est purement JSON (une seule page HTML : l'enquête de satisfaction). Le client appelle /api en relatif, ce qui impose de servir front et API sur la même origine (voir la documentation d'installation).


2. Pile technique#

Couche Choix Version
Runtime Node.js (ESM) 22.x
API Express ^4.21
ORM Prisma Client ^5.20
Base PostgreSQL 14+
Auth jsonwebtoken (HS256) + bcrypt ^9.0 / ^6.0
Sécurité HTTP helmet, cors, express-rate-limit
Email nodemailer (sortant), imapflow + mailparser (entrant)
Inventaire SNMP net-snmp ^3.26
Front React + React Router, build Vite, styles Tailwind ^19 / ^7

Le SSO Microsoft Entra ID est implémenté nativement (fetch + crypto), sans client OIDC tiers.

2.1 Tests#

Aucun framework : le lanceur intégré node --test suffit.

Commande Portée Base requise
npm test (dans server/) logique pure (src/**/*.test.js) : visibilité, workflows, inventaire, SNMP, emails entrants non
npm run test:api (dans server/) API de bout en bout (test/**/*.test.js) : requêtes HTTP réelles sur un serveur éphémère oui
npm run test:all (dans server/) les deux oui
npm test (dans client/) client d'API : jeton, interception du 401, erreurs réseau non

Les tests d'API utilisent une base séparée, par défaut parqueo_test sur le même serveur que DATABASE_URL (surchargeable par TEST_DATABASE_URL). Ils la vident entre les fichiers, d'où le garde-fou de test/setup.js qui refuse toute base dont le nom ne contient pas « test ». Préparation :

createdb parqueo_test
DATABASE_URL="postgresql://…/parqueo_test" npx prisma migrate deploy
npm run test:all

3. Modèle de données#

PostgreSQL, nommage snake_case en base et camelCase via Prisma.

3.1 Énumérations#

Enum Valeurs
Role admin, technician, user
TicketStatus new, in_progress, waiting, resolved, closed
TicketPriority low, medium, high
CommentType comment (message humain), event (journal)
AssetType pc, printer, server, software
AssetStatus in_service, in_repair, retired
FieldType text, textarea, select, date, checkbox

Le tri par statut ou priorité suit l'ordre de l'enum (newclosed, lowhigh), pas l'ordre alphabétique.

3.2 Entités#

3.3 Diagramme relationnel#

Team ──< User ──< Ticket >── Category
                    │  │        │
                    │  │        ├──< Form ──< FormField
                    │  │        └──< KbArticle
                    │  ├──< TicketComment
                    │  ├──< Attachment
                    │  └──< WorkflowRun >── Workflow ──< WorkflowStep
                    └── Asset ──< SoftwareInstall >── Software

4. Authentification et SSO#

4.1 Jeton#

POST /api/auth/login vérifie l'email/mot de passe (bcrypt) et émet un JWT HS256 signé avec JWT_SECRET, portant sub, role et teamId. Durée de vie 7 jours, sans révocation. Le client le stocke et l'envoie en Authorization: Bearer ….

Conséquence : un changement de rôle ou d'équipe, ou une suppression de compte, ne prend effet qu'à l'expiration du jeton (voir §9).

4.2 Visibilité des tickets#

Appliquée dans la requête SQL, à toute lecture :

Rôle Tickets visibles
admin tous
technician ceux dont il est l'auteur, ou qui lui sont assignés, ou non assignés, ou portés par son équipe
user ceux dont il est l'auteur

Pour l'inventaire, un user ne voit que ses actifs assignés, et l'accès peut lui être coupé (paramètre assetsVisibleToUsers).

4.3 SSO Microsoft Entra ID#

OIDC authorization code flow, client confidentiel. Activé dès que SSO_TENANT_ID, SSO_CLIENT_ID et SSO_CLIENT_SECRET sont renseignés (configuration : documentation d'installation §4).

Navigateur          API Parqueo                      Entra ID
    │  GET /api/auth/sso/login                           │
    │─────────────────►│  state = JWT{nonce} (10 min)    │
    │◄─── 302 ─────────│                                 │
    │──────────────── authorize?…&state&nonce ──────────►│
    │◄──────────────── 302 code ─────────────────────────│
    │  GET /api/auth/sso/callback?code&state             │
    │─────────────────►│──── POST /token (secret) ──────►│
    │                  │◄─── id_token ───────────────────│
    │                  │ vérifie RS256/JWKS, audience,    │
    │                  │ issuer, nonce ; provisioning JIT │
    │◄── 302 ?sso_token=… ────────────────────────────────

4.4 Limitation de débit#

Seul /api/auth/login est limité (20 tentatives / 15 min, les connexions réussies ne comptent pas, pour ne pas bloquer une équipe derrière un même NAT).


5. API HTTP#

Préfixe commun /api. Toutes les routes exigent un jeton sauf GET /api/health, GET /api/auth/config, POST /api/auth/login, les routes SSO et GET /api/tickets/satisfaction.

Erreurs : { "error": "message" } avec un statut cohérent (400 validation, 401 auth, 403 rôle, 404 introuvable ou invisible, 409 conflit, 413 corps trop volumineux, 500 bug). Le 404 sert aussi l'invisible : on ne distingue pas un ticket inexistant d'un ticket non autorisé.

Toutes les erreurs passent par un middleware d'erreur unique (src/app.js), alimenté par le routeur de src/lib/router.js qui enveloppe chaque handler async. Sans lui, Express 4 laisserait fuir les rejets de promesse : sous Node 20+ un rejet non géré tue le processus, et une simple requête malformée suffisait à couper l'API. Le middleware traduit aussi les erreurs Prisma en statuts métier (P2002 → 409, P2003 → 400, P2025 → 404) et les 5xx sont journalisés avec leur pile. Un identifiant d'URL non numérique est rejeté en 404 avant d'atteindre la base.

5.1 Authentification#

Méthode Chemin Accès
GET /api/health public — sonde de vie, ne divulgue rien d'autre
GET /api/setup/status public — { needsSetup }, vrai tant qu'aucun compte n'existe
POST /api/setup public — crée le premier administrateur ; 409 si un compte existe déjà
GET /api/about authentifié{ version, license }
GET /api/avatars/:fichier public par capacité — photo de profil, voir ci-dessous
POST /api/auth/avatar authentifié — envoie sa propre photo (multipart, 2 Mo, PNG/JPEG/WebP/GIF)
DELETE /api/auth/avatar authentifié — retire sa propre photo
GET /api/auth/config public — { sso, intune, snmp } pour l'affichage conditionnel de l'UI
POST /api/auth/login public — { email, password }{ token, user }
GET /api/auth/sso/login, /api/auth/sso/callback public — flux SSO
GET /api/auth/me authentifié — profil courant
PATCH /api/auth/password authentifié — { currentPassword, newPassword }, change son propre mot de passe

GET /api/about est authentifiée à dessein : publier le numéro de version d'un logiciel auto-hébergé indique à un visiteur anonyme quelles failles connues essayer. /api/health reste publique et ne renvoie que { ok: true }.

GET /api/avatars/:fichier ne demande pas de jeton, et c'est également un choix. Une balise <img> ne sait pas envoyer d'en-tête Authorization : servir les photos derrière authentification imposerait un fetch par pastille converti en object URL, soit jusqu'à cent requêtes non mises en cache sur une liste de tickets. L'accès est donc protégé par le nom du fichier — 32 caractères hexadécimaux tirés au hasard, renouvelés à chaque envoi, stockés dans users.avatar. Connaître un identifiant de compte ne permet pas de deviner l'adresse de sa photo. La contrepartie assumée : qui obtient l'adresse complète voit l'image sans être connecté. Les pièces jointes de tickets, elles, restent authentifiées.

POST /api/auth/login normalise l'email (minuscules, espaces retirés) : les comptes sont stockés en minuscules à la création, à l'import et via le SSO.

PATCH /api/auth/password exige l'ancien mot de passe, impose 8 caractères minimum et refuse les comptes SSO (pas de mot de passe local). Les autres sessions du même compte restent valables jusqu'à leur expiration : le jeton est sans état, voir §9.

5.2 Tickets#

Méthode Chemin Rôle
GET /api/tickets tous — filtres + pagination
GET /api/tickets/:id tous — détail + commentaires + pièces jointes + état des workflows
POST /api/tickets tous — crée, déclenche workflows et notification
PATCH /api/tickets/:id admin, technicien
POST /api/tickets/:id/comments tous (si visible)
POST/GET /api/tickets/:id/attachments[/:aId] tous (si visible) — upload / téléchargement
GET /api/tickets/satisfaction public (lien signé) — ?token=&value=up|down

Filtres : status, priority, assigneeId (id ou none), categoryId, teamId, q (titre/description), sort. Pagination : avec page, la réponse est { items, total, counts } (counts = nombre de tickets par statut, calculé hors filtre de statut) ; pageSize accepte un entier (≤ 500) ou all.

| GET | /api/tickets/stats | tous (filtré) — agrégats du tableau de bord | | DELETE | /api/tickets/:id | admin — suppression définitive (cascade + fichiers) | | DELETE | /api/tickets/:id/attachments/:pjId | support, ou l'auteur de l'envoi |

GET /api/tickets/stats renvoie en quelques kilo-octets tout ce qu'affiche le tableau de bord : répartitions par statut, priorité, catégorie, assigné et équipe, ventilation par âge, flux hebdomadaire sur douze semaines, compteurs, et les listes courtes des widgets. Tout est calculé en SQL (GROUP BY) dans les limites de visibilité du demandeur.

Le filtre ?status= accepte les cinq statuts et le regroupement open (nouveau + en cours + en attente). counts renvoie un total open en plus du détail par statut. ?authorId= filtre sur le demandeur ; comme tous les filtres, il est cumulé en AND avec la visibilité et ne peut donc que la restreindre.

Longueurs maximales (src/lib/input.js) : titre 200, description et commentaire 20 000, noms de référentiels 120, article 100 000 caractères. Un dépassement donne un 400 explicite.

5.3 Inventaire#

Méthode Chemin Rôle
GET /api/assets tous (filtré) — ?type=&status=&q=
GET /api/assets/:id tous (filtré) — tickets + logiciels installés
POST / PATCH /api/assets[/:id] admin, technicien
DELETE /api/assets/:id admin — 409 si des tickets le référencent
GET /api/software, /api/software/:id admin, technicien — catalogue + postes

Ingestion automatique (hors JWT, appelée par des agents/scripts, protégée par le token INVENTORY_TOKEN404 tant qu'il n'est pas défini) :

Méthode Chemin Auth
POST /api/inventory token — rapport au format normalisé
POST /api/inventory/glpi token — rapport natif de l'agent GLPI (zlib/gzip acceptés)
POST /api/inventory/intune/sync JWT admin — synchro Microsoft Intune
POST /api/inventory/snmp/scan JWT admin — scan réseau SNMP

Taille des rapports. Un inventaire réel (matériel, volumes, plusieurs centaines de logiciels) dépasse couramment les 100 ko. Les routes d'ingestion sont donc montées avant le parseur JSON global de l'application, avec leurs propres limites : 5 Mo pour /api/inventory, 15 Mo pour /api/inventory/glpi. Au-delà, la réponse est un 413. Les autres routes de l'API restent à la limite par défaut d'Express (100 ko), largement suffisante.

Ne pas remonter express.json() au-dessus de ce montage : le corps serait parsé en amont et toutes les ingestions replafonneraient à 100 ko, avec des agents recevant un 413 sans explication.

Mise en œuvre côté exploitant : documentation d'installation §6 ; côté usage : documentation fonctionnelle §9.

5.4 Utilisateurs#

Méthode Chemin Rôle
GET /api/users/assignable admin, technicien
GET / POST / PATCH / DELETE /api/users[/:id] admin
POST /api/users/import admin — import en masse (≤ 1000 lignes)

L'import est ligne à ligne (compte rendu created/skipped/error par ligne) ; un mot de passe aléatoire est généré et renvoyé en clair quand la ligne n'en fournit pas. Garde-fous : on ne peut ni retirer son propre rôle admin, ni se supprimer, ni supprimer un compte porteur de tickets (409).

5.5 Workflows, formulaires, base de connaissances, référentiels#

Méthode Chemin Rôle
GET/POST/PUT/DELETE /api/workflows[/:id] admin
GET /api/forms[/:id] tous (actifs ; ?all=1 admin)
POST /api/forms/:id/submit tous — crée le ticket
POST/PATCH/DELETE /api/forms[/:id] admin
GET /api/kb[/:id] tous (les user ne voient que le publié)
POST/PATCH/DELETE /api/kb[/:id] admin, techniciens si autorisé
GET /api/categories, /api/teams tous
POST /api/categories, /api/teams admin

Catégories et équipes se suppriment (DELETE /api/categories/:id, /api/teams/:id, admin) tant qu'elles ne sont référencées par rien : sinon 409 avec le détail des usages. Leurs noms sont uniques.

5.6 Paramètres#

Méthode Chemin Rôle
GET /api/settings/app tous (le client adapte l'UI)
PATCH /api/settings/app admin
GET/PUT/DELETE /api/settings/dashboard[…] selon rôle et permission

6. Workflows#

Un workflow est un graphe orienté qui automatise le traitement des tickets.

Catégorie Bloc Effet
Action assign_team, assign_user affecte l'équipe / assigne
Action set_priority, set_status change la priorité / le statut
Action add_note, send_email journalise / notifie (gabarits {{clé}})
Action webhook POST JSON vers un outil tiers
Branche condition teste priorité, statut, catégorie, formulaire ou assignation → oui/non
Attente wait_assigned, wait_status parque le ticket jusqu'à la prise en charge / un statut

Parking et reprise. Un ticket qui atteint un bloc d'attente y reste « parqué » ; il repart de ce bloc dès qu'un changement de statut ou d'assignation satisfait la condition. Un même ticket n'entre qu'une fois dans un workflow donné, et une modification faite par un workflow ne peut pas en redéclencher un autre (anti-boucle par construction).

Gabarits. Les notes et emails acceptent des variables {{ticket.title}}, {{author.name}}, {{assignee.email}}, {{category.name}}, etc.

Webhook. POST JSON { event, workflow, ticket }, en-tête optionnel X-Parqueo-Token, timeout 5 s ; un échec ne bloque pas le parcours.


7. Automatismes#

Quatre traitements tournent dans le processus de l'API, chacun activé seulement si sa configuration est présente.

Notifications email (mailer.js), chacune débrayable dans les paramètres :

Notification Destinataires
Création de ticket demandeur + assigné
Changement de statut demandeur + assigné (+ liens de satisfaction à la résolution)
Assignation le technicien qui reçoit (pas soi-même)
Nouveau message l'autre partie, jamais l'auteur

Sans SMTP_HOST, rien n'est envoyé (les messages sont journalisés). Un échec d'envoi ne fait jamais échouer la requête en cours.

Collecteur email. Si IMAP_HOST est configuré : un email d'un utilisateur connu crée un ticket, une réponse dont le sujet contient Ticket #n devient un commentaire. Les expéditeurs inconnus et les adresses système sont ignorés.

Prise en charge. Assigner un ticket au statut new le passe automatiquement en in_progress (src/lib/statut.js), depuis l'API comme depuis le bloc « assigner » d'un workflow — le statut ne doit pas dépendre de qui a assigné. La transition est tracée et suit le pipeline normal : notification et workflows status_changed.

Clôture automatique. Chaque heure, les tickets resolved sans activité depuis autoCloseDays jours passent en closed (0 = désactivé). La clôture emprunte le même chemin qu'une clôture manuelle : événement tracé, notification de changement de statut, et déclenchement des workflows status_changed. Un automatisme bâti sur le statut « Fermé » se comporte donc pareil, que la fermeture vienne d'un technicien ou de la tâche de fond. Un ticket en échec est journalisé et n'interrompt pas la passe.

Inventaire automatique. Toutes les sources (script/agent, Intune, SNMP) convergent vers un traitement unique : déduplication par uuid puis numéro de série, création ou rafraîchissement de l'actif. Les champs remontés (matériel, logiciels, dernière remontée) sont rafraîchis à chaque rapport ; les champs gérés par l'humain (nom, type, emplacement, état, utilisateur assigné) ne sont jamais écrasés. Le signalement des actifs « périmés » est purement visuel : aucun statut n'est modifié automatiquement.

Les logiciels d'un rapport sont écrits en lot (quelques requêtes, quel que soit le nombre d'applications) : le catalogue manquant est créé d'un bloc, puis les installations de l'actif sont remplacées dans une transaction. Mesure sur 1 000 logiciels : 92 ms, contre 1 916 ms avec une boucle d'upsert — l'écart se voit surtout sur une synchronisation Intune, qui enchaîne les machines.

Identité des équipements SNMP. Un switch ou une imprimante n'a pas d'UUID matériel : le scan fabrique une identité de substitution, du plus solide au plus fragile — numéro de série (snmp:sn:…, insensible au renommage et au changement d'IP), sinon sysObjectID + nom système (snmp:oid:…, deux matériels de familles différentes ne fusionnent pas), sinon adresse IP (snmp:ip:…, fragile en DHCP). Limite assumée : sans numéro de série, deux équipements laissés au nom d'usine par défaut restent indistinguables — leur donner un nom système unique est la parade.


8. Paramètres#

Configuration globale (table settings, clé app.config), éditée dans la page Paramètres (admin) et lue par tous les comptes pour adapter l'interface.

Clé Défaut Effet
ticketDefaultPriority medium priorité par défaut
userCanSetPriority true un utilisateur peut choisir la priorité
autoCloseDays 7 délai de clôture automatique (0 = désactivé)
satisfactionSurvey true enquête un clic à la résolution
notifyOnCreate/Status/Assign/Comment true notifications email
kbSuggest true suggestions d'articles à la création
kbTechniciansWrite true rédaction de la base de connaissances par les techniciens
assetsVisibleToUsers true inventaire visible des utilisateurs
assetStaleDays 30 délai au-delà duquel un actif automatique est signalé périmé (0 = jamais)
dashboardPersonalTechnician/User false personnalisation du tableau de bord

Écriture réservée aux admins, avec validation stricte : une clé inconnue ou une valeur invalide est refusée (400).


9. Sécurité#

En place :

À connaître avant mise en production :

  1. Jeton en localStorage : une faille XSS dans le front exposerait le jeton. Le choix évite CSRF et simplifie le déploiement.
  2. Jeton non révocable (7 j) : un changement de rôle, une suppression de compte ou un changement de mot de passe ne coupent pas les sessions en cours ; pour une révocation immédiate, il faut faire tourner JWT_SECRET (déconnecte tout le monde).
  3. sso_token dans l'URL de retour : retiré immédiatement de la barre d'adresse, mais susceptible d'apparaître dans les journaux du reverse proxy — filtrez les query strings.
  4. Collecteur email : l'expéditeur est vérifié par simple correspondance d'adresse (ni SPF/DKIM/DMARC) ; une adresse usurpée peut créer un ticket au nom de la personne usurpée. À filtrer en amont sur le serveur de messagerie. En revanche, une réponse par email applique la visibilité de l'expéditeur : répondre en citant « Ticket #n » n'écrit dans ce ticket que s'il serait visible dans l'interface ; sinon le message devient une demande à part.
  5. Pièces jointes : liste blanche d'extensions, sans analyse du contenu.
  6. Débit : seul le login est limité ; ajoutez un quota au reverse proxy si l'API est exposée publiquement.
  7. Webhook sortant : un administrateur peut cibler une IP interne (SSRF) — le rôle admin est de toute façon de confiance.
  8. Premier compte : aucun identifiant n'est livré avec l'application. Le compte administrateur se crée au premier accès via POST /api/setup, route publique qui ne répond que tant que la base ne contient aucun utilisateur. Le critère porte sur l'existence d'un utilisateur, non d'un administrateur : sinon, une base dont les administrateurs auraient été supprimés permettrait à n'importe qui de s'y déclarer administrateur. Deux appels concurrents sont sérialisés par un verrou consultatif PostgreSQL.

10. Dimensionnement et limites#