Apparence
05. Sécurité
Qui s'authentifie comment, qui a le droit de quoi, ce qui est exposé, et ce qui n'est pas protégé aujourd'hui.
Authentification
| Surface | Mécanisme | Durée de vie |
|---|---|---|
| Le SPA | Jeton JWT d'accès, obtenu sur /api/auth/token/ | 6 heures |
| Le back-office | Session Django vérifiée, mot de passe puis TOTP ou code de secours | Session Django |
| L'API navigable et Swagger | Session Django, en complément du JWT | Session Django |
Décision : un jeton d'accès seul, pas de jeton de rafraîchissement. La durée de vie est assez longue pour qu'un utilisateur ne soit pas redemandé en pleine journée de travail, et bornée pour qu'un jeton dérobé cesse d'être utile. Le compromis assumé : un jeton donné ne se révoque pas. Désactiver le compte, en revanche, coupe aussi les jetons déjà émis : chaque requête recharge le compte qu'un jeton désigne et refuse un compte inactif. Ce qui reste valable jusqu'à l'expiration, c'est un jeton dérobé d'un compte actif — changer le mot de passe ne l'invalide pas. Le révoquer demanderait une liste de refus côté serveur, donc un état à interroger à chaque requête — la contrepartie d'un jeton auto-porteur, et le point à revoir si le besoin devient réel.
Les mots de passe sont hachés par Django et validés par les quatre contrôles standards : similarité avec les attributs du compte, longueur minimale, mots de passe courants, mot de passe entièrement numérique.
Décision : le cache de données du navigateur appartient à la session (APAJH-290). Tout changement de jeton vide le cache TanStack Query et annule ses lectures en cours, à la connexion, déconnexion et prise d'identité. Une réponse obtenue sous les droits RH ne doit pas être réutilisée pour le même dossier sous des droits salarié, ni repeupler le cache après le changement. Les contrôles d'accès et le masquage restent appliqués côté serveur.
Décision : le brouillon d'une trame en cours d'édition reste dans le navigateur (APAJH-136). Chaque modification de l'éditeur de trame est écrite dans le localStorage, sous une clé propre au compte et à la trame, et proposée à la réouverture (« Reprendre le brouillon » / « Ignorer le brouillon »). Le brouillon n'est pas écrit sur la trame elle-même : une trame enregistrée est lue en direct par le planning de chaque salarié qui la porte, et la sauvegarder à chaque geste replanifierait des personnes au milieu d'une modification. Il survit à la déconnexion, à la différence du cache ci-dessus : c'est précisément l'interruption — session expirée comprise — qu'il doit traverser. Ce qu'il contient est un cycle de postes et d'horaires, sans donnée personnelle ; le compte qui l'a écrit est le seul à qui l'écran le propose, mais il reste lisible dans le navigateur par qui ouvre ses outils de développement sur un poste partagé. Il ne suit pas l'utilisateur sur un autre poste : un brouillon côté serveur le permettrait, au prix d'un modèle, d'une API et d'une migration que le besoin n'a pas justifiés. Il peut en revanche défaire ce qui a été fait ailleurs : chaque brouillon garde l'empreinte de la trame enregistrée contre laquelle il a été écrit, et la boîte de reprise prévient quand la trame a été enregistrée depuis — reprendre remplacerait alors cette version à l'enregistrement. Ce qui est relu du navigateur n'est pas présumé valide : un brouillon d'une autre forme que le cycle de l'éditeur (version du format, entrée altérée) est écarté plutôt qu'ouvert.
Ouverture d'un compte : aucun mot de passe transmis
Décision : un compte créé depuis le SPA naît sans mot de passe utilisable. La création d'un salarié (APAJH-205) écrit le compte via create_user sans mot de passe, ce qui pose un hachage inutilisable : le compte existe, personne ne peut s'y connecter. Avec un email professionnel sur la fiche, une invitation entre dans une file persistante dans la même transaction. Sans cette adresse, la personne emprunte le « mot de passe oublié », qui recherche l'email du compte.
Aucun mot de passe par défaut n'est fabriqué. Le token Django est généré au moment de l'envoi : l'attente en file ne réduit pas sa validité. Il n'est stocké ni dans la file ni dans les journaux. Définir le mot de passe invalide les liens déjà émis. Le back-office prépare également des liens pour une sélection de comptes actifs avec email professionnel, sous les droits de modification des comptes, superutilisateurs exclus. La page de confirmation affiche l'adresse visée, et la demande garde cette adresse : un éditeur de fiche qui la modifie ensuite ne redirige pas le lien, il annule la demande. Le mot de passe existant reste valable jusqu'à l'utilisation du lien. L'adresse du compte n'est pas modifiée par cette action. Une modification de la fiche ne déclenche jamais une nouvelle invitation automatique.
Les journaux d'accès nginx du frontend, et de l'hôte frontend sur le proxy, omettent les paramètres de requête et le référent. Le SPA sert Referrer-Policy: no-referrer pour empêcher la propagation du lien aux requêtes suivantes. Sur /reset-password, les erreurs nginx brutes sont supprimées car elles incluent l'URL originale ; le statut HTTP reste observable dans le journal d'accès expurgé, y compris en cas de panne d'upstream.
Activer et désactiver un compte
Décision (APAJH-175) : la fiche salarié ouvre et ferme le compte, selon à qui il appartient. Une case Compte actif écrit User.is_active par PUT /api/employees/<id>/account-activation/. Deux permissions en séparent les forces : change_account_activation (Admin, RH n1, RH n2) vaut pour tous les comptes, change_interim_account_activation (DA, Manager n1, Manager n2) pour les seuls intérimaires, reconnus au type de leur contrat de référence (celui en cours, sinon le plus récent). Ce sont des permissions et pas is_staff, parce que ces trois derniers rôles n'atteignent pas le back-office. Personne ne ferme son propre compte : c'est le seul geste que son auteur ne pourrait pas défaire.
Le back-office ajoute deux actions de masse sur la liste des utilisateurs, et une colonne Actif. La fiche comme ces actions écrivent la même entrée au journal des actions, portée par le compte. Le formulaire du compte, lui, modifie encore Actif sans trace, comme tout le reste du back-office.
Un compte fermé garde des compteurs lisibles, mais le calcul automatique ne les alimente plus. Rouvert, il rattrape la période fermée, parce que ce sont les contrats et non le compte qui décident des droits acquis.
Décision (APAJH-213) : un compte se ferme seul quand son salarié n'a plus de contrat. Une tâche planifiée quotidienne (§ 04, ordonnancement), jouée après l'import CEGI de la nuit, désactive tout compte actif dont le salarié a des contrats et les a tous terminés — la veille ou avant. Un contrat en cours, ou signé pour commencer plus tard, garde le compte ouvert : un CDD qui enchaîne sur le suivant, un embauché qui arrive le mois prochain ne partent pas. Une fiche sans aucun contrat n'est pas touchée, puisqu'APAJH-205 permet d'en créer une avant que son contrat soit saisi. La fermeture passe par le même écrivain que la fiche et le back-office, et laisse la même entrée au journal des actions, au nom de « Système ».
La tâche converge plutôt qu'elle ne réagit au lendemain de la date de fin : une nuit manquée est rattrapée la suivante, et un compte rouvert à la main alors que plus aucun contrat ne court est refermé la nuit d'après. Le rouvrir durablement, c'est saisir le nouveau contrat. Le risque nommé par APAJH-175 demeure : un dossier en retard sur la réalité — un avenant non saisi — coupe l'accès d'une personne présente, et la case Compte actif reste le moyen de la laisser revenir en attendant le contrat. Hors du SPA, la même tâche se lance à la main : make close-ended-accounts.
Un compte fermé n'est plus planifiable. Qu'il ait été fermé à la main ou à la fin de ses contrats, le SPA refuse de lui écrire une journée (« Modifier le poste », « Affecter un poste »), de lui affecter une trame, ou de l'ajouter à une équipe — depuis « Poste à pourvoir » comme depuis la Bibliothèque — et ne le propose plus dans « Poste à pourvoir ». Comme pour le contrat, la Bibliothèque ne juge que les nouveaux membres : un ancien salarié encore dans une équipe n'en bloque pas la sauvegarde. Terminer la trame qu'il suivait reste possible, et le back-office reste le lieu où l'on corrige une journée passée d'un salarié parti.
Second facteur du back-office
Décision (APAJH-284) : les rôles RH ouvrent le back-office, et tout le back-office. La porte n'est plus is_staff seul mais opens_back_office (apps.roles.models) : is_staff ou un rôle RH n1/RH n2 actif. Les RH tiennent les référentiels et les dossiers là ; les faire attendre une case cochée à la main, puis des permissions posées écran par écran, les laissait devant cinq entrées sur quarante. Leurs deux groupes reçoivent toutes les permissions à chaque migrate (apps.roles.signals) plutôt que par une migration, pour que les modèles ajoutés plus tard leur reviennent sans rien écrire. Le prix est assumé : un RH peut modifier les groupes et les rôles, donc ses propres droits, et un retrait fait depuis Groupes ne survit pas au déploiement suivant.
Décision : le back-office exige un TOTP après le mot de passe. Un compte actif qui passe cette porte — is_staff (Statut équipe dans l'interface) ou un rôle RH — enrôle son application d'authentification lors de sa première connexion. La pré-authentification ne contient que son identifiant et son backend pendant dix minutes : aucune session Django authentifiée n'est ouverte avant la validation du second facteur.
Le secret suit les paramètres TOTP standards : six chiffres, période de trente secondes et tolérance d'un pas. La confirmation initiale produit dix codes de secours à usage unique, affichés une seule fois. Un super-utilisateur dont la session est vérifiée peut supprimer les facteurs d'un compte ; cette suppression rend ses sessions non vérifiées dès leur prochaine requête et impose un nouvel enrôlement. Les secrets, QR et codes ne sont pas mis en cache ni exposés dans les écrans techniques du back-office.
Exception locale explicite : ADMIN_MFA_BYPASS=true autorise le mot de passe seul pour tester le back-office, uniquement lorsque DJANGO_DEBUG=true. L'écran de connexion signale visiblement cette dérogation. Si le bypass est demandé avec le mode debug désactivé, Django refuse de démarrer ; l'option reste désactivée par défaut et ne modifie ni l'authentification JWT du SPA ni les droits du compte.
Limite conservée : le SPA/JWT n'a pas de second facteur. Son jeton auto-porteur est émis après le mot de passe seul. Étendre le MFA au SPA demande un parcours et une politique de jetons propres ; ce chantier reste hors du périmètre du MFA de l'administration.
Autorisation
L'autorisation se lit sur trois niveaux, du plus large au plus fin.
1. Le défaut du projet est fermé. DEFAULT_PERMISSION_CLASSES exige un compte authentifié : une vue qui ne déclare rien est protégée. Les routes publiques s'exemptent explicitement, et l'annexe indique lesquelles — c'est le sens de la colonne « Accès » de l'annexe C.
Six routes sont publiques. Quatre le sont par nécessité :
| Route | Pourquoi elle est publique |
|---|---|
/api/health/ | Interrogée par Docker et par le proxy, avant toute session |
/api/auth/token/ | C'est la route qui crée la session |
/api/auth/password/reset/ | L'utilisateur a perdu son mot de passe, par définition |
/api/auth/password/reset/confirm/ | Appelée depuis le lien reçu par courriel |
Les deux autres méritent d'être signalées, parce qu'elles ne sont pas publiques par nécessité mais par défaut de la bibliothèque qui les fournit : /api/schema/ et /api/docs/ — le schéma OpenAPI et l'interface Swagger — sont accessibles sans authentification. Un visiteur non authentifié peut donc lire l'inventaire complet des points d'entrée, de leurs paramètres et de leurs réponses. Cela ne donne accès à aucune donnée : chaque route reste protégée par sa propre règle. Mais cela publie la surface de l'API à qui atteint API_HOST, ce qui n'a pas de raison d'être sur un environnement portant des données réelles. Les restreindre à un compte authentifié est un réglage d'une ligne.
2. Les référentiels sont en lecture pour tous, en écriture pour l'équipe.IsStaffOrReadOnly : tout compte authentifié lit le référentiel de l'organisation et les types de contrat ; seul un compte is_staff y écrit.
3. Le dossier du personnel a une politique par périmètre. Deux règles distinctes :
En lecture, un appelant qui n'a pas la permission d'annuaire ne voit que ses propres lignes. La restriction est appliquée sur la requête, pas après coup : une ligne hors périmètre n'est pas filtrée à l'affichage, elle n'est pas retournée, et un accès direct par identifiant répond « introuvable » plutôt que « interdit » — ce qui évite de confirmer l'existence d'un dossier.
En écriture, la permission
employee.change_employee_recordest exigée — attribuée àAdmin,RH n1etRH n2. Elle garde la fiche, ses contrats, ses affectations et ses compteurs (EmployeeDataPermission). Trois exceptions déclarées : les volets « Urgence » et « Lieux de travail & équipes » d'une fiche, et l'ouverture d'un dossier salarié, portent leurs propres permissions, plus larges, parce qu'un Manager ou un DA les tient sans tenir le reste du dossier. Les trois sont visibles dans l'annexe C, sur les routes/api/employees/<pk>/emergency-contacts/,/api/employees/<pk>/attachments/et/api/employees/— cette dernière portée parEmployeeRowPermission, qui applique les permissions de création auPOSTetchange_employee_recordau reste.Décision (APAJH-191) :
is_staffn'ouvre rien dans le portail. Le dossier s'écrivait sousis_staff, en supposant que ce drapeau désignait exactementAdmin,RH n1etRH n2. Rien ne le garantissait : le Statut équipe se coche à la main sur le compte, indépendamment des rôles. UnRH n1sans lui ne pouvait rien écrire, unManagerqui l'avait écrivait tout. L'écriture passe donc par une permission de rôle, etis_staffne sert plus de raccourci ni à l'annuaire (view_employee_directoryseule), ni à la gestion du catalogue des types d'évènement (les rôlesAdmin,RH n1,RH n2seuls). Un compteis_staffsans rôle métier n'ouvre donc ni l'annuaire ni aucune fiche, et n'écrit pas les équipes, que le portail tient sous la permission des rattachements. Les autres référentiels (point 2) restent sousis_staff: le portail ne les écrit pas, le back-office si.Le portail le dit aussi. Le rôle nommé sous l'avatar vient des rôles que le compte détient réellement (
rolede/api/auth/me/), jamais deis_staff— qui affichait « Administrateur » à tout détenteur d'un accès back-office et « Salarié » à un DA. Il les lit des deux sources donthas_permfait l'union, l'attribution active et l'appartenance native à un groupe, pour que la puce et la couche d'autorisation ne disent pas deux choses du même compte. Un compte en cumule presque toujours plusieurs, puisque l'ouverture d'un dossier attribue Salarié à chacun : l'API tranche selon l'ordreROLE_DISPLAY_PRECEDENCEet n'en renvoie qu'un, pour que le SPA n'ait pas à connaître cette règle métier. Un compte qui n'en détient aucun n'est nommé d'aucun rôle plutôt que deviné.
4. Arbitrer une absence est une permission distincte de la lire. Les demandes d'absence portent deux permissions, et non une :
| Permission | Rôles | Ce qu'elle ouvre |
|---|---|---|
absences.view_absence_queue | Admin, RH n1, RH n2, DA, Manager n1, Manager n2 | La liste des demandes et le détail de l'une |
absences.decide_absence | Admin, RH n1, RH n2, Manager n1, Manager n2 | POST /api/absences/<pk>/validate/ et .../refuse/ |
planning.decide_shift_change | Admin, RH n1, RH n2, Manager n1, Manager n2 | POST /api/shift-change-requests/<pk>/validate/ et .../refuse/ |
Décision : le DA lit sans trancher. Un directeur adjoint ouvre une demande pour savoir où en est son établissement — l'identité, la période, le motif, les justificatifs, l'effet sur les compteurs — et n'accorde pas les congés. Une seule permission aurait fait de « peut ouvrir le panneau » et « peut signer » la même réponse ; avec deux, le panneau d'un DA s'ouvre en fiche de lecture, sans pied d'action, et non avec des boutons grisés. Le SPA le sait par deux drapeaux de /api/auth/me/, can_view_absence_queue et can_decide_absences — qui ne sont pas la frontière d'application : l'API refuse de toute façon.
Décision : une permission nommée « absence » gouverne aussi la lecture des demandes de modification. La file est celle des absences avant d'être celle des modifications : absences.view_absence_queue ouvre la lecture des deux, sur le même périmètre. Trancher une modification reste une permission à part, planning.decide_shift_change, sur les cinq mêmes rôles, et le SPA le sait par can_decide_shift_changes. Nul ne tranche sa propre demande, d'absence comme de modification.
Service Paie ne porte ni l'une ni l'autre, comme le reste de ses accès : ce rôle ouvre une fiche pour régler une question de paie, non pour tenir ou arbitrer un dossier. Il continue en revanche de déclarer une absence, ce que AbsenceAccessPermission garde seul : la file de validation est une autre question que la déclaration. C'est ce que dit AbsenceRowPermission dans l'annexe C, dont le nom désigne la politique — « lire une ligne d'absence exige la permission de file » — et non une capacité, pour que la ligne du POST ne prétende pas le contraire.
Décision : les absences filtrent enfin par périmètre, en lecture. GET /api/absences/ ne rend que les absences des établissements de l'attribution d'encadrement de l'appelant, avec la convention générale — un périmètre vide vaut toute l'association. La restriction est appliquée sur la requête et non après coup, donc un accès direct par identifiant répond « introuvable » plutôt qu'« interdit », comme pour le dossier du personnel. Deux conséquences assumées :
- un salarié sans lieu de travail principal est invisible à un encadrant restreint. La colonne reste optionnelle, donc l'état est réel ; montrer ces lignes à tout le monde ferait d'un rattachement manquant un contournement du périmètre, ce qui est la faille que personne ne remarque ;
- la déclaration n'est pas filtrée, parce qu'elle ne consulte pas la requête de liste. Un encadrant restreint peut donc déclarer une absence pour un salarié hors de son périmètre — et ne plus la voir ensuite. Fermer cela demande une règle sur la charge, la même qui manque encore aux modulations de poste et aux affectations de planning : ces deux ressources ne filtrent aucun périmètre, ni en lecture ni en écriture. Une telle règle existe désormais, mais pour l'autre public seulement :
validate_employeeenferme un compte sans encadrement dans sa propre fiche. L'étendre à un encadrant, c'est lui appliquer son périmètre, ce qui reste à faire.
Décision : Bibliothèque ▸ Équipes, l'assistant de planification, le planning et les évènements lisent leurs référentiels dans le périmètre. GET /api/divisions/, /api/establishments/ et /api/employees/ acceptent ?scope=perimeter (APAJH-286 ; l'assistant de planification s'y range avec APAJH-288, recherche libre comprise ; le planning pour le filtre « Établissement » et les candidats d'un « Poste à pourvoir » ; la file des absences pour ses trois filtres, la file elle-même et le tableau de bord suivant la même règle côté serveur ; l'annuaire Utilisateurs et l'annualisation, qui le lit, pour leurs salariés et leur filtre « Établissement ») : les équipes dont un établissement au moins est dans le périmètre de l'appelant, les établissements du périmètre, et ses salariés — ceux qui y travaillent, en lieu principal ou secondaire, et ceux affectés ce jour-là à l'une de ses équipes : la grille du planning dessine une équipe entière, si bien qu'un salarié qui y est affecté sans que l'établissement soit l'un de ses lieux de travail reste quelqu'un que ce manager planifie (apps.employee.perimeter.in_perimeter). Le périmètre est celui des rôles, pour tous les rôles d'encadrement — RH, DA et Admin compris : qui doit tout voir porte un rôle sur tous les établissements, ou un rôle sans établissement. Un compte qui n'encadre personne n'obtient rien, alors que manager_establishments le lirait comme non restreint. Le paramètre est opt-in et non le défaut : la fiche salarié nomme les affectations d'une personne à travers la liste des équipes et les réenregistre, et une équipe transverse hors du périmètre du lecteur ne doit pas en disparaître. Ce n'est donc pas une barrière en lecture : sans le paramètre, les trois listes restent entières.
Décision : l'écriture d'une équipe est bornée au périmètre, sans paramètre (APAJH-285). Modifier ou supprimer une équipe, remplacer ses membres ou en ajouter un ne vaut que pour une équipe dont un établissement au moins est dans le périmètre de l'appelant (apps.employee.perimeter.reachable_divisions) ; une autre répond « introuvable », parce que la requête d'écriture la filtre — la lecture sans paramètre, elle, la montre toujours. La règle porte ensuite sur ce qui change (outside_perimeter) :
- créer ou modifier une équipe n'ajoute ni ne retire qu'un établissement du périmètre, si bien qu'un manager renomme une équipe transverse sans lui ôter l'établissement d'un autre ;
- supprimer une équipe retire tous ses établissements à la fois : une équipe transverse qui déborde du périmètre n'est donc pas supprimable par un seul de ses managers (403, qui nomme l'établissement en cause) — il faut un rôle qui les couvre tous ;
- un salarié ajouté à une équipe doit être dans le périmètre (
members_outside_perimeter,in_perimeter), jugé avant l'écriture, par les trois chemins qui écrivent cette affectation : la composition d'une équipe, l'ajout d'un membre depuis le planning, et le volet « Lieux de travail & équipes » de la fiche, qui n'accepte en outre que des équipes du périmètre ; les membres déjà affectés restent, d'où qu'ils viennent. Ce n'est pas un confort d'écran : une affectation à l'une de ses équipes fait entrer un salarié dans le périmètre d'un manager — fiche, planning, absences —, si bien qu'accepter n'importe quel identifiant aurait été une escalade de périmètre.
Manager n1, Manager n2 et DA y sont soumis à l'identique — un DA qui doit tout voir porte un rôle sans établissement. Le Service Paie n'écrit aucune équipe (il ne détient pas employee.change_employee_attachments) et l'écran ne lui en propose aucune écriture.
Les évènements appliquent la même règle sans paramètre, leurs listes ne servant qu'à eux : GET /api/events/ et le tableau de bord ne montrent que les évènements d'un établissement du périmètre ou transversaux, un évènement ne s'enregistre que sur un établissement du périmètre, et /api/events/participant-options/ ne propose que ses salariés — chacun avec les seuls établissements du périmètre où il travaille, est payé ou affecté, ce que lit le filtre du bloc Participants. Admin et RH n'y ont plus de portée globale : elle contredisait la règle du rôle ; seule la gestion du catalogue des types, qui n'appartient à aucun établissement, leur reste propre.
5. Le périmètre n'est pas seulement celui d'un encadrant : c'est aussi sa propre fiche. Deux routes répondent désormais à deux publics, et la politique est l'asymétrie entre eux :
| Route | Un compte de l'espace manager | Tout autre compte authentifié |
|---|---|---|
GET /api/absences/ | Les absences de son périmètre, sous absences.view_absence_queue | Ses absences, sans permission de file |
GET /api/planning/ | La grille de ses établissements | Sa semaine, et les astreintes de ses équipes |
Décision : la bascule se lit sur le rôle, jamais sur la taille du périmètre. Les deux routes branchent sur la permission d'annuaire — la même que le dossier du personnel — et non sur « le périmètre est-il vide ». La raison est la convention rappelée plus haut : un périmètre vide vaut toute l'association, donc un salarié qui tomberait dans la branche encadrante lirait tout. C'est la régression que les deux suites de tests surveillent nommément.
Décision : le salarié demande et retire, il ne corrige pas. Sur /api/absences/, un compte hors espace manager atteint cinq actions — lire, lire une ligne, chiffrer une demande avant de l'envoyer (POST /api/absences/preview/, pour sa seule fiche), la déposer, retirer la sienne. Corriger — et chiffrer une correction — restent des gestes d'encadrant : un salarié qui veut autre chose redépose, et corriger une demande sous arbitrage appartient à qui la tranche. La règle est portée par AbsenceAccessPermission, qui remplace IsManagerWorkspace sur /api/absences/ et nomme la politique plutôt que la capacité, pour que la ligne de l'annexe C ne prétende pas le contraire.
Ce qu'une écriture de salarié ne peut pas forger. Trois garde-fous, tous côté serveur, parce qu'une règle que seul le formulaire applique n'est pas une règle :
- Pour qui —
AbsenceSerializer.validate_employeerefuse, sous la cléemployee, une charge nommant quelqu'un d'autre. Le refus plutôt que la réécriture silencieuse vers la fiche de l'appelant : une charge inattendue se rejette, elle ne se corrige pas d'autorité. - Dans quel état —
statusest en lecture seule sur le sérialiseur, et l'état de naissance se déduit de l'auteur (§ 02). Il n'y a donc aucun drapeau à falsifier pour s'accorder un congé. - Quelles lignes — le queryset ne rend que ses propres lignes, donc une suppression visant un collègue répond
404, et parmi les siennes seules une demande en attente et un congé accordé se retirent : effacer un refus serait réécrire ce qu'un encadrant a répondu.
Le salarié relit ses propres justificatifs, y compris ceux déposés par un gestionnaire. Pour un tiers, les justificatifs d’absence et d’honorabilité exigent absences.view_absencedocument_file et le périmètre d’établissement.
Les rôles
Neuf rôles métier : Admin, RH n1, RH n2, DA, Manager n1, Manager n2, Salarié, Intérimaire, Service Paie. Ils sont déclarés en un seul endroit du code et leurs permissions sont attribuées par migration, donc rejouables à l'identique sur un environnement neuf — un droit n'est jamais accordé par une manipulation manuelle qu'un autre environnement n'aurait pas.
Une attribution de rôle porte des dates et, éventuellement, une liste d'établissements : un remplacement se déclare une fois avec sa période et cesse d'ouvrir des droits de lui-même (§ 02).
Décision : seuls les rôles d'encadrement portent un périmètre. La liste d'établissements d'une attribution ne restreint que si le rôle attribué ouvre l'espace manager — donc tout sauf « Salarié » et « Intérimaire ». La raison est mécanique : chaque compte reçoit un rôle « Salarié » sans établissement à l'ouverture de son dossier, et un rôle sans établissement vaut pour toute l'association. Compter celui-là rendait tout encadrant non restreint, quel que soit le périmètre inscrit dans son rôle de manager, et le planning d'équipe (§ 02) lui montrait l'association entière. Un compte qui ne tient aucun rôle d'encadrement reste non restreint au sens de cette règle, sans conséquence : toutes les routes qui la lisent exigent par ailleurs l'espace manager, que ces deux rôles n'ouvrent pas.
Limite à connaître : les permissions attribuées aux neuf rôles couvrent ce dont le SPA a besoin, pas la tenue du référentiel dans le back-office. Un administrateur qui n'est pas super-utilisateur doit se voir attribuer ces permissions explicitement. C'est décrit dans le guide d'administration.
Collaboration et droits temporaires des astreintes — APAJH-104/105/107
La consultation et la gestion sont ouvertes à Admin, RH n1/n2, DA et Manager n1/n2, le Service Paie disposant de la seule lecture ; Salarié et Intérimaire n'accèdent pas au module. Ces droits sont contrôlés côté serveur sur les listes, accès directs et commandes d'enregistrement.
Décision : l'écran Astreintes revient au périmètre du rôle. Le cadrage initial l'avait ouvert à toute l'association ; il suit désormais la règle commune, Admin et RH compris : la liste ne montre que les salariés du périmètre (in_perimeter) et ceux qui détiennent une période couvrant l'un de ses établissements — une astreinte est publiée aux sites qu'elle couvre —, chacun avec les seuls établissements et équipes du périmètre ; les filtres et l'éditeur ne proposent que les établissements du périmètre, et un salarié hors périmètre répond « introuvable », en lecture comme en écriture. Une période déjà enregistrée sur un établissement hors périmètre reste conservée telle quelle à la correction.
Une astreinte ne crée jamais un rôle manager. Pour une personne possédant déjà un rôle manager actif, les établissements couverts s'ajoutent à son périmètre pendant l'intervalle effectif, début inclus et fin exclue. Ses permissions restent celles qu'elle possédait. L'extension est calculée à chaque requête : fin, correction ou suppression prennent effet sans tâche différée ni modification permanente des rôles. Les périodes anciennes sans établissement renseigné n'étendent aucun périmètre.
Cette extension alimente les consommateurs existants de manager_establishments. Elle ne corrige pas les endpoints qui n'appliquent pas encore de périmètre ; la correction générale des écritures de planning et d'absence reste distincte.
La commande groupée verrouille le salarié et vérifie sa révision avant toute écriture. Les droits sont revérifiés après acquisition du verrou. Les identifiants de périodes doivent appartenir au salarié ciblé ; une erreur annule le lot complet. Le back-office utilise le même verrou et avance la même révision. Une modification ordinaire de fiche ne peut pas écraser ce compteur de concurrence.
Exposition
| Ce qui est publié sur l'hôte | Port | Restriction |
|---|---|---|
proxy — HTTP | 80 | Aucune : c'est le point d'entrée |
proxy — HTTPS | 443 | Aucune, et seulement si la surcouche TLS est active |
haproxy — MySQL | 3306 | Filtre par adresse source (ALLOWED_DB_IPS) |
sftpgo — SFTP | SFTP_PUBLISHED_PORT | SSH, comptes dédiés et défense anti-bruteforce |
sftpgo — WebAdmin | SFTPGO_ADMIN_PORT | Boucle locale de l'hôte uniquement |
Le proxy aiguille par nom d'hôte, et le nom compte autant que le port :
| Nom d'hôte | Ce qu'il sert | Restriction |
|---|---|---|
FRONTEND_HOST | Le SPA | Authentification applicative (JWT) |
API_HOST | L'API, le back-office, Swagger | Par route, voir l'annexe C |
DOCS_HOST | Le portail de documentation | auth_basic, si DOCS_BASIC_AUTH est renseignée |
Tout le reste est inatteignable depuis l'hôte : la base, le cache, l'API Django et le SPA ne sont joignables qu'au travers du proxy ou du réseau interne (§ 04).
Le dépôt SFTP
Décision : FTP et FTPS ne sont pas proposés. Le seul protocole de dépôt est SFTP, qui chiffre l'authentification et le contenu dans SSH. Son port est publié directement parce que nginx ne parle que HTTP ; cette exception est bornée au service sftpgo et au réseau isolé transfer (§ 04).
Deux comptes de protocole séparent les responsabilités. Le déposant est chrooté sur le répertoire incoming et ne reçoit que le droit de téléverser ; il ne peut ni lister, ni relire, ni supprimer les données déjà envoyées. Le compte de service Django peut lister, lire et déplacer vers processed ou rejected, sans accès à l'administration SFTPGo. Le mode d'envoi atomique évite que le collecteur voie le nom final avant la fin d'un téléversement.
Le connecteur Django n'accepte pas une clé hôte découverte à la connexion. La clé publique attendue est fournie par SFTP_HOST_PUBLIC_KEY; une clé absente ou différente interrompt la collecte. Les mots de passe et cette configuration ne sont jamais inscrits dans l'image : ils viennent du fichier .env de l'environnement. Le défenseur SFTPGo limite les tentatives répétées.
Le WebAdmin n'est publié que sur 127.0.0.1 et s'ouvre au travers d'un tunnel SSH. Après l'amorçage, l'administrateur par défaut est remplacé par un compte nommé, avec TOTP activé ; les comptes déposant, importeur et administrateur ne partagent pas de secret.
Point d'attention sur les ports publiés. Le nom d'hôte de la documentation a le sien, plus bas.
ALLOWED_DB_IPS vaut par défaut les plages privées et la boucle locale. C'est un défaut de développement : sur un serveur partagé, il autorise tout ce qui se trouve sur le même réseau. Il doit être restreint aux postes des administrateurs de base de données.
Les documents ne disposent d’aucun port ni URL publique de stockage. Les routes API vérifient chaque consultation : JWT pour la SPA, session Django pour le back-office. Le volume media n’est pas monté dans nginx.
Ce que la garde du portail vaut, et ce qu'elle ne vaut pas
Le portail publie ce document — donc la posture de sécurité, les manques listés ci-dessous et la surface de l'API — ainsi que le guide d'administration, qui décrit les écrans du back-office. Rien de tout cela n'est une donnée personnelle, et rien n'est un secret : les identifiants et les clés ne sortent jamais du fichier d'environnement, et l'annexe B ne publie pas leur valeur. Mais l'ensemble constitue une carte de la plateforme, qui n'a pas de raison d'être lisible par n'importe qui.
D'où un auth_basic sur ce nom d'hôte, actif dès que DOCS_BASIC_AUTH est renseignée. Il est vide en développement local, où le nom n'est pas résolvable depuis l'extérieur.
Ce que cette garde apporte : elle écarte l'indexation par un moteur de recherche et la lecture opportuniste par qui devine un nom d'hôte. C'est exactement ce qu'on lui demande.
Ce qu'elle n'apporte pas, et qu'il faut avoir en tête avant de considérer le portail comme protégé :
- un identifiant partagé n'est pas un compte. Il n'y a ni utilisateur nommé, ni révocation individuelle, ni trace de qui a lu quoi. Le changer suppose de prévenir tout le monde ;
- aucun lien avec les comptes de la plateforme. Un salarié qui quitte l'association garde l'identifiant du portail jusqu'à ce que quelqu'un le change ;
- la protection dépend du transport. En HTTP simple,
auth_basictransmet le couple en clair à chaque requête (encodé, pas chiffré). Sur le serveur de développement le TLS est actif, donc le couple circule chiffré ; sur un environnement qui servirait le portail en HTTP simple, cette garde ne vaudrait rien contre qui écoute le réseau.
Autrement dit : la garde est proportionnée à un contenu qui n'est pas confidentiel, et il ne faut pas l'étendre à un contenu qui le serait. Le jour où le portail devrait porter une information réellement sensible, la réponse ne serait pas un meilleur mot de passe partagé mais une authentification adossée aux comptes de la plateforme.
Le proxy et les en-têtes
Le backend est configuré pour se savoir derrière un proxy (SECURE_PROXY_SSL_HEADER) : il lit X-Forwarded-Proto pour déterminer si la requête d'origine était en HTTPS. Les origines de confiance CSRF sont alimentées par la même liste que CORS, donc une origine ajoutée l'est pour les deux à la fois — un oubli sur l'une des deux étant le défaut le plus fréquent de cette configuration.
Ce qui n'est pas activé : la redirection forcée vers HTTPS, HSTS, et le marquage Secure des cookies. La cause est expliquée au § 06 : en développement local, une redirection HTTPS casse les appels d'API entre deux origines dès la requête de pré-vol. Ces trois réglages appartiennent à une séparation des configurations par environnement, qui n'existe pas encore et est listée comme manque au § 06.
Limitation de débit
Trois limites, adossées au cache Redis :
| Portée | Limite | Pourquoi |
|---|---|---|
| Anonyme | 60 requêtes par minute | Plancher général |
| Authentifié | 1000 requêtes par minute | Usage normal du SPA |
| Demande de réinitialisation | 5 par heure | Cette route déclenche un envoi de courriel et permet d'énumérer des adresses |
Données personnelles
La plateforme traite des données de personnels : identité, coordonnées, situation familiale, personnes à prévenir, contrats. Trois décisions de conception y répondent.
La minimisation est dans le modèle, pas dans l'écran. Les données d'enfants se limitent par défaut au nombre et à l'âge. Ce n'est pas un masquage à l'affichage : la donnée détaillée n'est pas collectée.
Décision : le n° de sécurité sociale ne quitte l'API que pour trois rôles. C'est un identifiant national, et le périmètre qui ouvre une fiche est plus large que celui qui administre un dossier : un DA et les deux rangs de Manager lisent toutes les fiches pour planifier des personnes. Le champ répond donc à une permission à part — employee.view_social_security_number, attribuée à Admin, RH n1 et RH n2 — et non à view_employee_directory, que tout l'encadrement porte. Le sérialiseur retire la clé de chaque ligne servie, au détail comme en liste, plutôt que de la vider : une chaîne vide serait indiscernable d'un numéro jamais saisi. L'écriture suit la lecture : la seule présence de la clé est refusée, sans regarder sa valeur — sur une fiche existante, une chaîne vide effacerait le numéro enregistré aussi aveuglément qu'une valeur fausse l'écraserait. Le refus est un 403, jamais un abandon silencieux du champ, qui laisserait croire que le numéro a été pris. Conséquence assumée : un salarié ne relit pas son propre numéro depuis l'API — la fiche est derrière l'espace d'encadrement, qu'un compte sans rôle n'ouvre pas.
Décision : protéger les deux types de justificatifs avec la même règle. Le propriétaire de la fiche peut lire ses documents, quel que soit le déposant. Un tiers doit détenir absences.view_absencedocument_file (Admin / RH n1 / RH n2) et appartenir au périmètre autorisé : établissement de travail principal ou secondaire, ou affectation active à l'une de ses équipes, selon la règle commune apps.employee.perimeter.in_perimeter. Un compte sans rôle d'encadrement n'ouvre aucun dossier tiers, même avec la permission de consultation. Un rôle RH non borné conserve la convention « toute l’association ». Les serializers retirent l’URL aux lecteurs non autorisés ; le nom du fichier, la date et le déposant restent consultables. Chaque téléchargement revérifie les droits : absence d’authentification 401, permission manquante 403, dossier hors périmètre ou fichier absent 404.
L'asymétrie est assumée : on dépose sans pouvoir relire. Le dépôt reste ouvert au périmètre manager, parce que c'est l'encadrant qui tient le papier. Exiger le droit de lecture pour déposer aurait ouvert tous les certificats médicaux à l'espace manager : des deux risques, c'est le plus grave.
Les fichiers sont privés et servis uniquement par l’API.GET /api/absence-documents/<id>/file/ et GET /api/employees/<id>/honorability-certificate/file/ renvoient une FileResponse avec nom, type MIME, nosniff et Cache-Control: private, no-store. La SPA télécharge via axios avec son JWT et ouvre un blob ; le back-office utilise sa session. Les suppressions et remplacements enlèvent les anciens fichiers après commit, jamais lors d’un rollback. Les consultations ne sont pas encore journalisées dans le journal métier ; leur passage par l’API le permet désormais.
Aucune purge n'est en place, et c'est un choix daté. Une pièce vit tant que l'absence vit et disparaît avec elle ; rien ne supprime les justificatifs d'une absence encore en base. La durée légale de conservation d'un arrêt de travail n'a pas été arrêtée avec l'association, et l'inventer serait pire que de ne rien purger. La question est la même que celle des dossiers de salariés sortis, plus bas, et se réglera avec elle — sur le patron de purge_audit_logs.
Le lien de réinitialisation de mot de passe est un porteur d'identité — qui le détient peut changer le mot de passe. Sa durée de vie est donc courte (une heure par défaut, contre trois jours dans Django), et le backend refuse de démarrer si le mode d'envoi « console » est laissé actif hors développement : ce mode écrit le courriel entier, lien compris, dans les journaux. Un garde explicite plutôt qu'une convention, parce que la conséquence serait une prise de contrôle de compte publiée dans une journalisation centralisée.
L'application Entra ne peut envoyer que depuis la boîte dédiée. L'application APAJH SIRH Mailer s'authentifie avec un certificat, sans compte ni mot de passe, et Exchange Application RBAC limite Application Mail.Send à alertes-sirh@apajh81.org, dans le périmètre APAJH SIRH Mailer - Sender. Aucune permission applicative globale Mail.Send n'est accordée dans Entra : elle s'ajouterait au rôle Exchange et annulerait de fait ce cloisonnement. Le backend refuse aussi localement tout expéditeur différent avant de demander un jeton.
Les ID du locataire et de l'application restent dans le fichier d'environnement et le coffre de secrets. La clé privée, le certificat encodé, les jetons, le contenu MIME et les liens de réinitialisation ne sont ni journalisés ni intégrés à cette documentation.
Le bouton « Assumer l'identité » est désactivé par défaut. Quand ENABLE_USER_IMPERSONATION est actif, un compte de l'équipe peut fabriquer un jeton valide pour n'importe quel autre compte depuis le back-office, sans son mot de passe. C'est un outil de recette, et il n'a rien à faire sur un environnement portant des données réelles.
Les fichiers CEGI sont des données personnelles. Ils regroupent identité, coordonnées, numéro de sécurité sociale et données de contrat. Le déposant ne peut pas les relire, les archives réussies sont conservées 30 jours, les rejets 90 jours, puis le contenu est supprimé. Le journal applicatif ne recopie aucune ligne du fichier.
Journalisation et traçabilité
Le back-office enregistre les créations, modifications et suppressions faites au travers de lui dans le journal d'administration de Django. Cette source reste réservée au back-office : elle n'est ni recopiée ni fusionnée avec la piste de l'API. Une exception, les modulations : voir plus bas.
Les écritures API des domaines salarié et compteurs — fiches, contrats, affectations, personnes à prévenir, rattachements et corrections de soldes — produisent une ligne dans le Journal des actions. Les acquisitions automatiques de compteurs utilisent la même piste avec l'acteur Système. Une ligne porte le compte acteur et son identité figée, l'heure, le verbe, le modèle et l'identifiant ciblés, ainsi que les valeurs avant et après. L'identité figée survit au renommage ou à la suppression du compte ; la représentation de l'objet survit de la même façon à sa suppression.
Les deux décisions d'absence rejoignent cette piste. Valider ou refuser une demande écrit sa ligne dans la même transaction que la décision : qui a tranché, quand, quelle demande, l'état avant et après, et le motif du refus. Une trace d'une décision annulée par un compteur insuffisant serait la trace de quelque chose qui n'a pas eu lieu — d'où la transaction unique, et non un enregistrement « au mieux ».
Les trois autres écritures d'absence les rejoignent. Déclarer, corriger et supprimer étaient hors piste ; AbsenceViewSet porte désormais le mixin d'audit, qui trace la création et la correction, et la suppression écrit sa propre ligne — avant l'effacement et dans la même transaction, puisque le journal est tout ce qui reste de la ligne une fois qu'elle a disparu. Le domaine des absences est ainsi entièrement tracé : déposer, déclarer, corriger, trancher, retirer. C'est le dépôt d'une demande par un salarié qui l'a rendu nécessaire — une écriture faite depuis un compte sans encadrement est exactement ce qui a besoin d'une trace.
Les modulations de journée rejoignent cette piste, back-office compris. Toute écriture d'un ShiftOverride — « Modifier la journée », « Affecter un poste », le repos ponctuel, la suppression — laisse sa ligne, et la journée y figure avec ses créneaux (heures, poste, établissement, pause, options) : ce sont eux qui disent ce qui a changé. Un créneau y figure sans sa clé, puisque chaque enregistrement les réécrit tous ; sans cela, réenregistrer une journée inchangée laisserait une ligne. Le back-office écrit dans la même piste, et non dans le seul journal de Django : une journée est la même donnée, qu'on la change au planning ou au back-office, et c'est la donnée sur laquelle un salarié pourra bientôt demander une modification — la question « qui a changé ma journée, et que disait-elle ? » doit avoir une seule réponse. La ligne est écrite dans la transaction de l'écriture, et le recalcul de l'annualisation l'enveloppe : la journée « avant » est lue sous le verrou du salarié que prennent aussi l'API et le back-office. Lue plus tôt, elle manquerait une écriture concurrente committée entre-temps, et la piste dirait faux sur ce qu'était la journée.
Les demandes de modification de journée y figurent aussi, créneaux compris : le dépôt et le retrait par le salarié, la correction au back-office, la validation et le refus par qui tranche, depuis l'API comme depuis le back-office. Un accord écrit deux lignes dans la même transaction : la transition de la demande, et l'écriture de la modulation qu'elle devient — la journée a changé, et c'est sur la modulation que se pose la question « qui a changé ma journée ? ».
Restent hors piste les trames, les affectations de planning, les postes et les évènements. Ces domaines pourront adopter le même mixin explicitement, app par app. Les autres imports, seeds et tâches de fond restent hors de cette piste générale.
Décision : la localisation est métier, pas réseau. Le modèle, l'identifiant, la représentation de l'objet et les champs modifiés répondent à « où la modification a-t-elle eu lieu ? ». L'URL, la query string et l'adresse IP ne sont pas conservées : les écritures de ce journal viennent de l'API, du back-office ou du système, et une adresse vue derrière un proxy ne désigne pas de façon fiable une personne.
Les différences conservent les valeurs personnelles sans masquage, dont les anciennes valeurs. Leur lecture exige donc à la fois le statut équipe donnant accès au back-office et la permission explicite audit.view_actionlog. Le modèle n'accorde aucun droit d'ajout, de modification ou de suppression, et son écran refuse ces opérations même à un super-utilisateur.
La conservation est glissante : AUDIT_LOG_RETENTION_DAYS est obligatoire, strictement positif et vaut 1 095 jours dans la configuration de référence. Chaque dimanche à 03:47, Celery supprime les lignes strictement plus anciennes. La commande make purge-audit expose la même opération aux équipes d'exploitation.
L'import CEGI possède sa propre piste ciblée : empreinte et nom du fichier, horodatages, encodage, compteurs, statut, code d'erreur et résultat de chaque ligne. Une reprise depuis le back-office conserve l'opérateur et crée une nouvelle tentative sans écraser la précédente. Ces journaux sont conservés trois ans. Ils complètent la piste générale sans y mêler les traitements automatiques.
Récapitulatif des manques
| Manque | Portée | Où il est suivi |
|---|---|---|
| Second facteur d'authentification | Back-office et SPA | Ce document, ci-dessus |
| Schéma OpenAPI et Swagger ouverts | Serveur exposé | Ce document, ci-dessus |
| Journal métier des consultations de justificatifs non instrumenté | Données de santé | Ce document, ci-dessus |
| Périmètre absent des modulations de poste et des affectations de planning, et de la déclaration d'absence | Espace manager | Ce document, ci-dessus |
| Aucune purge des justificatifs d'absence | Production | Ce document, ci-dessus |
ALLOWED_DB_IPS par défaut trop large | Production | Ce document, ci-dessus |
Redirection HTTPS, HSTS, cookies Secure | Production | § 06, séparation des configurations |
Chacun figure aussi dans la liste « Not built yet » d'AGENTS.md quand il s'agit d'un choix de conception, et non d'un réglage d'environnement.
Droits propres aux EVP
La permission employee.view_payroll, attachée au modèle salarié, est attribuée à Admin, RH n1, RH n2 et Service Paie. Elle pilote les deux routes de consultation et le drapeau can_view_payroll du portail. Le statut équipe seul ne donne pas ce droit.
Le périmètre est calculé à partir des attributions actives qui portent cette permission. Un autre rôle de manager ou une astreinte ne l'élargit pas. Un rôle autorisé sans restriction, un groupe Django natif autorisé ou une permission directe donnent un périmètre global, comme un super-utilisateur actif. Les contrats sont filtrés par établissement de paie avant toute collecte de données.
Le détail des absences expose motif, dates et quantité, jamais les commentaires, raisons de décision ou justificatifs. Une astreinte n'apparaît que si elle couvre l'établissement du contrat ; les noms des autres établissements couverts ne sont pas transmis. Les évènements d'un autre établissement sont exclus. Aucune écriture métier ni transmission de fichier n'est effectuée par la page.