Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Résumé
Complète
admin/usersau-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).AdminUsersController+AdminUserAccessController(WIKI-167) en un seul contrôleur gardé uniquement parPermissionsGuard+@RequirePermission('user.manage')— le bypass admin déjà intégré àPermissionsService.hasGlobalEST le "admin OU user.manage" demandé par le ticket, donc l'ancien@Roles('admin')(RolesGuard) redondant et incohérent avec les routes/permissions//access-rulesest supprimé. Ça règle au passage l'incohérenceAdminUsersController/AdminUserAccessControllersignalé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).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, remplacePATCH /: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éthodePermissionsService.explainUserPermissions, dans le même esprit queexplaindéjà existant).user.managenon-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/refreshrejettent désormais les comptes désactivés (401 "Account disabled").refresh()compare aussi l'horodatage d'émission du refresh token à une nouvelle colonneusers.password_changed_at(migration1790000000000) : 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/updateRolerestent inchangés et toujours utilisés tels quels par les outils MCP (hors périmètre, WIKI-169) ; le nouveau chemin HTTP admin utilisefindAllFilteredPaginated/adminUpdateséparément pour ne rien casser côté MCP.user.create/update/status.update/password.reset/unlock/groups.update/permissions.update) câblées dansAdminAuditLogFilterscôté front, comme demandé par le ticket.Corrections apportées suite à la revue de code
adminUpdate/setStatus/deleteUserne 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 deuser.managepouvait 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é avecassertActorIsAdminToActOnAdmin, appliqué aux trois méthodes.frontend/src/api/users.tsupdateRole()appelait encorePATCH /admin/users/:id/role, supprimée par cette PR au profit duPATCH /admin/users/:idunifié — le changement de rôle dans l'écranAdminUsers.tsxexistant (pas encore refondu, ce sera WIKI-171) répondait donc en 404. Corrigé en pointant vers la nouvelle route.setStatus/setGroupsvalident maintenant explicitement le type deisActive/groupIdsplutô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'appelerUsersService.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").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
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éutiliserPwnedPasswordService(modulesecurity) depuisUsersServiceintroduirait une dépendance de module supplémentaire pour un gain marginal sur un flux admin-only ; noté si ça doit être durci plus tard.lastLoginAtest 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.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).Base de cette PR :
WIKI-170-use-permissions(PR stack, EPIC-30).🤖 Generated with Claude Code