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é : il n'y a pas de révocation. Désactiver un compte empêche toute nouvelle connexion, mais un jeton déjà émis reste valable jusqu'à son expiration. Une révocation immédiate 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.
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. La personne emprunte ensuite le « mot de passe oublié » décrit ci-dessus.
Aucun mot de passe par défaut n'est fabriqué, et il n'y a pas de courriel d'invitation — c'est un manque délibéré. Un mot de passe généré aurait dû voyager par un canal quelconque pour être communiqué ; un compte inerte n'a rien à transmettre.
Pas de révocation d'accès depuis le SPA
Manque délibéré. Fermer l'accès d'un salarié — un contrat terminé, un départ — se fait en décochant Actif sur son compte dans le back-office. L'application n'offre aucune action de révocation, et n'affiche même pas l'état du compte sur la fiche salarié.
L'orientation retenue pour la suite est une révocation automatique : une tâche planifiée quotidienne (§ 04, ordonnancement) désactiverait les comptes dont plus aucun contrat n'est en cours. Le risque à traiter à ce moment-là est nommé ici parce qu'il découle du ticket précédent : un dossier en retard sur la réalité — un avenant non encore saisi, un CDD qui enchaîne — verrait son accès coupé alors que la personne est présente. C'est exactement la situation qu'APAJH-205 rend possible en autorisant un dossier incomplet.
Voir aussi le compromis sur les jetons ci-dessus : même désactivé, un compte garde un jeton valable jusqu'à son expiration.
Second facteur du back-office
Décision : le back-office exige un TOTP après le mot de passe. Un compte actif avec is_staff — Statut équipe dans l'interface — 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,
is_staffest exigé, avec 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, parce qu'un Manager ou un DA les tient sans jamais atteindre le back-office. 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 rôle auPOSTet la règleis_staffau reste.La conséquence de ce choix est assumée dans les deux sens : un compte
is_staffsans rôle métier ne peut pas créer de salarié, parce queis_staffn'est pas un rôle.
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.
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) |
minio — API objet et console | 9000, 9001 | Aucune |
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.
Deux points 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 deux ports de minio sont publiés sans filtre, dont sa console d'administration, qui accepte les identifiants du compte de service du stockage. C'est acceptable pour un poste de développement et ne l'est pas sur un serveur exposé. Deux corrections possibles, aucune n'étant en place : restreindre la publication à la boucle locale, ou retirer la publication et passer par le proxy.
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.
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.
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.
Les écritures API du domaine salarié — fiches, contrats, affectations, personnes à prévenir et rattachements — produisent une ligne dans le Journal des actions. 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 autres domaines d'écriture de l'API, notamment les trames, le planning et les postes, ne sont pas encore instrumentés. Ils pourront adopter le même mixin explicitement, app par app, sans englober les imports, seeds ou tâches de fond.
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 : toutes les écritures de ce journal viennent déjà de l'API, 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 |
Publication non filtrée de minio | Serveur exposé | 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.