feat(gdpr): la file de suppression, inerte — le schéma sans l'exécuteur#282
Conversation
`/app/settings/deletion-status` affiche une date de suppression et un décompte de jours. Rien ne l'exécute : `executeDeletion()` n'a aucun appelant depuis avril. Ce n'est pas une omission, c'est une affirmation inexacte faite à la personne concernée (art. 12(1)) sur un droit garanti par l'art. 17. Cette PR pose ce sur quoi l'exécuteur reposera, et RIEN d'autre. Invariant tenu : `executeDeletion` n'acquiert aucun appelant. Rien ne peut supprimer un compte après ce merge — la route de cron part en PR-B, dont la revue ne portera alors que sur une question : est-ce que ça peut partir quand il ne faut pas ? Décisions : ADR-024. Deux conceptions ont été écrites puis abandonnées, chacune tuée par une mesure — `FORCE ROW LEVEL SECURITY` s'applique au propriétaire de la table, et `service_role` n'a aucun privilège sur le schéma `auth`. L'atomicité SQL de bout en bout est donc impossible par toute conception : c'est la frontière entre PostgREST et GoTrue. La garantie devient l'idempotence et la reprise, et aucun privilège nouveau n'est demandé. Ce que contient la migration : - statut `processing` + colonne `claimed_at` ; - collapse défensif des demandes `pending` en double, puis index unique partiel couvrant LES DEUX statuts actifs — sinon la remise en file violerait sa propre contrainte ; - `claim_pending_deletions()`, `SECURITY INVOKER`, dont le verrou de reprise teste `claimed_at` et JAMAIS `requested_at`. Une ligne n'étant réclamable que 14 jours après sa demande, un test sur `requested_at` serait toujours vrai : chaque exécution volerait le lot d'une exécution vivante, et le même compte serait supprimé deux fois ; - `deletion_self_insert` SUPPRIMÉE plutôt que durcie. Aucune insertion client n'existe (mesuré) ; la politique accordait une capacité que le produit n'utilise pas, et armée c'était une auto-suppression immédiate ; - `purge_audit_log_older_than_12_months()` passe en `SECURITY INVOKER` : en `DEFINER` sur une table `FORCE RLS` elle pouvait ne rien supprimer sans lever. Côté code, l'orchestration destructrice sort de l'enveloppe `server-only` (`deletion-core.ts`). Mesuré ici même : Playwright charge `deletion-core`, et lève sur `deletion.ts` à `server-only/index.js:1`. C'est ce qui rend la seule instruction irréversible du système enfin exécutable de bout en bout en CI. Trois réponses honnêtes remplacent trois silences : une demande en double renvoie l'échéance EXISTANTE et non une nouvelle ; une annulation dit quand rien n'a été annulé, et n'écrit plus de ligne d'audit affirmant le contraire ; `processing` a enfin sa branche d'affichage — il tombait jusqu'ici dans « Complétée », en rouge. Le trou trouvé par la revue de plan : la page settings filtrait sur `status='pending'` seul, donc pendant `processing` elle réaffichait le formulaire de demande et retirait le seul lien vers l'écran de statut — exactement quand l'effacement devenait irréversible. Falsification : 7 mutations du code et 3 du schéma, chacune rouge sur le test qui la garde, vertes après restauration. Planchers e2e mesurés, jamais estimés. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.
Please try again later or upgrade to continue using Sourcery
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Guide du/de la relecteur·riceImplémente le schéma de base de données, la logique cœur et les tests pour une file d’attente de suppression de compte RGPD reprenable, sans encore raccorder l’exécuteur à un appelant, tout en resserrant les sémantiques de requête/annulation et les messages d’interface afin que le produit ne fasse plus de déclarations approximatives sur l’état de la suppression. Diagramme de séquence pour le nouveau flux d’annulation et les issues honnêtessequenceDiagram
actor User
participant SettingsClient
participant cancelAccountDeletionAction
participant cancelDeletion
participant Supabase as deletion_requests
participant Audit as logAuditEvent
User->>SettingsClient: click cancel account deletion
SettingsClient->>cancelAccountDeletionAction: cancelAccountDeletionAction()
cancelAccountDeletionAction->>cancelAccountDeletionAction: rateLimit('mutation', user)
cancelAccountDeletionAction->>cancelDeletion: cancelDeletion(user.id)
cancelDeletion->>Supabase: update deletion_requests
activate Supabase
Supabase-->>cancelDeletion: { data, error }
deactivate Supabase
alt error
cancelDeletion-->>cancelAccountDeletionAction: throw Error
cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelFailed }
else success
cancelDeletion-->>cancelAccountDeletionAction: CancelDeletionResult
alt result.cancelled == true
cancelAccountDeletionAction->>Audit: logAuditEvent(GDPR_DELETION_CANCELLED)
Audit-->>cancelAccountDeletionAction: ok
cancelAccountDeletionAction-->>SettingsClient: { ok:true }
else result.cancelled == false and reason == in_progress
cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelTooLate }
else result.cancelled == false and reason == none
cancelAccountDeletionAction-->>SettingsClient: { ok:true }
end
end
Modifications au niveau des fichiers
Conseils et commandesInteragir avec Sourcery
Personnaliser votre expérienceAccédez à votre dashboard pour :
Obtenir de l’aide
Original review guide in EnglishReviewer's GuideImplements the database schema, core logic, and tests for a resumable GDPR account deletion queue, without yet wiring the executor to any caller, while tightening request/cancel semantics and UI messaging so the product no longer makes inexact statements about deletion status. Sequence diagram for updated cancellation flow and honest outcomessequenceDiagram
actor User
participant SettingsClient
participant cancelAccountDeletionAction
participant cancelDeletion
participant Supabase as deletion_requests
participant Audit as logAuditEvent
User->>SettingsClient: click cancel account deletion
SettingsClient->>cancelAccountDeletionAction: cancelAccountDeletionAction()
cancelAccountDeletionAction->>cancelAccountDeletionAction: rateLimit('mutation', user)
cancelAccountDeletionAction->>cancelDeletion: cancelDeletion(user.id)
cancelDeletion->>Supabase: update deletion_requests
activate Supabase
Supabase-->>cancelDeletion: { data, error }
deactivate Supabase
alt error
cancelDeletion-->>cancelAccountDeletionAction: throw Error
cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelFailed }
else success
cancelDeletion-->>cancelAccountDeletionAction: CancelDeletionResult
alt result.cancelled == true
cancelAccountDeletionAction->>Audit: logAuditEvent(GDPR_DELETION_CANCELLED)
Audit-->>cancelAccountDeletionAction: ok
cancelAccountDeletionAction-->>SettingsClient: { ok:true }
else result.cancelled == false and reason == in_progress
cancelAccountDeletionAction-->>SettingsClient: { ok:false, errorCode:errors.settings.deletionCancelTooLate }
else result.cancelled == false and reason == none
cancelAccountDeletionAction-->>SettingsClient: { ok:true }
end
end
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…erge pour une preuve Rapport PR-3B-A : les trois lectures production, les 10 mutations de falsification, les planchers e2e mesurés deux fois, et l'écart art. 17 de l'issue #278 énoncé sans détour — une personne qui exerce son droit à l'effacement verra son adresse e-mail rester en base, en clair, tant que `auth.audit_log_entries` n'est pas traitée. Registre de conformité (§6.5) : la vérification d'efficacité du correctif #273 en production est faite, datée, et racontée telle qu'elle s'est passée. Le premier relevé rendait des chiffres identiques à ceux de la veille — il ne distinguait pas « le correctif est cassé » de « personne ne s'est connecté ». C'est la reconnexion réelle qui a produit la preuve. Sa clôture affirmait « contresigné par le merge de la PR #273 ». Un merge ne démontre que le départ d'un code, jamais qu'une écriture atterrit. Corrigé. de-DE : le pronom `Sie` reprenait `die Löschung` et était correct, mais en tête de phrase il ressemble à une fuite de vouvoiement pour un relecteur ou un grep de registre. Reformulé. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Résidu d'une édition in-place pendant les mutations de test, committé par inadvertance. Aucun impact fonctionnel — mais un fichier source dupliqué dans le dépôt est exactement le genre de chose qu'on retrouve six mois plus tard en se demandant lequel fait foi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test-quality-auditor`, qui dispose de Bash, a injecté un import dynamique de `@/lib/supabase/admin` dans `deletion-core.ts` pour éprouver le test de frontière de module — et la ligne a été committée en 43b8ec8 parce que je committais depuis le même répertoire de travail que celui où l'agent opérait. Cause : la règle « un seul agent par répertoire de travail, worktree sinon » n'a pas été appliquée. L'expérience de l'agent était légitime ; sa présence dans un commit ne l'est pas. Sa conclusion, elle, est retenue : le test de frontière lit les imports de premier niveau et n'aurait PAS attrapé cet import dynamique. Consigné. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… garanties annoncées qui n'existaient pas
Trois défauts, tous trouvés en lisant l'état réel plutôt que le texte qui le
décrit.
1. GRANTS — `revoke execute … from public` ne retire QUE le grant du pseudo-rôle
PUBLIC. Les privilèges par défaut de Supabase accordent en plus un grant
EXPLICITE à `anon` et `authenticated` sur toute nouvelle fonction de `public`.
Deux choses différentes qui se lisent pareil. Mesuré :
claim_pending_deletions :: postgres=X | anon=X | authenticated=X | service_role=X
POST rpc/claim_pending_deletions avec la clé ANON -> HTTP 200
Impact aujourd'hui : nul. SECURITY INVOKER + FORCE RLS = zéro ligne touchée,
`[]` en réponse. Fermé quand même : moindre privilège, et un endpoint non
authentifié qui émet deux UPDATE sur une table RGPD ne doit pas dépendre de
politiques inchangées pour rester inoffensif.
Après : anon -> 42501/401, service_role -> 200 avec 5 lignes. Les deux sens.
2. Le commentaire de `deletion-status/page.tsx` affirmait que lister les statuts
ferait apparaître un futur statut comme cas manquant. Faux : `status` est
typé `string` et une chaîne de ternaires n'a aucun contrôle d'exhaustivité.
Un cinquième statut se serait affiché « Complétée », en rouge — annoncer un
compte effacé alors qu'il ne l'est pas. Table de correspondance + repli
neutre `statusUnknown` (5 locales).
3. `ARCHITECTURE.md` décrivait deux e-mails qui n'existent pas, et deux
affirmations que cette PR venait de rendre fausses. Réécrit sur le code.
Plus : le commentaire de migration disait « 14 jours » quand le code en écrit 30.
La purge de rétention est enfin observée : 2 lignes semées à -13 et -14 mois, la
fonction rend 2, il en reste 0. Première fois depuis avril qu'on la voit faire
quelque chose. Elle reste sans appelant — PR-B l'arme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 passed / 190 skipped au job public, 30 passed à l'authentifié — relevés dans les logs des jobs, après avoir été mesurés en local. Le public passe de 175 à 190 sautés : les 15 attendus, pas un de plus. Le critère 1 du DoD est vert sur 312e441 et PAS ENCORE sur le dernier commit. Écrit tel quel plutôt que coché d'avance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n agent vers un commit MESURE (demandée avant merge). Le motif `revoke … from public` sans nommer `anon, authenticated` ne concerne pas que `claim_pending_deletions`. Lu en base, pas dans les migrations : 4 des 9 fonctions de `public` sont exécutables par `anon`, dont DEUX en SECURITY DEFINER — donc « la RLS filtre » ne serait pas une réponse valable, elles s'exécutent avec les droits du propriétaire. Impact vérifié en appelant réellement avec la clé anon : assert_rls_coverage() -> [] HTTP 200 aucune divulgation (INVOKER) is_workspace_member(uuid) -> false aucun oracle (auth.uid() est NULL) is_workspace_editor(uuid) -> false idem touch_updated_at() -> PGRST202 inatteignable par REST Aucune exploitation possible aujourd'hui. Le résultat est rassurant et c'est pour ça qu'il faut nommer ce qui le produit : l'innocuité tient au CORPS de chaque fonction, pas au privilège. Une future fonction DEFINER qui n'interroge pas `auth.uid()` hériterait du même grant sans que personne ne le voie. Correctifs en PR DÉDIÉE — ces grants datent d'avril et mai, les corriger dans une PR dont l'invariant est de ne rien armer serait l'extension de scope que le plan interdit. Ordre : assert_rls_coverage (anon+authenticated), is_workspace_member/editor (anon SEULEMENT — `authenticated` est requis par les politiques RLS), touch_updated_at. Le MCP Supabase ne pouvait pas servir : il ne voit que `goldteam`, le PREMIER compte. L'utiliser aurait interrogé la mauvaise base avec l'air d'être vrai. PROCESS. `test-quality-auditor.md:73` disait déjà « Never modify code — only report ». Il a désobéi. Répéter l'instruction ne sert donc à rien. Ce qui manque est que sa désobéissance n'ait aucun chemin vers l'historique : jamais de `git add -A` après le passage d'un agent doté de Bash, lecture obligatoire de `git diff --cached --stat` avant chaque commit, et toute mutation de falsification hors de l'arbre de travail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… passait la CI
Trouvé par `test-quality-auditor` relancé proprement. La table
STATUS_PRESENTATION qui empêche `processing` de s'afficher « Complétée », le
`.in('status', [...])` de settings/page.tsx, et la branche `processing` de
DangerZone : aucune n'était gardée, ni en unitaire ni en e2e.
Ce sont précisément les corrections d'un défaut art. 12(1) — dire à quelqu'un
quelque chose de faux sur un acte irréversible. Le rapport se félicitait de 10
falsifications tout en laissant sans garde la correction que le commit lui-même
appelle « le trou trouvé par la revue de plan ».
Un sixième cas e2e parcourt le trajet complet : connexion réelle, /app/settings
pendant `processing` (lien « Voir le statut » présent, formulaire de demande
absent, promesse « annuler à tout moment » absente), puis l'écran de statut
(« En cours » visible, « Complétée » absent, aucun bouton d'annulation).
Falsifié : `.in(...)` -> `.eq('status','pending')` rend `element(s) not found`
sur le lien. 1 failed, restauré 6 passed.
Deux autres trous fermés :
- Le durcissement des grants n'avait aucun test. Une migration réaccordant
`execute … to anon` serait passée en silence — la faute même que
20260727000001 avait commise. Falsifié en réaccordant en base.
- La charge utile de la pseudonymisation n'était jamais vérifiée en unitaire :
le faux client jetait `_values`. Retirer `ip_address: null` ne laissait rouge
que le job Supabase réel, pas la suite de chaque push.
Plus la branche `23505` sans ligne retrouvée, et une fragilité que le test a
lui-même révélée : `claim_pending_deletions` étant GLOBALE, l'égalité exacte
échouait sur une base locale portant des lignes dues d'autres essais. Les
assertions sont scopées aux utilisateurs semés — identique sur une base
éphémère, robuste ailleurs.
Plancher authentifié mesuré : 30 -> 31. Un plancher qui monte parce qu'un trou a
été trouvé est le seul mouvement sain de ce tableau.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t mesurée
`rls-flow-tester` (verdict PASS) a fabriqué le mode de panne au lieu de le
raisonner : rôle dédié sans BYPASSRLS, propriétaire d'une fonction de purge,
7 lignes semées dans audit_log (FORCE RLS).
DEFINER, propriétaire SANS bypassrls -> rows_deleted = 0, survivants = 7
INVOKER -> rows_deleted = 7, survivants = 0
Zéro ligne supprimée, aucune erreur, retour « succès ». Le rejet de la
conception 1 et la décision D4 reposent désormais sur une mesure reproductible.
Plus inquiétant : `postgres` porte rolbypassrls = t sur une pile locale. La
version DEFINER d'avril aurait donc passé un test local avec un compteur
correct. Ce défaut n'a pas survécu trois mois faute de test — il a survécu
parce qu'un test local vert n'aurait rien prouvé.
Deux imprécisions relevées dans mes propres migrations, corrigées dans l'ADR et
le rapport PLUTÔT que dans les fichiers : schema_migrations porte une colonne
`statements`, donc éditer un fichier déjà appliqué le ferait diverger de ce qui
a tourné. Sur une migration qui touche à la suppression de comptes,
l'immuabilité vaut mieux qu'un commentaire plus juste.
1. ...000002:21-24 « both UPDATE statements match zero rows » — vrai pour
anon, FAUX pour un authenticated propriétaire d'une ligne échue, où
l'appel lève 42501/403 sur le with check (mesuré 5/5). La conclusion
tient, le mécanisme non — et la correction renforce l'argument.
2. ...000001:51-61 l'écrasement teste `requested_at >` STRICT : deux pending
au même instant se protègent et le create unique index échoue. Bruyant,
jamais silencieux.
Cause profonde du défaut de grants, décomposée par l'agent : le revoke de mai
sur is_workspace_member/editor a retiré le grant NOMINATIF à anon, mais le
grant à PUBLIC (grantee 0) est resté et anon en hérite. La migration de mai n'a
rien changé. La PR dédiée doit nommer `public` dans chaque revoke, sinon elle
répète la faute.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…andoff (#283) Deux fichiers de documentation qui devaient partir avec [#282](#282) et sont arrivés trop tard : la PR a été mergée (`d386aae`) pendant que j'écrivais le handoff, GitHub a supprimé la branche, et mon push l'a recréée. Ces deux fichiers vivaient donc sur une branche morte, hors de `main`. Aucun code, aucune migration. ## 1. Le handoff canonique de la session `docs/handoffs/2026-07-27-1900-etape-3b-pr-a-file-suppression-inerte.md`, 9 sections selon le template. Double redondance : ce fichier **et** le miroir dans le vault Obsidian — slug `ankora` **lu** dans `10_Projects/*/_index.md`, jamais inféré. ## 2. Le fait que l'historique Git ne dira pas Ajouté au rapport de PR à la demande de @Thierry, et c'est le point qui justifie à lui seul cette PR : > **La production portait déjà les deux migrations avant le merge.** Le comportement de la base a changé **au moment du `db push`, pas au moment du merge**. Concrètement, et indépendamment de `main` : `deletion_self_insert` **supprimée**, `deletion_self_update` resserrée en `pending → cancelled`, `claim_pending_deletions` fermée à `anon` et `authenticated`, `purge_audit_log_older_than_12_months()` passée en `SECURITY INVOKER`. **Pourquoi c'est sûr, et pourquoi ce n'est pas une supposition** : `deletion_requests` n'est écrit qu'en **un seul endroit** du code — `src/lib/gdpr/deletion.ts`, via `createServiceRoleClient()`, qui contourne la RLS. **Aucune insertion client n'existe.** La politique retirée accordait une capacité que rien n'utilisait. Et si #282 n'avait **pas** été mergée : la migration serait **restée**. Un revert de PR sur GitHub ne défait pas un `db push`. Elle est additive et ses politiques sont plus restrictives qu'avant, donc sans danger — mais présente. Revenir en arrière pour de bon demanderait une migration inverse écrite exprès. ## 3. Mesure avant prescription @Thierry a refusé qu'une future PR d'hygiène propose un `revoke` sur la foi de la documentation. `touch_updated_at()` est une fonction de trigger branchée sur **6 tables** (`accounts`, `charges`, `commitments`, `users`, `workspace_settings`, `workspaces`) : la révoquer à l'aveugle pouvait casser **chaque écriture de l'application**. Mesuré en trois temps sur la pile locale, en rôle `authenticated` avec claims JWT : ``` updated_at remis à 2020-01-01, UPDATE → 2026-07-27 17:02:40.346726 (avant revoke) revoke execute … from public, anon, authenticated updated_at remis à 2020-01-01, UPDATE → 2026-07-27 17:02:40.368922 (APRÈS revoke) ``` **`updated_at` bouge toujours.** Le privilège `EXECUTE` d'une fonction de trigger est vérifié **à la création du trigger**, pas à chaque déclenchement. Le `revoke` ne casse rien. Grant local restauré après la mesure. `assert_rls_coverage()` : **aucun appelant** dans le dépôt — le « Callable from CI » de `20260417000002:36` décrit une intention jamais réalisée. Les deux révocations de la future PR grants sont donc **prouvées**, pas documentées. Cette PR-là part **après PR-B**, décision @Thierry : ces fonctions sont invoquées par toutes les policies RLS, et ce risque ne doit pas courir en parallèle de l'armement d'une suppression de comptes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by Sourcery Documenter l’état de la base de données de production et le calendrier de déploiement pour les migrations de la file de suppression, et ajouter le rapport de passation pour PR-3B-A. Documentation : - Étendre le rapport PR-3B-A avec une description explicite de la manière dont les migrations de la file de suppression affectent déjà la production indépendamment de la fusion du code, et pourquoi cela est sans risque. - Ajouter un document de passation détaillé pour l’étape 3B / PR-A couvrant l’état de la branche, le statut de déploiement, les décisions prises, les actions de suivi et les garde-fous pour les travaux futurs sur la file de suppression et les révocations de privilèges. <details> <summary>Original summary in English</summary> ## Summary by Sourcery Document the production database state and deployment timing for the deletion queue migrations, and add the handoff report for PR-3B-A. Documentation: - Extend the PR-3B-A report with an explicit description of how the deletion queue migrations already affect production independently of the code merge, and why this is safe. - Add a detailed handoff document for step 3B / PR-A covering branch state, deployment status, decisions taken, follow-up actions, and guardrails for future work on the deletion queue and privilege revocations. </details> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Ce que fait cette PR, et ce qu'elle ne fait surtout pas
/app/settings/deletion-statusaffiche à l'utilisateur une date de suppression et undécompte de jours. Rien ne l'exécute :
executeDeletion()n'a aucun appelant depuisavril. Cinq comptes réels en production, dont trois appartiennent à des tiers. Ce n'est
pas une omission — c'est une affirmation inexacte faite à la personne concernée
(art. 12(1)) sur un droit garanti par l'art. 17.
Cette PR pose ce sur quoi l'exécuteur reposera, et rien d'autre.
Décisions : ADR-024.
Plan : step-3b. Trois tours de
plan-revieweravant écriture, un quatrième de confirmation pendant.
Le fait qui contraint toute la conception
Deux conceptions ont été écrites puis abandonnées, chacune tuée par une mesure :
SECURITY DEFINERatomique —FORCE ROW LEVEL SECURITYs'applique aupropriétaire de la table. Possédée par
postgres, elle écrirait zéro ligne sanslever si le
postgreshébergé n'a pasBYPASSRLS. Invérifiable.SECURITY INVOKERappelée parservice_role— mesuré :service_rolen'a aucun privilège sur
auth.users.L'atomicité SQL de bout en bout est donc impossible par toute conception. C'est la
frontière entre PostgREST et GoTrue, pas une limite de notre code. La garantie devient
l'idempotence et la reprise, et aucun privilège nouveau n'est demandé — c'est ce
qui rend cette conception défendable là où les deux précédentes reposaient sur une
hypothèse invérifiable.
Migration
processing+ colonneclaimed_atpendingen double, puis index unique partielclaim_pending_deletions()SECURITY INVOKER, ne touche quepublic,revoke from publicavantgrant(advisor 0028)deletion_self_insertsuppriméepurge_audit_log_older_than_12_months()enSECURITY INVOKERLe verrou teste
claimed_at, jamaisrequested_at. Une ligne n'étant réclamablequ'à
scheduled_for <= now(), soit 14 jours après sa demande, un test surrequested_atserait toujours vrai : chaque exécution remettrait en file les lignes qu'une autre
traite, et le même compte serait supprimé deux fois. Invariant écrit dans la
migration : le seuil de reprise (1 h) doit rester supérieur au
maxDuration(60 s) de laroute qui arrivera en PR-B.
deletion_self_insertest supprimée plutôt que durcie parce que la mesure le permet :deletion_requestsn'est écrit qu'en un seul endroit du code, viaservice_role, quicontourne la RLS. Aucune insertion client n'existe. La politique accordait une
capacité que le produit n'utilise pas — et armée, c'était une auto-suppression immédiate,
alors que la grâce de 14 jours est précisément la mitigation contre le vol de session.
L'extraction qui rend le chemin destructeur testable
src/lib/gdpr/deletion.tsimporte@/lib/supabase/admin, qui porteimport 'server-only'— lequel lève inconditionnellement. Vitest l'aliase, Playwright non. La seule
instruction irréversible du système n'était donc prouvée nulle part.
Mesuré sur ce dépôt, avant d'écrire quoi que ce soit — import jetable, puis contrôle
négatif :
@/lib/gdpr/deletion-core: 3 cas listés, module chargé ;@/lib/gdpr/deletion: échec à la collecte,server-only/index.js:1.D'où
deletion-core.ts, sans le marqueur, recevant le client en paramètre. Le garde-foun'est pas affaibli :
npm run lint:use-serverpasse, et un test lit le graphe d'importsdu module pour refuser toute réintroduction — y compris via
@/lib/security/audit-log,qui ré-exporte depuis
admin.tset suffirait à tout casser.Trois silences remplacés par trois réponses honnêtes
une date que la file n'honorera pas est le défaut même qu'on corrige.
cancelDeletionrenvoyaitvoid: unfiltre ne touchant aucune ligne était indiscernable d'un succès, et l'action écrivait
quand même une ligne d'audit affirmant une annulation qui n'avait pas eu lieu.
processinga enfin une branche d'affichage. Il tombait jusqu'ici dans « Complétée »,en rouge.
Le trou trouvé par la revue de plan
settings/page.tsxfiltrait surstatus='pending'seul. Pendantprocessing,deletionvalait doncnull, la zone de danger réaffichait le formulaire de demandeet retirait le seul lien vers l'écran de statut — exactement quand l'effacement était
devenu irréversible. Corrigé, plus une chaîne dédiée qui ne promet plus « tu peux
annuler à tout moment ».
Preuves
Falsification — 7 mutations du code, 3 du schéma. Chacune rouge sur le test qui la
garde, vertes après restauration. Détail complet dans
docs/prs/PR-3B-A-report.md.Chemin destructeur exercé de bout en bout contre un vrai Postgres avec la migration
appliquée — 5 cas, comptes de lignes exacts sur chaque table fille, et
audit_logquiconserve ses lignes avec
ip_addressetuser_agentàNULL. Cette dernière assertionest celle qui compte :
user_idseul ne prouverait rien,on delete set nulll'effacecomme effet de bord de la cascade.
Planchers e2e mesurés, jamais annoncés : job authentifié 25 → 30 (+5 dans un
seul projet,
iPhone 14filtrant sur**/mobile-ios/**) ; job public inchangé à 215(+15 sautés, +0 passé). Liste de quarantaine inchangée à 6.
Ce qui reste vrai après ce merge, et qu'il faut nommer
auth.audit_log_entriesconserve l'email en clair et l'IP, n'a aucune clé étrangèrevers
auth.users, et survit à l'effacement.service_rolene peut même pas la lire.C'est un manquement art. 17 mesuré — issue #278. Il existe à partir du jour où le cron
s'arme, pas avant : donc pas avec cette PR, mais avec PR-B.
status='completed'etcompleted_atsont inatteignables : la ligne de demandecascade avec le compte. La branche « Complétée » de l'écran de statut est du code mort,
laissée en place pour une PR d'hygiène séparée.
Production — fait
N° 3 : concluante — et sa première mesure ne prouvait rien, elle rendait les
chiffres de la veille ; c'est la reconnexion réelle qui a produit
auth.loginetauth.logout. Le NO-GO de PR-B est levé.supabase db push --linked— migration appliquée,local = remote. Collapse desdoublons : 0 ligne, conformément à la lecture n° 2.
npm run supabase:typesconfirme les deux ajouts au caractère près.Un défaut trouvé après coup, et fermé
En relisant les privilèges réels en base plutôt que le texte de la migration :
revoke execute … from publicne retire que le grant du pseudo-rôlePUBLIC. Il neretire pas les grants explicites que les privilèges par défaut de Supabase accordent à
anonetauthenticated. Deux choses qui se lisent pareil. Et mon commentaire demigration affirmait que le
revokesuffisait — une garantie annoncée qui n'existaitpas.
Impact mesuré : nul —
SECURITY INVOKER+FORCE RLS= zéro ligne touchée,[]enréponse. Fermé quand même (
20260727000002, appliquée en prod) : un endpoint nonauthentifié qui émet deux
UPDATEsur une table RGPD ne doit pas dépendre de politiquesinchangées pour rester inoffensif. Après :
anon→ 42501/401,service_role→ 200.Au passage,
purge_audit_log_older_than_12_months()est observée pour la première foisdepuis avril : 2 lignes semées à −13 et −14 mois, la fonction rend 2, il en reste 0. Elle
supprime réellement. Elle reste sans appelant — PR-B l'arme.
Agents QA — dont deux qui n'ont pas tourné
i18n-auditorPASS_WITH_NOTES,gdpr-compliance-auditorCOMPLIANT_WITH_NOTES(3 défauts réels chez moi, corrigés — dont un commentaire qui promettait une exhaustivité
de statuts inexistante,
statusétant typéstring).rls-flow-testerettest-quality-auditorse sont arrêtés sur la limite de session.Ce n'est pas un vert par défaut. J'ai repris à la main les points de
rls-flow-tester—c'est ce qui a sorti le défaut de grants ci-dessus — mais une vérification faite par
l'auteur du code n'a pas la valeur d'une vérification indépendante.
Détail complet, y compris un incident de process (un agent QA doté de Bash a modifié mon
arbre de travail et sa mutation s'est retrouvée dans un commit) :
docs/prs/PR-3B-A-report.md.🤖 Generated with Claude Code