# RBAC à deux plans — rôles org + Staff SYS à permissions atomiques

**Date** : 2026-07-09
**Statut** : implémenté (branche feature/rbac-two-planes, 2026-07-10)
**Périmètre** : refonte du modèle de rôles/permissions. Fondation sous le futur back-office admin (Track A) et l'onboarding « org sans site » (Track B) — **ces deux tracks sont hors périmètre de cette spec.**

---

## 1. Contexte & motivation

Le projet a deux populations qu'il faut **dissocier explicitement** :

- **Le staff / god-mode** (nous) qui opère le back-office `/admin`.
- **Les clients** (owners et membres d'une org) qui utilisent l'app.

Le modèle actuel couvre déjà ~80 % du besoin, mais présente trois manques/défauts :

1. **Aucun « staff système à droits partiels ».** L'accès god-mode est **binaire** : email dans `SUPER_ADMIN_EMAILS` (+ 2FA) = tout ; sinon rien. Impossible de déléguer un sous-ensemble d'actions à un collaborateur.
2. **Collision de nommage.** Le rôle org le plus bas s'appelle `staff` dans le code (`src/lib/permissions.ts`), ce qui entre en conflit avec la notion de « Staff SYS » qu'on introduit.
3. **Deux droits mal placés côté org** : `billing` est ouvert au rôle `admin` (on veut owner-only) ; le lien GSC est owner-only en dur (on veut l'ouvrir à `admin`).

Cette spec corrige ces trois points et introduit un plan système à deux niveaux (Admin SYS / Staff SYS) avec des **permissions atomiques**.

### État de l'existant (vérifié)

- **Access-control org** : `src/lib/permissions.ts` — `createAccessControl(statement)` avec `statement = defaultStatements + billing:[view,manage]`. Rôles `owner` (full+billing), `admin` (adminAc+billing), `staff` (memberAc, pas de billing). Câblés dans `auth.ts` → `organization({ ac, roles: { owner, admin, staff } })`.
- **Plan système** : plugin `admin` Better Auth (`adminPlugin({ defaultRole: "user", adminRoles: ["admin"] })`). Colonne globale `user.role` (nullable). Les emails de `SUPER_ADMIN_EMAILS` sont stampés `role:"admin"` (hook create + backfill). **Le gate applicatif autoritatif reste `assertSuperAdmin()` (env), pas `user.role`.**
- **Gate god-mode** : `src/lib/auth/super-admin.ts` — `assertSuperAdmin()`, `assertSuperAdminWith2FA()` (mur 2FA dur), `isCurrentUserSuperAdmin()`, tous en lecture DB fraîche (`disableCookieCache`).
- **Actions admin** : `src/lib/admin/actions.ts` (+ org-actions, billing-actions) ré-assertent le gate côté serveur. Toutes tracées via `recordAdminAction` (audit).
- **Accès par-site** : `hasFullSiteAccess()` (owner/admin voient tout ; les membres sont confinés à des sites accordés) — mécanisme déjà en place (`src/lib/sites/access.ts`).

---

## 2. Le modèle à deux plans (cible)

### Plan SYSTÈME — accès au back-office `/admin` (exige toujours 2FA)

| Rôle | Source | Droits |
|---|---|---|
| **Admin SYS** | env `SUPER_ADMIN_EMAILS` (inchangé) | **Toutes** les permissions atomiques, implicitement. Non-verrouillable (bootstrap). Seul à détenir `staff:manage`. |
| **Staff SYS** | DB : `user.role = "staff"` + table `staff_permission` | Sous-ensemble de permissions atomiques **coché par un Admin SYS**. |

### Plan ORG — le tenant (`src/lib/permissions.ts`)

| Rôle | Droits | Changement vs existant |
|---|---|---|
| **owner** | Full org : billing/Stripe, lien GSC, accès par-site, gestion membres, transfert | inchangé |
| **admin** | User management + **lien GSC** + accès par-site | ➖ retire `billing` · ➕ ajoute `gsc` |
| **member** (ex-`staff`) | Utilise l'app, **confiné aux sites accordés** | **renommé** `staff` → `member` |

**Accès par-site** : `owner` **et** `admin` peuvent accorder à un `member` l'accès à des sites précis (l'admin fait déjà le user management). Mécanisme de confinement inchangé.

### 2.bis — Les comptes SYS sont une population DÉDIÉE (dissociation)

Principe (validé) : **on dissocie totalement les opérateurs système des utilisateurs de l'app.** Un compte SYS n'a **ni org, ni site, ni membership** — sa seule surface est le dash de management `/admin`. Un utilisateur normal ne voit que l'app `(app)`. On obtient la séparation « deux apps » **par routage strict dans la même app**, sans modifier Better Auth (jugé trop risqué).

| Population | Org/sites | Surface | Comment on la reconnaît |
|---|---|---|---|
| **Admin SYS** | **aucune, par construction** (orgless enforced) | `/admin` **uniquement** — jamais l'app | email ∈ `SUPER_ADMIN_EMAILS` (⇒ `user.role = "admin"`) |
| **Staff SYS** | **aucune, par construction** | `/admin` uniquement | `user.role = "staff"` |
| **Client** (owner/admin/member d'org) | ≥ 1 org | `(app)` uniquement | ni l'un ni l'autre |

**Orgless enforcé pour les DEUX rôles système** (`shouldBootstrapOrg` exempte `staff` ET `admin`) : ni le bootstrap au signup, ni `allowUserToCreateOrganization`, ni l'acceptation d'invitation ne peuvent donner une org à un compte SYS. Le layout `(app)` redirige **inconditionnellement** tout opérateur SYS vers `/admin` (aucun accès dual). **Conséquence opérationnelle** : les comptes qui possèdent des orgs (données démo, comptes clients) ne doivent PAS être dans `SUPER_ADMIN_EMAILS` — l'Admin SYS est un compte **dédié**, orgless. **Provisioning des Staff SYS** : jamais par le signup public — créés depuis `/admin/staff` (Admin SYS) via `auth.api.createUser` avec `role:"staff"`.

**Routage (la dissociation effective)** :
- Layout `(app)` : si l'utilisateur courant est un opérateur SYS (`role === "staff"` ou super-admin **sans org**) → `redirect("/admin")` **avant** tout appel à `getActiveOrgId()`. Vérifié dans le code : `src/lib/active-org.ts:15-33` a un self-heal qui résout/active une org pour toute session orgless touchant `(app)` — un Staff SYS ne doit jamais l'atteindre (il n'a pas d'org à résoudre → 500 sinon).
- Layout `(superadmin)` : rejette quiconque n'est pas opérateur SYS (existant, étendu au rôle staff).
- Post-login : un opérateur SYS atterrit sur `/admin`, pas sur le dashboard app.

---

## 3. Corrections du plan ORG (`src/lib/permissions.ts` + `auth.ts`)

1. **Nouveau statement** `gsc: ["link"]` ajouté à `statement`. `owner` et `admin` le reçoivent. Le point d'entrée « connecter Search Console » (aujourd'hui gate owner-only en dur) se re-gate sur `gsc:link`.
2. **Rôle `admin`** : retirer `billing: ["view", "manage"]`. Billing devient **owner-only**.
3. **Renommage `staff` → `member`** :
   - `permissions.ts` : export `member` (ex-`staff`), `roles = { owner, admin, member }`.
   - `auth.ts` : `organization({ ac, roles: { owner, admin, member } })`.
   - **Migration de données** : `UPDATE member SET role = 'member' WHERE role = 'staff'` (table `member` de Better Auth). Migration custom sans snapshot (pattern 0024/0055).
   - **Balayage code** : remplacer toute référence à la string de rôle org `"staff"` par `"member"` (invitations, gates, tests, libellés UI). ⚠️ Ne PAS confondre avec le futur `user.role = "staff"` du **plan système** (sémantique différente, même string — voir §5, note d'ambiguïté).

---

## 4. Permissions atomiques (vocabulaire)

Format `ressource:action`. Grille figée (validée) :

| Ressource | Permissions |
|---|---|
| **users** | `users:read`, `users:ban`, `users:role`, `users:2fa-reset`, `users:email`, `users:sessions` |
| **orgs** | `orgs:read`, `orgs:members`, `orgs:limits` |
| **billing** | `billing:read`, `billing:manage` |
| **data** | `export:run`, `audit:read` |
| **réservées Admin SYS** | `staff:manage`, `users:impersonate` — **jamais délégables** |

Deux permissions sont des garde-fous anti-escalade, **exclues de la grille assignable** :

- `staff:manage` : un Staff SYS qui l'aurait pourrait s'auto-promouvoir.
- `users:impersonate` : **l'impersonation court-circuite tout le RBAC système.** Une session impersonée hérite de TOUS les droits de la cible (vérifié doc Better Auth : la session forgée porte `impersonatedBy` mais agit avec l'identité complète de l'usurpé). Scénario : un Staff SYS sans `billing:manage` impersonate un owner d'org → il hérite instantanément du billing Stripe de ce client, peut altérer/supprimer l'org. **Décision v1 : impersonation exclusivement Admin SYS.** Pas de demi-mesure. Si un jour on l'ouvre au staff (v2+), il faudra un mécanisme global qui bride l'app (lecture seule / blocage des actions sensibles) dès que `session.impersonatedBy` est présent — chantier séparé.

Notes Better Auth vérifiées (doc plugin admin) : (a) par défaut un admin ne peut pas impersonate un autre admin (`impersonate-admins` requis) — les comptes Admin SYS sont donc protégés entre eux ; (b) le warning officiel confirme que tout rôle hors `adminRoles` ne peut PAS effectuer d'opérations admin sur les endpoints bruts — c'est exactement le comportement qu'on exploite au §5.

**Source de vérité** : une constante `SYS_PERMISSIONS` (union de string literals + descriptions groupées par ressource) dans un module dédié, ex. `src/lib/auth/sys-permissions.ts`. La grille UI et le gate lisent cette même constante.

### Mapping action → permission requise (indicatif, à figer dans le plan)

| Action admin existante | Permission |
|---|---|
| `listUsersAction`, `getUserDetailAction` | `users:read` |
| `banUserAction`, `unbanUserAction` | `users:ban` |
| `setUserRoleAction` | `users:role` |
| `impersonateUserAction`, `stopImpersonatingAction` | `users:impersonate` (**Admin SYS only, v1**) |
| `resetUserTwoFactorAction` | `users:2fa-reset` |
| `updateUserEmailAction`, `forceVerifyEmailAction` | `users:email` |
| `revokeAllUserSessionsAction`, `revokeOneUserSessionAction` | `users:sessions` |
| `listOrgsAction`, `getOrgDetailAction` | `orgs:read` |
| `addMemberAction`, `removeMemberAction`, `setMemberRoleAction`, `transferOrgOwnershipAction` | `orgs:members` |
| `setOrgLimitOverrideAction`, `clearOrgLimitOverrideAction` | `orgs:limits` |
| `listBillingOverviewAction`, `getOrgBillingDetailAction` | `billing:read` |
| `resyncOrgSubscriptionAction`, `extendOrgTrialAction`, `applyCouponAction`, `grantComplimentaryAction` | `billing:manage` |
| export datasets (`/admin/export/[dataset]`) | `export:run` |
| `listAuditLog` | `audit:read` |
| gestion des Staff SYS (§6) | `staff:manage` |

---

## 5. Stockage

- **Admin SYS** : inchangé. `isSuperAdmin(email)` ⇒ toutes les permissions.
- **Staff SYS** : `user.role = "staff"` **+** nouvelle table :

```sql
staff_permission (
  user_id     text    not null references "user"(id) on delete cascade,
  permission  text    not null,           -- une valeur de SYS_PERMISSIONS
  granted_by  text    references "user"(id) on delete set null,
  granted_at  timestamptz not null default now(),
  primary key (user_id, permission)
)
```

Une ligne par permission accordée. Normalisé ⇒ requête « qui peut faire X » triviale + audit fin.

**Câblage Better Auth — `adminRoles` reste `["admin"]` (inchangé).** On **n'ajoute PAS** `"staff"` à `adminRoles` : sinon un Staff SYS obtiendrait un accès **brut** aux endpoints `/api/auth/admin/*` (ban, set-role, impersonate…) qu'il pourrait appeler en direct, **court-circuitant notre gate fin** `assertSysPermission`. Un Staff SYS n'est donc **pas** un admin Better Auth ; sa seule porte d'entrée sont **nos server actions**, chacune gatée par `assertSysPermission`.

**Conséquence** : une action admin destinée à être utilisable par un Staff SYS ne doit pas dépendre du fait que l'appelant soit un admin Better Auth. Deux cas :
- **Lectures / mutations simples** (list users, ban/unban, set-role, révocation de sessions, reset 2FA, edit email) → réimplémentées en **requêtes Drizzle directes** (elles ne font qu'un `select`/`update`/`delete` sur `user`/`session`/`two_factor`). On ne perd rien : le gate `assertSysPermission` autorise, Drizzle exécute. **Chaque mutation Drizzle et son écriture d'audit (`recordAdminAction`) partagent la MÊME transaction** — l'action et sa trace réussissent ou échouent ensemble (pas de mutation non tracée, pas de trace fantôme).
- **Impersonation** → **tranché (§4) : Admin SYS uniquement en v1.** Elle continue de passer par le plugin (`auth.api.impersonateUser`), ce qui marche puisque l'Admin SYS a `role:"admin"` ∈ `adminRoles`. Aucune réimplémentation, aucun cas staff.

> **Note d'ambiguïté (importante).** Après cette refonte, la string `"staff"` a **deux sens disjoints** : (a) plan système → `user.role = "staff"` (un opérateur du back-office) ; (b) plan org → **n'existe plus** (renommé `member`). Le renommage §3 élimine le chevauchement : `user.role` et `member.role` sont deux colonnes distinctes, jamais mélangées. Tout code doit être explicite sur la colonne qu'il lit.

---

## 6. Enforcement

Nouveau module `src/lib/auth/sys-permissions.ts` (à côté de `super-admin.ts`, qu'il généralise) :

```
assertSysPermission(perm: SysPermission): Promise<void>
  1. session fraîche (disableCookieCache)                → sinon throw 401
  2. twoFactorEnabled obligatoire                        → sinon throw 403 (mur god-mode)
  3. isSuperAdmin(session.user.email)                    → OK (toutes perms)
  4. session.user.role === "staff"
       ET EXISTS staff_permission(user_id, perm)         → OK
  5. sinon                                               → throw "Forbidden"
```

Helpers dérivés :
- `currentSysPermissions(): Promise<Set<SysPermission>>` — pour l'UI (masquer nav/boutons). Admin SYS ⇒ set complet.
- `isCurrentUserSysOperator(): Promise<boolean>` — Admin SYS **ou** Staff SYS (avec 2FA) — pour le gate du layout `(superadmin)`.

**Refactor d'appel** : chaque action de `src/lib/admin/*` remplace son `assertSuperAdmin()` par `assertSysPermission("<perm>")` selon le mapping §4. `staff:manage`-gated actions restent de fait Admin-SYS-only (un Staff ne peut jamais détenir `staff:manage`).

**Layout `(superadmin)/layout.tsx`** : passe de « super-admin + 2FA » à `isCurrentUserSysOperator()`. Le mur 2FA reste dur pour les deux.

---

## 7. UI de gestion — `/admin/staff` (gated `staff:manage` ⇒ Admin SYS only)

- **Liste** des Staff SYS + résumé de leur grille.
- **Créer un Staff SYS** (voie normale, §2.bis) : formulaire nom/email/mot de passe → `auth.api.createUser` avec `role:"staff"` — **sans bootstrap d'org** (exemption du hook). Le compte doit activer son **2FA à la première connexion** avant de voir quoi que ce soit d'autre que l'écran d'enrôlement.
- **Promouvoir un user existant** (voie exceptionnelle) : possible seulement si le compte n'a **aucune membership d'org** (sinon refus — la population SYS est orgless par construction).
- **Éditer la grille** : cases à cocher groupées par ressource (source `SYS_PERMISSIONS`). `staff:manage` et `users:impersonate` **absentes** de la grille (jamais cochables).
- **Retirer** : dans **une transaction** — `user.role` → `"user"`, `DELETE FROM staff_permission WHERE user_id = …`, puis révocation de toutes les sessions du staff. Pas d'état fantôme : un compte redevenu `"user"` ne garde ni grants dormants ni session porteuse de l'ancien rôle.
- Nav `/admin` : entrée « Staff » visible seulement si `staff:manage`. Les autres entrées (Users, Orgs, Billing, Export, Audit) visibles selon `currentSysPermissions()`.

Chaque grant/révoke/ajout/retrait passe par `recordAdminAction` (acteur = l'Admin SYS réel).

---

## 8. Garde-fous

1. **Admin SYS jamais verrouillable** — l'allow-list env reste autoritative ; un Staff SYS ne peut ni la modifier ni se hisser au niveau Admin SYS.
2. **`staff:manage` non-délégable** — exclue de la grille assignable.
3. **Mur 2FA** — un Staff SYS sans 2FA activé ne peut pas entrer dans `/admin` (blocage au niveau accès, pas seulement action).
4. **Attribution d'audit** — l'acteur réel (Staff SYS) est tracé, pas un « super-admin » générique. Sous impersonation, l'action reste attribuée à l'opérateur.
5. **Cohérence de session** — lecture DB fraîche (`disableCookieCache`) : révoquer un staff (role → user) ou une permission prend effet immédiatement, sans attendre l'expiration du cache 5 min.
6. **Pas de bypass d'endpoint brut** — `adminRoles` reste `["admin"]` (Admin SYS only). Les Staff SYS ne sont pas des admins Better Auth : ils ne peuvent atteindre `/api/auth/admin/*` en direct, seulement nos server actions gatées (voir §5). Le gate `assertSysPermission` est donc le **seul** chemin d'autorisation, non contournable.
7. **Impersonation = Admin SYS only (v1)** — voir §4. L'impersonation fait hériter tous les droits org de la cible et court-circuiterait le RBAC système ; elle reste hors de portée du staff tant qu'un mécanisme de bridage global (`impersonatedBy` ⇒ app en lecture seule) n'existe pas.
8. **Population SYS orgless** — un Staff SYS n'a jamais d'org/site (§2.bis) : le bootstrap d'org est exempté à la création, la promotion d'un compte avec memberships est refusée, et `(app)` le redirige vers `/admin` avant le self-heal orgless (`active-org.ts`) qui, sinon, tenterait de lui résoudre une org.
9. **Atomicité mutation + audit** — les actions réimplémentées en Drizzle direct embarquent leur `recordAdminAction` dans la même transaction (§5).
10. **Migration & sessions en vol** — le rename `staff`→`member` (plan org) se fait pendant que des sessions actives peuvent porter l'ancienne string dans le cookie cache (5 min). Les gates org lisent `member.role` en DB côté serveur, mais tout chemin qui lit le rôle depuis la session/le client doit tolérer les deux strings pendant la fenêtre de rollout (ou forcer une relecture). À vérifier au balayage §3.

---

## 9. Tests

- **Purs** : `emailInCsv` (déjà), résolution `assertSysPermission` (matrice : super-admin ⇒ tout ; staff avec/sans perm ; sans 2FA ; role user ; role staff sans ligne). `staff:manage` **et** `users:impersonate` jamais accordables (grant refusé côté action ET absents de la grille).
- **DB** (`DB_TESTS=1`, `seo_insight_test`) : CRUD `staff_permission`, cascade `on delete`, retrait staff purge les grants + sessions.
- **Gate d'action** : un échantillon d'actions admin refuse un Staff SYS sans la perm et accepte avec.
- **Migration** : `role = 'staff'` → `'member'` sur `member` ne touche pas `user.role`.
- **Population SYS** : la création d'un Staff SYS ne bootstrap pas d'org ; la promotion d'un user avec membership est refusée ; le retrait purge grants + sessions dans une transaction.
- **Routage** : un Staff SYS qui requête une page `(app)` est redirigé vers `/admin` (jamais le self-heal orgless) ; un user normal qui requête `/admin` est rejeté.
- **Impersonation** : refusée à un Staff SYS même détenteur (théorique) de la permission ; acceptée pour Admin SYS.
- **Régression** : aucune action admin ne reste sur l'ancien `assertSuperAdmin()` (grep de garde dans le plan).

---

## 9.bis Options Better Auth évaluées et écartées

Docs vérifiées (plugin admin, plugin organization complet, plugin infra `dash()`) :

- **`dash()` (Better Auth Infrastructure) — ÉCARTÉ.** Dashboard admin hébergé chez Better Auth (apiKey, apiUrl, KV tiers) offrant ban/delete users, révocation de sessions, gestion d'orgs via LEURS APIs. Incompatible avec le garde-fou §8.6 : ce serait un **second chemin d'administration** contournant `assertSysPermission`, la grille atomique et notre audit log — précisément le trou qu'on ferme en gardant `adminRoles: ["admin"]`. S'ajoutent la souveraineté des données (prod self-hosted) et la dépendance payante. **On retient en revanche le pattern `lastActiveAt`** (activity tracking) : à implémenter nous-mêmes (colonne `user.last_active_at` + update throttlé) quand la brique Vision (Track A) en aura besoin.
- **Dynamic Access Control (plugin organization) — pas pour le plan SYS, candidat v2 plan ORG.** Rôles créés à runtime **par org** (table `organizationRole`). Inapplicable aux Staff SYS (aucun contexte org — notre table `staff_permission` reste le bon outil). À reconsidérer plus tard si on veut laisser les agences définir leurs propres rôles custom ; caveat doc : `checkRolePermission` client n'inclut pas les rôles dynamiques (utiliser `hasPermission`).

## 10. Hors périmètre (autres cycles)

- **Track A — back-office admin** (vision/segmentation org, interaction Stripe approfondie, communication, actions de masse, cycle de vie). Cette spec ne fait que poser le RBAC dessous.
- **Track B — onboarding « org sans site »** (app cliente : owner d'une org sans site → onboarding créant le 1er site).
- **RLS defense-in-depth** (déjà backloggé, après deprivilege du rôle DB).
- Refonte de l'impersonation UI (notée non-construite dans `auth.ts`).

---

## 11. Ordre d'implémentation (pour le plan)

1. Corrections plan org : statement `gsc`, retrait `billing` d'`admin`, rename `staff`→`member` (code + migration data + balayage, vigilance sessions en vol §8.10).
2. `sys-permissions.ts` : constante `SYS_PERMISSIONS`, `assertSysPermission`, helpers UI. **`adminRoles` reste `["admin"]`** (pas de `"staff"` — voir §5).
3. Migration table `staff_permission`.
4. Population SYS : exemption du bootstrap d'org à la création (`auth.ts:435`), garde de routage `(app)` → `/admin` pour les opérateurs SYS, post-login redirect.
5. Refactor des actions `src/lib/admin/*` : `assertSuperAdmin` → `assertSysPermission("<perm>")` selon §4 ; réimplémenter en Drizzle direct (mutation + audit même transaction) les actions qui déléguaient à `auth.api.*` (§5). Impersonation : inchangée, Admin SYS only.
6. Gate layout `(superadmin)` → `isCurrentUserSysOperator`, nav conditionnée aux permissions.
7. UI `/admin/staff` (création orgless + promotion conditionnelle + grille + retrait transactionnel), audit.
8. Tests (§9).
