feat/user-deprovisioning #12

Merged
AzSiAz merged 20 commits from feat/user-deprovisioning into main 2026-07-18 08:52:31 +02:00
Owner

Déprovisionnement des utilisateurs + logout fiable

Ferme les deux trous du cycle de vie des identités : un utilisateur désactivé/supprimé dans PocketID gardait ses sessions (7 j) et ses clés API (30 j), et le logout prétendait réussir même en échec (état frontend + cookie jamais réellement suppri$

Spec : docs/superpowers/specs/2026-07-17-user-deprovisioning-logout-design.md · Plan : docs/superpowers/plans/2026-07-17-user-deprovisioning-logout.md

Déprovisionnement

  • users.disabled_at (soft-disable, NULL = actif) + users.scim_user_name.
  • Contrôle par requête = la ligne de défense : sessions::resolve_user (JOIN users), api_keys::resolve (EXISTS) et begin_user_transaction (garde fusionnée dans le set_config, exécutée en indara_control avant SET ROLE — échec fermé)$
  • Login OIDC : un compte localement désactivé est refusé (403) avant toute resync — un re-login ne réactive jamais ; seuls SCIM (active=true) ou un admin lèvent le flag.
  • Serveur SCIM 2.0 minimal (/scim/v2, monté uniquement si SCIM_TOKEN est configuré, mur bearer en temps constant) : provisionnement et dé-provisionnement des utilisateursexternalIdoidc_subject, même clé d'upsert que le logi$
  • Groupes : writer unique = claim OIDC au login (décision alignée sur Grafana « SCIM users + Team Sync », jamais deux writers). La surface SCIM Groups répond en écho sans persistance — nécessaire car un client autoritaire (PocketID) avorte to$
  • Coupe-circuit admin : PATCH /api/admin/users/{id} {"disabled": bool} (garde anti-lock-out), badge + toggle dans l'UI admin. C'est la voie immédiate : la sync SCIM PocketID est horaire (+ boot + manuelle), pas de push événementiel →$

Logout fiable

  • Serveur : les cookies de suppression (sid, oidc_flow) portent Path=/ — sans lui, le navigateur scope la suppression à /api/auth et garde le cookie.
  • Frontend : anon uniquement après le 204 ; sur échec l'état reste authed et UserMenu affiche un toast (401 ignoré : le handler global a déjà basculé).

Validation

  • 16 tâches TDD (subagents Opus, revue par tâche + revue finale whole-branch : READY TO MERGE).
  • Gates : fmt, clippy -D warnings, tests workspace, sqlx prepare --check, build offline CI, 87 tests frontend, tests DB ignorés (5 deprovisioning + matrice RLS 4/4), boot réel (healthz, mur SCIM 401/200).
  • Validation contre une vraie instance PocketID 2.11.0 (bac à sable docker, 100 % REST) : cycle provision → désactivation (session+clé mortes) → réactivation (clé revit) → suppression (soft-disable) ; zéro erreur sur ~55 requêtes SCIM. Re-$
  • Enseignements consignés dans le spec : PocketID matche par externalId uniquement, n'émet jamais de PATCH, sync horaire ; optionnel — restreindre le client Indara par groupe dans PocketID pour que la perte d'accès soit poussée par SCIM.

Config

  • SCIM_TOKEN (optionnel) : active /scim/v2. Absent → routes non montées, comportement identique à avant.

🤖 Generated with Claude Code

# Déprovisionnement des utilisateurs + logout fiable Ferme les deux trous du cycle de vie des identités : un utilisateur désactivé/supprimé dans PocketID gardait ses sessions (7 j) et ses clés API (30 j), et le logout prétendait réussir même en échec (état frontend + cookie jamais réellement suppri$ Spec : `docs/superpowers/specs/2026-07-17-user-deprovisioning-logout-design.md` · Plan : `docs/superpowers/plans/2026-07-17-user-deprovisioning-logout.md` ## Déprovisionnement - **`users.disabled_at`** (soft-disable, NULL = actif) + `users.scim_user_name`. - **Contrôle par requête = la ligne de défense** : `sessions::resolve_user` (JOIN users), `api_keys::resolve` (EXISTS) et `begin_user_transaction` (garde fusionnée dans le `set_config`, exécutée en `indara_control` avant `SET ROLE` — échec fermé)$ - **Login OIDC** : un compte localement désactivé est refusé (403) avant toute resync — un re-login ne réactive jamais ; seuls SCIM (`active=true`) ou un admin lèvent le flag. - **Serveur SCIM 2.0 minimal** (`/scim/v2`, monté uniquement si `SCIM_TOKEN` est configuré, mur bearer en temps constant) : provisionnement et dé-provisionnement des **utilisateurs** — `externalId` ↔ `oidc_subject`, même clé d'upsert que le logi$ - **Groupes : writer unique = claim OIDC au login** (décision alignée sur Grafana « SCIM users + Team Sync », jamais deux writers). La surface SCIM Groups répond en écho sans persistance — nécessaire car un client autoritaire (PocketID) avorte to$ - **Coupe-circuit admin** : `PATCH /api/admin/users/{id}` `{"disabled": bool}` (garde anti-lock-out), badge + toggle dans l'UI admin. C'est la voie immédiate : la sync SCIM PocketID est horaire (+ boot + manuelle), **pas de push événementiel** →$ ## Logout fiable - Serveur : les cookies de suppression (`sid`, `oidc_flow`) portent `Path=/` — sans lui, le navigateur scope la suppression à `/api/auth` et garde le cookie. - Frontend : `anon` uniquement après le 204 ; sur échec l'état reste `authed` et UserMenu affiche un toast (401 ignoré : le handler global a déjà basculé). ## Validation - 16 tâches TDD (subagents Opus, revue par tâche + revue finale whole-branch : READY TO MERGE). - Gates : fmt, clippy `-D warnings`, tests workspace, `sqlx prepare --check`, build offline CI, 87 tests frontend, tests DB ignorés (5 deprovisioning + matrice RLS 4/4), boot réel (healthz, mur SCIM 401/200). - **Validation contre une vraie instance PocketID 2.11.0** (bac à sable docker, 100 % REST) : cycle provision → désactivation (session+clé mortes) → réactivation (clé revit) → suppression (soft-disable) ; zéro erreur sur ~55 requêtes SCIM. Re-$ - Enseignements consignés dans le spec : PocketID matche par `externalId` uniquement, n'émet jamais de PATCH, sync horaire ; optionnel — restreindre le client Indara par groupe dans PocketID pour que la perte d'accès soit poussée par SCIM. ## Config - `SCIM_TOKEN` (optionnel) : active `/scim/v2`. Absent → routes non montées, comportement identique à avant. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
AzSiAz self-assigned this 2026-07-18 08:51:49 +02:00
Spec validée en brainstorming : flag users.disabled_at contrôlé par requête,
serveur SCIM 2.0 minimal optionnel (PocketID), coupe-circuit admin, défense
en profondeur dans begin_user_transaction, et logout honnête (cookie de
suppression avec Path=/, état anon seulement après le 204).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 tâches TDD : migration disabled_at, gardes par requête (sessions,
clés API, begin_user_transaction), cookies de suppression Path=/,
serveur SCIM 2.0 optionnel, coupe-circuit admin, frontend honnête au
logout, validation manuelle contre PocketID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Soft-disable local (NULL = actif) posé par SCIM ou un admin, et userName
SCIM re-présentable. Plomberie de décodage seulement, aucun comportement
ne change encore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pose/lève disabled_at (timestamp d'origine préservé, idempotent) et
supprime les sessions dans la même transaction à la désactivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Garde par requête du déprovisionnement : le JOIN users exige
disabled_at IS NULL, indépendamment de la survie des lignes sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Les clés d'un utilisateur désactivé restent en base mais deviennent
inertes ; la réactivation les restaure (soft-disable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
La vérification disabled_at est fusionnée dans le set_config (exécuté en
indara_control avant SET ROLE, les GUC locales survivent) : zéro ligne =
inconnu ou désactivé, la transaction RLS ne s'ouvre jamais.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Un cookie de suppression sans Path est scopé par le navigateur au
répertoire de la requête (/api/auth) et ne matche jamais le cookie posé
avec Path=/ : le sid restait dans le navigateur après logout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Un login IdP réussi ne réactive jamais un compte : seuls SCIM
(active=true) ou un admin lèvent disabled_at, sinon le coupe-circuit
admin serait annulé par un simple re-login.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comparaison bearer en temps constant, filtre eq, pagination 1-based,
sémantique PATCH active (path direct, objet valeur, booléens stringifiés)
et erreurs au format SCIM. Aucune route montée encore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Même clé de conflit que le login OIDC : provisioning et premier login
convergent sur une seule ligne quel que soit l'ordre d'arrivée. Le filtre
userName matche aussi email et subject pour adopter les comptes pré-SCIM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Le member set devient exactement celui poussé (rôles applicatifs
préservés pour les membres conservés), le flag app-admin est recalculé
comme au login, et la « suppression » vide les membres sans toucher la
ligne (owner_group_id des documents y réfère).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Users (POST/GET/PUT/PATCH/DELETE, filter userName/externalId) et Groups
(POST/GET/PUT/DELETE, filter displayName) + ServiceProviderConfig,
derrière un mur bearer en temps constant. DELETE user = soft-disable,
DELETE group = vidage des membres. Sans token, les routes n'existent pas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pose/lève disabled_at (sessions supprimées à la désactivation via
set_disabled), expose disabled_at dans la liste admin, et refuse
l'auto-désactivation pour éviter le lock-out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sur échec réseau/5xx l'état reste authed et UserMenu affiche un toast ;
un 401 est déjà géré par le handler global (session morte côté serveur).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose disabled_at, ajoute api.setUserDisabled (PATCH) et le
coupe-circuit Enable/Disable avec refetch et toast d'erreur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Les groupes ne sont plus provisionnés par SCIM. Le writer unique des
groupes et des memberships redevient la claim de groupes OIDC appliquée
au login (sync_oidc_memberships) — le même partage que Grafana impose
avec « SCIM users + Team Sync », où provisioning des users et sync des
groupes ne sont jamais deux writers concurrents. Côté PocketID la claim
porte le name technique et le SCIM le friendlyName, sans pont possible,
donc un second writer SCIM ne ferait que se battre avec la claim.

La surface Groups reste néanmoins un écho : un client SCIM autoritaire
(PocketID, observé en validation réelle) avorte TOUTE la sync — users
compris — si la surface Groups répond en erreur. GET /Groups renvoie
donc une page 200 vide et POST /Groups renvoie 201 en réfléchissant
l'externalId (id v5 dérivé, stable d'une sync à l'autre, aucune
écriture) ; les opérations par id sont des 404. L'écho doit porter
l'externalId car le client supprime toute ressource sans externalId
correspondant.

Retrait des helpers DB SCIM groupes (scim_replace/put/get/list/
clear_members, replace_members, ScimGroupRecord) et de leurs requêtes
préparées, plus le test d'intégration associé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
La sync SCIM de PocketID n'est pas un push événementiel : elle tourne
toutes les heures (plus au boot et manuellement), donc la latence SCIM
est bornée à ≤ 1 h ; le coupe-circuit admin reste la voie immédiate.
Objectifs et Décisions structurantes corrigés en conséquence.

Section 4 (Groupes) réécrite : non provisionnés par SCIM, writer unique
= claim OIDC au login, surface Groups = écho sans persistance (nécessaire
car un client autoritaire avorte toute la sync sur erreur). Décision
alignée sur Grafana. Ajout des observations réelles : PocketID ne matche
que par externalId et n'émet jamais de PATCH (GET/POST/PUT/DELETE), et
l'option de scoper le client par groupe côté PocketID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AzSiAz merged commit 1460df675e into main 2026-07-18 08:52:31 +02:00
AzSiAz deleted branch feat/user-deprovisioning 2026-07-18 08:52:31 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
AzSiAz/Indara!12
No description provided.