# Hiérarchie des permissions — état de l'existant

**Relevé du 25 juillet 2026**, sur `dev`, **mis à jour le 26 juillet 2026** pour la bascule de
l'autorité Search Console au niveau du site (sections 5, 6, 7 et points 2, 10, 11, 12 de la
section 8). Document de référence descriptif : il décrit ce que le
code fait aujourd'hui, pas ce qu'on voudrait qu'il fasse. Chaque affirmation est adossée à un
`fichier:ligne`.

Il a été produit pour éclairer une question précise — faut-il ouvrir Search Console et la création
de site aux membres ? — mais il vaut comme état des lieux général de l'autorisation.

---

## 1. Trois systèmes de rôles indépendants

C'est le point de structure à comprendre avant tout le reste : il n'existe pas *une* hiérarchie,
mais **trois axes qui ne se recouvrent pas**.

| Axe | Porté par | Valeurs | Autorité |
|---|---|---|---|
| **Rôle système (SYS)** | `user.role` (colonne globale) | `null` · `"user"` · `"staff"` · `"admin"` | Plugin `admin()` de Better Auth + couche maison |
| **Rôle d'organisation** | `member.role` | `"owner"` · `"admin"` · `"member"` | Plugin `organization()` + access control custom |
| **Accès par site** | table `site_access` | présence d'une ligne | Code applicatif seul |

S'y ajoute un quatrième mécanisme, hors DB : le **super-admin par variable d'environnement**
(`SUPER_ADMIN_EMAILS`), qui prime sur tout.

Un compte SYS est **orgless par construction** : le layout applicatif le renvoie vers `/admin`
avant toute résolution d'organisation (`src/app/(app)/layout.tsx:49-56`), et `promoteToStaff`
refuse toute cible déjà membre d'une organisation
(`src/lib/admin/staff-management/core.ts:80-94`).

---

## 2. Le rôle système

### La colonne

`user.role` — `src/lib/db/auth-schema.ts:40`, `text`, **nullable, sans défaut en base**. Le défaut
`"user"` est appliqué par le plugin, pas par la DB.

Qui l'écrit :

| Écriture | Emplacement |
|---|---|
| `"user"` à la création | `adminPlugin({ defaultRole: "user" })`, `src/lib/auth.ts:550-554` |
| `"admin"` si l'email est dans `SUPER_ADMIN_EMAILS` | `databaseHooks.user.create.before`, `src/lib/auth.ts:451-467` |
| `"staff"` | `promoteToStaff`, `src/lib/admin/staff-management/core.ts:80-94` |
| retour à `"user"` | `removeStaff`, même fichier `:138-158` (transaction : purge aussi `staff_permission` et les sessions) |
| `"admin"` / `"user"` | `setUserRoleAction`, `src/lib/admin/actions.ts:259-304` — n'écrit jamais `"staff"` |

### Le plugin ne connaît que `"admin"`

```ts
adminPlugin({ defaultRole: "user", adminRoles: ["admin"], impersonationSessionDuration: 60 * 30 })
```
`src/lib/auth.ts:550-554`

**`"staff"` n'est pas reconnu par Better Auth.** Un Staff SYS ne peut pas frapper
`/api/auth/admin/*`. C'est une couche d'autorisation entièrement maison, ce qui explique pourquoi
les mutations exécutables par un staff passent par Drizzle en direct plutôt que par `auth.api.*`.

### La grille de permissions du staff

`src/lib/auth/sys-permissions-const.ts` — quatre groupes délégables (`users`, `orgs`, `billing`,
`data`) et deux permissions **jamais délégables** :

```ts
export const RESERVED_SYS_PERMISSIONS = ["staff:manage", "users:impersonate"] as const;
```

C'est une garde anti-escalade explicite : `staff:manage` permettrait l'auto-promotion,
`users:impersonate` ferait hériter de tous les droits d'organisation de la cible.

L'évaluation est pure et testable (`src/lib/auth/sys-permissions.ts`) :

```ts
export function resolveSysPermission(op, perm, staffGrants): "ok" | "forbidden" | "2fa-required"
```

Trois propriétés notables : la **2FA est obligatoire** pour toute permission SYS ; un Admin SYS
obtient tout implicitement sans ligne en base ; les grants d'un Staff SYS sont relus en base à
chaque appel, donc **une révocation prend effet immédiatement**.

### Le super-admin par environnement

`src/lib/auth/super-admin.ts` — cinq fonctions autour de `SUPER_ADMIN_EMAILS`. La distinction qui
compte :

| Fonction | Exige la 2FA |
|---|---|
| `assertSuperAdmin()` `:37` | **non** |
| `assertSuperAdminWith2FA()` `:77` | oui |
| `assertSysPermission(perm)` `sys-permissions.ts:108` | oui, plus la permission |

Toutes relisent la session avec `disableCookieCache: true` : le god-mode ne s'authentifie jamais
sur le cache de cinq minutes.

**Duplication assumée** : `isSuperAdminEmail` est réimplémentée dans `src/lib/auth.ts:84-92`, à
l'identique, pour casser un cycle d'import. Le commentaire demande de garder les deux
synchronisées ; aucun test ne les compare.

### La surface `/admin`

`src/app/(superadmin)/layout.tsx` garde en quatre étapes : pas de session → `/login` ; pas
opérateur SYS → **`notFound()`, jamais 403**, pour ne pas révéler l'existence de la surface ; 2FA
absente → interstitiel plein écran sans redirect (évite les boucles) ; sinon rendu avec navigation
filtrée par `currentSysPermissions()`.

Les server actions re-vérifient toutes derrière : le layout n'est pas le seul rempart.

---

## 3. Le rôle d'organisation

### Configuration

```ts
organization({
  ac,
  roles: { owner, admin, member },
  organizationLimit: 5,
  allowUserToCreateOrganization: async (user) => user.emailVerified && !isSuperAdminEmail(user.email),
  invitationExpiresIn: 60 * 60 * 24 * 7,
  sendInvitationEmail: async (data) => { ... },
})
```
`src/lib/auth.ts:498-519`

`membershipLimit`, `invitationLimit`, `creatorRole` et `teams` ne sont **pas** configurés. Le quota
de sièges est volontairement géré hors plugin, pour pouvoir renvoyer un message applicatif.
**Il n'y a pas de sous-groupes** : l'organisation est plate.

L'organisation personnelle créée au signup contourne entièrement
`allowUserToCreateOrganization` — elle est écrite en direct par `createOrgForUser`
(`src/lib/auth/org-bootstrap.ts`).

### Les trois rôles

`src/lib/permissions.ts:12-37` étend les statements par défaut avec deux ressources maison :

| Ressource | `member` | `admin` | `owner` |
|---|---|---|---|
| `billing: ["view","manage"]` | — | — | ✓ |
| `gsc: ["link"]` | — | ✓ | ✓ |

**La facturation est la seule capacité où `admin` ≠ `owner`.** Partout ailleurs dans le code, la
formule est « owner ou admin ».

### La table

`member` — `src/lib/db/auth-schema.ts:232-249`. `role` est un `text` libre avec défaut `"member"` :
**aucune contrainte CHECK, aucun enum Postgres**. La validité des trois valeurs repose uniquement
sur les schémas Zod applicatifs et l'access control du plugin. Il n'y a pas non plus d'index unique
`(organizationId, userId)` déclaré dans Drizzle.

### Qui a le droit de quoi

| Action | Droit | Vérifié où |
|---|---|---|
| Créer son organisation personnelle | tout compte non-SYS | `databaseHooks.user.create.after`, `src/lib/auth.ts:472-476` |
| Créer une organisation de plus | email vérifié, non super-admin, moins de 5 | `src/lib/auth.ts:503-508` |
| Inviter | owner \| admin, plus quota de sièges | access control + `hooks.before`, `src/lib/auth.ts:205-232` |
| Accepter une invitation | l'invité, **sauf compte SYS** | `src/lib/auth.ts:238-282` |
| Changer un rôle | owner \| admin ; jamais soi-même, jamais un owner | UI `team-management.tsx:100-102` **+ plugin** (voir ci-dessous) |
| Retirer un membre | owner \| admin | UI `:443` + plugin |
| Quitter | tout membre sauf le dernier owner | plugin |
| Renommer | owner \| admin | `:816-823` |
| Supprimer l'organisation | owner, et seulement sans autre membre | `:845-880` |
| Acheter / gérer l'abonnement | **owner strictement** | `isOrgBillingManager`, `src/lib/billing/authorize.ts:9-26` |

**La protection du dernier propriétaire existe bien**, contrairement à ce qu'on pourrait croire en
ne lisant que le code applicatif : elle est portée nativement par le plugin, dans
`node_modules/better-auth/dist/plugins/organization/routes/crud-members.mjs` — lignes 176 et 180
pour `removeMember`, ligne 305 pour `updateMemberRole`. Le code maison n'a pas à la réimplémenter,
et le commentaire de `team-management.tsx:438-440` qui l'affirme dit vrai.

### Les sièges

Un siège consommé = `count(member) + count(invitation WHERE status='pending')`
(`src/lib/billing/usage.ts:61-80`) — une invitation en attente réserve donc un siège.

Limites : solo 1, starter 1, pro 3, agency 10 (`src/lib/billing/plans.ts`), extensibles par siège
additionnel sur pro et agency. En essai, les limites Pro s'appliquent.

**Trois points de contrôle volontairement redondants**, dont un seul est atomique :

1. À l'invitation — `assertWithinQuota(orgId,"seats",1)`, `src/lib/auth.ts:205-232`. Lecture puis
   décision, TOCTOU documenté.
2. À l'acceptation — `checkInvitationSeatBlock`, `src/lib/auth/seat-guard.ts:35-78`, avec
   incrément **0** puisque l'invitation compte déjà. Si bloqué, l'invitation reste `pending` et les
   administrateurs sont notifiés une seule fois (`invitation.seatBlockNotifiedAt`).
3. **Trigger Postgres** `member_enforce_seats_limit` (migration `0060`), qui verrouille la ligne
   `organization` et rejette l'`INSERT`. C'est le seul point réellement atomique, parce que
   l'insertion du membre appartient entièrement à Better Auth et échappe à toute transaction
   applicative.

---

## 4. L'accès par site

### La table

```ts
siteAccess = pgTable("site_access", {
  siteId: text().notNull().references(() => sites.id, { onDelete: "cascade" }),
  userId: text().notNull().references(() => user.id, { onDelete: "cascade" }),
  grantedAt: timestamp().defaultNow().notNull(),
}, (t) => [primaryKey({ columns: [t.siteId, t.userId] }), index("site_access_user_idx").on(t.userId)]);
```
`src/lib/db/domain-schema.ts:107-124`

**Aucune colonne `orgId`** : l'appartenance n'est portée que transitivement par `sites.orgId`.
**Aucune clé étrangère vers `member`** — voir la section 8, silence n°1.

### Les primitives

`src/lib/sites/access.ts`, toutes dédupées par requête via `react.cache` :

| Fonction | Ligne | Comportement |
|---|---|---|
| `currentCaller()` *(non exporté)* | `:35-70` | session → `getActiveOrgId()` → lecture de `member.role`. Renvoie `null` sur toute rupture |
| `isFullAccess(role)` *(non exporté)* | `:72-73` | `role === "owner" \|\| role === "admin"`, comparaison stricte |
| `hasFullSiteAccess()` | `:77-80` | **fail-closed** : caller nul → `false` |
| `getGrantedSiteIds(userId)` | `:83-91` | `SELECT site_id FROM site_access WHERE user_id = $1` — **sans filtre org**, primitive cross-org |
| `getSitesForActiveOrg()` | `:95-102` | owner/admin → tous les sites ; member → intersection avec ses grants |
| `currentUserCanAccessSite(siteId)` | `:106-121` | **fail-closed** ; owner/admin passent **sans vérifier l'org** |

`assertSiteInActiveOrg(siteId)` (`src/lib/sites/assert-site.ts:27-38`) est le point où les deux
moitiés se recomposent : tenancy (`siteBelongsToOrg`) **puis** partage
(`currentUserCanAccessSite`). Aucune des deux n'est sûre isolément — c'est leur composition qui
l'est. Deux variantes existent pour les mots-clés et les items de contenu, qui résolvent le
`siteId` puis délèguent.

### Accorder l'accès

`setMemberSiteAccessAction` (`src/lib/sites/access-actions.ts:43-73`), gardée par
`assertManagerAndMember` (owner/admin + la cible doit être membre). Sémantique **remplacement** :
suppression puis insertion, dans une transaction, **bornée aux sites de l'organisation active** —
les grants d'un utilisateur dans d'autres organisations survivent, ce qui est correct.

L'action ne fait aucun `revalidatePath` : le membre concerné ne voit le changement qu'à sa
prochaine navigation serveur.

### Le cookie de site actif

`"active-site-id"`, écrit **côté client uniquement** via `document.cookie`
(`src/lib/sites/active-site-client.ts:6-10`), `path=/`, un an, `samesite=lax`, sans `Secure` ni
`HttpOnly`.

Il n'est jamais une source d'autorité : `pickActiveSiteId` (`active-site.ts:10-15`) ne l'honore que
s'il figure dans la liste de sites que **l'appelant** lui passe, sinon repli sur le premier. Les
douze appelants passent tous `getSitesForActiveOrg()`, la liste filtrée par accès — sauf
`src/lib/sites/site-language.ts:27,29`, qui utilise la liste org non filtrée. Impact limité à la
résolution de langue de l'interface.

---

## 5. Les gardes, en un coup d'œil

| Garde | Fichier:ligne | Exige |
|---|---|---|
| `getActiveOrgId()` | `src/lib/active-org.ts:10` | une organisation active — **ni rôle, ni accès au site** |
| `assertSiteInActiveOrg()` | `src/lib/sites/assert-site.ts:27` | site dans l'org **et** partagé avec l'appelant |
| `hasFullSiteAccess()` | `src/lib/sites/access.ts:77` | owner \| admin |
| `currentUserCanAccessSite()` | `src/lib/sites/access.ts:144` | l'appelant a accès **à ce site** (owner/admin, ou membre avec grant) |
| `canManageOrgConnectors()` | `src/lib/connectors/access.ts:8` | owner \| admin |
| `isOrgBillingManager()` | `src/lib/billing/authorize.ts:10` | owner **strictement** |
| `assertSuperAdmin()` | `src/lib/auth/super-admin.ts:37` | Admin SYS, **sans 2FA** |
| `assertSuperAdminWith2FA()` | `src/lib/auth/super-admin.ts:77` | Admin SYS + 2FA |
| `assertSysPermission(perm)` | `src/lib/auth/sys-permissions.ts:108` | opérateur SYS + 2FA + permission |

---

## 6. La matrice, par domaine

Plutôt que de lister cent fonctions, voici la **règle** de chaque domaine et ses exceptions.

| Domaine | Règle dominante | Exceptions |
|---|---|---|
| **Mots-clés** (≈35 actions) | `assertSiteInActiveOrg` ou `assertKeywordInActiveOrg` — donc **membre + site partagé** | aucune |
| **Contenu** (≈20 actions) | `assertContentItemInActiveOrg` / `assertSiteInActiveOrg` | aucune |
| **Pages / crawl** | `assertSiteInActiveOrg` | `runSiteCrawl` — worker sans session, allowlisté |
| **Backlinks : ciblage, planning, profil** | `assertSiteInActiveOrg` | — |
| **Backlinks : catalogue de sources** | **`getActiveOrgId()` seul** — tout membre peut importer en masse | `setSourceGlobalAction` exige Admin SYS |
| **Sites / projets** | `hasFullSiteAccess()` — owner \| admin | — |
| **Search Console : mutations** | `currentUserCanAccessSite(siteId)` — donc **membre + site partagé** | — |
| **Search Console : lecture de statut** | `getActiveOrgId()` seul, renvoie **tous** les sites de l'org | — |
| **Connecteurs Mangools / SE-Ranking** | `canManageOrgConnectors()` pour écrire, `getActiveOrgId()` pour lire le statut | — |
| **Facturation** | `isOrgBillingManager` — **owner strict** | — |
| **Clés API MCP** | membre quelconque + add-on actif | `setOrgAddonAction` exige Admin SYS |
| **Support** | auteur du ticket, ou Admin SYS | — |
| **Back-office `/admin`** | `assertSysPermission(perm)` — SYS + 2FA + permission | `impersonateUserAction` exige Admin SYS + 2FA ; `stopImpersonatingAction` n'a **aucune** garde, délibérément |

Ce que le tableau met en évidence : **un membre disposant d'un accès au site peut faire tout le
travail SEO** — importer, enrichir, cibler, planifier, produire du contenu, et depuis la bascule de
l'autorité Search Console au niveau du site, mapper une propriété GSC et lancer un sync sur les
sites qu'il voit. Ce qu'il ne peut pas faire, c'est **administrer** : créer un site, brancher un
connecteur d'organisation (Mangools, SE-Ranking), payer.

---

## 7. La résolution du token Search Console

C'est le mécanisme le plus fragile de l'ensemble, et il mérite sa section.

### Une seule chaîne, ancrée sur le site

L'autorité n'est plus déduite d'un rôle d'organisation. Elle est **épinglée sur le site** au moment
du mapping, sous la forme d'un couple (utilisateur, compte Google) :
`sites.gsc_authorized_user_id` (migration `0070`) et `sites.gsc_authorized_account_id`
(migration `0071`).

Un unique point de résolution, `resolveSiteGscToken` (`src/lib/gsc/site-token.ts`), sert **à la fois
l'application et le worker de snapshots** : les trois anciens chemins (jeton de l'owner de l'org,
jeton dédié du worker, jeton de la session courante) sont supprimés, donc il n'existe plus de
divergence possible entre ce que l'interface affiche et ce que le worker obtient.

Il s'appuie sur `deriveSiteGscState` (`src/lib/gsc/site-state.ts`), qui diagnostique l'état du site
**sans aucun appel réseau** et renvoie une raison typée : `no-property`, `no-authority`,
`authority-lost`, `no-google-account`, `missing-scope`, `token-refused`.

Deux propriétés méritent d'être retenues :

- **Revalidation à l'usage.** `/organization/leave` ne déclenche aucun hook Better Auth : l'autorité
  stockée ne peut donc pas faire foi à elle seule. À chaque résolution, l'accès de l'autorité au
  site est revérifié (`userCanAccessSite`) ; s'il a disparu, l'état passe à `authority-lost` et
  l'interface demande une reconnexion.
- **Le compte Google épinglé, ou rien.** Si le compte épinglé a été délié, la résolution échoue
  (`no-google-account`) au lieu de retomber en silence sur un autre compte de la même personne —
  ce repli rendrait « vert » un site que Google refusera ensuite par un 403 opaque.

⚠️ `resolveSiteGscToken` est une frontière d'autorisation **pour l'autorité seulement**. Le worker
n'a pas de session : la fonction ne peut pas vérifier l'appelant. Toute server action ou route qui
l'appelle doit gater son appelant séparément via `currentUserCanAccessSite(siteId)`.

### Ce qui n'est traité nulle part

Quand le porteur du scope quitte l'organisation, délie son compte Google, ou voit son jeton
révoqué :

- `sites.gscProperty` **n'est jamais remis à `null`**. Comme l'index unique
  `sites_gsc_property_uidx` est **global** (`domain-schema.ts:98-100`), une propriété orpheline
  peut bloquer une autre organisation qui voudrait légitimement la mapper après un transfert de
  client.
- Aucune notification, aucun drapeau persistant. La rupture n'est visible qu'en ouvrant `/gsc` ou
  en lisant les logs.
- Le worker retente les sites cassés à chaque exécution, sans backoff ni marquage.

Ce qui **est** correctement traité depuis la bascule par site : `deriveSiteGscState` détecte sans
réseau la perte du scope (`missing-scope`), la déliaison du compte épinglé (`no-google-account`) et
la perte d'accès de l'autorité au site (`authority-lost`), et alimente un état « à reconnecter »
par site dans l'interface. Ce qui manque reste les **hooks de nettoyage**
(`afterRemoveMember`, `afterUpdateMemberRole`) : la correction est complète, mais elle n'intervient
qu'au prochain usage.

---

## 8. Incohérences et silences

Classés par gravité décroissante, dans l'ordre du relevé du 25/07. Les entrées barrées ont été
corrigées depuis ; les autres restent ouvertes.

**1. Les accès aux sites survivent au retrait d'un membre.** Les clés étrangères de `site_access`
cascadent sur `sites` et `user`, **pas sur `member`**. `removeMemberAction`
(`src/lib/admin/org-actions.ts:196-232`) et le chemin client Better Auth suppriment la ligne
`member` sans rien d'autre, et aucun `databaseHook` ne couvre `member`. Les grants restent en base
indéfiniment. Ils sont inertes tant que la personne n'est plus membre — mais **si elle est
réinvitée dans la même organisation, ses anciens accès se réactivent silencieusement**.

**2. ~~Le worker Search Console ne voit que l'owner~~ — CORRIGÉ.** L'autorité est désormais épinglée
sur le site et un unique point de résolution sert l'application comme le worker (section 7). La
divergence « owner strict » n'existe plus. Reste ouvert : les hooks de nettoyage, qui ne feraient
qu'avancer l'affichage de l'état « à reconnecter ».

**3. `getSitesForActiveOrg` est fail-open sur un appelant nul.** Ligne `access.ts:99` :
`if (!c || isFullAccess(c.role)) return all` fusionne une décision légitime (owner/admin voient
tout) et un échec de résolution (renvoie **tous** les sites). C'est l'inverse de la politique
déclarée en tête de fichier — « no row = no access » — et l'inverse de
`currentUserCanAccessSite` qui, quatorze lignes plus bas, est explicitement fail-closed. Aucun
commentaire ne justifie la ligne 99. La fenêtre a été **restreinte** le 25/07 en routant
`currentCaller` par le self-heal de `getActiveOrgId`, mais la branche demeure.

**4. Deux niveaux de god-mode pour des capacités comparables.** `assertSuperAdmin()` **n'exige pas
la 2FA** et garde : la lecture cross-tenant du catalogue de backlinks
(`global-actions.ts:19,35`), l'activation d'un add-on facturable (`mcp/keys-actions.ts:123`), et
**toute la boîte de réception du support**, c'est-à-dire du contenu client
(`support/staff-actions.ts:52,101,112,122,143`). Le back-office `/admin`, lui, exige la 2FA
partout.

**5. Une clé MCP contourne `site_access`.** N'importe quel membre peut créer une clé
(`mcp/keys-actions.ts:35`), et le serveur MCP ne scope que par organisation
(`src/app/api/mcp/route.ts:97-102`), exposant `list_sites`, `audit_overview`, `get_site_audit` et
l'écriture `run_site_audit` sur **tous** les sites. Le chemin interface équivalent est strictement
plus restrictif.

**6. Trois lectures org-wide échappent au filtrage par site.** `getOrgAuditOverviewAction`
(`spec/audit/actions.ts:79`) expose nom, URL et score d'audit de tous les sites ;
`getGscHealthAction` (`gsc/health-actions.ts:22`) agrège org-wide ; `getGscStatusAction`
(`gsc/actions.ts:72`) renvoie la liste complète des sites sans aucune garde de rôle. Toutes trois
passent le contrôle de tenancy parce que `getActiveOrgId` y figure.

**7. Le catalogue de backlinks est ouvert à tout membre.** `previewImportAction`,
`commitImportAction` et `addSourceAction` (`backlink/import-actions.ts:39,82,150`) écrivent dans le
catalogue partagé de l'organisation et consomment son quota avec `getActiveOrgId()` pour seule
garde — alors que créer un simple projet exige owner/admin.

**8. `createSiteAction` n'accorde aucun `site_access` au créateur** (`sites/actions.ts:31-103`,
vérifié : le fichier n'importe même pas `siteAccess`). Sans effet aujourd'hui puisque seuls les
owners et admins créent des sites. **Mais si la création était ouverte aux membres, le créateur
perdrait immédiatement l'accès à sa propre création.**

**9. `assertCanAnalyze` est un no-op.** `src/lib/semantic/actions.ts:23-25`, marqué `TODO(D6)` :
l'analyse sémantique enfile un job sans aucun contrôle de quota, contrairement à toutes les
opérations coûteuses voisines.

**10. Le contrôle de tenancy est textuel, pas sémantique.**
`src/lib/security/tenancy-guard.ts` détecte l'absence totale de garde, pas la mauvaise garde. Il
accepte `getActiveOrgId` comme suffisant — d'où les points 6 et 7 — et la présence du helper
**n'importe où** dans le fichier couvre ses voisines. **Aucune garde de rôle** (`hasFullSiteAccess`,
`canManageOrgConnectors`, `assertSuperAdmin`, `assertSysPermission`) ne figure dans sa liste. Le seul filet
statique sur un rôle est `sites/actions-gate.test.ts`, qui vérifie par recherche de texte que trois
fonctions mentionnent `hasFullSiteAccess`.

**11. Messages et commentaires faux.** ~~`setSiteGscPropertyAction` répond « Action réservée au
propriétaire. » alors qu'elle accepte les admins~~ — corrigé : la garde est
`currentUserCanAccessSite` et le message « Accès refusé à ce site. ». Reste : le JSDoc de
`stopImpersonatingAction` et celui d'`ImpersonationBanner` annoncent une redirection vers
`/support/users` quand le code va vers `/admin/users`.

**12. Détails de robustesse.** ~~`canManageGsc` et `canLinkGsc` ont des corps identiques~~ —
corrigé : un seul helper, `canManageOrgConnectors` (`src/lib/connectors/access.ts`).
`isImpersonable` (`src/lib/admin/impersonation.ts`) refuse `"admin"` et les
super-admins mais **pas `"staff"`** ; sans conséquence aujourd'hui car aucune interface n'expose un
Staff SYS comme cible. `member.role` n'a aucune contrainte en base.

---

## 8 bis. Depuis ce relevé — ce qui a été corrigé

Le relevé ci-dessus décrit l'état au 25 juillet au matin. La branche
`fix/site-access-and-impersonation` a traité une partie de ces points le jour même. Les sections
précédentes ne sont pas réécrites — un relevé daté doit rester lisible tel qu'il a été fait — mais
voici ce qui a changé.

**Silence n°8 fermé.** `createSiteAction` enregistre désormais un `site_access` pour son créateur,
dans la transaction existante. La garantie a une limite à connaître :
`setMemberSiteAccessAction` a une sémantique de remplacement, donc la ligne du créateur est effacée
dès qu'un administrateur modifie la liste des sites de ce membre.

**Une famille entière découverte après le relevé : les écritures sous impersonation.** Better Auth
remplace `session.user` par la personne cible, donc toute écriture clée sur `session.user.id`
atterrissait sur le compte du client. Ce n'était pas une escalade de privilège — l'impersonation
exige déjà Admin SYS avec 2FA — mais **un contournement du journal d'audit**, puisque les gardes
système échouent dans ce contexte et que `recordAdminAction` n'est jamais atteint.

Dix effets de bord ont été inventoriés, dont plusieurs actifs en production : `setUserLocale`
réécrivait durablement la langue du client, et `setInitialPasswordAction` pouvait poser un mot de
passe permanent sans challenge et sans trace. Deux mécanismes ont été mis en place :

- un helper partagé, appliqué à nos propres Server Actions ;
- un ensemble de chemins bloqués dans `hooks.before` pour les endpoints Better Auth appelés
  directement par le client, qui ne traversent jamais notre code.

Deux pièges rencontrés en chemin méritent d'être retenus. La garde lisait d'abord une session
pouvant venir du cache de cookie, alors que six des endpoints bloqués relisent la session de façon
autoritaire — un opérateur conservant son cookie d'avant l'impersonation obtenait une garde aveugle
et un endpoint qui écrivait. Et le premier correctif a **enfermé l'opérateur** deux fois : une fois
sur `/onboarding`, une fois sur `/billing-locked`, faute de bouton de sortie hors du groupe `(app)`.

**Ce qui reste ouvert.** La liste bloquée couvre les écritures de **compte** et rien d'autre :
`/subscription/*` et `/organization/*` restent atteignables pendant une impersonation, ce qui
permettrait de résilier l'abonnement d'un client sans trace. Les bloquer demande d'abord une
décision produit sur ce qu'un opérateur a légitimement le droit de faire. Le périmètre est
documenté en tête de `src/lib/auth/impersonation-blocked-paths.ts`.

Les silences n°1 à 7 et 9 à 12 du relevé sont **inchangés**.

## 9. Ce qui n'est vérifié nulle part

- Qu'un `site_access` corresponde à une adhésion vivante à l'organisation (silence n°1).
- Que le worker Search Console puisse réellement synchroniser un site marqué « connecté »
  (silence n°2).
- Que les gardes de rôle owner/admin de Search Console, des connecteurs et de la facturation soient
  présentes — aucun test de régression ne les couvre (silence n°10).
- Qu'une nouvelle section dépendante d'un site porte bien son gate d'accès — rien n'échoue si on
  l'oublie.
