From f587b466b2594c9efce2d43fe3bde6488a875ab4 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Wed, 25 Feb 2026 14:37:55 +0100 Subject: [PATCH] feat(shared): add admin role fine grained permissions This changes the behaviour of admin access from a single toggle to fine-tuned permissions. The permissions hierarchy basically works as follows: Manage > ManageXXX > List. There are a few exceptions, such as the project hierarchy, in which the Manage permission from admin roles gives access to certain resources inside a project, while ManageProjects only manages the resource itself. This means that Manage acts as a sort of equivalent to sudo; it's an intermediary design choice, but it needs revising. Signed-off-by: William Phetsinorath Change-Id: I07287d8d2c8fd287a9fbaefc9019f81a6a6a6964 --- .../cypress/e2e/specs/admin/projects.e2e.ts | 12 ++ .../e2e/specs/admin/system-settings.e2e.ts | 21 --- apps/client/cypress/e2e/specs/roles.e2e.ts | 3 - .../src/components/ProjectResources.vue | 2 +- apps/client/src/components/SelectProject.vue | 6 + apps/client/src/components/SideMenu.vue | 3 +- apps/client/src/components/TeamCt.vue | 4 +- apps/client/src/router/index.spec.ts | 117 ++++++++++--- apps/client/src/router/index.ts | 84 ++++----- apps/client/src/stores/admin-role.ts | 2 +- apps/client/src/stores/system-settings.ts | 12 +- apps/client/src/stores/user.ts | 13 +- apps/client/src/views/CreateProject.vue | 9 +- apps/client/src/views/ProjectDashboard.vue | 1 + apps/client/src/views/ServicesHealth.vue | 4 +- .../client/src/views/projects/DsoProjects.vue | 7 + .../migration.sql | 5 + .../src/resources/admin-role/router.spec.ts | 23 +-- .../server/src/resources/admin-role/router.ts | 12 +- .../src/resources/admin-token/router.spec.ts | 16 +- .../src/resources/admin-token/router.ts | 8 +- .../src/resources/cluster/router.spec.ts | 36 ++-- apps/server/src/resources/cluster/router.ts | 20 ++- .../src/resources/environment/router.spec.ts | 91 +++------- .../src/resources/environment/router.ts | 12 +- apps/server/src/resources/log/router.spec.ts | 10 +- apps/server/src/resources/log/router.ts | 6 +- .../resources/project-member/router.spec.ts | 56 +++--- .../src/resources/project-member/router.ts | 13 +- .../src/resources/project-role/router.spec.ts | 77 ++++---- .../src/resources/project-role/router.ts | 11 +- .../resources/project-service/router.spec.ts | 28 +-- .../src/resources/project-service/router.ts | 10 +- .../src/resources/project/router.spec.ts | 111 +++++++----- apps/server/src/resources/project/router.ts | 43 ++--- .../src/resources/repository/router.spec.ts | 76 ++++---- .../server/src/resources/repository/router.ts | 13 +- .../resources/service-chain/router.spec.ts | 21 +-- .../src/resources/service-chain/router.ts | 14 +- .../resources/service-monitor/router.spec.ts | 10 +- .../src/resources/service-monitor/router.ts | 5 +- .../server/src/resources/stage/router.spec.ts | 32 ++-- apps/server/src/resources/stage/router.ts | 12 +- .../resources/system/config/router.spec.ts | 14 +- .../src/resources/system/config/router.ts | 6 +- .../resources/system/settings/router.spec.ts | 25 ++- .../src/resources/system/settings/router.ts | 3 +- apps/server/src/resources/user/router.spec.ts | 19 +- apps/server/src/resources/user/router.ts | 5 +- apps/server/src/resources/zone/router.spec.ts | 24 +-- apps/server/src/resources/zone/router.ts | 9 +- apps/server/src/utils/controller.ts | 12 +- apps/server/src/utils/mocks.ts | 12 +- packages/shared/src/utils/permissions.ts | 164 ++++++++++++++++-- packages/test-utils/src/imports/data.ts | 4 + 55 files changed, 808 insertions(+), 560 deletions(-) create mode 100644 apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql diff --git a/apps/client/cypress/e2e/specs/admin/projects.e2e.ts b/apps/client/cypress/e2e/specs/admin/projects.e2e.ts index 4171b50a63..7a9b8c9cb9 100644 --- a/apps/client/cypress/e2e/specs/admin/projects.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/projects.e2e.ts @@ -299,6 +299,12 @@ describe('Administration projects', () => { .its('response.statusCode') .should('match', /^20\d$/) + cy.getByDataTestid('tableAdministrationProjects').within(() => { + cy.get('tr').contains(project.name) + .click() + }) + cy.getByDataTestid('test-tab-team').click() + cy.getByDataTestid('teamTable').get('tr').contains('Propriétaire') .should('have.length', 1) .parent() @@ -317,6 +323,12 @@ describe('Administration projects', () => { .its('response.statusCode') .should('match', /^20\d$/) + cy.getByDataTestid('tableAdministrationProjects').within(() => { + cy.get('tr').contains(project.name) + .click() + }) + cy.getByDataTestid('test-tab-team').click() + cy.getByDataTestid('teamTable').get('tr').contains('Propriétaire') .should('have.length', 1) .parent() diff --git a/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts b/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts index 6d304e145a..8a7b641f9b 100644 --- a/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts @@ -6,7 +6,6 @@ describe('Administration system settings', () => { const contactEmail = Cypress.env('CONTACT_EMAIL') || 'cloudpinative-relations@interieur.gouv.fr' beforeEach(() => { - cy.intercept('GET', 'api/v1/system/settings?key=maintenance').as('listMaintenanceSetting') cy.intercept('GET', 'api/v1/system/settings').as('listSystemSettings') cy.intercept('POST', 'api/v1/system/settings').as('upsertSystemSetting') @@ -52,19 +51,6 @@ describe('Administration system settings', () => { cy.getByDataTestid('maintenance-notice') .should('not.exist') cy.visit('/projects') - // TODO à creuser : La requête est faite deux fois - // la première renvoie "off" alors qu'en bdd la valeur est à "on" - cy.wait('@listMaintenanceSetting').its('response').then(($response) => { - cy.log(JSON.stringify($response?.body)) - }) - cy.wait('@listMaintenanceSetting').its('response').then(($response) => { - cy.log(JSON.stringify($response?.body)) - expect($response?.statusCode).to.match(/^20\d$/) - expect(JSON.stringify($response?.body)).to.equal(JSON.stringify([{ - key: 'maintenance', - value: 'on', - }])) - }) cy.wait('@listRoles') cy.getByDataTestid('maintenance-notice') .should('be.visible') @@ -93,13 +79,6 @@ describe('Administration system settings', () => { }) cy.visit('/projects') - cy.wait('@listMaintenanceSetting').its('response').then(($response) => { - expect($response?.statusCode).to.match(/^20\d$/) - expect(JSON.stringify($response?.body)).to.equal(JSON.stringify([{ - key: 'maintenance', - value: 'on', - }])) - }) cy.getByDataTestid('maintenance-notice') .should('not.exist') cy.url().should('contain', '/projects') diff --git a/apps/client/cypress/e2e/specs/roles.e2e.ts b/apps/client/cypress/e2e/specs/roles.e2e.ts index 47203435ee..db110a20ec 100644 --- a/apps/client/cypress/e2e/specs/roles.e2e.ts +++ b/apps/client/cypress/e2e/specs/roles.e2e.ts @@ -82,9 +82,6 @@ describe('Project roles', () => { cy.getByDataTestid('replayHooksBtn').should('not.exist') cy.getByDataTestid('showSecretsBtn').should('not.exist') - cy.getByDataTestid('test-tab-resources').click() - cy.getByDataTestid('noEnvsTr').should('exist') - cy.getByDataTestid('noReposTr').should('exist') cy.getByDataTestid('test-tab-roles').should('exist') }) }) diff --git a/apps/client/src/components/ProjectResources.vue b/apps/client/src/components/ProjectResources.vue index 5d259a5ce9..ba6fa06149 100644 --- a/apps/client/src/components/ProjectResources.vue +++ b/apps/client/src/components/ProjectResources.vue @@ -390,7 +390,7 @@ async function copyToClipboard(text: string) { :environment="selectedEnv" :is-editable="false" :is-project-locked="project.locked" - :can-manage="canManageEnvs || (AdminAuthorized.isAdmin(userStore.adminPerms) && asProfile === 'admin')" + :can-manage="canManageEnvs || (AdminAuthorized.Manage(userStore.adminPerms) && asProfile === 'admin')" @put-environment="(environmentUpdate: UpdateEnvironmentBody) => putEnvironment(environmentUpdate, selectedEnv!.id)" @delete-environment="() => deleteEnvironment(selectedEnv!.id)" @cancel="selectedEnv = undefined" diff --git a/apps/client/src/components/SelectProject.vue b/apps/client/src/components/SelectProject.vue index a74faa1b57..72dc3a808d 100644 --- a/apps/client/src/components/SelectProject.vue +++ b/apps/client/src/components/SelectProject.vue @@ -2,10 +2,15 @@ import router, { isInProject, selectedProjectSlug } from '../router/index.js' import { useUserStore } from '@/stores/user.js' import { useProjectStore } from '@/stores/project.js' +import { AdminAuthorized } from '@cpn-console/shared' const projectStore = useProjectStore() const userStore = useUserStore() +const canCreateProject = computed(() => { + return AdminAuthorized.ManageProjects(userStore.adminPerms) +}) + watch(userStore, async () => { if (userStore.isLoggedIn && !projectStore.myProjects?.length) { await projectStore.listMyProjects() @@ -71,6 +76,7 @@ function selectProject(slug: string) { :icon-only="!!projectStore.myProjects.length" :label="!projectStore.myProjects.length ? 'Créer un nouveau projet' : ''" small + :disabled="!canCreateProject" @click="() => router.currentRoute.value.name !== 'CreateProject' && router.push({ name: 'CreateProject' })" /> diff --git a/apps/client/src/components/SideMenu.vue b/apps/client/src/components/SideMenu.vue index 2b6e74b19c..286223a491 100644 --- a/apps/client/src/components/SideMenu.vue +++ b/apps/client/src/components/SideMenu.vue @@ -3,6 +3,7 @@ import { isInProject } from '../router/index.js' import { useUserStore } from '@/stores/user.js' import { useServiceStore } from '@/stores/services-monitor.js' import { openCDSEnabled } from '@/utils/env.js' +import { AdminAuthorized } from '@cpn-console/shared' const route = useRoute() const userStore = useUserStore() @@ -148,7 +149,7 @@ onMounted(() => { () const projectStore = useProjectStore() @@ -105,7 +106,8 @@ async function removeUserFromProject(userId: string) { async function transferOwnerShip() { if (nextOwnerId.value && props.project.members.find(member => member.userId === nextOwnerId.value)) { await props.project.Commands.update({ ownerId: nextOwnerId.value }) - .finally(() => emit('refresh')) + .then(() => emit('transfer')) + .catch(() => emit('refresh')) } } diff --git a/apps/client/src/router/index.spec.ts b/apps/client/src/router/index.spec.ts index 5dd57f68dd..879a772aa3 100644 --- a/apps/client/src/router/index.spec.ts +++ b/apps/client/src/router/index.spec.ts @@ -1,31 +1,102 @@ -import { describe, it, expect } from 'vitest' -import { detectProjectslug } from './index.js' +import { describe, it, expect, beforeEach } from 'vitest' +import { faker } from '@faker-js/faker' +import type { ProjectV2 } from '@cpn-console/shared' +import { detectProjectslug, createAppRouter } from './index.js' +import { useUserStore } from '@/stores/user.js' import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' -setActivePinia(createPinia()) -describe('test router functions: detectProjectslug', () => { - const projectStore = useProjectStore() - const slug = 'the-slug' - const uuid = crypto.randomUUID() - projectStore.updateStore([{ - slug, - id: uuid, - }]) - it('it should return project\'slug with uuid passed', () => { - const slugFound = detectProjectslug({ - params: { - slug: uuid, - }, - }) - expect(slugFound).toEqual(slug) +describe('router index', () => { + beforeEach(() => { + setActivePinia(createPinia()) }) - it('it should return project\'slug with slug passed', () => { - const slugFound = detectProjectslug({ - params: { + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, slug, - }, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) }) - expect(slugFound).toEqual(slug) }) }) diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts index 24dda947c5..1d1b1cbaff 100644 --- a/apps/client/src/router/index.ts +++ b/apps/client/src/router/index.ts @@ -12,7 +12,7 @@ import { useSystemSettingsStore } from '@/stores/system-settings.js' import DsoHome from '@/views/DsoHome.vue' import NotFound from '@/views/NotFound.vue' -import { swaggerUiPath } from '@cpn-console/shared' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' import { uuid } from '@/utils/regex.js' const AdminCluster = () => import('@/views/admin/AdminCluster.vue') @@ -270,53 +270,43 @@ export const routes: Readonly = [ }, ] -const router = createRouter({ - history: createWebHistory(import.meta.env?.BASE_URL || ''), - scrollBehavior: (to) => { if (to.hash && !/^#state=/.exec(to.hash)) return ({ el: to.hash }) }, - routes, -}) - -/** - * Set application title - */ -router.beforeEach((to) => { // Cf. https://github.com/vueuse/head pour des transformations avancées de Head - const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' - document.title = `${specificTitle}${MAIN_TITLE}` -}) - -/** - * Redirect unlogged user to login view - */ -router.beforeEach(async (to, _from, next) => { - const validPath = ['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger'] - const userStore = useUserStore() - const systemStore = useSystemSettingsStore() - await userStore.setIsLoggedIn() - - // Redirige sur la page login si le path le requiert et l'utilisateur n'est pas connecté - if ( - !validPath.includes(to.name?.toString() ?? '') - && !userStore.isLoggedIn - ) { - return next('/login') - } - - // Redirige sur l'accueil si le path est Login et que l'utilisateur est connecté - if (to.name === 'Login' && userStore.isLoggedIn) { - return next('/') - } - - // Redirige vers la page maintenance si la maintenance est activée - if ( - !validPath.includes(to.name?.toString() ?? '') - && userStore.isLoggedIn - ) { - await systemStore.listSystemSettings('maintenance') - if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && userStore.adminPerms === 0n) return next('/maintenance') - } +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !/^#state=/.exec(to.hash)) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} - next() -}) +const router = createAppRouter() export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) export const selectedProjectSlug = computed(() => { diff --git a/apps/client/src/stores/admin-role.ts b/apps/client/src/stores/admin-role.ts index aa143fcd2d..ecbc882c06 100644 --- a/apps/client/src/stores/admin-role.ts +++ b/apps/client/src/stores/admin-role.ts @@ -22,7 +22,7 @@ export const useAdminRoleStore = defineStore('adminRole', () => { roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => extractData(response, 200), ) - if (AdminAuthorized.isAdmin(userStore.adminPerms)) { + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { await countMembersRoles() } return roles.value diff --git a/apps/client/src/stores/system-settings.ts b/apps/client/src/stores/system-settings.ts index 992e3df740..a4311f5d22 100644 --- a/apps/client/src/stores/system-settings.ts +++ b/apps/client/src/stores/system-settings.ts @@ -1,8 +1,10 @@ import { defineStore } from 'pinia' +import type { + UpsertSystemSettingBody, + SystemSettings, + systemSettingsContract, +} from '@cpn-console/shared' import { - type SystemSetting, - type SystemSettings, - type UpsertSystemSettingBody, resourceListToDictByKey, } from '@cpn-console/shared' import { apiClient, extractData } from '@/api/xhr-client.js' @@ -11,8 +13,8 @@ export const useSystemSettingsStore = defineStore('systemSettings', () => { const systemSettings = ref([]) const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) - const listSystemSettings = async (key?: SystemSetting['key']) => { - systemSettings.value = await apiClient.SystemSettings.listSystemSettings({ query: { key } }) + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) .then((response: any) => extractData(response, 200)) } diff --git a/apps/client/src/stores/user.ts b/apps/client/src/stores/user.ts index 919e76f39a..e4bfb59029 100644 --- a/apps/client/src/stores/user.ts +++ b/apps/client/src/stores/user.ts @@ -1,12 +1,15 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { getEffectiveAdminPermissions } from '@cpn-console/shared' import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' import { apiClient, extractData } from '@/api/xhr-client.js' import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' export const useUserStore = defineStore('user', () => { const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() const isLoggedIn = ref() const userProfile = ref() const apiAuthInfos = ref() @@ -14,14 +17,16 @@ export const useUserStore = defineStore('user', () => { const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) const adminPerms = computed(() => { - return apiAuthInfos.value - ? myAdminRoles.value - .reduce((acc, curr) => acc | BigInt(curr.permissions), 0n) - : null + if (!apiAuthInfos.value) return null + const perms = myAdminRoles.value.reduce((acc, curr) => acc | BigInt(curr.permissions), 0n) + const refinedRermissions = systemSettingsStore.systemSettingsByKey['refined-permissions'] + const refinedEnabled = refinedRermissions ? refinedRermissions.value === 'on' : false + return getEffectiveAdminPermissions(perms, { refined: refinedEnabled }) }) const setUserProfile = async () => { userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) await apiClient.Users.auth() .then((res: any) => apiAuthInfos.value = extractData(res, 200)) } diff --git a/apps/client/src/views/CreateProject.vue b/apps/client/src/views/CreateProject.vue index cd3dd62ee9..d58d031b13 100644 --- a/apps/client/src/views/CreateProject.vue +++ b/apps/client/src/views/CreateProject.vue @@ -1,10 +1,11 @@