Skip to content

Journal des connexions : traçabilité des accès et alerte sur contexte inconnu - #5

Merged
THIOYE97 merged 1 commit into
mainfrom
feat/journal-connexions
Aug 7, 2026
Merged

THIOYE97 merged 1 commit into
mainfrom
feat/journal-connexions

Conversation

@THIOYE97

@THIOYE97 THIOYE97 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Pourquoi

Le TDR exige des traces historisées systématiquement. Aujourd'hui, une tentative d'accès n'est écrite que dans le journal applicatif (stdout de l'hébergeur) : ni requêtable, ni conservée. On ne peut donc répondre ni à « qui s'est connecté, depuis où, et quand », ni voir une série d'échecs se former.

Ce que fait cette PR

Table login_events, alimentée aux quatre points de /auth : connexion réussie, échec avec son motif (unknown_user / bad_password / disabled), déconnexion, prolongation de session. Plus users.last_login_at / last_login_ip.

Deux choix de conception explicités dans le code : pas de clé étrangère vers users — la trace doit survivre à la suppression du compte suspect, et un échec sur une adresse inconnue n'a par définition aucun utilisateur à référencer. Et toute écriture passe par record_safe : un journal en panne dégrade la traçabilité, il ne doit pas fermer l'accès à la plateforme.

Détection de contexte inconnu — une connexion est signalée quand son adresse IP ou son appareil n'a jamais été observé pour ce compte. La première connexion d'un compte l'est donc toujours : c'est précisément l'événement qu'on attend après avoir remis des accès à un tiers.

Alerte par courriel via le relais Brevo déjà en place, envoyée en tâche de fond pour ne pas peser sur le temps de connexion. Les identifiants SMTP n'existaient que sur les services cron : ils sont ajoutés au service web dans render.yaml, faute de quoi l'alerte serait restée inerte en production.

Console de sécurité /security/*, réservée au super-administrateur, en trois lectures complémentaires : qui a tenté d'accéder, qui est connecté maintenant (sessions ouvertes), et qui ne s'est jamais connecté (comptes dormants). Ces endpoints passent délibérément par le rôle d'authentification et non par la session RLS : la politique `tenant_isolation_users` filtre par tenant sans échappatoire super-admin, une session RLS ne verrait qu'un seul tenant.

Correction d'un défaut découvert au passage

`is_super_admin` n'était calculé nulle part : la revendication émise dans l'access token valait toujours `false`. Toutes les portes qui en dépendent étaient donc closes pour tout le monde — bascule de tenant `X-Tenant-Id`, contexte RLS élargi, écrans d'administration.

Elle est désormais lue depuis le rôle `SUPER_ADMIN` de `user_roles`, au login comme au renouvellement (sans quoi un refresh dégradait silencieusement le jeton). Aucun compte ne porte ce rôle par défaut : rien ne change pour les comptes existants. L'attribution se fait par script :

```bash
python -m app.scripts.grant_super_admin conformite@bcrg-guinee.org
python -m app.scripts.grant_super_admin --list
```

Vérifications

  • 19 tests ajoutés : 9 unitaires (courriel d'alerte, garde-fous) et 10 d'intégration sur Postgres réel (écriture, contexte inconnu non signalé deux fois, échecs journalisés, fermeture de la console aux non super-admins) ;
  • suite complète : 152 tests verts ;
  • migration s4_login_events appliquée et annulée sur une base jetable — colonnes, index et GRANT conformes ;
  • ruff check propre.

Après fusion

Le preDeployCommand de Render applique alembic upgrade head avant que le nouveau code ne prenne le trafic : la migration se pose seule. Restent à saisir dans le dashboard Render, sur le service web, les variables sync: false : BREVO_SMTP_LOGIN, BREVO_SMTP_KEY, BREVO_FROM_EMAIL, LOGIN_ALERT_TO_EMAIL. Sans elles, le journal enregistre tout normalement — seul l'envoi du courriel est ignoré.

Le front correspondant (page « Journal de connexions ») fait l'objet d'une PR séparée sur le dépôt frontend. L'ordre de déploiement est indifférent.

… inconnu

Le TDR exige des traces historisées systématiquement. Les tentatives d'accès
n'étaient écrites que dans le journal applicatif (stdout de l'hébergeur) :
ni requêtables, ni conservées. Impossible donc de répondre à « qui s'est
connecté, depuis où, et quand » — ni de voir une série d'échecs.

Table `login_events` (sans clé étrangère vers `users` : la trace doit
survivre à la suppression du compte, et un échec sur une adresse inconnue n'a
aucun utilisateur à référencer), alimentée aux quatre points de /auth :
connexion réussie, échec avec son motif, déconnexion, prolongation de session.
Plus `users.last_login_at` / `last_login_ip`.

Une connexion est signalée « contexte inconnu » quand son adresse IP ou son
appareil n'a jamais été observé pour ce compte ; la première connexion l'est
donc toujours. Elle déclenche un courriel par le relais Brevo déjà en place,
envoyé en tâche de fond pour ne pas peser sur le temps de connexion. Les
identifiants SMTP n'existaient que sur les crons : ils sont ajoutés au service
web, faute de quoi l'alerte serait restée inerte en production.

Toute écriture passe par `record_safe` : un journal en panne dégrade la
traçabilité, il ne doit pas fermer l'accès à la plateforme.

Console de sécurité `/security/*` réservée au super-administrateur, en trois
lectures : qui a tenté d'accéder, qui est connecté maintenant, et qui ne s'est
jamais connecté. Ces endpoints passent par le rôle d'authentification et non
par la session RLS : la politique `tenant_isolation_users` filtre par tenant
sans échappatoire super-admin.

Correction d'un défaut découvert au passage : `is_super_admin` n'était calculé
nulle part, la revendication du jeton valait donc toujours faux. Toutes les
portes qui en dépendent — bascule de tenant, contexte RLS élargi, écrans
d'administration — étaient closes pour tout le monde. Elle est désormais lue
depuis le rôle SUPER_ADMIN de `user_roles`, au login comme au renouvellement
(sans quoi un refresh dégradait silencieusement le jeton). Aucun compte ne
porte ce rôle par défaut : rien ne change pour les comptes existants.
`app/scripts/grant_super_admin.py` permet de l'attribuer.

19 tests ajoutés (9 unitaires, 10 d'intégration). Migration s4 vérifiée à
l'aller et au retour sur une base jetable.
@THIOYE97
THIOYE97 merged commit e4f249c into main Aug 7, 2026
3 checks passed
@THIOYE97
THIOYE97 deleted the feat/journal-connexions branch August 7, 2026 16:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant