Skip to content

WIKI-168: gestion complète des utilisateurs (admin/users) - #71

Open
FireDroX wants to merge 3 commits into
WIKI-170-use-permissionsfrom
WIKI-168-full-user-management
Open

FireDroX wants to merge 3 commits into
WIKI-170-use-permissionsfrom
WIKI-168-full-user-management

Conversation

@FireDroX

@FireDroX FireDroX commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Résumé

Complète admin/users au-delà de liste/rôle/suppression : recherche/filtres, création, détail, activation/désactivation, reset password, déverrouillage, groupes, et une vue « permissions effectives » avec origine (directe ou groupe X).

  • Fusion AdminUsersController + AdminUserAccessController (WIKI-167) en un seul contrôleur gardé uniquement par PermissionsGuard + @RequirePermission('user.manage') — le bypass admin déjà intégré à PermissionsService.hasGlobal EST le "admin OU user.manage" demandé par le ticket, donc l'ancien @Roles('admin') (RolesGuard) redondant et incohérent avec les routes /permissions//access-rules est supprimé. Ça règle au passage l'incohérence AdminUsersController/AdminUserAccessController signalée par la revue de code de WIKI-170 (liste/rôle/suppression restaient strictement admin pendant que permissions/access-rules acceptaient déjà user.manage).
  • Nouvelles routes : POST /admin/users (mot de passe temporaire généré si absent, renvoyé une seule fois), GET /admin/users/:id (détail : groupes, permissions directes, dernière connexion, verrouillage), PATCH /admin/users/:id (displayName/email/role, remplace PATCH /:id/role), PATCH /admin/users/:id/status, POST /admin/users/:id/reset-password, POST /admin/users/:id/unlock, PUT /admin/users/:id/groups, GET /admin/users/:id/effective-permissions (nouvelle méthode PermissionsService.explainUserPermissions, dans le même esprit que explain déjà existant).
  • Garde-fous : un admin ne peut ni se rétrograder, ni se désactiver, ni se supprimer lui-même ; le dernier administrateur actif est protégé contre ces trois actions (409) ; un détenteur de user.manage non-admin ne peut promouvoir personne (y compris lui-même) au rôle admin, ni agir sur un admin existant (démotion/désactivation/suppression — corrigé suite revue, voir plus bas).
  • AuthService.login/refresh rejettent désormais les comptes désactivés (401 "Account disabled"). refresh() compare aussi l'horodatage d'émission du refresh token à une nouvelle colonne users.password_changed_at (migration 1790000000000) : un reset de mot de passe forcé par un admin invalide réellement l'ancienne session au lieu de laisser son refresh token valide jusqu'à 7 jours de plus — l'implémentation précédente n'avait aucun moyen d'invalider un refresh token.
  • findAllPaginated/updateRole restent inchangés et toujours utilisés tels quels par les outils MCP (hors périmètre, WIKI-169) ; le nouveau chemin HTTP admin utilise findAllFilteredPaginated/adminUpdate séparément pour ne rien casser côté MCP.
  • Actions d'audit (user.create/update/status.update/password.reset/unlock/groups.update/permissions.update) câblées dans AdminAuditLogFilters côté front, comme demandé par le ticket.
  • README §5 mis à jour, bump 0.30.5 → 0.30.6 + changelog.

Corrections apportées suite à la revue de code

  1. Faille d'escalade réelle : adminUpdate/setStatus/deleteUser ne vérifiaient l'auto-protection et la garde "dernier admin actif" que sur l'acteur et le compteur global, jamais que l'acteur soit lui-même admin avant d'agir sur un admin existant. Un détenteur non-admin de user.manage pouvait donc rétrograder/désactiver/supprimer les autres admins un par un (jusqu'au dernier, protégé par la garde existante), démantelant la supervision admin sans jamais l'être lui-même. Corrigé avec assertActorIsAdminToActOnAdmin, appliqué aux trois méthodes.
  2. Régression frontend : frontend/src/api/users.ts updateRole() appelait encore PATCH /admin/users/:id/role, supprimée par cette PR au profit du PATCH /admin/users/:id unifié — le changement de rôle dans l'écran AdminUsers.tsx existant (pas encore refondu, ce sera WIKI-171) répondait donc en 404. Corrigé en pointant vers la nouvelle route.
  3. Durcissement mineur : setStatus/setGroups valident maintenant explicitement le type de isActive/groupIds plutôt que de laisser une requête malformée tomber dans une erreur 500 brute.

10 tests de régression supplémentaires sur UsersService (272/272 au total).

Signalé mais non corrigé dans cette PR

  • wiki_update_user_role (MCP) continue d'appeler UsersService.updateRole() directement, qui ne porte aucune des nouvelles gardes (auto-protection, dernier admin, anti-promotion). C'est un contournement réel, mais déjà présent avant cette PR (le tool MCP appelait déjà cette méthode non gardée) — son périmètre de correction est explicitement WIKI-169 ("MCP : appliquer les permissions de l'utilisateur au lieu d'un accès complet").
  • Condition de course sur assertNotRemovingLastActiveAdmin : le comptage des admins actifs puis la mutation ne sont pas atomiques — deux requêtes strictement simultanées ciblant deux admins différents alors qu'il n'en reste exactement 2 pourraient toutes les deux passer la garde. Le rendre atomique demanderait une transaction avec verrou de lignes, un pattern absent du reste de la couche service actuelle ; fenêtre de course jugée assez étroite (deux requêtes concurrentes sur la même limite exacte) pour ne pas justifier d'introduire ce pattern dans ce ticket. À garder en tête si la charge concurrente sur cet écran augmente.

Écarts assumés

  • Pas de check "pwned password" (PwnedPasswordService) sur les mots de passe fournis par l'admin à la création — uniquement la même regex de complexité que l'auto-inscription. Réutiliser PwnedPasswordService (module security) depuis UsersService introduirait une dépendance de module supplémentaire pour un gain marginal sur un flux admin-only ; noté si ça doit être durci plus tard.
  • lastLoginAt est dérivé du journal d'activité existant (auth.login) plutôt qu'une nouvelle colonne dédiée — évite une migration superflue, la donnée existe déjà.

Test plan

  • tsc --noEmit (backend + frontend) : clean.
  • eslint --fix (backend) / oxlint (frontend) : clean.
  • Unit backend : 272/272 (dont ~35 nouveaux sur UsersService : création, doublon d'email, garde dernier admin, auto-protections, garde admin-sur-admin, désactivation, reset password, déverrouillage, remplacement de groupes, validation d'entrée).
  • E2E backend : 16/16 (suite existante, migration de test appliquée automatiquement).
  • Unit frontend : 35/35.

Base de cette PR : WIKI-170-use-permissions (PR stack, EPIC-30).

🤖 Generated with Claude Code

FireDroX and others added 2 commits September 28, 2026 10:59
Complete admin/users beyond the previous list/role/delete: search and
filter by role/group/active status, create (with a generated one-time
temporary password when none is given), full detail (groups, direct
permissions, last login, lock status), update displayName/email/role,
activate/deactivate, reset-password, unlock, replace group membership,
and an effective-permissions view built on the already-existing
PermissionsService.explain machinery, annotating each global permission
and access rule with its origin (direct or via a named group).

- Merge AdminUsersController and AdminUserAccessController (WIKI-167)
  into one controller gated solely by PermissionsGuard +
  RequirePermission('user.manage') — the admin bypass already built
  into PermissionsService.hasGlobal is the "admin OR user.manage" the
  ticket asks for, so the redundant/inconsistent @roles('admin') guard
  is dropped. This also closes the split-brain gap the WIKI-170 code
  review flagged (list/role/delete stayed admin-only while
  permissions/access-rules already accepted user.manage).
- Self-action guards: an admin can't demote, deactivate or delete their
  own account. Last-active-admin guard: none of those three actions may
  remove the last active admin (409). A user.manage-holding non-admin
  can't promote anyone (including themselves) to admin.
- AuthService.login/refresh now reject disabled accounts (401 "Account
  disabled"). refresh() also compares the refresh token's issued-at
  against a new users.password_changed_at column (migration
  1790000000000), so an admin's forced password reset actually revokes
  the old session instead of leaving its refresh token usable for up to
  7 more days — the previous implementation had no way to invalidate a
  refresh token at all.
- UsersService.findAllPaginated/updateRole stay untouched and are still
  used as-is by the MCP user tools (out of scope here, WIKI-169) and by
  the new findAllFilteredPaginated/adminUpdate for the HTTP admin path.
- Audit log actions (user.create/update/status.update/password.reset/
  unlock/groups.update/permissions.update) wired into
  AdminAuditLogFilters on the frontend per the ticket, with new vitest
  coverage on UsersService (creation, duplicate email, last-admin guard,
  self-guards, deactivation, password reset, unlock, group replacement).

Bump to 0.30.6 with a changelog entry and README §5 update.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgjQXuhToMzd4FfvJFdgUw
Found by code review of the WIKI-168 branch:

- adminUpdate/setStatus/deleteUser only guarded against self-action and
  removing the last active admin, but let any user.manage-holding
  non-admin demote, deactivate, or delete OTHER admins one at a time —
  asymmetric with the admin-only gate already enforced on promotion.
  Add assertActorIsAdminToActOnAdmin: acting on a currently-admin target
  in a way that would strip that status now requires the actor to
  actually be admin, not just hold user.manage.
- setStatus/setGroups took isActive/groupIds at face value; a malformed
  request (missing/wrong-typed body) fell through to a raw exception
  instead of the clean ValidationException every other admin mutation
  in this file returns. Added explicit type checks.
- frontend/src/api/users.ts's updateRole() still PATCHed the now-removed
  /admin/users/:id/role route — the existing AdminUsers.tsx role-change
  UI was 404ing since PATCH :id/role was replaced by the unified
  PATCH :id. Point it at the new route.

10 new regression tests on UsersService.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgjQXuhToMzd4FfvJFdgUw
@FireDroX
FireDroX added this pull request to stack #66 September 28, 2026 09:35
The filter's switch dispatches on exception.name as a string literal
(matching the rest of this file's cases), so the class import was
never actually referenced — eslint's no-unused-vars caught it in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgjQXuhToMzd4FfvJFdgUw

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant