From c959b16db578e4bd1f9d2b42efc7a9f010c66582 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 09:21:07 -0400 Subject: [PATCH] refactor(api): add granular permission enforcement on admin routes - add optional `permission` field to route definitions with type validation in `define.js` - check `hasPermission()` in router after `requireAdmin()` and return 403 if denied - document `permission` and `skipRateLimit` optional fields in api README - load user permissions in `AdminPage.server.js` and pass them to client via `user` prop - use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions - expose permission-gated API routes in `auth/api.js` --- src/core/api/README.md | 8 ++++ src/core/api/define.js | 9 +++++ src/core/api/router.js | 6 +++ src/features/admin/AdminPage.server.js | 9 ++++- src/features/admin/pages/RolesPage.client.js | 39 +++++++++++-------- src/features/admin/pages/UsersPage.client.js | 41 +++++++++++--------- src/features/auth/api.js | 32 +++++++-------- 7 files changed, 91 insertions(+), 53 deletions(-) diff --git a/src/core/api/README.md b/src/core/api/README.md index fadab97..2fb4e62 100644 --- a/src/core/api/README.md +++ b/src/core/api/README.md @@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH) ├─ matchRoute(pattern, path) — exact, :param, /** ├─ Auth enforcement (depuis la définition de la route) │ 'admin' → requireAdmin() — session dans context.session + │ │ si `permission` est défini → hasPermission() → 403 si refusé │ 'user' → requireAuth() — session dans context.session │ 'public'→ aucun — context.session = undefined └─ handler(request, params, context) @@ -175,6 +176,13 @@ Champs requis par route : | `handler` | `Function` | Signature : `(request, params, context) => Promise` | | `auth` | `string` | `'public'` \| `'user'` \| `'admin'` | +Champs optionnels : + +| Champ | Type | Description | +|-------|------|-------------| +| `skipRateLimit` | `boolean` | Exempte la route du rate limiting par IP (ex. health checks) | +| `permission` | `string` | Sur une route `auth: 'admin'`, exige en plus cette clé de permission granulaire (ex. `'users.edit'`). Retourne 403 si l'utilisateur ne la possède pas. Voir `PERMISSIONS` dans `src/core/users/constants.js` | + --- ## Note — handler storage diff --git a/src/core/api/define.js b/src/core/api/define.js index 0dff974..6aeba46 100644 --- a/src/core/api/define.js +++ b/src/core/api/define.js @@ -23,6 +23,10 @@ * check for this route. Use sparingly — only for routes * that must remain accessible under high probe frequency * (e.g. health checks from monitoring systems). + * permission {string} When set on an 'admin' route, the router additionally + * verifies that the authenticated user holds this granular + * permission key (e.g. 'users.edit'). If the user lacks + * the permission, the request is rejected with 403 Forbidden. * * Auth levels: * 'public' Anyone can call this route. context.session is undefined. @@ -77,6 +81,11 @@ export function defineApiRoutes(routes) { `${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}` ); } + if (route.permission !== undefined && typeof route.permission !== 'string') { + throw new TypeError( + `${at} (${route.method} ${route.path}) — "permission" must be a string when provided, got: ${JSON.stringify(route.permission)}` + ); + } } // Freeze to prevent accidental mutation of route definitions at runtime. diff --git a/src/core/api/router.js b/src/core/api/router.js index c950338..a84c5d2 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -271,6 +271,12 @@ export async function routeRequest(request, path) { try { if (matchedRoute.auth === 'admin') { context.session = await requireAdmin(); + if (matchedRoute.permission) { + const allowed = await hasPermission(context.session.user.id, matchedRoute.permission); + if (!allowed) { + return apiError('Forbidden', 'Permission insuffisante'); + } + } } else if (matchedRoute.auth === 'user') { context.session = await requireAuth(); } diff --git a/src/features/admin/AdminPage.server.js b/src/features/admin/AdminPage.server.js index f019332..960e031 100644 --- a/src/features/admin/AdminPage.server.js +++ b/src/features/admin/AdminPage.server.js @@ -3,18 +3,23 @@ import { protectAdmin } from './protect.js'; import { collectWidgetData } from './registry.js'; import { getAppConfig, getPublicBaseUrl } from '@zen/core'; import { isDevkitEnabled } from '../../shared/lib/appConfig.js'; +import { getUserPermissions } from '@zen/core/users'; export default async function AdminPage({ params }) { const resolvedParams = await params; const session = await protectAdmin(); - const widgetData = await collectWidgetData(); + const [widgetData, permissions] = await Promise.all([ + collectWidgetData(), + getUserPermissions(session.user.id), + ]); const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() }; const devkitEnabled = isDevkitEnabled(); + const user = { ...session.user, permissions }; return ( { +const RolesPageClient = ({ canManage }) => { const toast = useToast(); const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(true); @@ -73,7 +73,7 @@ const RolesPageClient = () => { render: (role) => role.is_system ? système : null, skeleton: { height: 'h-4', width: '60px' }, }, - { + ...(canManage ? [{ key: 'actions', label: '', sortable: false, @@ -99,7 +99,7 @@ const RolesPageClient = () => { ), skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }, - }, + }] : []), ]; const fetchRoles = async () => { @@ -161,14 +161,17 @@ const RolesPageClient = () => { ); }; -const RolesPage = () => ( -
- - -
-); +const RolesPage = ({ user }) => { + const canManage = user?.permissions?.includes('roles.manage'); + return ( +
+ + +
+ ); +}; -const RolesPageHeader = () => { +const RolesPageHeader = ({ canManage }) => { const [modalOpen, setModalOpen] = useState(false); return ( @@ -176,17 +179,19 @@ const RolesPageHeader = () => { setModalOpen(true)}> Nouveau rôle - } - /> - setModalOpen(false)} + )} /> + {canManage && ( + setModalOpen(false)} + /> + )} ); }; diff --git a/src/features/admin/pages/UsersPage.client.js b/src/features/admin/pages/UsersPage.client.js index 8c39f25..a583095 100644 --- a/src/features/admin/pages/UsersPage.client.js +++ b/src/features/admin/pages/UsersPage.client.js @@ -9,7 +9,7 @@ import AdminHeader from '../components/AdminHeader.js'; import UserEditModal from '../components/UserEditModal.client.js'; import UserCreateModal from '../components/UserCreateModal.client.js'; -const UsersPageClient = ({ currentUserId, refreshKey }) => { +const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => { const toast = useToast(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -78,7 +78,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => { render: (user) => , skeleton: { height: 'h-4', width: '70%' }, }, - { + ...(canEdit ? [{ key: 'actions', label: '', sortable: false, @@ -94,7 +94,7 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => { ), skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }, - }, + }] : []), ]; const fetchUsers = async () => { @@ -158,13 +158,15 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => { /> - setEditingUserId(null)} - onSaved={fetchUsers} - /> + {canEdit && ( + setEditingUserId(null)} + onSaved={fetchUsers} + /> + )} ); }; @@ -172,24 +174,27 @@ const UsersPageClient = ({ currentUserId, refreshKey }) => { const UsersPage = ({ user }) => { const [createModalOpen, setCreateModalOpen] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const canEdit = user?.permissions?.includes('users.edit'); return (
setCreateModalOpen(true)}> Nouvel utilisateur - } - /> - - setCreateModalOpen(false)} - onSaved={() => setRefreshKey(k => k + 1)} + )} /> + + {canEdit && ( + setCreateModalOpen(false)} + onSaved={() => setRefreshKey(k => k + 1)} + /> + )}
); }; diff --git a/src/features/auth/api.js b/src/features/auth/api.js index 67f90b6..243a753 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -12,7 +12,7 @@ import { updateUser, requestPasswordReset } from './auth.js'; import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js'; import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js'; import { createAccountSetup } from '../../core/users/verifications.js'; -import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users'; +import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS } from '@zen/core/users'; import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; import { getPublicBaseUrl } from '@zen/core/shared/config'; @@ -896,8 +896,8 @@ async function handleAdminCreateUser(request) { // parameterised paths (/users/:id) so they match first. export const routes = defineApiRoutes([ - { path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' }, - { path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin' }, + { path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, + { path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, { path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' }, { path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' }, { path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' }, @@ -907,17 +907,17 @@ export const routes = defineApiRoutes([ { path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' }, { path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' }, { path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' }, - { path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' }, - { path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' }, - { path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' }, - { path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' }, - { path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' }, - { path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' }, - { path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin' }, - { path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin' }, - { path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' }, - { path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' }, - { path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' }, - { path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' }, - { path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' }, + { path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, + { path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW }, + { path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_EDIT }, + { path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, + { path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, + { path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, + { path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, + { path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, ]);