Skip to content

fix(security): le client service_role fuitait la session utilisateur (H3, #192)#273

Merged
thierryvm merged 10 commits into
mainfrom
fix/h3-service-role-client
Jul 27, 2026
Merged

fix(security): le client service_role fuitait la session utilisateur (H3, #192)#273
thierryvm merged 10 commits into
mainfrom
fix/h3-service-role-client

Conversation

@thierryvm

Copy link
Copy Markdown
Owner

Le défaut, mesuré

createAdminClient() associait la clé service_role à un adaptateur de cookies. @supabase/ssr y trouvait la session de l'appelant et envoyait son JWT en Authorization à la place de la clé. Chaque requête tournait donc en rôle authenticated — à qui audit_log est révoqué.

Deux inserts, même clé, même base, même table, seul le bocal à cookies change :

service_role, SANS cookie          INSERTED
service_role, AVEC cookie session  DENIED  [42501] permission denied for table audit_log

C'est H3, issue #192, ouverte le 28 mai, type:security, sortie du scope de la PR security-hardening. Elle y était « piste forte ». Elle n'avait jamais été mesurée.

Ce que ça cassait réellement

logAuditEvent() est appelé depuis toutes les Server Actions, qui tournent toutes avec un cookie de session. Sur un build non corrigé, après une connexion réussie et un renommage de compte, le journal local contenait :

Événement Contexte Présent ?
auth.rate_limited avant toute session ✅ 81
auth.password_reset avant toute session ✅ 3
auth.login session écrite dans l'appel jamais
account.renamed Server Action authentifiée jamais

Seul ce qui précédait l'existence d'un cookie était écrit. logAuditEvent avalant son erreur, rien n'a protesté pendant trois mois.

⚠️ Production non vérifiée — la même migration revoke s'y applique, l'inférence est forte, mais mes deux tentatives de lecture d'agrégats ont été bloquées. Requête à passer côté @Thierry, détail dans le rapport.

Le correctif

createServiceRoleClient() dans un module neuf, sans cookies, sans storage, avec refus explicite d'exécution navigateur. createAdminClient supprimé — laisser les deux côte à côte, c'est garantir qu'on reprenne le mauvais.

Surtout pas un GRANT sur audit_log : ça aurait ouvert le journal d'audit en écriture à tout utilisateur connecté, l'inverse exact de ce que verrouille 20260417000003.

Deux défauts que ce correctif ACTIVAIT

  1. executeDeletion journalisait GDPR_DELETION_COMPLETED avec resource_id = l'UUID qu'il venait d'effacer → compte ré-identifiable une ligne après sa suppression (le jsonb n'est pas cascadé par on delete set null). Invisible tant que l'insert échouait.
  2. L'erreur de pseudonymisation n'était pas lue : un refus laissait les lignes identifiantes en place pendant que la suppression continuait.

Preuve

Rouge avant, vert après, même spec, même serveur :

build non corrigé : 1 failed  — "auth.login never reached audit_log"
build corrigé     : 1 passed

Garde-fous falsifiés : module saboté volontairement → 2 échecs sur 3, puis restauration et retour au vert.

typecheck / lint / lint:use-server 0 erreur
npm run test 132 fichiers, 1708 tests
Job authentifié (local) 25 passed — plancher 24 → 25, mesuré
Spec sous env public factice skipped → plancher public inchangé (215)

Tests ajoutés

  • admin.test.ts — assertion sur le contrat réseau (quelle identité part réellement), refus navigateur, interdiction statique des imports @supabase/ssr / next/headers.
  • deletion.test.tsles premiers tests de src/lib/gdpr/, qui n'en avait aucun.
  • e2e/audit-log.spec.ts — parcours connecté réel, assertions avant la suppression de l'utilisateur semé (la supprimer d'abord effacerait la preuve).

Décision demandée

Dépendance server-only non ajoutée, volontairement. L'ancien client importait next/headers, ce qui faisait échouer le build en cas d'import depuis un composant client ; le nouveau module perd ce garde-fou machine. server-only (Vercel, ~1 Ko, gratuit) le restituerait, mais la doctrine exige une validation explicite pour toute dépendance. Deux garde-fous sans dépendance tiennent en attendant. Le risque n'est pas une fuite de clé — Next n'inline pas les variables non NEXT_PUBLIC_*.

Hors périmètre

Export RGPD aux 14 tables, Zod consent, file de suppression + cron, purge audit_log, THI-206. executeDeletion n'a toujours aucun appelant : cette PR rend correct un code pas encore branché, elle ne fait pas marcher la suppression de compte.

Rapport complet : docs/prs/PR-h3-service-role-client-report.md

Closes #192

…ssion

`createAdminClient()` paired the service_role key with a cookie adapter, so
@supabase/ssr found the caller session and sent that JWT as `Authorization`
instead of the key. Every query silently ran as `authenticated` — a role
`audit_log` denies. Measured: same key, same table, same database, INSERTED
without cookies and 42501 permission denied with them.

Known since 2026-05-28 as H3 (#192) and never measured until now. On an
uncorrected build, a successful login plus an account rename left NOTHING in
audit_log: only events emitted before a session cookie exists were ever
written, and logAuditEvent swallows its error, so nothing ever reported it.

Replaced by createServiceRoleClient() in a new cookie-free module. Not a GRANT
on audit_log — that would open the audit trail to every signed-in user, the
exact opposite of migration 20260417000003.

Two defects this fix would otherwise ACTIVATE are closed here:
  - executeDeletion logged GDPR_DELETION_COMPLETED with resource_id set to the
    UUID it had just erased, re-identifying the account one row after deletion
  - the audit pseudonymisation result was never checked, so a refusal left
    identifying rows behind while the deletion carried on

Tests: network-contract assertion on the outgoing identity, a static import
ban, the first tests src/lib/gdpr/ has ever had, and an authenticated e2e spec
proven red on the uncorrected build and green after. Authenticated floor
measured 24 -> 25; public floor unchanged at 215 (the spec skips there).

Closes #192

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jul 26, 2026 11:45pm

…tion

Both defects were dormant only because audit writes were being refused. This
commit makes them land, so it has to fix them too.

Export truncation was silent and arbitrary. `audit_log` is capped at 1000 rows
with no `order`, so PostgREST returned rows in physical order — a user past the
cap would have received an unpredictable subset of their own audit trail inside
a file the UI presents as a complete art. 20 export. Now ordered newest-first,
and the cap is asserted.

Deletion requests were logged twice. `requestAccountDeletionAction` already
emits GDPR_DELETION_REQUESTED with the request IP and user agent;
`requestDeletion` emitted a second, poorer row without them. The library keeps
the write, the caller keeps the audit line.

Test coverage on the paths this PR repointed but never exercised:
  - requestDeletion and cancelDeletion were 0% covered and are both reachable
    from settings.ts; cancelDeletion's two-filter scope is now pinned, since
    dropping the status filter would rewrite already-processed requests
  - export.ts was 0% covered with no test file at all. Every one of the seven
    queries now has its scoping column asserted by name — with a real
    service_role client, an unscoped one would hand a user someone else's
    financial data, and RLS no longer backstops that
  - the "never a module-level singleton" invariant admin.ts documents is now
    tested rather than merely stated

The fake Supabase client caught the export change on its own, which is the
point of building it to the real chain shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tach

`persistSession: false` stops a session being STORED, not HELD. A later
`.auth.setSession()` or `.auth.signInWithPassword()` on the returned client
would have re-opened H3 without anyone editing admin.ts — the guarantee was
behavioural, and neither the static import test nor the header test could see
it. The security audit measured exactly that: the service_role key overwritten
by a user JWT, on a client this module had built.

`accessToken` makes the SDK ask a callback for the request identity instead of
consulting its own auth state, and it makes the entire `.auth` namespace throw.
Measured here: `Authorization: Bearer <service_role>` still on every request,
`.auth.getSession()` and `.auth.admin` both refusing. Convention becomes an SDK
guarantee.

Sealing also hides `auth.admin.deleteUser`, which the erasure flow needs — hence
`createServiceRoleAdminClient()`, named the long way round so that reaching for
it takes a decision. One legitimate caller: executeDeletion.

Also corrects a comment that would have misled the next reader: `user_consents`
and `deletion_requests` cascade from `users`, not from `workspaces`. And records
on the module the two erasure limits the audit established — audit rows written
with a null user_id keep an IP the pseudonymisation can never reach, and the
three destructive statements are not one transaction. Both belong to the step
that wires executeDeletion to a cron; it still has no caller.

Verified end to end: the authenticated e2e spec still passes against a rebuilt
server, so audit writes authenticate correctly through the sealed path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the audit outcomes, the six findings that entered the PR, and the
eight tracked out-of-scope items — including the three public texts that
become inaccurate at merge and need @Thierry's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n the browser

@Thierry approved server-only, so it is installed. Measuring it then contradicted
what I claimed for it: a 'use client' page importing the service-role module
still BUILDS on Next 16 with Turbopack. Next aliases the package to its own copy,
server-side to empty, client-side to the throwing one — so the failure lands when
the chunk runs in the browser, not at build. The guard lost with next/headers is
not restored by the package.

What restores it is a rule in scripts/lint-use-server.mjs, which CI already runs,
so no workflow change: any 'use client' module importing the service-role client,
the audit logger, or the GDPR modules fails the lint. Falsified — exit 1 with a
probe page in place, exit 0 without it.

server-only stays: canonical marker, zero cost, a named error instead of an
undefined key. It is now documented for what it does rather than what it was
supposed to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said "every row the user owns". It returns 7 of 14 tables. i18n-auditor
found it while checking the public copy for the same claim — and a comment that
keeps asserting completeness is how the corrected UI text grows back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction

The production table holds four event types and all four are written before a
session cookie exists: auth.rate_limited, admin.access.rate_limited (both from
unauthenticated requests), auth.signup and auth.password_reset. auth.login has
zero rows while the project shows 47 monthly active users. No financial
mutation, no GDPR event, no successful admin access ever reached it.

So the trail has been blind from 2026-04-16 to 2026-07-27 — the same hole
measured locally, not a test artefact.

The register entry argues the qualification rather than asserting it: the
failure is fail-closed, a degraded client gets FEWER rights, so nothing was
exposed or altered and art. 33/34 do not apply. What does apply is art. 32(1)(b)
and (d) — the privacy policy declares an append-only audit log as a security
measure, and a declared measure that silently did not work for three months is a
breach of the duty to test its effectiveness. Art. 5(2) too: there is no way to
show when an export was served over that period.

Consent proof (art. 7(1)) never travelled through this client and is intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supabase counts distinct users who made an auth request during the billing
cycle. npm run e2e:auth exercises the authenticated journeys AGAINST PRODUCTION,
seeding throwaway ankora-e2e+<hex>@ankora.test accounts; deleting them at the
end does not remove them from the cycle's count. So 47 is mostly test users, and
citing it as evidence of real usage was wrong — in a compliance document, which
is where it matters most.

The argument never needed it. auth.signup and auth.rate_limited rows exist,
which proves people signed up and attempted to sign in, while not one successful
login was ever recorded. That stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lure

5 accounts in production, none of them test users, one signed in within the last
30 days. Three belong to third parties — people close to @Thierry, but data
subjects with full rights all the same. I had told him the deletion queue could
wait for "the first third-party user"; that user already exists, and existed
before this session. Corrected here and in the step-3b notes.

Also records an independent cross-check: auth.users.last_sign_in_at is populated
for all five accounts, so real logins happened between April and July, while
audit_log holds zero auth.login rows. Two sources, one conclusion — the evidence
no longer rests on reading the absence of rows alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thierryvm added a commit that referenced this pull request Jul 26, 2026
…t », base légale) (#274)

## Trois affirmations publiques devenues fausses

Relevées par l'audit RGPD de la PR #273, corrigées ici dans les **cinq
locales**.

**1. L'export RGPD n'est pas complet.** Le texte annonce « un JSON
complet de tout ce qu'Ankora détient sur toi ». Il interroge **7 tables
sur 14**. Manquent `commitments` et `commitment_payments` (les dettes)
et `accounts` (les soldes) — pas un arrondi, les deux catégories les
plus sensibles de l'app. Remplacé par ce que le fichier contient
réellement.

L'affirmation devient plus problématique aujourd'hui qu'hier : le
journal d'activité qu'elle embarque était vide jusqu'au correctif #273,
donc le fichier a maintenant l'air assez fourni pour qu'on le croie
exhaustif.

**2. La base légale du journal d'audit était fausse.** Déclarée «
obligation légale ». Aucune loi belge n'impose un journal d'audit à un
outil budgétaire — c'est de l'**intérêt légitime** (art. 6(1)(f)), qui
suppose une mise en balance et non un devoir statutaire. Se tromper de
base n'est pas une approximation de traduction, c'est une déclaration de
conformité indéfendable.

**3. Même correction** sur la description du bouton d'export dans les
Paramètres.

## Ce que l'audit i18n a rattrapé

Deux formulations que je ne pouvais pas relire moi-même :

- **nl-BE** mélangeait `veiligheid` et `beveiligingslogs` dans la même
liste → `behoud van de beveiliging`
- **es-ES** écrivait « derecho de portabilidad » là où le règlement dit
« derecho a la portabilidad de los datos »

Les termes statutaires des bases légales sont désormais **verrouillés au
glossaire** (§2bis, v1.4) pour les quatre bases de l'art. 6 — un
relecteur natif serait tenté de les « améliorer » en synonymes que le
règlement n'emploie pas (`legitiem belang`, `interés justificado`).

## Vérifications

Parité des clés intacte (aucune clé ajoutée ni supprimée), balises `<b>`
cohérentes sur les 5 locales, 1702 tests verts, typecheck et lint
propres. JSON validé fichier par fichier.

## Hors périmètre, signalé

L'audit a confirmé une dette connue : les sections `landing.pricing` de
nl/de/es sont encore intégralement en français. Sans rapport avec ce
correctif, mais c'est la section voisine.

Ne rend PAS l'export complet — c'est une PR à part. Celle-ci arrête
seulement de promettre ce qui n'est pas livré.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ontext

@Thierry confirmed the facts: five accounts, three of them third parties, and no
alert ever reached him during the period. The third parties are family, two of
them his children, and none has used the app since April or May — so the
practical risk of harm over the blind window is nil.

Recorded with the limit stated rather than implied: the household exemption
(art. 2(2)(c)) does NOT apply, because Ankora ships publicly with a marketing
site, a pricing page and a privacy policy. Family ties lower the risk, they do
not remove the obligations — art. 17 in particular, which is why the deletion
queue is the next task.

Also states the reservation plainly: neither of us is a lawyer. The
no-notification conclusion rests on the fail-closed analysis and on measured
facts, not on professional advice, and should be revisited if the number of
third-party users grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thierryvm
thierryvm merged commit cb96c91 into main Jul 27, 2026
10 checks passed
@thierryvm
thierryvm deleted the fix/h3-service-role-client branch July 27, 2026 00:08
thierryvm added a commit that referenced this pull request Jul 27, 2026
## Le trou

Les 16 agents QA posaient tous, chacun à sa manière, la même question :
**est-ce présent ?**
Aucun ne demandait : **est-ce que ça marche, et le saurait-on si ça
s'arrêtait ?**

Trois incidents en trois mois sont sortis par ce trou. Les trois étaient
**verts** pendant
qu'ils échouaient :

| Incident | Déguisement | Durée d'invisibilité |
| --- | --- | --- |
| H3 (#273) | Client `service_role` dégradé en `authenticated` ; toutes
les écritures d'audit refusées. `logAuditEvent()` avale ses erreurs par
conception. | **3 mois** |
| `purge_audit_log_older_than_12_months()` | `SECURITY DEFINER` sur une
table `FORCE RLS` : renvoie `0` qu'elle ait purgé ou été refusée. |
Depuis avril — **jamais appelée** |
| `Playwright E2E` | 214 passed, **173 skipped** — tous les parcours
connectés dans les 173. | Jusqu'à ce que quelqu'un lise la ligne du
reporter |

La preuve la plus gênante était dans `gdpr-compliance-auditor` lui-même.
Sa checklist
affirmait :

- « `exportUserData()` returns a **complete** JSON bundle » → 7 tables
sur 14
- « `executeDeletion()` **wipes** » → aucun appelant depuis avril

**Il aurait validé les deux bugs qu'on a passé deux jours à corriger.**

## Ce que fait cette PR

Un agent créé, trois corrigés — plutôt qu'un agent par symptôme.

### `silent-failure-auditor` (nouveau, Opus)

Question unique : *« si ça s'arrêtait cette nuit, qu'est-ce qui serait
différent demain
matin ? »*. Si la réponse honnête est « rien d'observable », c'est un
constat — même si le
code est correct aujourd'hui.

Six terrains de chasse : écritures à zéro ligne prises pour des succès,
chemins privilégiés
refusés en silence, `catch` qui mangent la preuve, tâches planifiées
jamais armées, gates
qui passent à vide, mesures déclarées jamais construites.

Deux règles de méthode : **mesurer plutôt que raisonner** (il a `Bash`
et la stack locale),
et **étiqueter chaque affirmation** MEASURED / READ IN CODE / INFERRED /
UNVERIFIABLE HERE.
Classement par **durée d'invisibilité**, pas par gravité — un bug
critique qui hurle est
moins dangereux ici qu'un bug moyen qui ne hurlera jamais.

### `rls-flow-tester` — le sens qui manquait

Il ne testait que la direction attaquant. Il teste maintenant aussi la
direction
privilégiée : `FORCE RLS` s'applique **au propriétaire de la table**, un
`SECURITY DEFINER`
possédé par `postgres` peut écrire **0 ligne sans erreur**, et le
`postgres` hébergé n'est
pas celui de la stack locale. Il doit désormais rapporter des **nombres
de lignes mesurés**,
pas « aucune erreur ».

### `security-auditor` — endpoints non authentifiés + sous-permission

`src/app/api/**` est **exclu du matcher du proxy** (`src/proxy.ts:139`)
: pas de session du
tout. Six points bloquants ajoutés (fail-closed, comparaison de digests
parce que
`timingSafeEqual` lève sur des longueurs inégales, pas de secret en URL,
sémantique sans
réessai, plafond bruyant…), plus le rappel que **la sous-permission est
une vulnérabilité** :
l'art. 32(1)(b) tombe sans qu'aucune donnée ne fuite.

### `gdpr-compliance-auditor` — déclaré vs implémenté

Les deux affirmations fausses deviennent des **questions à poser au
code**. Nouveau
chapitre : deux des trois constats les plus coûteux n'étaient pas des
bugs, mais des
**phrases publiées dans cinq locales** que le code ne soutenait pas.

## Portée et risque

Markdown uniquement — aucun code applicatif, aucune migration, aucune
dépendance. Zéro
impact runtime, donc aucun check CI ne peut valider le fond : la revue
est humaine.

**Deux réserves que je signale plutôt que de les taire :**

1. **`plan-reviewer` n'a pas été invoqué.** La règle vise le code > 50
lignes et les chemins
sensibles (Server Actions, `proxy.ts`, migrations…). Ce sont des
prompts. Il tourne en
parallèle sur le plan de l'étape 3b, autrement plus conséquent.
Arbitrage assumé, à
   contredire si tu préfères la règle littérale.
2. **J'écris les agents qui auditent mon propre travail.** C'est
structurellement une
copie corrigée par son auteur. Les trois incidents cités sont mesurés et
vérifiables,
   mais le choix de *ce qu'on cherche* reste le mien.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Summary by Sourcery

Introduire un nouvel agent QA axé sur les défaillances silencieuses et
renforcer les auditeurs existants afin de vérifier que les protections
et les engagements RGPD fonctionnent réellement plutôt que de simplement
exister.

Nouvelles fonctionnalités :
- Ajouter un agent `silent-failure-auditor` pour détecter les mécanismes
pouvant échouer sans impact observable, en couvrant les journaux
d’audit, les écritures privilégiées, les tâches en arrière-plan, les
garde-fous CI et les tâches de rétention.

Améliorations :
- Renforcer `gdpr-compliance-auditor` afin de valider l’exhaustivité des
exports de données utilisateur, le comportement d’effacement, ainsi que
l’alignement entre les déclarations légales visibles par l’utilisateur
et l’implémentation réelle à travers les différentes locales.
- Étendre `rls-flow-tester` pour couvrir les chemins RLS privilégiés, en
garantissant que les opérations `service_role` et `SECURITY DEFINER` ne
sont pas bloquées silencieusement et que les résultats sont mesurés via
les comptes de lignes.
- Étendre les recommandations de `security-auditor` pour les endpoints
non authentifiés et planifiés, en imposant un comportement fail-closed,
une gestion sûre des secrets, des comparaisons en temps constant
correctes, et des vérifications préalables explicites avant les
opérations destructrices.
- Mettre à jour `CLAUDE.md` pour documenter le nouvel agent QA dédié aux
défaillances silencieuses, les responsabilités élargies de
`rls-flow-tester` et le nombre accru d’agents QA.

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Introduce a new QA agent focused on silent failures and strengthen
existing auditors to verify that protections and GDPR promises actually
work rather than just exist.

New Features:
- Add a silent-failure-auditor agent to detect mechanisms that can fail
without observable impact, covering audit logs, privileged writes,
background jobs, CI gates, and retention tasks.

Enhancements:
- Tighten the gdpr-compliance-auditor to validate completeness of user
data exports, erasure behavior, and alignment between user-facing legal
claims and actual implementation across locales.
- Expand rls-flow-tester to cover privileged RLS paths, ensuring
service_role and SECURITY DEFINER operations are not silently blocked
and that results are measured via row counts.
- Extend security-auditor guidance for unauthenticated and scheduled
endpoints, enforcing fail-closed behavior, safe secret handling, correct
constant-time comparisons, and explicit preflight checks before
destructive operations.
- Update CLAUDE.md to document the new silent-failure QA agent, the
expanded responsibilities of rls-flow-tester, and the increased QA agent
count.

</details>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
thierryvm added a commit that referenced this pull request Jul 27, 2026
…ne (ADR-024) (#279)

## Pourquoi cet ADR existe

L'étape 3b doit brancher un exécuteur sur une file de suppression que
**rien ne vide**,
pendant que `/app/settings/deletion-status` affiche à l'utilisateur une
date et un décompte
de jours restants. 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.

Brancher cet exécuteur impose des choix de schéma qui ne se défont pas :
statut
supplémentaire, colonne de verrou, index unique partiel, fonction SQL,
réécriture de deux
politiques RLS. ADR-023 ne couvrait que le délai de grâce. Doctrine
projet : **décision en
session N, implémentation en session N+1** — donc zéro ligne de code
applicatif ici.

## Deux conceptions mortes, consignées exprès

Rien n'invite plus sûrement à réécrire une mauvaise idée que son absence
du dossier.

**Conception 1 — fonction `SECURITY DEFINER` atomique. Rejetée.**
`FORCE ROW LEVEL SECURITY` s'applique **au propriétaire de la table**.
Possédée par
`postgres`, la fonction écrirait **zéro ligne sans lever d'erreur** si
le `postgres`
hébergé n'a pas `BYPASSRLS` — ce qu'on ne peut pas mesurer. Compte
supprimé, IP et
user-agent conservés, plus aucune clé de jointure pour les retrouver.

**Conception 2 — la même en `SECURITY INVOKER` appelée par
`service_role`. Rejetée aussi.**
Mesuré : `service_role` n'a **aucun privilège** sur `auth.users` ni sur
`auth.audit_log_entries`.

**Conséquence** : l'atomicité SQL de bout en bout est **impossible par
toute conception**.
Ce n'est pas une limite de notre code, c'est la frontière entre
PostgREST et GoTrue.

## La décision

**La garantie devient l'idempotence et la reprise, pas l'atomicité.** Il
n'y a qu'une
instruction irréversible ; la précéder d'un nettoyage rejouable suffit.
Et surtout :
**aucun privilège nouveau n'est demandé** — c'est ce qui rend cette
conception défendable
là où les deux autres reposaient sur une hypothèse invérifiable.

Trois corollaires contre-intuitifs, écrits pour ne pas être redécouverts
:

- `completed` / `completed_at` sont **inatteignables** — la ligne
cascade avec le compte.
- Un `deleteUser` « user not found » compte comme un **succès**, sinon
la ligne devient une
  pilule empoisonnée réclamée et échouée chaque jour, pour toujours.
- Une pseudonymisation touchant **0 ligne est un succès** aussi.
L'inverse gèlerait la file
pour tout compte sans événement d'audit — exactement la panne muette
qu'on corrige.

**Le verrou anti-double-suppression** repose sur une nouvelle colonne
`claimed_at`, jamais
sur `requested_at` : une ligne n'étant réclamable que 14 jours après sa
demande, un test
sur `requested_at` serait **toujours vrai** et remettrait en file les
lignes qu'une
exécution concurrente est en train de traiter.

**`deletion_self_insert` est supprimée plutôt que durcie.** Vérifié :
aucune insertion
client n'existe dans `src/`. La politique accorde une capacité que le
produit n'utilise
pas — et c'est la capacité, pas sa date, qui est le vecteur de vol de
session.

**Livraison en deux PR** : A inerte (rien ne peut détruire), B armement.
La revue de B ne
portera alors que sur une question : *est-ce que ça peut partir quand il
ne faut pas ?*

## Contenu

- `docs/adr/ADR-024-…` — la décision, ce qui est écarté, et **ce qui
restera non prouvé**
- `docs/plans/step-3b-deletion-queue.md` — le plan d'exécution pour la
session suivante

## Ce qui reste à faire côté @Thierry

Trois lectures SQL en production, **aucune écriture** (détail dans le
plan). La troisième
est un **NO-GO de PR-B** : toute la conception repose sur un chemin
PostgREST `service_role`
jamais re-vérifié en production depuis le correctif #273.

## Portée

Markdown uniquement, aucun code applicatif. `plan-reviewer` a fait
**trois tours** sur ce
dossier et a bloqué à chaque fois quelque chose de réel — dont, au
dernier tour, le fait
que cette décision de schéma ne pouvait pas partir dans la même session
que son
implémentation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Résumé par Sourcery

Documenter la conception de la file d’attente de suppression de compte
et le plan de mise en œuvre, en séparant les modifications de schéma et
d’orchestration (PR-A) du câblage du cron et de la modification de la
période de grâce (PR-B).

Améliorations :
- Clarifier les implications RGPD et produit du flux de suppression, y
compris les sémantiques de suppression idempotente, la gestion du
journal d’audit et le périmètre de destruction de l’espace de travail.
- Consigner les vérifications requises en production, la Definition of
Done et les responsabilités QA pour activer en toute sécurité le cron de
suppression et la période de grâce de 14 jours.

Documentation :
- Ajouter ADR-024 documentant la conception choisie de la file d’attente
de suppression de compte, les approches rejetées, les invariants et les
risques ouverts.
- Ajouter un plan d’exécution détaillé pour l’étape 3b décrivant comment
introduire la file d’attente de suppression, scindé en une PR-A non
destructive et une PR-B armant le cron, incluant les tests, les
vérifications de déploiement et le rollback.

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Document the account deletion queue design and implementation plan,
separating schema and orchestration changes (PR-A) from cron wiring and
grace-period change (PR-B).

Enhancements:
- Clarify GDPR and product implications of the deletion flow, including
idempotent deletion semantics, audit log handling, and workspace
destruction radius.
- Record required production checks, Definition of Done, and QA
responsibilities for safely enabling the deletion cron and 14-day grace
period.

Documentation:
- Add ADR-024 documenting the chosen account deletion queue design,
rejected approaches, invariants, and open risks.
- Add a detailed execution plan for step 3b describing how to introduce
the deletion queue, split into a non-destructive PR-A and a cron-arming
PR-B, including tests, rollout checks, and rollback.

</details>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
thierryvm added a commit that referenced this pull request Jul 27, 2026
#280)

## Pourquoi maintenant

Règle durable du projet : **le ROADMAP se remet à jour avant d'ouvrir la
branche suivante,
pas après.** La branche suivante est PR-A de l'étape 3b. Deux écarts à
solder d'abord.

## Écart 1 — l'étape 3 était annoncée « suivante »

Elle est en cours depuis deux jours, et son détail vaut d'être lisible.
L'exécution des
spécifications a montré que le préalable n'était pas la file de
suppression, mais **ce sur
quoi elle repose** : le journal d'audit n'enregistrait rien depuis
avril, et trois
affirmations publiques étaient inexactes.

Six lots livrés (#273#279), deux restants (PR-A inerte, PR-B
armement), et un écart
art. 17 mesuré qui part en session dédiée (#278).

Le **verrou de PR-B** est désormais écrit dans le ROADMAP : trois
lectures production, dont
une NO-GO — toute la conception repose sur un chemin PostgREST
`service_role` jamais
re-vérifié en production depuis #273.

## Écart 2 — une dette réglée encore listée comme ouverte

« Angle mort du préflight comptes » figurait dans les dettes ouvertes
alors que #276 l'a
comblé ce matin. Un ROADMAP qui garde une dette réglée est aussi
trompeur qu'un qui en
oublie une : il fait perdre du temps à qui la reprend.

## Portée

Un seul fichier, markdown. Aucun code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Summary by Sourcery

Mettre à jour le ROADMAP pour refléter l’état actuel et la répartition
détaillée de l’étape 3, ainsi que la résolution récente de la dette.

Documentation :
- Ajouter une sous-section détaillée documentant l’avancement de l’étape
3, les lots livrés, les PR restantes et les contraintes associées en
matière d’audit/journalisation.
- Marquer la dette « Angle mort du préflight comptes » comme résolue,
avec une explication mise à jour du nouveau comportement de vérification
pour les comptes Supabase et Vercel.

<details>
<summary>Original summary in English</summary>

## Summary by Sourcery

Update the ROADMAP to reflect the current status and detailed breakdown
of step 3 and recent debt resolution.

Documentation:
- Add a detailed sub-section documenting step 3 progress, delivered
lots, remaining PRs, and associated audit/logging constraints.
- Mark the 'Angle mort du préflight comptes' debt as resolved with an
updated explanation of the new verification behavior for Supabase and
Vercel accounts.

</details>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
thierryvm added a commit that referenced this pull request Jul 27, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:review-needed Ready for review type:fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(security): fix audit_log service_role client (H3 createServerClient leak)

1 participant