From f0235daf8640209f8b35f06792ce864c494ce4ed Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:52:43 +0200 Subject: [PATCH] feat: :sparkles: introduce fine grained perms and and roles --- .../components/specs/permission-form.ct.ts | 150 ---- apps/client/src/App.vue | 7 + apps/client/src/api/xhr-client.ts | 2 +- apps/client/src/components/AdminRoleForm.vue | 223 +++++ apps/client/src/components/ClusterForm.vue | 5 +- .../client/src/components/EnvironmentForm.vue | 22 +- apps/client/src/components/PermissionForm.vue | 178 ---- .../client/src/components/ProjectRoleForm.vue | 149 ++++ apps/client/src/components/QuotaForm.vue | 6 +- apps/client/src/components/RangeInput.vue | 5 +- apps/client/src/components/RepoForm.vue | 25 +- apps/client/src/components/SideMenu.vue | 79 +- apps/client/src/components/StageForm.vue | 9 +- .../client/src/components/SuggestionInput.vue | 21 +- apps/client/src/components/TeamCt.vue | 174 ++-- apps/client/src/icons.ts | 1 + apps/client/src/router/index.ts | 29 +- apps/client/src/stores/admin-role.ts | 20 + apps/client/src/stores/ci-files.ts | 12 - apps/client/src/stores/project-user.ts | 28 +- apps/client/src/stores/project.ts | 14 +- apps/client/src/stores/user.ts | 28 +- apps/client/src/views/UserProfile.vue | 62 ++ apps/client/src/views/admin/AdminRoles.vue | 117 +++ apps/client/src/views/admin/ListProjects.vue | 33 +- apps/client/src/views/admin/ListUser.vue | 45 +- .../src/views/projects/DsoDashboard.vue | 36 +- apps/client/src/views/projects/DsoRepos.vue | 19 +- apps/client/src/views/projects/DsoRoles.vue | 162 ++++ apps/client/src/views/projects/DsoTeam.vue | 21 +- .../src/views/projects/ManageEnvironments.vue | 14 +- apps/server/Dockerfile | 1 + apps/server/package.json | 2 +- apps/server/src/app.ts | 22 +- .../src/generate-files/controllers.spec.ts | 86 -- apps/server/src/generate-files/router.ts | 61 -- .../src/generate-files/templates/docker.yml | 25 - .../generate-files/templates/gitlab-ci.yml | 106 --- .../src/generate-files/templates/java.yml | 30 - .../src/generate-files/templates/node.yml | 25 - .../src/generate-files/templates/python.yml | 11 - .../src/generate-files/templates/rules.yml | 6 - .../src/generate-files/templates/vault.yml | 24 - apps/server/src/init/db/index.ts | 19 +- apps/server/src/prepare-app.ts | 1 + .../20240723135420_dso/migration.sql | 120 +++ .../20240725162050_dso/migration.sql | 5 + .../20240726210139_dso/migration.sql | 14 + .../20240826143230_dso/migration.sql | 3 + apps/server/src/prisma/schema.prisma | 257 +++--- .../src/resources/admin-role/business.ts | 72 ++ .../src/resources/admin-role/queries.ts | 29 + .../server/src/resources/admin-role/router.ts | 75 ++ apps/server/src/resources/cluster/business.ts | 268 +++--- apps/server/src/resources/cluster/queries.ts | 22 +- apps/server/src/resources/cluster/router.ts | 67 +- .../src/resources/environment/business.ts | 318 +++----- .../src/resources/environment/queries.ts | 49 +- .../src/resources/environment/router.ts | 98 ++- apps/server/src/resources/index.ts | 39 +- apps/server/src/resources/log/queries.ts | 2 - apps/server/src/resources/log/router.ts | 27 +- .../src/resources/organization/business.ts | 21 +- .../src/resources/organization/queries.ts | 2 - .../src/resources/organization/router.ts | 59 +- .../src/resources/permission/business.ts | 104 --- .../resources/permission/controllers.spec.ts | 201 ----- .../src/resources/permission/queries.ts | 61 -- .../server/src/resources/permission/router.ts | 79 -- .../src/resources/project-member/business.ts | 56 ++ .../src/resources/project-member/queries.ts | 30 + .../src/resources/project-member/router.ts | 77 ++ .../src/resources/project-role/business.ts | 73 ++ .../src/resources/project-role/queries.ts | 51 ++ .../src/resources/project-role/router.ts | 91 +++ .../src/resources/project-service/business.ts | 34 +- .../src/resources/project-service/router.ts | 31 +- apps/server/src/resources/project/business.ts | 315 +++---- apps/server/src/resources/project/queries.ts | 192 +---- apps/server/src/resources/project/router.ts | 166 ++-- apps/server/src/resources/queries-index.ts | 7 +- apps/server/src/resources/quota/business.ts | 142 ++-- apps/server/src/resources/quota/queries.ts | 12 +- apps/server/src/resources/quota/router.ts | 91 +-- .../src/resources/repository/business.ts | 176 ++-- .../src/resources/repository/queries.ts | 3 - .../server/src/resources/repository/router.ts | 104 ++- .../src/resources/service-monitor/router.ts | 8 +- apps/server/src/resources/stage/business.ts | 157 ++-- apps/server/src/resources/stage/queries.ts | 10 +- apps/server/src/resources/stage/router.ts | 91 +-- .../src/resources/system/config/business.ts | 16 +- .../src/resources/system/config/router.ts | 39 +- .../src/resources/system/db/business.ts | 3 - .../src/resources/system/db/controllers.ts | 19 - .../server/src/resources/system/db/queries.ts | 45 - apps/server/src/resources/user/business.ts | 207 ++--- apps/server/src/resources/user/queries.ts | 25 +- .../server/src/resources/user/role-queries.ts | 80 -- apps/server/src/resources/user/router.ts | 174 +--- apps/server/src/resources/zone/business.ts | 11 +- apps/server/src/resources/zone/queries.ts | 2 - apps/server/src/resources/zone/router.ts | 40 +- apps/server/src/utils/business.ts | 4 +- apps/server/src/utils/controller.ts | 186 +++-- apps/server/src/utils/errors.ts | 54 -- apps/server/src/utils/hook-wrapper.ts | 32 +- apps/server/src/utils/logger.ts | 7 +- apps/server/tsconfig.json | 3 +- packages/hooks/src/hooks/hook-project.ts | 6 +- packages/shared/src/api-client.ts | 4 +- packages/shared/src/contracts/admin-role.ts | 68 ++ packages/shared/src/contracts/files.ts | 33 - packages/shared/src/contracts/index.ts | 4 +- .../shared/src/contracts/project-member.ts | 63 ++ packages/shared/src/contracts/project-role.ts | 75 ++ packages/shared/src/contracts/project.ts | 1 + packages/shared/src/contracts/user.ts | 99 +-- packages/shared/src/schemas/environment.ts | 1 + packages/shared/src/schemas/index.ts | 1 + packages/shared/src/schemas/project.ts | 11 +- packages/shared/src/schemas/role.ts | 21 + packages/shared/src/schemas/user.ts | 68 +- packages/shared/src/schemas/utils.ts | 14 +- packages/shared/src/utils/functions.ts | 17 + packages/shared/src/utils/index.ts | 1 + packages/shared/src/utils/permissions.ts | 177 ++++ packages/shared/src/utils/schemas.spec.ts | 34 +- packages/test-utils/src/imports/data.ts | 770 +++++++----------- plugins/gitlab/src/repositories.ts | 3 +- plugins/gitlab/src/utils.ts | 17 - plugins/kubernetes/src/class.ts | 8 +- plugins/nexus/src/project.ts | 3 +- 133 files changed, 4002 insertions(+), 4468 deletions(-) delete mode 100644 apps/client/cypress/components/specs/permission-form.ct.ts create mode 100644 apps/client/src/components/AdminRoleForm.vue delete mode 100644 apps/client/src/components/PermissionForm.vue create mode 100644 apps/client/src/components/ProjectRoleForm.vue create mode 100644 apps/client/src/stores/admin-role.ts delete mode 100644 apps/client/src/stores/ci-files.ts create mode 100644 apps/client/src/views/UserProfile.vue create mode 100644 apps/client/src/views/admin/AdminRoles.vue create mode 100644 apps/client/src/views/projects/DsoRoles.vue delete mode 100644 apps/server/src/generate-files/controllers.spec.ts delete mode 100644 apps/server/src/generate-files/router.ts delete mode 100644 apps/server/src/generate-files/templates/docker.yml delete mode 100644 apps/server/src/generate-files/templates/gitlab-ci.yml delete mode 100644 apps/server/src/generate-files/templates/java.yml delete mode 100644 apps/server/src/generate-files/templates/node.yml delete mode 100644 apps/server/src/generate-files/templates/python.yml delete mode 100644 apps/server/src/generate-files/templates/rules.yml delete mode 100644 apps/server/src/generate-files/templates/vault.yml create mode 100644 apps/server/src/prisma/migrations/20240723135420_dso/migration.sql create mode 100644 apps/server/src/prisma/migrations/20240725162050_dso/migration.sql create mode 100644 apps/server/src/prisma/migrations/20240726210139_dso/migration.sql create mode 100644 apps/server/src/prisma/migrations/20240826143230_dso/migration.sql create mode 100644 apps/server/src/resources/admin-role/business.ts create mode 100644 apps/server/src/resources/admin-role/queries.ts create mode 100644 apps/server/src/resources/admin-role/router.ts delete mode 100644 apps/server/src/resources/permission/business.ts delete mode 100644 apps/server/src/resources/permission/controllers.spec.ts delete mode 100644 apps/server/src/resources/permission/queries.ts delete mode 100644 apps/server/src/resources/permission/router.ts create mode 100644 apps/server/src/resources/project-member/business.ts create mode 100644 apps/server/src/resources/project-member/queries.ts create mode 100644 apps/server/src/resources/project-member/router.ts create mode 100644 apps/server/src/resources/project-role/business.ts create mode 100644 apps/server/src/resources/project-role/queries.ts create mode 100644 apps/server/src/resources/project-role/router.ts delete mode 100644 apps/server/src/resources/system/db/business.ts delete mode 100644 apps/server/src/resources/system/db/controllers.ts delete mode 100644 apps/server/src/resources/system/db/queries.ts delete mode 100644 apps/server/src/resources/user/role-queries.ts create mode 100644 packages/shared/src/contracts/admin-role.ts delete mode 100644 packages/shared/src/contracts/files.ts create mode 100644 packages/shared/src/contracts/project-member.ts create mode 100644 packages/shared/src/contracts/project-role.ts create mode 100644 packages/shared/src/schemas/role.ts create mode 100644 packages/shared/src/utils/permissions.ts diff --git a/apps/client/cypress/components/specs/permission-form.ct.ts b/apps/client/cypress/components/specs/permission-form.ct.ts deleted file mode 100644 index 9996290bd..000000000 --- a/apps/client/cypress/components/specs/permission-form.ct.ts +++ /dev/null @@ -1,150 +0,0 @@ -import '@gouvminint/vue-dsfr/styles' -import '@gouvfr/dsfr/dist/dsfr.min.css' -import '@gouvfr/dsfr/dist/utility/icons/icons.min.css' -import '@gouvfr/dsfr/dist/utility/utility.main.min.css' -import '@/main.css' -import PermissionForm from '@/components/PermissionForm.vue' -import { createRandomDbSetup, getRandomUser, getRandomMember } from '@cpn-console/test-utils' -import { useProjectStore } from '@/stores/project.js' -import { useUserStore } from '@/stores/user.js' -import { Pinia, createPinia, setActivePinia } from 'pinia' -import { useUsersStore } from '@/stores/users.js' - -describe('PermissionForm.vue', () => { - let pinia: Pinia - - beforeEach(() => { - pinia = createPinia() - - setActivePinia(pinia) - }) - - it('Should mount a PermissionForm with users to licence', () => { - const randomDbSetup = createRandomDbSetup({ nbUsers: 3, envs: ['dev'] }) - const projectStore = useProjectStore() - const userStore = useUserStore() - const usersStore = useUsersStore() - - let userToLicence = getRandomUser() - userToLicence = { ...getRandomMember(userToLicence.id), ...userToLicence } - randomDbSetup.project.members = [...randomDbSetup.project.members, userToLicence] - usersStore.users = randomDbSetup.users.reduce((acc, curr) => { - return { ...acc, [curr.id]: curr } - }, {}) - usersStore.users[userToLicence.userId] = userToLicence - - projectStore.selectedProject = randomDbSetup.project - const owner = randomDbSetup.project.roles?.find(role => role.role === 'owner')?.user - userStore.userProfile = randomDbSetup.users[1] - - const environment = projectStore.selectedProject?.environments[0] - const ownerPermission = environment.permissions.find(permission => permission.user.email === owner.email) - const userPermission = environment.permissions.find(permission => permission.user.email !== owner.email) - - const props = { - environment, - } - - cy.mount(PermissionForm, { props }) - - cy.getByDataTestid('permissionsFieldset') - .should('contain', `Droits des utilisateurs sur l'environnement ${props.environment.name}`) - cy.get('li') - .should('have.length', props.environment.permissions.length) - - cy.getByDataTestid(`userPermissionLi-${ownerPermission.user.email}`) - .within(() => { - cy.getByDataTestid('userEmail') - .should('contain', ownerPermission.user.email) - .get('input#range') - .should('have.value', ownerPermission.level) - .and('be.disabled') - .getByDataTestid('deletePermissionBtn') - .should('have.attr', 'title', 'Les droits du owner ne peuvent être supprimés') - .and('be.disabled') - .get('[data-testid$=UpsertPermissionBtn]') - .should('be.disabled') - .and('have.attr', 'title', 'Les droits du owner ne peuvent être inférieurs à rwd') - }) - - cy.getByDataTestid(`userPermissionLi-${userPermission.user.email}`) - .within(() => { - cy.getByDataTestid('userEmail') - .should('contain', userPermission.user.email) - .get('input#range') - .should('have.value', userPermission.level) - .and('be.enabled') - .getByDataTestid('deletePermissionBtn') - .should('have.attr', 'title', `Supprimer les droits de ${userPermission.user.email}`) - .and('be.enabled') - .get('[data-testid$=UpsertPermissionBtn]') - .should('have.attr', 'title', `Modifier les droits de ${userPermission.user.email}`) - }) - cy.getByDataTestid('newPermissionFieldset') - .should('contain', 'Accréditer un membre du projet') - .within(() => { - cy.get('label') - .should('contain', `E-mail de l'utilisateur à accréditer sur l'environnement ${props.environment.name}`) - cy.get('.fr-hint-text') - .should('contain', `Entrez l'e-mail d'un membre du projet ${projectStore.selectedProject.name}. Ex : ${userToLicence.email}`) - cy.get('datalist#suggestionList') - .find('option') - .should('have.length', projectStore.selectedProject.members.length - props.environment.permissions.length) - .should('have.value', userToLicence.email) - }) - }) - it('Should mount a PermissionForm with no user to licence', () => { - const randomDbSetup = createRandomDbSetup({ nbUsers: 3, envs: ['dev'] }) - const projectStore = useProjectStore() - const userStore = useUserStore() - const usersStore = useUsersStore() - - usersStore.users = randomDbSetup.users.reduce((acc, curr) => { - return { ...acc, [curr.id]: curr } - }, {}) - projectStore.selectedProject = randomDbSetup.project - userStore.userProfile = randomDbSetup.users[1] - - const environment = projectStore.selectedProject?.environments[0] - - const props = { - environment, - } - - cy.mount(PermissionForm, { props }) - - cy.getByDataTestid('newPermissionFieldset') - .should('contain', 'Accréditer un membre du projet') - .within(() => { - cy.get('label') - .should('contain', `E-mail de l'utilisateur à accréditer sur l'environnement ${props.environment.name}`) - cy.get('.fr-hint-text') - .should('contain', `Tous les membres du projet ${projectStore.selectedProject.name} sont déjà accrédités.`) - }) - }) - - it('Should mount a PermissionForm without permission for current user', () => { - const randomDbSetup = createRandomDbSetup({ nbUsers: 3, envs: ['dev'] }) - const projectStore = useProjectStore() - const userStore = useUserStore() - const usersStore = useUsersStore() - - usersStore.users = randomDbSetup.users.reduce((acc, curr) => { - return { ...acc, [curr.id]: curr } - }, {}) - - projectStore.selectedProject = randomDbSetup.project - userStore.userProfile = getRandomUser() - - const environment = projectStore.selectedProject?.environments[0] - - const props = { - environment, - } - - cy.mount(PermissionForm, { props }) - - cy.getByDataTestid('notPermittedAlert') - .should('contain', `Vous n'avez aucun droit sur l'environnement ${props.environment.name}. Un membre possédant des droits sur cet environnement peut vous accréditer.`) - }) -}) diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue index 80d5ad09c..ff11458f1 100644 --- a/apps/client/src/App.vue +++ b/apps/client/src/App.vue @@ -3,6 +3,7 @@ import { apiPrefix } from '@cpn-console/shared' import { getKeycloak } from './utils/keycloak/keycloak.js' import { useUserStore } from './stores/user.js' import { useSnackbarStore } from './stores/snackbar.js' +import router from './router/index.js' const keycloak = getKeycloak() const userStore = useUserStore() @@ -44,6 +45,12 @@ watch(label, (label: string) => { quickLinks.value[0].label = label }) +userStore.$subscribe(() => { + if (router.currentRoute.value.fullPath.startsWith('/admin') && userStore.adminPerms === 0n) { + window.location.pathname = '/' + } +}) + diff --git a/apps/client/src/api/xhr-client.ts b/apps/client/src/api/xhr-client.ts index 309c8a901..0fb0009fa 100644 --- a/apps/client/src/api/xhr-client.ts +++ b/apps/client/src/api/xhr-client.ts @@ -35,7 +35,7 @@ export const extractData = ['body'] => { if (response.status >= 400 && response.status <= 599) { // @ts-ignore - throw Error(response.body?.error ?? 'Erreur inconnue') + throw Error(response.body?.error ?? response.body?.message ?? 'Erreur inconnue') } if (response.status === expectedStatus) return response.body try { diff --git a/apps/client/src/components/AdminRoleForm.vue b/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 000000000..46da47696 --- /dev/null +++ b/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,223 @@ + + + + + Nom du rôle + + Permissions + updateChecked(checked, ADMIN_PERMS[key])" + /> + OIDC Groupe + + + + + + switchUserMembership(checked, user)" + /> + + Vous n'avez pas d'utilisateur dans votre projet + + + newUser = value" + @update:model-value="(value: string) => retrieveUsersToAdd(value)" + /> + + newUser && switchUserMembership(true, newUser, true)" + /> + + + $emit('cancel')" + /> + + diff --git a/apps/client/src/components/ClusterForm.vue b/apps/client/src/components/ClusterForm.vue index 8aa5a02e5..7034e65ad 100644 --- a/apps/client/src/components/ClusterForm.vue +++ b/apps/client/src/components/ClusterForm.vue @@ -9,6 +9,7 @@ import { type ClusterDetails, type Stage, type Zone, + AdminAuthorized, } from '@cpn-console/shared' // @ts-ignore import { load } from 'js-yaml' @@ -18,7 +19,9 @@ import { useSnackbarStore } from '@/stores/snackbar.js' import ChoiceSelector from './ChoiceSelector.vue' import { toCodeComponent } from '@/utils/func.js' import type { ProjectWithOrganization } from '../stores/project.js' +import { useUserStore } from '@/stores/user.js' +const userStore = useUserStore() const snackbarStore = useSnackbarStore() const props = withDefaults(defineProps<{ @@ -377,7 +380,7 @@ const isConnectionDetailsShown = ref(true) :options-selected="props.allStages.filter(stage => props.cluster.stageIds.includes(stage.id))" label-key="name" value-key="id" - :disabled="false" + :disabled="!AdminAuthorized.ManageStages(userStore.adminPerms)" @update="(_s, stageIds) => localCluster.stageIds = stageIds" /> diff --git a/apps/client/src/components/EnvironmentForm.vue b/apps/client/src/components/EnvironmentForm.vue index 9d0c33a02..1039a046d 100644 --- a/apps/client/src/components/EnvironmentForm.vue +++ b/apps/client/src/components/EnvironmentForm.vue @@ -21,16 +21,16 @@ type OptionType = { } const props = withDefaults(defineProps<{ - environment: Partial + environment: Environment isEditable: boolean - isOwner: boolean + canManage: boolean isProjectLocked: boolean projectClustersIds: CleanedCluster['id'][] allClusters: CleanedCluster[] }>(), { environment: undefined, isEditable: true, - isOwner: false, + canManage: false, isProjectLocked: false, }) @@ -109,7 +109,7 @@ const setEnvironmentOptions = () => { } const resetCluster = () => { - localEnvironment.value.clusterId = undefined + localEnvironment.value.clusterId = props.allClusters[0]?.id } const addEnvironment = () => { @@ -159,7 +159,7 @@ watch(localEnvironment.value, () => { class="relative" > Ajouter un environnement au projet @@ -226,7 +226,7 @@ watch(localEnvironment.value, () => { description="Si votre projet nécessite d'avantage de ressources que celles proposées ci-dessus, contactez les administrateurs." required :options="quotaOptions" - :disabled="!!(localEnvironment.quotaId && quotaStore.quotasById[localEnvironment.quotaId]?.isPrivate)" + :disabled="!!(localEnvironment.quotaId && quotaStore.quotasById[localEnvironment.quotaId]?.isPrivate) || !props.canManage" /> { :description="clusterInfos" /> { - @@ -337,7 +333,7 @@ watch(localEnvironment.value, () => { -import { ref, onMounted, computed } from 'vue' -import { getRandomId } from '@gouvminint/vue-dsfr' -import { levels, projectIsLockedInfo, type Permission, type Environment, type UpsertPermissionBody } from '@cpn-console/shared' -import { useProjectStore } from '@/stores/project.js' -import { useProjectPermissionStore } from '@/stores/project-permission.js' -import { useUserStore } from '@/stores/user.js' -import { useUsersStore } from '@/stores/users.js' -import { useProjectEnvironmentStore } from '@/stores/project-environment.js' - -const props = withDefaults(defineProps<{ - environment: Partial -}>(), { - environment: () => ({}), -}) - -const projectStore = useProjectStore() -const projectEnvironmentStore = useProjectEnvironmentStore() -const projectPermissionStore = useProjectPermissionStore() -const userStore = useUserStore() -const usersStore = useUsersStore() -const environment = ref>(props.environment) -const permissions = ref([]) -const permissionToUpdate = ref({ - userId: '', - level: '', -}) -const userToLicence = ref('') -const permissionSuggestionKey = ref(getRandomId('input')) - -const ownersIds = computed(() => projectStore.selectedProject?.members.filter(({ role }) => role === 'owner').map(({ userId }) => userId) ?? []) -const projectMembers = computed(() => projectStore.selectedProject?.members) -const permittedUsersId = computed(() => [...permissions.value.map(permission => permission.userId), ...ownersIds.value]) -const isPermitted = computed(() => userStore.userProfile ? permittedUsersId.value.includes(userStore.userProfile.id) : false) -const usersToLicence = computed(() => { - return projectMembers.value?.filter(projectMember => - !permittedUsersId.value.includes(projectMember.userId)) -}) -const suggestions = computed(() => usersToLicence.value?.map(user => user.email)) - -const setPermissions = () => { - permissions.value = environment.value?.permissions?.toSorted((a: Permission, b: Permission) => a?.userId >= b?.userId ? 1 : -1) -} - -const addPermission = async (userEmail: string) => { - if (!projectStore.selectedProject?.locked) { - const userId = usersToLicence.value?.find(user => user.email === userEmail)?.userId - if (userId) await projectPermissionStore.upsertPermission(environment.value.id, { userId, level: 0 }) - } - userToLicence.value = '' - permissionSuggestionKey.value = getRandomId('input') -} - -const upsertPermission = async () => { - if (!projectStore.selectedProject?.locked) { - await projectPermissionStore.upsertPermission(environment.value.id, permissionToUpdate.value) - permissionToUpdate.value = { - userId: '', - level: '', - } - } -} - -const deletePermission = async (userId: string) => { - await projectPermissionStore.deletePermission(environment.value?.id, userId) -} - -const getDynamicTitle = (permission: Permission) => { - if (projectStore.selectedProject?.locked) return projectIsLockedInfo - if (ownersIds.value?.includes(permission.userId) && permission.level === 2) return 'Les droits du owner ne peuvent être inférieurs à rwd' - if (!isPermitted.value) return `Vous n'avez aucun droit sur l'environnement ${environment.value?.name}` - return `Modifier les droits de ${usersStore.users[permission.userId]?.email}` -} - -projectEnvironmentStore.$subscribe((_mutation, state) => { - environment.value = state.environments.find(env => - env.id === environment.value.id, - ) as Environment - setPermissions() -}) - -onMounted(() => { - setPermissions() -}) - - - - - - - - - - - - {{ usersStore.users[permission.userId]?.email }} - - - - - - { - permissionToUpdate.userId = permission.userId - permissionToUpdate.level = event - }" - /> - - - - - - - addPermission(value)" - /> - - diff --git a/apps/client/src/components/ProjectRoleForm.vue b/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 000000000..ddf017ced --- /dev/null +++ b/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,149 @@ + + + + + Nom du rôle + + Permissions + updateChecked(checked, PROJECT_PERMS[key])" + /> + + + + + $emit('updateMemberRoles', checked, member.userId)" + /> + + + Vous n'avez pas d'utilisateur dans votre projet + + + + + $emit('cancel')" + /> + + diff --git a/apps/client/src/components/QuotaForm.vue b/apps/client/src/components/QuotaForm.vue index 77f72546f..b589320a8 100644 --- a/apps/client/src/components/QuotaForm.vue +++ b/apps/client/src/components/QuotaForm.vue @@ -1,11 +1,13 @@ diff --git a/apps/client/src/components/RepoForm.vue b/apps/client/src/components/RepoForm.vue index 6faa3c1d4..6c41f3c6f 100644 --- a/apps/client/src/components/RepoForm.vue +++ b/apps/client/src/components/RepoForm.vue @@ -5,11 +5,11 @@ import { useSnackbarStore } from '@/stores/snackbar.js' const props = withDefaults(defineProps<{ repo: Partial - isOwner: boolean + canManage: boolean isProjectLocked: boolean }>(), { repo: () => ({ isInfra: false, isPrivate: false, internalRepoName: '' }), - isOwner: false, + canManage: false, isProjectLocked: false, }) @@ -66,7 +66,7 @@ const cancel = () => { { data-testid="internalRepoNameInput" type="text" :required="true" - :disabled="localRepo.id || props.isProjectLocked" + :disabled="localRepo.id || props.isProjectLocked || !canManage" :error-message="!!updatedValues.internalRepoName && !RepoSchema.pick({internalRepoName: true}).safeParse({internalRepoName: localRepo.internalRepoName}).success ? 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique': undefined" label="Nom du dépôt Git interne" label-visible @@ -93,7 +93,7 @@ const cancel = () => { data-testid="externalRepoUrlInput" type="text" :required="true" - :disabled="props.isProjectLocked" + :disabled="props.isProjectLocked || !canManage" :error-message="!!updatedValues.externalRepoUrl && !RepoSchema.pick({externalRepoUrl: true}).safeParse({externalRepoUrl: localRepo.externalRepoUrl}).success ? 'L\'url du dépôt doit commencer par https et se terminer par .git': undefined" label="Url du dépôt Git externe" label-visible @@ -106,7 +106,7 @@ const cancel = () => { { data-testid="externalUserNameInput" type="text" :required="localRepo.isPrivate" - :disabled="props.isProjectLocked" + :disabled="props.isProjectLocked || !canManage" :error-message="!!updatedValues.externalUserName && !RepoSchema.pick({externalUserName: true}).safeParse({externalUserName: localRepo.externalUserName}).success ? 'Le nom du propriétaire du token est obligatoire en cas de dépôt privé': undefined" label="Nom d'utilisateur" label-visible @@ -137,7 +137,7 @@ const cancel = () => { data-testid="externalTokenInput" type="text" :required="localRepo.isPrivate" - :disabled="props.isProjectLocked" + :disabled="props.isProjectLocked || !canManage" label="Token d'accès au dépôt Git externe" label-visible hint="Token d'accès permettant le clone du dépôt par la chaîne DevSecOps" @@ -150,19 +150,16 @@ const cancel = () => { - { /> diff --git a/apps/client/src/components/SideMenu.vue b/apps/client/src/components/SideMenu.vue index fde9c913d..a92c15fc1 100644 --- a/apps/client/src/components/SideMenu.vue +++ b/apps/client/src/components/SideMenu.vue @@ -1,6 +1,8 @@ @@ -220,16 +203,17 @@ watch(() => props.members, setRows) :rows="rows" /> newUserEmail = value" + @select-suggestion="(value: User) => newUserEmail = value.email" @update:model-value="(value: string) => retrieveUsersToAdd(value)" /> props.members, setRows) class="w-max fr-mb-2w" /> props.members, setRows) /> - diff --git a/apps/client/src/icons.ts b/apps/client/src/icons.ts index 2a38f5cba..716be2b28 100644 --- a/apps/client/src/icons.ts +++ b/apps/client/src/icons.ts @@ -60,4 +60,5 @@ export { RiUserAddLine, RiUserFill, RiUserStarFill, + RiAdminLine, } from 'oh-vue-icons/icons/ri/index.js' diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts index 22176be64..802767adb 100644 --- a/apps/client/src/router/index.ts +++ b/apps/client/src/router/index.ts @@ -5,16 +5,17 @@ import { } from 'vue-router' import { useUserStore } from '@/stores/user.js' import { useProjectStore } from '@/stores/project.js' -import { useSnackbarStore } from '@/stores/snackbar.js' import { uuid } from '@/utils/regex.js' import DsoHome from '@/views/DsoHome.vue' import NotFound from '@/views/NotFound.vue' const ServicesHealth = () => import('@/views/ServicesHealth.vue') const CreateProject = () => import('@/views/CreateProject.vue') +const UserProfile = () => import('@/views/UserProfile.vue') const ManageEnvironments = () => import('@/views/projects/ManageEnvironments.vue') const DsoProjects = () => import('@/views/projects/DsoProjects.vue') const DsoDashboard = () => import('@/views/projects/DsoDashboard.vue') +const DsoRoles = () => import('@/views/projects/DsoRoles.vue') const DsoServices = () => import('@/views/projects/DsoServices.vue') const DsoTeam = () => import('@/views/projects/DsoTeam.vue') const DsoRepos = () => import('@/views/projects/DsoRepos.vue') @@ -22,6 +23,7 @@ const ListUser = () => import('@/views/admin/ListUser.vue') const ListOrganizations = () => import('@/views/admin/ListOrganizations.vue') const ListProjects = () => import('@/views/admin/ListProjects.vue') const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') const ListClusters = () => import('@/views/admin/ListClusters.vue') const ListQuotas = () => import('@/views/admin/ListQuotas.vue') const ListStages = () => import('@/views/admin/ListStages.vue') @@ -54,6 +56,11 @@ const routes: Readonly = [ name: 'Home', component: DsoHome, }, + { + path: '/profile', + name: 'UserProfile', + component: UserProfile, + }, { path: '/404', name: 'NotFound', @@ -94,6 +101,11 @@ const routes: Readonly = [ name: 'Services', component: DsoServices, }, + { + path: 'roles', + name: 'ProjectRoles', + component: DsoRoles, + }, { path: 'team', name: 'Team', @@ -136,6 +148,11 @@ const routes: Readonly = [ name: 'ListProjects', component: ListProjects, }, + { + path: '/admin/roles', + name: 'AdminRoles', + component: AdminRoles, + }, { path: '/admin/logs', name: 'ListLogs', @@ -187,7 +204,6 @@ router.beforeEach((to) => { // Cf. https://github.com/vueuse/head pour des trans */ router.beforeEach(async (to, _from, next) => { const validPath = ['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth'] - const snackbarStore = useSnackbarStore() const userStore = useUserStore() userStore.setIsLoggedIn() @@ -205,15 +221,6 @@ router.beforeEach(async (to, _from, next) => { return next('/') } - // Redirige sur la page d'accueil si le path est admin et l'utilisateur n'est pas admin - if (to.path.match('^/admin/') && !userStore.isAdmin) { - snackbarStore.setMessage( - 'Vous ne possédez pas les droits administeurs', - 'error', - ) - return next('/') - } - // Redirige vers une 404 si la page n'existe pas if (to.name === undefined) { return next('/404') diff --git a/apps/client/src/stores/admin-role.ts b/apps/client/src/stores/admin-role.ts new file mode 100644 index 000000000..6abdb3037 --- /dev/null +++ b/apps/client/src/stores/admin-role.ts @@ -0,0 +1,20 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { Role } from '@cpn-console/shared' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const roles = ref([]) + + const listAdminRoles = async () => { + const res = await apiClient.AdminRoles.listAdminRoles() + .then(response => extractData(response, 200)) + roles.value = res + return res + } + + return { + roles, + listAdminRoles, + } +}) diff --git a/apps/client/src/stores/ci-files.ts b/apps/client/src/stores/ci-files.ts deleted file mode 100644 index 8946d915f..000000000 --- a/apps/client/src/stores/ci-files.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineStore } from 'pinia' -import type { GenerateCIFilesBody } from '@cpn-console/shared' -import { apiClient, extractData } from '@/api/xhr-client.js' - -export const useCIFilesStore = defineStore('ciFiles', () => { - const generateCIFiles = (ciData: GenerateCIFilesBody) => apiClient.Files.generateCIFiles({ body: ciData }) - .then(response => extractData(response, 201)) - - return { - generateCIFiles, - } -}) diff --git a/apps/client/src/stores/project-user.ts b/apps/client/src/stores/project-user.ts index fbca3818e..6660a1f94 100644 --- a/apps/client/src/stores/project-user.ts +++ b/apps/client/src/stores/project-user.ts @@ -1,5 +1,4 @@ import { defineStore } from 'pinia' -import type { AddUserToProjectBody } from '@cpn-console/shared' import { useUsersStore } from './users.js' import { apiClient, extractData } from '@/api/xhr-client.js' @@ -10,36 +9,25 @@ export const useProjectUserStore = defineStore('project-user', () => { apiClient.Users.getAllUsers() .then(response => extractData(response, 200)) - const updateUserAdminRole = (userId: string, isAdmin: boolean) => - apiClient.Users.updateUserAdminRole({ params: { userId }, body: { isAdmin } }) - .then(response => extractData(response, 204)) - const getMatchingUsers = async (projectId: string, letters: string) => { - const users = await apiClient.Users.getMatchingUsers({ params: { projectId }, query: { letters } }) + const users = await apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: projectId } }) .then(response => extractData(response, 200)) usersStore.addUsers(users) return users } - const addUserToProject = async (projectId: string, body: AddUserToProjectBody) => { - const newMembers = await apiClient.Users.createUserRoleInProject({ body, params: { projectId } }) + const addMember = async (projectId: string, email: string) => + apiClient.ProjectsMembers.addMember({ params: { projectId }, body: { email } }) .then(response => extractData(response, 201)) - usersStore.addUsersFromMembers(newMembers) - return newMembers - } - const transferProjectOwnership = async (projectId: string, userId: string) => apiClient.Users.transferProjectOwnership({ params: { projectId, userId } }) - .then(response => extractData(response, 200)) - - const removeUserFromProject = async (projectId: string, userId: string) => apiClient.Users.deleteUserRoleInProject({ params: { projectId, userId } }) - .then(response => extractData(response, 200)) + const removeMember = async (projectId: string, userId: string) => + apiClient.ProjectsMembers.removeMember({ params: { projectId, userId } }) + .then(response => extractData(response, 200)) return { getAllUsers, getMatchingUsers, - addUserToProject, - transferProjectOwnership, - updateUserAdminRole, - removeUserFromProject, + addMember, + removeMember, } }) diff --git a/apps/client/src/stores/project.ts b/apps/client/src/stores/project.ts index 3817f26d9..02f7002d0 100644 --- a/apps/client/src/stores/project.ts +++ b/apps/client/src/stores/project.ts @@ -1,8 +1,9 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import type { CreateProjectBody, Organization, ProjectV2, UpdateProjectBody, projectContract } from '@cpn-console/shared' +import { CreateProjectBody, Organization, ProjectV2, UpdateProjectBody, projectContract, getPermsByUserRoles, PROJECT_PERMS, resourceListToDict } from '@cpn-console/shared' import { apiClient, extractData } from '@/api/xhr-client.js' import { useUsersStore } from './users.js' +import { useUserStore } from './user.js' import { useOrganizationStore } from './organization.js' export type ProjectWithOrganization = ProjectV2 & { organization: Organization } @@ -11,7 +12,17 @@ export const useProjectStore = defineStore('project', () => { const selectedProject = ref() const projects = ref([]) const usersStore = useUsersStore() + const userStore = useUserStore() const organizationStore = useOrganizationStore() + const selectedProjectPerms = computed(() => { + if (!selectedProject.value) return 0n + const selfId = userStore.userProfile?.id + if (selfId === selectedProject.value?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = selectedProject.value.members.find(member => member.userId === selfId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(selectedProject.value.roles), selectedProject.value.everyonePerms) + }) const setSelectedProject = (id: string) => { selectedProject.value = projects.value.find(project => project.id === id) @@ -71,6 +82,7 @@ export const useProjectStore = defineStore('project', () => { generateProjectsData, selectedProject, projects, + selectedProjectPerms, setSelectedProject, listProjects, updateProject, diff --git a/apps/client/src/stores/user.ts b/apps/client/src/stores/user.ts index 0554fe11e..9220237a5 100644 --- a/apps/client/src/stores/user.ts +++ b/apps/client/src/stores/user.ts @@ -1,31 +1,34 @@ -import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' import { defineStore } from 'pinia' import { ref } from 'vue' -import { adminGroupPath, type UserProfile } from '@cpn-console/shared' -import { apiClient } from '@/api/xhr-client.js' +import type { UserProfile } from '@cpn-console/shared' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { useAdminRoleStore } from './admin-role.js' export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() const isLoggedIn = ref() - const isAdmin = ref() const userProfile = ref() - const registeredInDb = ref(false) + const adminPerms = ref() const setIsLoggedIn = () => { const keycloak = getKeycloak() isLoggedIn.value = keycloak.authenticated if (isLoggedIn.value) { setUserProfile() - if (!registeredInDb.value) { - apiClient.Users.auth().then(() => { - registeredInDb.value = true - }) - } } } const setUserProfile = () => { userProfile.value = getUserProfile() - isAdmin.value = userProfile.value?.groups?.includes(adminGroupPath) + apiClient.Users.auth().then((res) => { + const authDetails = extractData(res, 200) + adminRoleStore.listAdminRoles().then((adminRoles) => { + adminPerms.value = adminRoles + .filter(role => authDetails.adminRoleIds.includes(role.id)) + .reduce((acc, curr) => acc | BigInt(curr.permissions), 0n) + }) + }) } const login = () => keycloakLogin() @@ -34,10 +37,9 @@ export const useUserStore = defineStore('user', () => { return { isLoggedIn, - isAdmin, setIsLoggedIn, userProfile, - registeredInDb, + adminPerms, setUserProfile, login, logout, diff --git a/apps/client/src/views/UserProfile.vue b/apps/client/src/views/UserProfile.vue new file mode 100644 index 000000000..6bc381bd1 --- /dev/null +++ b/apps/client/src/views/UserProfile.vue @@ -0,0 +1,62 @@ + + + + + + + + Nom, prénom{{ userStore.userProfile.lastName }}, {{ userStore.userProfile.firstName }} + + + Email{{ userStore.userProfile.email }} + + + Id Keycloak{{ userStore.userProfile.id }} + + + Groupes Keycloak + + + + {{ group }} + + + + + + Roles Admins + + + + {{ role }} + + + + + + + diff --git a/apps/client/src/views/admin/AdminRoles.vue b/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 000000000..b48d75dd6 --- /dev/null +++ b/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,117 @@ + + + + + + + + + + + {{ role.name }} + + + {{ role.memberCounts ?? '-' }} + + + + + + ) => saveRole(role)" + @cancel="() => cancel()" + /> + + diff --git a/apps/client/src/views/admin/ListProjects.vue b/apps/client/src/views/admin/ListProjects.vue index adb1aef5e..391966d20 100644 --- a/apps/client/src/views/admin/ListProjects.vue +++ b/apps/client/src/views/admin/ListProjects.vue @@ -12,19 +12,20 @@ import { type Repo, type Environment, projectContract, + AdminAuthorized, } from '@cpn-console/shared' import { useSnackbarStore } from '@/stores/snackbar.js' import { useOrganizationStore } from '@/stores/organization.js' import { useProjectEnvironmentStore } from '@/stores/project-environment.js' import { useUserStore } from '@/stores/user.js' import { useUsersStore } from '@/stores/users.js' -import { useProjectUserStore } from '@/stores/project-user.js' import { useQuotaStore } from '@/stores/quota.js' import { useProjectServiceStore } from '@/stores/project-services.js' import { useProjectRepositoryStore } from '@/stores/project-repository.js' import { useProjectStore } from '@/stores/project.js' import { useStageStore } from '@/stores/stage.js' import { bts } from '@/utils/func.js' +import { apiClient } from '@/api/xhr-client.js' const projectStore = useProjectStore() const organizationStore = useOrganizationStore() @@ -32,7 +33,6 @@ const projectServiceStore = useProjectServiceStore() const userStore = useUserStore() const usersStore = useUsersStore() const snackbarStore = useSnackbarStore() -const projectUserStore = useProjectUserStore() const quotaStore = useQuotaStore() const stageStore = useStageStore() const projectRepositoryStore = useProjectRepositoryStore() @@ -133,7 +133,7 @@ interface DomElement extends Event { const setRows = () => { rows.value = sortArrByObjKeyAsc(projectStore.projects, 'name') - ?.map(({ id, organization, name, description, members, status, locked, createdAt, updatedAt }) => ( + ?.map(({ id, organization, name, description, status, locked, createdAt, updatedAt, owner }) => ( { status, locked, @@ -150,7 +150,7 @@ const setRows = () => { organization.label, name, truncateDescription(description ?? ''), - members.find(member => member.role === 'owner')?.email ?? '', + owner.email, { component: 'v-icon', name: statusDict.status[status].icon, @@ -183,6 +183,7 @@ const getEnvironmentsRows = () => { component: 'DsfrSelect', modelValue: quotaId, selectId: 'quota-select', + disabled: !AdminAuthorized.ManageProjects(userStore.adminPerms), options: quotaStore.quotas.filter(quota => quota.stageIds.includes(stageId)).map(quota => ({ text: quota.name + ' (' + quota.cpu + 'CPU, ' + quota.memory + ')', value: quota.id, @@ -205,10 +206,12 @@ const getRepositoriesRows = () => { if (!selectedProject.value) return repositoriesRows.value = selectedProject.value.repositories.length ? sortArrByObjKeyAsc(selectedProject.value.repositories, 'internalRepoName') - ?.map(({ internalRepoName, isInfra }) => ( + ?.map(({ internalRepoName, isInfra, externalRepoUrl, isPrivate }) => ( [ internalRepoName, isInfra ? 'Infra' : 'Applicatif', + isPrivate ? 'oui' : 'non', + externalRepoUrl, ] ), ) @@ -280,11 +283,11 @@ const archiveProject = async (projectId: string) => { snackbarStore.isWaitingForResponse = false } -const addUserToProject = async (email: string) => { +const addUserToProject = async (userId: string) => { + if (!selectedProject.value) return snackbarStore.isWaitingForResponse = true - if (selectedProject.value) { - selectedProject.value.members = await projectUserStore.addUserToProject(selectedProject.value.id, { email }) - } + await apiClient.ProjectsMembers.addMember({ params: { projectId: selectedProject.value.id }, body: { userId } }) + await getAllProjects() teamCtKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false } @@ -292,9 +295,9 @@ const addUserToProject = async (email: string) => { const updateUserRole = async (userId: string) => { if (!selectedProject.value) return snackbarStore.setMessage('Veuillez sélectionner un projet') snackbarStore.isWaitingForResponse = true - selectedProject.value.members = await projectUserStore.transferProjectOwnership(selectedProject.value.id, userId) - teamCtKey.value = getRandomId('team') + await apiClient.Projects.updateProject({ params: { projectId: selectedProject.value.id }, body: { ownerId: userId } }) await getAllProjects() + teamCtKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false } @@ -302,7 +305,8 @@ const removeUserFromProject = async (userId: string) => { if (!selectedProject.value) return snackbarStore.isWaitingForResponse = true if (selectedProject.value.id) { - selectedProject.value.members = await projectUserStore.removeUserFromProject(selectedProject.value.id, userId) + await apiClient.ProjectsMembers.removeMember({ params: { projectId: selectedProject.value.id, userId } }) + selectedProject.value.members = selectedProject.value.members.filter(user => user.userId !== userId) } teamCtKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false @@ -552,16 +556,17 @@ const untruncateDescription = (span: HTMLElement) => { :id="repositoriesId" :key="repositoriesCtKey" title="Dépôts" - :headers="['Nom', 'Type']" + :headers="['Nom', 'Type', 'Privé ?', 'url']" :rows="repositoriesRows" /> addUserToProject(email)" @update-role="(userId: string) => updateUserRole(userId)" @remove-member="(userId: string) => removeUserFromProject(userId)" diff --git a/apps/client/src/views/admin/ListUser.vue b/apps/client/src/views/admin/ListUser.vue index 8a27c482b..786ac057f 100644 --- a/apps/client/src/views/admin/ListUser.vue +++ b/apps/client/src/views/admin/ListUser.vue @@ -1,15 +1,20 @@ + + + + + + + + + + + {{ role.name }} + + + {{ role.memberCounts }} + + + + + + updateMember(checked, userId)" + @save="(role: RoleItem) => saveRole(role)" + @cancel="() => cancel()" + /> + + + + diff --git a/apps/client/src/views/projects/DsoTeam.vue b/apps/client/src/views/projects/DsoTeam.vue index 391e6f1b2..5bcdfb6a4 100644 --- a/apps/client/src/views/projects/DsoTeam.vue +++ b/apps/client/src/views/projects/DsoTeam.vue @@ -4,6 +4,7 @@ import { useProjectStore } from '@/stores/project.js' import { useProjectUserStore } from '@/stores/project-user.js' import { useUserStore } from '@/stores/user.js' import { useUsersStore } from '@/stores/users.js' +import { ProjectAuthorized } from '@cpn-console/shared' import { useSnackbarStore } from '@/stores/snackbar.js' const projectStore = useProjectStore() @@ -14,18 +15,10 @@ const snackbarStore = useSnackbarStore() const teamKey = ref('team') -const addUserToProject = async (email: string) => { +const addUserToProject = async (userEmail: string) => { if (!projectStore.selectedProject) return snackbarStore.isWaitingForResponse = true - projectStore.selectedProject.members = await projectUserStore.addUserToProject(projectStore.selectedProject.id, { email }) - teamKey.value = getRandomId('team') - snackbarStore.isWaitingForResponse = false -} - -const updateUserRole = async (userId: string) => { - if (!projectStore.selectedProject) return snackbarStore.setMessage('Veuillez sélectionner un projet') - snackbarStore.isWaitingForResponse = true - projectStore.selectedProject.members = await projectUserStore.transferProjectOwnership(projectStore.selectedProject.id, userId) + projectStore.selectedProject.members = await projectUserStore.addMember(projectStore.selectedProject.id, userEmail) teamKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false } @@ -33,7 +26,7 @@ const updateUserRole = async (userId: string) => { const removeUserFromProject = async (userId: string) => { if (!projectStore.selectedProject) return snackbarStore.isWaitingForResponse = true - projectStore.selectedProject.members = await projectUserStore.removeUserFromProject(projectStore.selectedProject?.id, userId) + projectStore.selectedProject.members = await projectUserStore.removeMember(projectStore.selectedProject.id, userId) teamKey.value = getRandomId('team') snackbarStore.isWaitingForResponse = false } @@ -45,11 +38,11 @@ const removeUserFromProject = async (userId: string) => { v-if="projectStore.selectedProject" :key="teamKey" :user-profile="userStore.userProfile" - :project="{id: projectStore.selectedProject.id ?? '', name: projectStore.selectedProject.name ?? '', locked: projectStore.selectedProject.locked ?? false }" + :project="projectStore.selectedProject" :known-users="usersStore.users" :members="projectStore.selectedProject.members ?? []" - @add-member="(email: string) => addUserToProject(email)" - @update-role="(userId: string) => updateUserRole(userId)" + :can-manage="ProjectAuthorized.ManageMembers({ projectPermissions: projectStore.selectedProjectPerms})" + @add-member="(userEmail: string) => addUserToProject(userEmail)" @remove-member="(userId: string) => removeUserFromProject(userId)" /> ([]) -const isOwner = computed(() => projectStore.selectedProject?.members.some(member => member.userId === userStore.userProfile?.id && member.role === 'owner')) const environmentNames = computed(() => environmentsTiles.value.map(env => env.title)) const allClusters = computed(() => clusterStore.clusters) @@ -96,6 +93,8 @@ onMounted(async () => { projectEnvironmentStore.$subscribe(() => { setEnvironmentsTiles() }) + +const canManageEnvs = !projectStore.selectedProject?.locked || ProjectAuthorized.ManageEnvironments({ projectPermissions: projectStore.selectedProjectPerms }) @@ -107,11 +106,11 @@ projectEnvironmentStore.$subscribe(() => { class="flex { :is-project-locked="projectStore.selectedProject.locked" :project-clusters-ids="projectClustersIds" :all-clusters="clusterStore.clusters" + :can-manage="canManageEnvs" @add-environment="(environment: Omit) => addEnvironment(environment)" @cancel="cancel()" /> @@ -173,7 +173,7 @@ projectEnvironmentStore.$subscribe(() => { :project-clusters-ids="[selectedEnvironment.clusterId]" :is-editable="false" :is-project-locked="projectStore.selectedProject.locked" - :is-owner="isOwner" + :can-manage="canManageEnvs" :all-clusters="allClusters" @put-environment="(environmentUpdate: Pick) => putEnvironment({...environmentUpdate, id: environment.id })" @delete-environment="(environmentId: Environment['id']) => deleteEnvironment(environmentId)" diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index f16d2e34d..b0da35610 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -35,6 +35,7 @@ CMD [ "dev" ] # Build stage FROM dev AS build +RUN pnpm run db:generate RUN pnpm run build RUN pnpm --filter @cpn-console/server run build RUN pnpm --filter @cpn-console/server --prod deploy build diff --git a/apps/server/package.json b/apps/server/package.json index 060bf5b76..66ef34596 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -7,7 +7,7 @@ "module": "dist/server.js", "types": "types/server.d.ts", "scripts": { - "build": "tspc || exit 0", + "build": "tspc", "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", "db:deploy": "prisma migrate deploy --schema ./src/prisma/schema.prisma", "db:diff": "prisma migrate diff --schema ./src/prisma/schema.prisma", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b669a1d2b..4444f485e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -13,7 +13,6 @@ import { fastifyConf, swaggerUiConf, swaggerConf } from './utils/fastify.js' import { apiRouter } from './resources/index.js' import { keycloakConf, sessionConf } from './utils/keycloak.js' import { addReqLogs } from './utils/logger.js' -import { DsoError } from './utils/errors.js' export const serverInstance: ReturnType = initServer() @@ -35,19 +34,26 @@ const app = fastify(fastifyConf) opts.logLevel = 'silent' } }) - .setErrorHandler(function (error: DsoError | Error, req: FastifyRequest, reply) { - const isDsoError = error instanceof DsoError - - const statusCode = isDsoError ? error.statusCode : 500 - const message = isDsoError ? error.description : error.message + .setErrorHandler(function (error: Error, req: FastifyRequest, reply) { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = error.description || error.message reply.status(statusCode).send({ status: statusCode, error: message, stack: error.stack }) addReqLogs({ req, message, - ...(isDsoError ? { extras: error.extras } : {}), - error: isDsoError ? undefined : error, + error, }) }) + .addHook('onResponse', (req, res) => { + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: req.session.user.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: req.session.user.id }) + } else { + req.log.error({ status: res.statusCode, userId: req.session.user.id }) + } + }) await app.ready() diff --git a/apps/server/src/generate-files/controllers.spec.ts b/apps/server/src/generate-files/controllers.spec.ts deleted file mode 100644 index 480f63d3d..000000000 --- a/apps/server/src/generate-files/controllers.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { vi, describe, it, expect, beforeAll, afterAll } from 'vitest' -import { closeConnections, getConnection } from '../connect.js' -import { createRandomDbSetup } from '@cpn-console/test-utils' -import app from '../app.js' - -vi.mock('fastify-keycloak-adapter', (await import('../utils/mocks.js')).mockSessionPlugin) -vi.mock('../prisma.js') - -describe('ciFiles routes', () => { - beforeAll(async () => { - await getConnection() - }) - - afterAll(async () => { - return closeConnections() - }) - - afterAll(async () => { - vi.clearAllMocks() - }) - - it('Should generate files for a node project', async () => { - const randomDbSetup = createRandomDbSetup({}) - const ciData = { - projectName: randomDbSetup.project.name, - internalRepoName: - randomDbSetup.project.repositories[0].internalRepoName, - typeLanguage: 'node', - nodeVersion: '18.1.1', - nodeInstallCommand: 'npm install', - nodeBuildCommand: 'npm run build', - workingDir: '../client', - } - - const response = await app.inject() - .post('/api/v1/ci-files') - .body(ciData) - .end() - - expect(response.statusCode).toEqual(201) - expect(response.body) - .to.contain(`IMAGE_NAME: ${ciData.projectName}`) - .and.to.contain(`NODE_INSTALL_COMMAND: ${ciData.nodeInstallCommand}`) - }) - - it('Should generate files for a java project', async () => { - const randomDbSetup = createRandomDbSetup({}) - const ciData = { - projectName: randomDbSetup.project.name, - internalRepoName: randomDbSetup.project.repositories[0].internalRepoName, - typeLanguage: 'java', - workingDir: '../client', - javaVersion: '8.1.2', - artefactDir: './', - } - - const response = await app.inject() - .post('/api/v1/ci-files') - .body(ciData) - .end() - - expect(response.statusCode).toEqual(201) - expect(response.body) - .to.contain(`IMAGE_NAME: ${ciData.projectName}`) - .and.to.contain(`BUILD_IMAGE_NAME: maven:3.8-openjdk-${ciData.javaVersion}`) - }) - - it('Should generate files for a python project', async () => { - const randomDbSetup = createRandomDbSetup({}) - const ciData = { - projectName: randomDbSetup.project.name, - internalRepoName: randomDbSetup.project.repositories[0].internalRepoName, - typeLanguage: 'python', - workingDir: './', - } - - const response = await app.inject() - .post('/api/v1/ci-files') - .body(ciData) - .end() - - expect(response.statusCode).toEqual(201) - expect(response.body) - .to.contain(`IMAGE_NAME: ${ciData.projectName}`) - }) -}) diff --git a/apps/server/src/generate-files/router.ts b/apps/server/src/generate-files/router.ts deleted file mode 100644 index 7ca5d44cc..000000000 --- a/apps/server/src/generate-files/router.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as fs from 'node:fs/promises' -import path from 'node:path' -// @ts-ignore -import Mustache from 'mustache' - -import { addReqLogs } from '@/utils/logger.js' -import { serverInstance } from '@/app.js' -import { filesContract } from '@cpn-console/shared' - -export const filesRouter = () => serverInstance.router(filesContract, { - generateCIFiles: async ({ request: req, body: data }) => { - const content: Record = {} - - if (data.typeLanguage === 'java') { - data.isJava = true - data.isNode = false - data.isPython = false - } - if (data.typeLanguage === 'node') { - data.isNode = true - data.isJava = false - data.isPython = false - } - if (data.typeLanguage === 'python') { - data.isPython = true - data.isNode = false - data.isJava = false - } - - const template = await fs.readFile(path.resolve('src/generate-files/templates/gitlab-ci.yml')) - const gitlab = Mustache.render(template.toString(), data) - content['gitlab-ci-dso'] = gitlab - - const rules = await fs.readFile(path.resolve('src/generate-files/templates/rules.yml')) - content.rules = Mustache.render(rules.toString()) - const vault = await fs.readFile(path.resolve('src/generate-files/templates/vault.yml')) - content.vault = Mustache.render(vault.toString()) - const docker = await fs.readFile(path.resolve('src/generate-files/templates/docker.yml')) - content.docker = Mustache.render(docker.toString()) - - if (data.typeLanguage === 'python') { - const python = await fs.readFile(path.resolve('src/generate-files/templates/python.yml')) - content.python = Mustache.render(python.toString()) - } else if (data.typeLanguage === 'java') { - const java = await fs.readFile(path.resolve('src/generate-files/templates/java.yml')) - content.java = Mustache.render(java.toString()) - } else if (data.typeLanguage === 'node') { - const node = await fs.readFile(path.resolve('src/generate-files/templates/node.yml')) - content.node = Mustache.render(node.toString()) - } - - addReqLogs({ - req, - message: 'Fichiers de gitlab-ci créés avec succès', - }) - return { - status: 201, - body: content, - } - }, -}) diff --git a/apps/server/src/generate-files/templates/docker.yml b/apps/server/src/generate-files/templates/docker.yml deleted file mode 100644 index 4a7f5ffe5..000000000 --- a/apps/server/src/generate-files/templates/docker.yml +++ /dev/null @@ -1,25 +0,0 @@ -.docker:build: - image: docker:stable - variables: - DOCKER_HOST: tcp://dindservice:2375 - DOCKER_TLS_CERTDIR: "" - services: - - name: docker:stable-dind - alias: dindservice - before_script: - - docker info - script: - - cd "$WORKING_DIR" - - echo "$DOCKER_AUTH" > $HOME/.docker/config.json - - docker login $REGISTRY_URL - - docker build -t $REGISTRY_URL/$IMAGE_NAME:$TAG . - - docker push $REGISTRY_URL/$IMAGE_NAME:$TAG - -.kaniko:build-push: - image: - name: gcr.io/kaniko-project/executor:debug - entrypoint: [""] - script: - - mkdir -p /kaniko/.docker - - echo "$DOCKER_AUTH" > /kaniko/.docker/config.json - - /kaniko/executor --context="$CI_PROJECT_DIR/$WORKING_DIR" --dockerfile="$CI_PROJECT_DIR/$WORKING_DIR/$DOCKERFILE" --destination $REGISTRY_URL/$IMAGE_NAME:$TAG diff --git a/apps/server/src/generate-files/templates/gitlab-ci.yml b/apps/server/src/generate-files/templates/gitlab-ci.yml deleted file mode 100644 index 32a541e54..000000000 --- a/apps/server/src/generate-files/templates/gitlab-ci.yml +++ /dev/null @@ -1,106 +0,0 @@ -include: - - local: "/includes/rules.yml" - {{#isJava}} - - local: "/includes/java.yml" - {{/isJava}} - {{#isNode}} - - local: "/includes/node.yml" - {{/isNode}} - {{#isPython}} - - local: "/includes/python.yml" - {{/isPython}} - - project: $CATALOG_PATH - file: vault-ci.yml - ref: main - - project: $CATALOG_PATH - file: kaniko-ci.yml - ref: main - -default: - image: alpine:latest - -{{#isJava}} -cache: - paths: - - .m2/repository/ - -{{/isJava}} -{{#isNode}} -cache: - paths: - - node_modules - -{{/isNode}} -variables: - TAG: "${CI_COMMIT_REF_SLUG}" - DOCKERFILE: Dockerfile - REGISTRY_URL: "${REGISTRY_HOST}/${PROJECT_PATH}" - -stages: - - read-secret -{{#isJava}} - - quality-app - - package-app -{{/isJava}} -{{#isNode}} - - quality-app - - package-app -{{/isNode}} - - docker-build - -read_secret: - stage: read-secret - extends: - - .vault:read_secret - -{{#isJava}} -quality-app: - variables: - BUILD_IMAGE_NAME: maven:3.8-openjdk-{{javaVersion}} - WORKING_DIR: "{{workingDir}}" - MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" - MAVEN_CLI_OPTS: "" - MVN_CONFIG_FILE: $MVN_CONFIG - stage: quality-app - extends: - - .java:sonar - -package-app: - variables: - BUILD_IMAGE_NAME: maven:3.8-openjdk-{{javaVersion}} - WORKING_DIR: "{{workingDir}}" - ARTEFACT_DIR: "{{artefactDir}}" - MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" - MAVEN_CLI_OPTS: "" - MVN_CONFIG_FILE: $MVN_CONFIG - stage: build-app - extends: - - .java:build - -{{/isJava}} -{{#isNode}} -quality-app: - variables: - WORKING_DIR: "{{workingDir}}" - stage: quality-app - extends: - - .node:sonar - -package-app: - variables: - BUILD_IMAGE_NAME: node:{{nodeVersion}} - NODE_INSTALL_COMMAND: {{nodeInstallCommand}} - NODE_BUILD_COMMAND: {{nodeBuildCommand}} - WORKING_DIR: "{{workingDir}}" - stage: build-app - extends: - - .node:build - -{{/isNode}} -docker-build: - variables: - WORKING_DIR: "{{workingDir}}" - IMAGE_NAME: {{projectName}} - stage: docker-build - extends: - - .kaniko:build-push \ No newline at end of file diff --git a/apps/server/src/generate-files/templates/java.yml b/apps/server/src/generate-files/templates/java.yml deleted file mode 100644 index 073edcb8c..000000000 --- a/apps/server/src/generate-files/templates/java.yml +++ /dev/null @@ -1,30 +0,0 @@ -.java:build: - image: - name: ${BUILD_IMAGE_NAME} - cache: - key: "$CI_COMMIT_REF_SLUG" - paths: - - .m2/repository/ - script: - - cd $WORKING_DIR - - echo ${PROJECT_PATH} - - mvn $MAVEN_CLI_OPTS clean deploy -s $MVN_CONFIG_FILE -DaltReleaseDeploymentRepository=nexus::default::${NEXUS_HOST_URL}/${PROJECT_PATH}-repository-release/ -DaltSnapshotDeploymentRepository=nexus::default::${NEXUS_HOST_URL}/${PROJECT_PATH}-repository-snapshot/ - artifacts: - paths: - - ${ARTEFACT_DIR} - expire_in: 1 seconds - interruptible: true - -.java:sonar: - image: - name: ${BUILD_IMAGE_NAME} - cache: - key: "$CI_COMMIT_REF_SLUG" - paths: - - .m2/repository/ - variables: - GIT_DEPTH: "0" - script: - - cd $WORKING_DIR - - mvn $MAVEN_CLI_OPTS clean org.jacoco:jacoco-maven-plugin:prepare-agent package test jacoco:report sonar:sonar -Dsonar.qualitygate.wait=true -Dsonar.login=$SONAR_TOKEN -s $MVN_CONFIG_FILE - allow_failure: true diff --git a/apps/server/src/generate-files/templates/node.yml b/apps/server/src/generate-files/templates/node.yml deleted file mode 100644 index faf09bc1a..000000000 --- a/apps/server/src/generate-files/templates/node.yml +++ /dev/null @@ -1,25 +0,0 @@ -.node:build: - image: - name: $BUILD_IMAGE_NAME - cache: - paths: - - $WORKING_DIR/node_modules - script: - - cd $WORKING_DIR - - echo "export BUILD_VAR_SENTRY_RELEASE=$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA" >> .variables - - $NODE_INSTALL_COMMAND - - $NODE_BUILD_COMMAND - artifacts: - paths: - - $WORKING_DIR/ - expire_in: 2 hrs - interruptible: true - -.node:sonar: - image: - name: sonarsource/sonar-scanner-cli:latest - entrypoint: [""] - script: - - cd $WORKING_DIR - - sonar-scanner -Dsonar.qualitygate.wait=true -Dsonar.projectKey=${PROJECT_PATH} -Dsonar.login=$SONAR_TOKEN - allow_failure: true diff --git a/apps/server/src/generate-files/templates/python.yml b/apps/server/src/generate-files/templates/python.yml deleted file mode 100644 index 07f5bab81..000000000 --- a/apps/server/src/generate-files/templates/python.yml +++ /dev/null @@ -1,11 +0,0 @@ -.python:build: - image: - name: python:3.8-slim-buster - script: - - cd $WORKING_DIR - - pip3 install -r requirements.txt - artifacts: - paths: - - $WORKING_DIR/ - expire_in: 2 hrs - interruptible: true diff --git a/apps/server/src/generate-files/templates/rules.yml b/apps/server/src/generate-files/templates/rules.yml deleted file mode 100644 index 80da845f6..000000000 --- a/apps/server/src/generate-files/templates/rules.yml +++ /dev/null @@ -1,6 +0,0 @@ -.rule:changes: - rules: - - changes: - - $WORKING_DIR/* - - $WORKING_DIR/**/* - - .gitlab-ci-dso.yml diff --git a/apps/server/src/generate-files/templates/vault.yml b/apps/server/src/generate-files/templates/vault.yml deleted file mode 100644 index 6358e17ad..000000000 --- a/apps/server/src/generate-files/templates/vault.yml +++ /dev/null @@ -1,24 +0,0 @@ -.vault:read_secret: - image: - name: vault:latest - script: - - export PROJECT_PATH=`echo "$CI_PROJECT_NAMESPACE" | sed 's/forge-mi\/projects\///g' | sed 's/\//-/g'` - - export VAULT_ADDR=$VAULT_SERVER_URL - - export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=default-ci jwt=$CI_JOB_JWT)" - - export DOCKER_AUTH=`vault kv get -field=DOCKER_CONFIG forge-dso/${CI_PROJECT_NAMESPACE}/REGISTRY` - - export REGISTRY_HOST=`vault kv get -field=HOST forge-dso/${CI_PROJECT_NAMESPACE}/REGISTRY` - - export REGISTRY_USERNAME=`vault kv get -field=USERNAME forge-dso/${CI_PROJECT_NAMESPACE}/REGISTRY` - - export REGISTRY_TOKEN=`vault kv get -field=TOKEN forge-dso/${CI_PROJECT_NAMESPACE}/REGISTRY` - - | - cat < vault.env - REGISTRY_HOST=$REGISTRY_HOST - PROJECT_PATH=$PROJECT_PATH - REGISTRY_USERNAME=$REGISTRY_USERNAME - REGISTRY_TOKEN=$REGISTRY_TOKEN - DOCKER_AUTH=$DOCKER_AUTH - EOF - - cat vault.env - artifacts: - reports: - dotenv: vault.env - expire_in: 1 seconds diff --git a/apps/server/src/init/db/index.ts b/apps/server/src/init/db/index.ts index 1705adbce..ba3f25a48 100644 --- a/apps/server/src/init/db/index.ts +++ b/apps/server/src/init/db/index.ts @@ -13,6 +13,17 @@ type Imports = Partial> & { } export const initDb = async (data: Imports) => { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch (error) { + return value + } + return value + }) app.log.info('Drop tables') for (const modelKey of modelKeys.toReversed()) { // @ts-ignore @@ -21,19 +32,19 @@ export const initDb = async (data: Imports) => { app.log.info('Import models') for (const modelKey of modelKeys) { // @ts-ignore - await prisma[modelKey].createMany({ data: data[modelKey] }) + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) } app.log.info('Import associations') - for (const [modelKey, rows] of data.associations) { + for (const [modelKey, rows] of dataParsed.associations) { for (const row of rows) { const idKey = 'id' const connectKeys = Object.keys(row).filter(key => key !== idKey) - const data = connectKeys.reduce((acc, curr) => { + const dataConnects = connectKeys.reduce((acc, curr) => { acc[curr] = { connect: row[curr] } return acc }, {} as Record) // @ts-ignore - await prisma[modelKey].update({ where: { id: row.id }, data }) + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) } } app.log.info('End import') diff --git a/apps/server/src/prepare-app.ts b/apps/server/src/prepare-app.ts index a450ed5e5..c4903087c 100644 --- a/apps/server/src/prepare-app.ts +++ b/apps/server/src/prepare-app.ts @@ -29,6 +29,7 @@ export async function startServer(defaultPort: number = (port ? +port : 8080)) { try { await getConnection() } catch (error) { + if (!(error instanceof Error)) return app.log.error(error.message) throw error } diff --git a/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 000000000..bc31485b7 --- /dev/null +++ b/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,120 @@ +/* + Warnings: + + - You are about to drop the `Permission` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Role` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[projectId,name]` on the table `Environment` will be added. If there are existing duplicate values, this will fail. + - Added the required column `ownerId` to the `Project` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + +-- DropForeignKey +ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770'); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +-- DropTable +DROP TABLE "Permission"; + +-- DropTable +DROP TABLE "Role"; + +-- DropEnum +DROP TYPE "RoleList"; + +-- CreateTable +CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] +); + +-- CreateTable +CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "adminRoleIds" TEXT[]; \ No newline at end of file diff --git a/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 000000000..c9b41827b --- /dev/null +++ b/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 000000000..265f262ab --- /dev/null +++ b/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 000000000..95ab54869 --- /dev/null +++ b/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/apps/server/src/prisma/schema.prisma b/apps/server/src/prisma/schema.prisma index d631811f5..77bbe35c3 100644 --- a/apps/server/src/prisma/schema.prisma +++ b/apps/server/src/prisma/schema.prisma @@ -7,20 +7,64 @@ datasource db { url = env("DB_URL") } +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") +} + +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(200) + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + model Environment { - id String @id @default(uuid()) @db.Uuid - name String @db.VarChar(11) - projectId String @db.Uuid - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - clusterId String @db.Uuid - quotaId String @db.Uuid - stageId String @db.Uuid - cluster Cluster @relation(fields: [clusterId], references: [id]) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - quota Quota @relation(fields: [quotaId], references: [id], onUpdate: Cascade, onDelete: Restrict) - stage Stage @relation(fields: [stageId], references: [id], onUpdate: Cascade, onDelete: Restrict) - permissions Permission[] + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + quotaId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + quota Quota @relation(fields: [quotaId], references: [id]) + stage Stage @relation(fields: [stageId], references: [id]) + + @@unique([projectId, name]) +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? } model Log { @@ -28,9 +72,11 @@ model Log { data Json action String @default("") userId String @db.Uuid - requestId String? @db.VarChar(21) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + requestId String? @db.VarChar(21) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) user User @relation(fields: [userId], references: [id]) } @@ -45,44 +91,26 @@ model Organization { projects Project[] } -model Zone { - id String @id @unique @default(uuid()) @db.Uuid - slug String @unique @db.VarChar(10) - label String @db.VarChar(50) - description String? @db.VarChar(200) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - clusters Cluster[] -} - -model Permission { - id String @id @unique @default(uuid()) @db.Uuid - userId String @db.Uuid - environmentId String @db.Uuid - level Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([userId, environmentId]) -} - model Project { - id String @id @unique @default(uuid()) @db.Uuid + id String @id @unique @default(uuid()) @db.Uuid name String - organizationId String @db.Uuid - description String @default("") - status ProjectStatus @default(initializing) - locked Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + organizationId String @db.Uuid + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid environments Environment[] - organization Organization @relation(fields: [organizationId], references: [id]) + logs Log[] + organization Organization @relation(fields: [organizationId], references: [id]) + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] repositories Repository[] - roles Role[] - clusters Cluster[] @relation("ClusterToProject") - projectPlugin ProjectPlugin[] + clusters Cluster[] @relation("ClusterToProject") } model ProjectClusterHistory { @@ -92,69 +120,33 @@ model ProjectClusterHistory { @@unique([projectId, clusterId]) } -model Repository { - id String @id @default(uuid()) @db.Uuid - projectId String @db.Uuid - internalRepoName String - externalRepoUrl String - externalUserName String? - isInfra Boolean @default(false) - isPrivate Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) -} +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) -model User { - id String @id @default(uuid()) @db.Uuid - firstName String - lastName String - email String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - logs Log[] - permissions Permission[] - roles Role[] + @@unique([projectId, userId]) } -model Cluster { - id String @id @unique @default(uuid()) @db.Uuid - label String @unique @db.VarChar(50) - privacy ClusterPrivacy @default(dedicated) - secretName String @unique @default(uuid()) @db.VarChar(50) - clusterResources Boolean @default(false) - zoneId String @db.Uuid - kubeConfigId String @unique @db.Uuid - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - infos String? @db.VarChar(200) - zone Zone @relation(fields: [zoneId], references: [id]) - kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) - environments Environment[] - projects Project[] @relation("ClusterToProject") - stages Stage[] @relation("ClusterToStage") -} +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) -model Kubeconfig { - id String @id @unique @default(uuid()) @db.Uuid - user Json - cluster Json - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - parentCluster Cluster? + @@unique([projectId, pluginName, key]) } -model Role { - userId String @db.Uuid - projectId String @db.Uuid - role RoleList - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@id([userId, projectId]) - @@unique([userId, projectId]) +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + project Project @relation(fields: [projectId], references: [id]) } model Quota { @@ -164,7 +156,20 @@ model Quota { name String @unique @db.VarChar isPrivate Boolean @default(false) environments Environment[] - stages Stage[] + stages Stage[] @relation("QuotaToStage") +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String + externalUserName String? + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) } model Stage { @@ -172,25 +177,30 @@ model Stage { name String @unique @db.VarChar environments Environment[] clusters Cluster[] @relation("ClusterToStage") - quotas Quota[] + quotas Quota[] @relation("QuotaToStage") } -model ProjectPlugin { - pluginName String - projectId String @db.Uuid - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - key String - value String - - @@unique([projectId, pluginName, key]) +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + logs Log[] + projectsOwned Project[] + ProjectMembers ProjectMembers[] + adminRoleIds String[] } -model AdminPlugin { - pluginName String - key String - value String - - @@unique([pluginName, key]) +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusters Cluster[] } enum ClusterPrivacy { @@ -204,8 +214,3 @@ enum ProjectStatus { failed archived } - -enum RoleList { - owner - user -} diff --git a/apps/server/src/resources/admin-role/business.ts b/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 000000000..f69d226b8 --- /dev/null +++ b/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,72 @@ +import type { Project, ProjectRole } from '@prisma/client' +import { + deleteRole as deleteRoleQuery, + listAdminRoles, +} from '@/resources/queries-index.js' +import { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { BadRequest400, ErrorResType } from '@/utils/controller.js' +import prisma from '@/prisma.js' + +export const listRoles = async () => listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString() }))) + +export const patchRoles = async (roles: typeof adminRoleContract.patchAdminRoles.body._type): Promise => { + const dbRoles = await listRoles() + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = dbRoles.map((dbRole) => { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole?.position && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + return { + id: matchingRole?.id ?? dbRole.id, + name: matchingRole?.name ?? dbRole.name, + permissions: matchingRole?.permissions ? BigInt(matchingRole?.permissions) : BigInt(dbRole.permissions), + position: matchingRole?.position ?? dbRole.position, + oidcGroup: matchingRole?.oidcGroup ?? dbRole.oidcGroup, + } + }) + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + } + + return listRoles() +} + +export const createRole = async (role: typeof adminRoleContract.createAdminRole.body._type) => { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position + + await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole ? dbMaxPosRole + 1 : 0, + permissions: 0n, + }, + }) + + return listRoles() +} + +export const countRolesMembers = async () => { + const roles = await listRoles() + const users = await prisma.user.findMany({ + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export const deleteRole = async (roleId: Project['id']) => { + await deleteRoleQuery(roleId) + return null +} diff --git a/apps/server/src/resources/admin-role/queries.ts b/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 000000000..4fcd22497 --- /dev/null +++ b/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,29 @@ +import { + Prisma, + AdminRole, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export const createAdminRole = (data: Pick) => + prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + }, + }) + +export const updateAdminRole = (id: AdminRole['id'], data: Pick) => + prisma.projectRole.updateMany({ + where: { id }, + data, + }) + +export const deleteAdminRole = (id: AdminRole['id']) => + prisma.projectRole.delete({ + where: { + id, + }, + }) diff --git a/apps/server/src/resources/admin-role/router.ts b/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 000000000..7a8c22032 --- /dev/null +++ b/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,75 @@ +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' +import { AdminAuthorized, adminRoleContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser, NotFound404, Forbidden403, ErrorResType } from '@/utils/controller.js' + +export const adminRoleRouter = () => serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const user = req.session.user + const perms = await authUser(user) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + + const resBody = await createRole(body) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const user = req.session.user + const perms = await authUser(user) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + + const resBody = await patchRoles(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new NotFound404() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const user = req.session.user + const perms = await authUser(user) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId) + + return { + status: 200, + body: resBody, + } + }, +}) diff --git a/apps/server/src/resources/cluster/business.ts b/apps/server/src/resources/cluster/business.ts index f7f419436..73d73c5c5 100644 --- a/apps/server/src/resources/cluster/business.ts +++ b/apps/server/src/resources/cluster/business.ts @@ -1,6 +1,5 @@ import { type Prisma, User } from '@prisma/client' - -import { type UserProfile, adminGroupPath, ClusterDetails, ClusterDetailsSchema, ClusterPrivacy, Kubeconfig, Project, type Cluster } from '@cpn-console/shared' +import { type ClusterDetails, ClusterDetailsSchema, ClusterPrivacy, Kubeconfig, Project, type Cluster } from '@cpn-console/shared' import { addLogs, createCluster as createClusterQuery, @@ -16,32 +15,31 @@ import { removeClusterFromStage, updateCluster as updateClusterQuery, getClusterDetails as getClusterDetailsQuery, - listClustersForUser, + listClusters, } from '@/resources/queries-index.js' import { linkClusterToStages } from '@/resources/stage/business.js' import { validateSchema } from '@/utils/business.js' -import { BadRequestError, DsoError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' import { hook } from '@/utils/hook-wrapper.js' +import { ErrorResType, BadRequest400, NotFound404, Unprocessable422 } from '@/utils/controller.js' -export const getAllUserClusters = async (kcUser: UserProfile) => { - const isAdmin = kcUser.groups?.includes(adminGroupPath) - const where: Prisma.ClusterWhereInput = isAdmin - ? {} - : { +export const getClusters = async (userId?: User['id']) => { + const where: Prisma.ClusterWhereInput = userId + ? { OR: [ // Sélectionne tous les clusters publics { privacy: 'public' }, // Sélectionne les clusters associés aux projets dont l'user est membre { - projects: { some: { roles: { some: { userId: kcUser.id } } } }, + projects: { some: { members: { some: { userId } } } }, }, // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre { - environments: { some: { project: { roles: { some: { userId: kcUser.id } } } } }, + environments: { some: { project: { members: { some: { userId } } } } }, }, ], } - const clusters = await listClustersForUser(where) + : {} + const clusters = await listClusters(where) return clusters.map(({ stages, ...cluster }) => ({ ...cluster, stageIds: stages?.map(({ id }) => id) ?? [], @@ -49,175 +47,151 @@ export const getAllUserClusters = async (kcUser: UserProfile) => { } export const getClusterAssociatedEnvironments = async (clusterId: string) => { - try { - const clusterEnvironments = await getClusterEnvironments(clusterId) - - return clusterEnvironments.map((environment) => { - return ({ - organization: environment.project?.organization?.name, - project: environment.project?.name, - name: environment.name, - owner: environment.project.roles.find(role => role?.role === 'owner')?.user.email ?? 'Impossible de trouver le souscripteur', - }) + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + organization: environment.project?.organization?.name, + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, }) - } catch (error) { - throw new Error(error?.message) - } + }) } export const getClusterDetails = async (clusterId: string): Promise => { - try { - const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) - - return { - ...details, - infos: infos ?? '', - projectIds: projects.map(project => project.id), - stageIds: stages.map(({ id }) => id), - kubeconfig: { - cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], - user: kubeconfig.user as unknown as Kubeconfig['user'], - }, - } - } catch (error) { - throw new Error(error?.message) + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, } } export const createCluster = async (data: Omit, userId: User['id'], requestId: string) => { - try { - const isLabelTaken = await getClusterByLabel(data.label) - if (isLabelTaken) throw new BadRequestError('Ce label existe déjà pour un autre cluster', undefined) - - data.projectIds = data.privacy === ClusterPrivacy.PUBLIC - ? [] - : data.projectIds ?? [] - - const { - projectIds, - stageIds, - kubeconfig, - zoneId, - ...clusterData - } = data - - const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) - - if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { - await linkClusterToProjects(clusterCreated.id, projectIds) - } + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') - if (stageIds?.length) { - await linkClusterToStages(clusterCreated.id, stageIds) - } + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] - const hookReply = await hook.cluster.upsert(clusterCreated.id) - await addLogs('Create Cluster', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la création du cluster') - } + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data - return getClusterDetails(clusterCreated.id) - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id) + await addLogs('Create Cluster', hookReply, userId, requestId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) } -export const updateCluster = async (data: Partial, clusterId: Cluster['id'], userId: User['id'], requestId: string) => { - try { - if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds +export const updateCluster = async (data: Partial, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise => { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds - const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) - validateSchema(schemaValidation) + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult - const dbCluster = await getClusterById(clusterId) - if (!dbCluster) throw new NotFoundError('Aucun cluster trouvé pour cet id') - if (data?.label && data.label !== dbCluster.label) throw new BadRequestError('Le label d\'un cluster ne peut être modifié') + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + if (data?.label && data.label !== dbCluster.label) return new BadRequest400('Le label d\'un cluster ne peut être modifié') - const { - projectIds, - stageIds, - kubeconfig, - zoneId, - ...clusterData - } = data + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + const clusterUpdated = await updateClusterQuery(clusterId, + clusterData, // @ts-ignore - const clusterUpdated = await updateClusterQuery(clusterId, clusterData, kubeconfig) + kubeconfig, + ) - // zone - if (zoneId) { - await linkZoneToClusters(zoneId, [clusterId]) - } + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } - // projects - const dbProjects = await getProjectsByClusterId(clusterId) + // projects + const dbProjects = await getProjectsByClusterId(clusterId) - let projectsToRemove: Project['id'][] = [] + let projectsToRemove: Project['id'][] = [] - if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { - await linkClusterToProjects(clusterId, projectIds) - projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] - } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { - projectsToRemove = dbProjects?.map(project => project.id) ?? [] - } + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } - for (const projectId of projectsToRemove) { - await removeClusterFromProject(clusterUpdated.id, projectId) - } + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } - // stages - if (stageIds) { - await linkClusterToStages(clusterId, stageIds) + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) - const dbStages = await listStagesByClusterId(clusterId) - if (dbStages) { - for (const stage of dbStages) { - if (!stageIds.includes(stage.id)) { - await removeClusterFromStage(clusterUpdated.id, stage.id) - } + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) } } } + } - const hookReply = await hook.cluster.upsert(clusterId) - await addLogs('Update Cluster', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la mise à jour du cluster') - } - - return getClusterDetails(clusterId) - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + const hookReply = await hook.cluster.upsert(clusterId) + await addLogs('Update Cluster', hookReply, userId, requestId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') } + + return getClusterDetails(clusterId) } export const deleteCluster = async (clusterId: Cluster['id'], userId: User['id'], requestId: string) => { - try { - const environments = await getClusterAssociatedEnvironments(clusterId) - if (environments?.length) throw new BadRequestError('Impossible de supprimer le cluster, des environnements en activité y sont déployés', { extras: environments }) - - const cluster = await getClusterById(clusterId) - if (!cluster) { - throw new NotFoundError('Cluster introuvable') - } - const hookReply = await hook.cluster.delete(cluster.id) - await addLogs('Delete Cluster', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la suppression du cluster') - } + const environments = await getClusterAssociatedEnvironments(clusterId) + if (environments?.length) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') - await deleteClusterQuery(clusterId) - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + const cluster = await getClusterById(clusterId) + if (!cluster) { + return new NotFound404() + } + const hookReply = await hook.cluster.delete(cluster.id) + await addLogs('Delete Cluster', hookReply, userId, requestId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') } + + await deleteClusterQuery(clusterId) + return null } diff --git a/apps/server/src/resources/cluster/queries.ts b/apps/server/src/resources/cluster/queries.ts index fdeedda1b..8a5d978b1 100644 --- a/apps/server/src/resources/cluster/queries.ts +++ b/apps/server/src/resources/cluster/queries.ts @@ -81,14 +81,8 @@ export const getClusterEnvironments = (clusterId: Cluster['id']) => organization: { select: { name: true }, }, - roles: { - select: { - role: true, - user: { - select: { email: true }, - }, - }, - }, + owner: true, + members: true, }, }, }, @@ -171,7 +165,7 @@ export const getClustersWithProjectIdAndConfig = () => }, }) -export const listClustersForUser = (where: Prisma.ClusterWhereInput) => +export const listClusters = (where: Prisma.ClusterWhereInput) => prisma.cluster.findMany({ where, select: { @@ -185,12 +179,6 @@ export const listClustersForUser = (where: Prisma.ClusterWhereInput) => }, }) -export const listAllClusters = () => prisma.cluster.findMany({ - include: { - stages: true, - }, -}) - export const getProjectsByClusterId = async (id: Cluster['id']) => (await prisma.cluster.findUniqueOrThrow({ where: { id }, @@ -281,7 +269,3 @@ export const deleteCluster = (id: Cluster['id']) => prisma.cluster.delete({ where: { id }, }) - -export const _dropClusterTable = prisma.cluster.deleteMany -export const _dropProjectClusterHistoryTable = prisma.projectClusterHistory.deleteMany -export const _dropKubeconfigTable = prisma.kubeconfig.deleteMany diff --git a/apps/server/src/resources/cluster/router.ts b/apps/server/src/resources/cluster/router.ts index 8f6057ef8..838411327 100644 --- a/apps/server/src/resources/cluster/router.ts +++ b/apps/server/src/resources/cluster/router.ts @@ -1,7 +1,6 @@ -import { clusterContract } from '@cpn-console/shared' -import { addReqLogs } from '@/utils/logger.js' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' import { - getAllUserClusters, + getClusters, createCluster, deleteCluster, getClusterAssociatedEnvironments, @@ -10,22 +9,27 @@ import { } from './business.js' import '@/types/index.js' import { serverInstance } from '@/app.js' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, ErrorResType, Forbidden403 } from '@/utils/controller.js' export const clusterRouter = () => serverInstance.router(clusterContract, { listClusters: async ({ request: req }) => { const user = req.session.user - const cleanedClusters = await getAllUserClusters(user) + const { adminPermissions } = await authUser(user) + const body = AdminAuthorized.ManageClusters(adminPermissions) + ? await getClusters() + : await getClusters(user.id) - addReqLogs({ req, message: 'Clusters récupérés avec succès' }) return { status: 200, - body: cleanedClusters, + body, } }, getClusterDetails: async ({ params, request: req }) => { - assertIsAdmin(req.session.user) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) return new Forbidden403() + const clusterId = params.clusterId const cluster = await getClusterDetailsBusiness(clusterId) @@ -36,24 +40,28 @@ export const clusterRouter = () => serverInstance.router(clusterContract, { }, createCluster: async ({ request: req, body: data }) => { - assertIsAdmin(req.session.user) - const userId = req.session.user.id + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) data.stageIds = [] - const cluster = await createCluster(data, userId, req.id) + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body - addReqLogs({ req, message: 'Cluster créé avec succès', infos: { clusterId: cluster.id } }) return { status: 201, - body: cluster, + body, } }, getClusterEnvironments: async ({ request: req, params }) => { - assertIsAdmin(req.session.user) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) return new Forbidden403() + const clusterId = params.clusterId const environments = await getClusterAssociatedEnvironments(clusterId) - addReqLogs({ req, message: 'Environnements associés au cluster récupérés', infos: { clusterId } }) return { status: 200, body: environments, @@ -61,30 +69,33 @@ export const clusterRouter = () => serverInstance.router(clusterContract, { }, updateCluster: async ({ request: req, params, body: data }) => { - assertIsAdmin(req.session.user) - const userId = req.session.user.id + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) delete data.stageIds + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) - const cluster = await updateCluster(data, clusterId, userId, req.id) + if (body instanceof ErrorResType) return body - addReqLogs({ req, message: 'Cluster mis à jour avec succès', infos: { clusterId: cluster.id } }) return { status: 200, - body: cluster, + body, } }, deleteCluster: async ({ request: req, params }) => { - assertIsAdmin(req.session.user) - const userId = req.session.user.id - const clusterId = params.clusterId - - await deleteCluster(clusterId, userId, req.id) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) return new Forbidden403() - addReqLogs({ req, message: 'Cluster supprimé avec succès', infos: { clusterId } }) + const clusterId = params.clusterId + const body = await deleteCluster(clusterId, user.id, req.id) + if (body instanceof ErrorResType) return body return { - status: 204, - body: null, + status: 200, + body, } }, }) diff --git a/apps/server/src/resources/environment/business.ts b/apps/server/src/resources/environment/business.ts index 730ceaef5..91e2bcbb9 100644 --- a/apps/server/src/resources/environment/business.ts +++ b/apps/server/src/resources/environment/business.ts @@ -1,35 +1,28 @@ -import type { Cluster, Environment, Project, Quota, Role, Stage, User } from '@prisma/client' -import { XOR, adminGroupPath } from '@cpn-console/shared' -import { getProjectInfosAndClusters } from '@/resources/project/business.js' +import type { Cluster, Environment, Project, Quota, Stage, User } from '@prisma/client' +import { XOR } from '@cpn-console/shared' import { addLogs, deleteEnvironment as deleteEnvironmentQuery, - getClusterById, - getEnvironmentById, getEnvironmentInfos as getEnvironmentInfosQuery, - getProjectInfosOrThrow, getPublicClusters, - getQuotaById, - getStageById, - getUserById, initializeEnvironment, updateEnvironment as updateEnvironmentQuery, getEnvironmentsByProjectId, } from '@/resources/queries-index.js' import type { UserDetails } from '@/types/index.js' import { - checkClusterUnavailable, - checkRoleAndLocked, - filterOwners, + ErrorResType, + BadRequest400, + NotFound404, + Unprocessable422, } from '@/utils/controller.js' -import { BadRequestError, DsoError, ForbiddenError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' import { hook } from '@/utils/hook-wrapper.js' -import { getProjectAndCheckRole } from '../repository/business.js' +import prisma from '@/prisma.js' // Fetch infos export const getEnvironmentInfosAndClusters = async (environmentId: string) => { const env = await getEnvironmentInfosQuery(environmentId) - if (!env) throw new NotFoundError('Environnement introuvable') + if (!env) return new NotFound404() const authorizedClusters = [...await getPublicClusters(), ...env.project.clusters] return { env, authorizedClusters } @@ -37,116 +30,9 @@ export const getEnvironmentInfosAndClusters = async (environmentId: string) => { export const getEnvironmentInfos = async (environmentId: string) => getEnvironmentInfosQuery(environmentId) -export const getProjectEnvironments = async ( - userId: User['id'], - isAdmin: boolean, +export const getProjectEnvironments = ( projectId: Project['id'], -) => { - return isAdmin ? await getEnvironmentsByProjectId(projectId) : (await getProjectAndCheckRole(userId, projectId)).environments -} - -type GetInitializeEnvironmentInfosParam = { - userId: User['id'] - projectId: Project['id'] - stageId: Stage['id'] - quotaId: Quota['id'] -} - -export const getInitializeEnvironmentInfos = async ({ - userId, - projectId, - quotaId, - stageId, -}: GetInitializeEnvironmentInfosParam) => { - try { - const user = await getUserById(userId) - const { project, projectClusters } = await getProjectInfosAndClusters(projectId) - const quota = await getQuotaById(quotaId) - const stage = await getStageById(stageId) - if (!stage) throw new BadRequestError('Stage introuvable') - const authorizedClusters = projectClusters - ?.filter(projectCluster => stage.clusters - ?.find(stageCluster => stageCluster.id === projectCluster.id)) - return { user, project, quota, stage, authorizedClusters } - } catch (error) { - throw new Error(error?.message) - } -} - -// Check logic -type CheckEnvironmentParam = { - project: { locked: boolean, roles: Role[], id: string, environments: Environment[] } - userId: User['id'] - name: Environment['name'] - authorizedClusterIds: Cluster['id'][] - clusterId: Cluster['id'] - quotaId: Quota['id'] - stage: Stage & { quotas: Quota[] } -} - -export const checkCreateEnvironment = ({ - project, - userId, - name, - authorizedClusterIds, - clusterId, - stage, - quotaId, -}: CheckEnvironmentParam) => { - const errorMessage = checkRoleAndLocked(project, userId, 'owner') - || checkExistingEnvironment(clusterId, name, project.environments) - || checkClusterUnavailable(clusterId, authorizedClusterIds) - || checkQuotaStageStatus(stage, quotaId) - if (errorMessage) throw new ForbiddenError(errorMessage, undefined) -} - -type CheckUpdateEnvironmentParam = { - project: { locked: boolean, roles: Role[], id: string, environments: Environment[] } - userId: User['id'] - quotaId: Quota['id'] - dbEnvQuotaId: Quota['id'] - stage: Stage & { quotas: Quota[] } -} - -export const checkUpdateEnvironment = ({ - project, - userId, - quotaId, - dbEnvQuotaId, - stage, -}: CheckUpdateEnvironmentParam) => { - const errorMessage = checkRoleAndLocked(project, userId, 'owner') || dbEnvQuotaId !== quotaId - ? checkQuotaStageStatus(stage, quotaId) - : '' - if (errorMessage) throw new ForbiddenError(errorMessage, undefined) -} - -type CheckDeleteEnvironmentParam = { - project: { locked: boolean, roles: Role[], id: string } - userId: string -} - -export const checkDeleteEnvironment = ({ - project, - userId, -}: CheckDeleteEnvironmentParam) => { - const errorMessage = checkRoleAndLocked(project, userId, 'owner') - if (errorMessage) throw new ForbiddenError(errorMessage, { description: '', extras: { userId, projectId: project.id } }) -} - -export const checkExistingEnvironment = (clusterId: Cluster['id'], name: Environment['name'], environments: Environment[]) => { - if (environments?.find(env => env.clusterId === clusterId && env.name === name)) { - return 'Un environnement avec le même nom et déployé sur le même cluster existe déjà pour ce projet.' - } -} - -type ByStageOrQuota = XOR -export const checkQuotaStageStatus = (resource: ByStageOrQuota, matchingId: string) => { - const association = resource.quotas - ? resource.quotas.find(({ id }) => id === matchingId) - : resource.stages.find(({ id }) => id === matchingId) - if (!association) return 'Cette association quota / type d\'environnement n\'est plus disponible.' -} +) => getEnvironmentsByProjectId(projectId) // Routes logic type CreateEnvironmentParam = { @@ -169,50 +55,18 @@ export const createEnvironment = async ( stageId, requestId, }: CreateEnvironmentParam) => { - const { user, project, stage, quota, authorizedClusters } = await getInitializeEnvironmentInfos({ - userId, - projectId, - quotaId, - stageId, - }) + const environment = await initializeEnvironment({ projectId, name, clusterId, quotaId, stageId }) - if (!project) throw new NotFoundError('Projet introuvable') - if (!user) throw new NotFoundError('Utilisateur introuvable') - if (!quota) throw new NotFoundError('Quota introuvable') - - checkCreateEnvironment({ - project, - userId, - name, - authorizedClusterIds: authorizedClusters.map(authorizedCluster => authorizedCluster.id), - clusterId, - stage, - quotaId: quota.id, - }) - - const projectOwners = filterOwners(project.roles) - const environment = await initializeEnvironment({ projectId: project.id, name, projectOwners, clusterId, quotaId: quota.id, stageId: stage.id }) - - try { - const cluster = await getClusterById(clusterId) - if (!cluster) throw new NotFoundError('Cluster introuvable') - - const { results } = await hook.project.upsert(project.id) - await addLogs('Create Environment', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la création de l\'environnement') - } + const { results } = await hook.project.upsert(projectId) + await addLogs('Create Environment', results, userId, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la création de l\'environnement') + } - return { - ...environment, - quotaId, - stageId, - } - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + return { + ...environment, + quotaId, + stageId, } } @@ -229,80 +83,94 @@ export const updateEnvironment = async ({ requestId, quotaId, }: UpdateEnvironmentParam) => { - try { - const dbEnvironment = await getEnvironmentById(environmentId) - if (!dbEnvironment) throw new NotFoundError('Environment introuvable') - const [stage, project, quota] = await Promise.all([ - getStageById(dbEnvironment.stageId), - getProjectInfosOrThrow(dbEnvironment.projectId), - getQuotaById(quotaId), - ]) - - if (!project) throw new NotFoundError('Projet introuvable') - if (!stage) throw new NotFoundError('Stage introuvable') - if (!quota) throw new NotFoundError('Quota introuvable') - - if (!user.groups?.includes(adminGroupPath)) { - checkUpdateEnvironment({ - project, - userId: user.id, - stage, - quotaId: quota.id, - dbEnvQuotaId: dbEnvironment.quotaId, - }) - } - if (!user.groups?.includes(adminGroupPath) && (quota.id !== quotaId && quota.isPrivate)) { - throw new ForbiddenError('Ce quota est privé, accès restreint aux administrateurs') - } - // Modification du quota - const env = await updateEnvironmentQuery({ id: environmentId, quotaId }) - if (quotaId) { - const { results } = await hook.project.upsert(project.id) - await addLogs('Update Environment Quotas', results, user.id, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la mise à jour des quotas pour l\'environnement') - } - } - - return env - } catch (error) { - if (error instanceof DsoError) { - throw error + // Modification du quota + const env = await updateEnvironmentQuery({ id: environmentId, quotaId }) + if (quotaId) { + const { results } = await hook.project.upsert(env.projectId) + await addLogs('Update Environment Quotas', results, user.id, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour des quotas pour l\'environnement') } - throw new Error(error?.message) } + + return env } type DeleteEnvironmentParam = { userId: User['id'] environmentId: Environment['id'] + projectId: Project['id'] requestId: string } export const deleteEnvironment = async ({ userId, environmentId, + projectId, requestId, }: DeleteEnvironmentParam) => { - try { - const environment = await getEnvironmentInfos(environmentId) - const project = await getProjectInfosOrThrow(environment.projectId) - - checkDeleteEnvironment({ project, userId }) - - await deleteEnvironmentQuery(environment.id) - - // Suppression de l'environnement dans les services - const cluster = await getClusterById(environment.clusterId) - if (!cluster) throw new NotFoundError('Cluster introuvable') + await deleteEnvironmentQuery(environmentId) - const { results } = await hook.project.upsert(project.id) - await addLogs('Delete Environment', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la suppression de l\'environnement') - } - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error(error?.message) + const { results } = await hook.project.upsert(projectId) + await addLogs('Delete Environment', results, userId, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression de l\'environnement') } + return null +} + +type checkEnvironmentInput = { + allowInvalidQuotaStage: boolean + allowPrivateQuota: boolean + quotaId: Quota['id'] +} & XOR<{ // mode create + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] +}, { + environmentId: Environment['id'] // mode update + }> +export const checkEnvironmentInput = async (input: checkEnvironmentInput): Promise => { + const [quota, environment, stage, sameNameEnvironment, cluster] = await Promise.all([ + prisma.quota.findUnique({ where: { id: input.quotaId }, include: { stages: true } }), + input.environmentId + ? prisma.environment.findUnique({ where: { id: input.environmentId } }) + : undefined, + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }, { + id: input.clusterId, // le cluster actuel de l'environment + environments: { some: { id: input.environmentId } }, + }], + }, + }) + : undefined, + ]) + if (!quota) return new BadRequest400('Invalid Quota') + + // update + if (input.environmentId && (quota.id !== environment?.quotaId && quota.isPrivate && !input.allowPrivateQuota)) return new BadRequest400('Invalid Quota') + if (input.environmentId && quota.id) return + + // create + if (quota.isPrivate && !input.allowPrivateQuota) return new BadRequest400('Invalid Quota') + if (sameNameEnvironment) return new BadRequest400('Environment name already taken') + if (!cluster) return new BadRequest400('Invalid Cluster') + if (!stage) return new BadRequest400('Invalid Stage') + if (!input.allowInvalidQuotaStage && !quota.stages.find(stage => stage.id === input.stageId)) return new BadRequest400('Invalid quota stage association') } diff --git a/apps/server/src/resources/environment/queries.ts b/apps/server/src/resources/environment/queries.ts index 4c82a9ae9..5865a005d 100644 --- a/apps/server/src/resources/environment/queries.ts +++ b/apps/server/src/resources/environment/queries.ts @@ -1,10 +1,10 @@ -import type { Environment, Project, Role, Cluster, Stage } from '@prisma/client' +import type { Environment, Project, Prisma } from '@prisma/client' import prisma from '@/prisma.js' import { Quota } from '@cpn-console/shared' // SELECT -export const getEnvironmentById = (id: Environment['id']) => - prisma.environment.findUnique({ where: { id }, include: { quota: true, stage: true } }) +export const getEnvironmentByIdOrThrow = (id: Environment['id']) => + prisma.environment.findUniqueOrThrow({ where: { id }, include: { quota: true, stage: true } }) export const getEnvironmentInfos = (id: Environment['id']) => prisma.environment.findUniqueOrThrow({ @@ -13,9 +13,7 @@ export const getEnvironmentInfos = (id: Environment['id']) => project: { select: { organization: true, - roles: { - include: { user: true }, - }, + owner: true, name: true, id: true, status: true, @@ -33,9 +31,6 @@ export const getEnvironmentInfos = (id: Environment['id']) => }, }, }, - permissions: { - include: { user: true }, - }, stage: true, }, }) @@ -43,7 +38,6 @@ export const getEnvironmentInfos = (id: Environment['id']) => export const getEnvironmentsByProjectId = async (projectId: Project['id']) => prisma.environment.findMany({ where: { projectId }, include: { - permissions: true, quota: true, stage: true, }, @@ -60,33 +54,11 @@ export const getEnvironmentByIdWithCluster = (id: Environment['id']) => }) // INSERT -type CreateEnvironmentParams = { - name: Environment['name'] - projectId: Project['id'] - projectOwners: Role[] - clusterId: Cluster['id'] - stageId: Stage['id'] - quotaId: Quota['id'] -} export const initializeEnvironment = ( - { name, projectId, projectOwners, clusterId, stageId, quotaId }: CreateEnvironmentParams, + // TODO + data: Prisma.EnvironmentUncheckedCreateInput, ) => prisma.environment.create({ - data: { - name, - project: { - connect: { id: projectId }, - }, - quota: { connect: { id: quotaId } }, - stage: { connect: { id: stageId } }, - cluster: { - connect: { id: clusterId }, - }, - permissions: { - createMany: { - data: projectOwners.map(({ userId }) => ({ userId, level: 2 })), - }, - }, - }, + data, include: { project: { include: { @@ -120,10 +92,3 @@ export const deleteAllEnvironmentForProject = (id: Project['id']) => prisma.environment.deleteMany({ where: { projectId: id }, }) - -// TECH -export const _dropEnvironmentsTable = prisma.environment.deleteMany - -export const _dropQuotaTable = prisma.quota.deleteMany - -export const _dropStageTable = prisma.stage.deleteMany diff --git a/apps/server/src/resources/environment/router.ts b/apps/server/src/resources/environment/router.ts index e08b2a0b8..ee6acf873 100644 --- a/apps/server/src/resources/environment/router.ts +++ b/apps/server/src/resources/environment/router.ts @@ -1,24 +1,18 @@ import { serverInstance } from '@/app.js' -import { adminGroupPath, environmentContract } from '@cpn-console/shared' -import { createEnvironment, deleteEnvironment, updateEnvironment, getProjectEnvironments } from './business.js' -import { addReqLogs } from '@/utils/logger.js' +import { AdminAuthorized, environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { createEnvironment, deleteEnvironment, updateEnvironment, getProjectEnvironments, checkEnvironmentInput } from './business.js' + +import { authUser, NotFound404, Forbidden403, ErrorResType } from '@/utils/controller.js' export const environmentRouter = () => serverInstance.router(environmentContract, { listEnvironments: async ({ request: req, query }) => { const projectId = query.projectId - const userId = req.session.user.id - const isAdmin = req.session.user.groups?.includes(adminGroupPath) + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (perms.projectPermissions && !ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() - const environments = await getProjectEnvironments(userId, isAdmin, projectId) + const environments = await getProjectEnvironments(projectId) - addReqLogs({ - req, - message: 'Environnements du projet récupérés avec succès', - infos: { - projectId, - environmentsId: environments.map(({ id }) => id).join(', '), - }, - }) return { status: 200, body: environments, @@ -26,11 +20,21 @@ export const environmentRouter = () => serverInstance.router(environmentContract }, createEnvironment: async ({ request: req, body: data }) => { - const userId = req.session.user.id const projectId = data.projectId + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouilé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') - const environment = await createEnvironment({ - userId, + const allowPrivateQuota = AdminAuthorized.ManageQuotas(perms.adminPermissions) + const allowInvalidQuotaStage = AdminAuthorized.ManageProjects(perms.adminPermissions) + const invalidReason = await checkEnvironmentInput({ allowPrivateQuota, allowInvalidQuotaStage, ...data }) + if (invalidReason) return invalidReason + + const body = await createEnvironment({ + userId: user.id, projectId, name: data.name, clusterId: data.clusterId, @@ -38,68 +42,62 @@ export const environmentRouter = () => serverInstance.router(environmentContract stageId: data.stageId, requestId: req.id, }) - - addReqLogs({ - req, - message: 'Environnement et permissions créés avec succès', - infos: { - environmentId: environment.id, - projectId, - }, - }) + if (body instanceof ErrorResType) return body return { status: 201, - body: environment, + body, } }, updateEnvironment: async ({ request: req, body: data, params }) => { - const user = req.session.user const { environmentId } = params + const user = req.session.user + const perms = await authUser(user, { environmentId }) + if (!ProjectAuthorized.ListEnvironments(perms)) return new NotFound404() + if (!ProjectAuthorized.ManageEnvironments(perms) && !AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouilé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') - const environment = await updateEnvironment({ + const allowPrivateQuota = AdminAuthorized.ManageQuotas(perms.adminPermissions) + const allowInvalidQuotaStage = AdminAuthorized.ManageProjects(perms.adminPermissions) + const invalidReason = await checkEnvironmentInput({ allowPrivateQuota, allowInvalidQuotaStage, environmentId, ...data }) + if (invalidReason) return invalidReason + + const body = await updateEnvironment({ user, environmentId, quotaId: data.quotaId, requestId: req.id, }) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Environnement mis à jour avec succès', - infos: { - environmentId, - projectId: environment.projectId, - }, - }) return { status: 200, - body: environment, + body, } }, deleteEnvironment: async ({ request: req, params }) => { + const user = req.session.user const { environmentId } = params - const userId = req.session.user.id + const perms = await authUser(user, { environmentId }) + if (!ProjectAuthorized.ListEnvironments(perms)) return new NotFound404() + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouilé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') - await deleteEnvironment({ - userId, + const body = await deleteEnvironment({ + userId: user.id, environmentId, requestId: req.id, + projectId: perms.projectId, }) - - addReqLogs({ - req, - message: 'Environnement supprimé avec succès', - infos: { - environmentId, - }, - }) + if (body instanceof ErrorResType) return body return { status: 204, - body: null, + body, } }, }) diff --git a/apps/server/src/resources/index.ts b/apps/server/src/resources/index.ts index 04f9c27e3..5e9864fa8 100644 --- a/apps/server/src/resources/index.ts +++ b/apps/server/src/resources/index.ts @@ -1,11 +1,12 @@ import { type FastifyInstance } from 'fastify' import { clusterRouter } from './cluster/router.js' import { environmentRouter } from './environment/router.js' -import { filesRouter } from '../generate-files/router.js' import { logRouter } from './log/router.js' import { organizationRouter } from './organization/router.js' -import { permissionRouter } from './permission/router.js' import { projectRouter } from './project/router.js' +import { adminRoleRouter } from './admin-role/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectMemberRouter } from './project-member/router.js' import { quotaRouter } from './quota/router.js' import { repositoryRouter } from './repository/router.js' import { serviceMonitorRouter } from './service-monitor/router.js' @@ -17,21 +18,23 @@ import { userRouter } from './user/router.js' import { zoneRouter } from './zone/router.js' import { serverInstance } from '@/app.js' +const validateTrue = { responseValidation: true } export const apiRouter = () => async (app: FastifyInstance) => { - await app.register(serverInstance.plugin(clusterRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(environmentRouter())) - await app.register(serverInstance.plugin(filesRouter())) - await app.register(serverInstance.plugin(logRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(organizationRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(permissionRouter())) - await app.register(serverInstance.plugin(projectRouter())) - await app.register(serverInstance.plugin(projectServiceRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(quotaRouter())) - await app.register(serverInstance.plugin(repositoryRouter())) - await app.register(serverInstance.plugin(serviceMonitorRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(pluginConfigRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(stageRouter())) - await app.register(serverInstance.plugin(systemRouter()), { responseValidation: true }) - await app.register(serverInstance.plugin(userRouter())) - await app.register(serverInstance.plugin(zoneRouter())) + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(organizationRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(quotaRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) } diff --git a/apps/server/src/resources/log/queries.ts b/apps/server/src/resources/log/queries.ts index 3bbac90cc..da8bec81e 100644 --- a/apps/server/src/resources/log/queries.ts +++ b/apps/server/src/resources/log/queries.ts @@ -35,8 +35,6 @@ export const addLogs = ( }) // TECH -export const _dropLogsTable = prisma.log.deleteMany - export const _createLog = (data: Parameters[0]['create']) => prisma.log.upsert({ where: { diff --git a/apps/server/src/resources/log/router.ts b/apps/server/src/resources/log/router.ts index 04f3ba5a4..3775108b4 100644 --- a/apps/server/src/resources/log/router.ts +++ b/apps/server/src/resources/log/router.ts @@ -1,26 +1,21 @@ -import { addReqLogs } from '@/utils/logger.js' import { getLogs } from './business.js' import { serverInstance } from '@/app.js' -import { Log, logContract } from '@cpn-console/shared' +import { AdminAuthorized, Log, logContract } from '@cpn-console/shared' import { Log as LogModel } from '@prisma/client' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, Forbidden403 } from '@/utils/controller.js' export const logRouter = () => serverInstance.router(logContract, { // Récupérer des logs getLogs: async ({ request: req, query }) => { - assertIsAdmin(req.session.user) - try { - const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] - addReqLogs({ - req, - message: 'Logs récupérés avec succès', - }) - return { - status: 200, - body: { total, logs }, - } - } catch (error) { - throw new Error(error.message) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ViewLogs(perms.adminPermissions)) return new Forbidden403() + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, } }, }) diff --git a/apps/server/src/resources/organization/business.ts b/apps/server/src/resources/organization/business.ts index 386bc2707..40e92c141 100644 --- a/apps/server/src/resources/organization/business.ts +++ b/apps/server/src/resources/organization/business.ts @@ -7,7 +7,7 @@ import { updateOrganization as updateOrganizationQuery, } from '@/resources/queries-index.js' import { validateSchema } from '@/utils/business.js' -import { BadRequestError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/controller.js' import { hook } from '@/utils/hook-wrapper.js' import { getUniqueListBy, objectValues, organizationContract, OrganizationSchema } from '@cpn-console/shared' import { User } from '@prisma/client' @@ -17,7 +17,7 @@ export const listOrganizations = (query?: typeof organizationContract.listOrgani export const createOrganization = async (data: typeof organizationContract.createOrganization.body._type) => { const isNameTaken = await getOrganizationByName(data.name) - if (isNameTaken) throw new BadRequestError('Cette organisation existe déjà', undefined) + if (isNameTaken) return new BadRequest400('Cette organisation existe déjà') return createOrganizationQuery(data) } @@ -27,7 +27,7 @@ export const updateOrganization = async ( org: typeof organizationContract.updateOrganization.body._type, ) => { const organization = await getOrganizationByName(name) - if (!organization) throw new NotFoundError(`Organisation ${name} introuvable`) + if (!organization) return new NotFound404() /** lock project if organization becomes inactive */ if (organization.active === true && org.active === false) { @@ -53,24 +53,27 @@ export const fetchOrganizations = async (userId: User['id'], requestId: string) const hookReply = await hook.misc.fetchOrganizations() await addLogs('Fetch organizations', hookReply, userId, requestId) if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la synchronisation des organisations') + return new Unprocessable422('Echec des services à la synchronisation des organisations') } /** * Filter plugin results to get a single array of organizations with unique name */ const externalOrganizations = getUniqueListBy(objectValues(hookReply.results) - ?.reduce((acc: Record[], value) => { + .reduce((acc, value) => { if (typeof value !== 'object' || !value.result.organizations?.length) return acc return [...acc, ...value.result.organizations] - }, []) - ?.filter(externalOrg => externalOrg.name), 'name') as PluginOrganization[] + }, [] as Record[]) + // @ts-ignore cast le typage + .filter(externalOrg => externalOrg.name), 'name') as PluginOrganization[] - if (!externalOrganizations.length) throw new NotFoundError('Aucune organisation à synchroniser', undefined) + if (!externalOrganizations.length) return new NotFound404() for (const externalOrg of externalOrganizations) { const schemaValidation = OrganizationSchema.pick({ name: true, label: true }).safeParse(externalOrg) - validateSchema(schemaValidation) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) continue + if (consoleOrganizations.find(consoleOrg => consoleOrg.name === externalOrg.name)) { await updateOrganizationQuery(externalOrg) } else { diff --git a/apps/server/src/resources/organization/queries.ts b/apps/server/src/resources/organization/queries.ts index c148cad57..cdb22cc62 100644 --- a/apps/server/src/resources/organization/queries.ts +++ b/apps/server/src/resources/organization/queries.ts @@ -50,5 +50,3 @@ export const _createOrganizations = ( create: data, update: data, }) - -export const _dropOrganizationsTable = prisma.organization.deleteMany diff --git a/apps/server/src/resources/organization/router.ts b/apps/server/src/resources/organization/router.ts index d048987e2..29ac38ff9 100644 --- a/apps/server/src/resources/organization/router.ts +++ b/apps/server/src/resources/organization/router.ts @@ -1,22 +1,18 @@ -import { organizationContract } from '@cpn-console/shared' +import { AdminAuthorized, organizationContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' -import { addReqLogs } from '@/utils/logger.js' + import { createOrganization, fetchOrganizations, listOrganizations, updateOrganization, } from './business.js' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, ErrorResType, Forbidden403 } from '@/utils/controller.js' export const organizationRouter = () => serverInstance.router(organizationContract, { - listOrganizations: async ({ request: req, query }) => { + listOrganizations: async ({ query }) => { const organizations = await listOrganizations(query) - addReqLogs({ - req, - message: 'Organisations récupérées avec succès', - }) return { status: 200, body: organizations, @@ -25,56 +21,47 @@ export const organizationRouter = () => serverInstance.router(organizationContra // Créer une organisation createOrganization: async ({ request: req, body: data }) => { - assertIsAdmin(req.session.user) - const organization = await createOrganization(data) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageOrganizations(perms.adminPermissions)) return new Forbidden403() + const body = await createOrganization(data) + + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Organisation créée avec succès', - infos: { - organizationId: organization.id, - }, - }) return { status: 201, - body: organization, + body, } }, // Synchroniser les organisations via les plugins externes syncOrganizations: async ({ request: req }) => { - assertIsAdmin(req.session.user) - const userId = req.session.user.id + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageOrganizations(perms.adminPermissions)) return new Forbidden403() + const body = await fetchOrganizations(user.id, req.id) - const consoleOrganizations = await fetchOrganizations(userId, req.id) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Organisations synchronisées avec succès', - }) return { status: 200, - body: consoleOrganizations, + body, } }, // Mettre à jour une organisation updateOrganization: async ({ request: req, body: data, params }) => { - assertIsAdmin(req.session.user) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageOrganizations(perms.adminPermissions)) return new Forbidden403() const name = params.organizationName - const organization = await updateOrganization(name, data) + const body = await updateOrganization(name, data) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Organisation mise à jour avec succès', - infos: { - organizationId: organization.id, - }, - }) return { status: 200, - body: organization, + body, } }, }) diff --git a/apps/server/src/resources/permission/business.ts b/apps/server/src/resources/permission/business.ts deleted file mode 100644 index 26bdf8dca..000000000 --- a/apps/server/src/resources/permission/business.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Environment, Permission, Project, User } from '@prisma/client' -import { PermissionSchema } from '@cpn-console/shared' -import { addLogs, deletePermission as deletePermissionQuery, getEnvironmentByIdWithCluster, getEnvironmentPermissions as getEnvironmentPermissionsQuery, getPermissionByUserIdAndEnvironmentId, getProjectInfos, getSingleOwnerByProjectId, setPermission as setPermissionQuery } from '@/resources/queries-index.js' -import { validateSchema } from '@/utils/business.js' -import { checkInsufficientRoleInProject, checkRoleAndLocked } from '@/utils/controller.js' -import { BadRequestError, ForbiddenError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' -import { hook } from '@/utils/hook-wrapper.js' - -export enum Action { - update = 'modifiée', - delete = 'supprimée', -} - -export const preventUpdatingOwnerPermission = async ( - projectId: Project['id'], - userId: User['id'], - action: Action = Action.update, -) => { - const owner = await getSingleOwnerByProjectId(projectId) - if (userId === owner?.id) throw new ForbiddenError(`La permission du owner du projet ne peut être ${action}`) -} - -export const getEnvironmentPermissions = async ( - userId: User['id'], - projectId: Project['id'], - environmentId: Environment['id'], -) => { - const roles = (await getProjectInfos(projectId))?.roles ?? [] - const errorMessage = checkInsufficientRoleInProject(userId, { roles }) - if (errorMessage) throw new ForbiddenError(errorMessage) - return getEnvironmentPermissionsQuery(environmentId) -} - -export const upsertPermission = async ( - projectId: Project['id'], - requestorId: User['id'], - userId: User['id'], - environmentId: Environment['id'], - level: Permission['level'], - requestId: string, -) => { - const project = await getProjectInfos(projectId) - if (!project) throw new BadRequestError('Le projet n\'existe pas') - - const ownerIds = project.roles - .filter(({ role }) => role === 'owner') - .map(({ userId }) => userId) - - const errorMessage = checkRoleAndLocked(project, requestorId) - if (errorMessage) throw new ForbiddenError(errorMessage) - - const requestorPermission = await getPermissionByUserIdAndEnvironmentId(requestorId, environmentId) - if (!ownerIds.includes(requestorId) && (!requestorPermission || requestorPermission.level <= 0)) { - throw new ForbiddenError('Vous n\'avez pas de droits sur cet environnement') - } - - if (level !== 2 && ownerIds.includes(userId)) { - throw new ForbiddenError('La permission du owner du projet ne peut être inférieure à rwd') - } - - const schemaValidation = PermissionSchema.omit({ id: true }).safeParse({ userId, environmentId, level }) - validateSchema(schemaValidation) - - const permission = await setPermissionQuery({ userId, environmentId, level }) - - const { results } = await hook.project.upsert(project.id) - await addLogs('Upsert Permission', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à l\'application de la permission') - } - return permission -} - -export const deletePermission = async ( - userId: User['id'], - environmentId: Environment['id'], - requestorId: Environment['id'], - requestId: string, -) => { - const environment = await getEnvironmentByIdWithCluster(environmentId) - if (!environment) throw new NotFoundError('Environnement introuvable') - const project = await getProjectInfos(environment.projectId) - if (!project) throw new NotFoundError('Projet introuvable') - - const ownerIds = project.roles - .filter(({ role }) => role === 'owner') - .map(({ userId }) => userId) - - const requestorPermission = await getPermissionByUserIdAndEnvironmentId(requestorId, environmentId) - if (!ownerIds.includes(requestorId) && (!requestorPermission || requestorPermission.level <= 0)) { - throw new ForbiddenError('Vous n\'avez pas de droits sur cet environnement') - } - - if (ownerIds.includes(userId)) { - throw new ForbiddenError(`La permission du owner du projet ne peut être ${Action.delete}`) - } - - const { results } = await hook.project.upsert(project.id) - await addLogs('Delete Permission', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la suppression de la permission') - } - return deletePermissionQuery(userId, environmentId) -} diff --git a/apps/server/src/resources/permission/controllers.spec.ts b/apps/server/src/resources/permission/controllers.spec.ts deleted file mode 100644 index 8833f5594..000000000 --- a/apps/server/src/resources/permission/controllers.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import prisma from '../../__mocks__/prisma.js' -import { vi, describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { createRandomDbSetup, getRandomPerm, getRandomRole, getRandomUser } from '@cpn-console/test-utils' -import { getConnection, closeConnections } from '../../connect.js' -import { getRequestor, setRequestor } from '../../utils/mocks.js' -import app from '../../app.js' - -vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) -vi.mock('../../utils/hook-wrapper.js', (await import('../../utils/mocks.js')).mockHookWrapper) - -describe('Permission routes', () => { - const requestor = getRandomUser() - setRequestor(requestor) - - beforeAll(async () => { - await getConnection() - }) - - afterAll(async () => { - return closeConnections() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - // GET - describe('getEnvironmentPermissionsController', () => { - it('Should retrieve permissions for an environment', async () => { - const projectInfos = createRandomDbSetup({}).project - projectInfos.roles = [...projectInfos.roles, getRandomRole(getRequestor().id, projectInfos.id, 'owner')] - const environment = projectInfos.environments[0] - - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.permission.findMany.mockResolvedValue(environment.permissions) - - const response = await app.inject() - .get(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions`) - .end() - - expect(response.json()).toStrictEqual(environment.permissions) - expect(response.statusCode).toEqual(200) - }) - - it('Should not retrieve permissions for an environment if requestor is not member', async () => { - const projectInfos = createRandomDbSetup({}).project - const environment = projectInfos.environments[0] - - prisma.project.findUnique.mockResolvedValue(projectInfos) - - const response = await app.inject() - .get(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions`) - .end() - - expect(response.statusCode).toEqual(403) - expect(JSON.parse(response.body).error).toEqual('Vous n’avez pas les permissions suffisantes dans le projet') - }) - }) - - // PUT - describe('upsertPermissionController', () => { - it('Should update a permission', async () => { - const dbSetup = createRandomDbSetup({ envs: ['dev'] }) - const projectInfos = dbSetup.project - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id, 'owner'), user: requestor } - projectInfos.roles = projectInfos.roles ? [...projectInfos.roles.map(({ role: _, ...details }) => ({ ...details, role: 'user' })), requestorRole] : [] - const environment = projectInfos.environments[0] - const requestorPermission = getRandomPerm(environment.id, requestor, 1) - projectInfos.environments[0].permissions = [...environment.permissions, getRandomPerm(environment.id, requestor)] - const permissionToUpdate = environment.permissions[0] - projectInfos.roles[0].role = 'user' - - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.permission.findUnique.mockResolvedValue(requestorPermission) - prisma.permission.upsert.mockResolvedValue(permissionToUpdate) - - const response = await app.inject() - .put(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions`) - .body(permissionToUpdate) - .end() - - expect(response.json()).toStrictEqual(permissionToUpdate) - expect(response.statusCode).toEqual(200) - }) - - it('Should not update owner permission if level <= 2', async () => { - const projectInfos = createRandomDbSetup({ envs: ['dev'] }).project - const environment = projectInfos.environments[0] - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id), user: requestor } - const requestorPermission = getRandomPerm(environment.id, requestor, 2) - projectInfos.roles = [...projectInfos.roles, requestorRole] - projectInfos.environments[0].permissions = [...environment.permissions, requestorPermission] - const permissionToUpdate = { ...environment.permissions[0], level: 0 } - - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.permission.findUnique.mockResolvedValue(requestorPermission) - prisma.permission.upsert.mockResolvedValue(permissionToUpdate) - - const response = await app.inject() - .put(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions`) - .body(permissionToUpdate) - .end() - - expect(response.statusCode).toEqual(403) - expect(JSON.parse(response.body).error).toStrictEqual('La permission du owner du projet ne peut être inférieure à rwd') - }) - - it('Should not update a permission if not permitted on given environment', async () => { - const projectInfos = createRandomDbSetup({ envs: ['dev'] }).project - projectInfos.roles = [...projectInfos.roles, getRandomRole(getRequestor().id, projectInfos.id, 'user')] - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id, 'user'), user: requestor } - const environment = projectInfos.environments[0] - const environmentInfos = { ...environment, project: projectInfos } - const permissionToUpdate = environment.permissions[0] - - prisma.environment.findUnique.mockResolvedValue(environmentInfos) - prisma.role.findFirst.mockResolvedValue(requestorRole) - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.permission.findUnique.mockResolvedValue(null) - - const response = await app.inject() - .put(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions`) - .body(permissionToUpdate) - .end() - - expect(JSON.parse(response.body).error).toStrictEqual('Vous n\'avez pas de droits sur cet environnement') - expect(response.statusCode).toEqual(403) - }) - }) - - // DELETE - describe('deletePermissionController', () => { - it('Should delete a permission', async () => { - const projectInfos = createRandomDbSetup({ envs: ['dev'] }).project - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id, 'owner'), user: requestor } - projectInfos.roles = projectInfos.roles ? [...projectInfos.roles.map(({ role: _, ...details }) => ({ ...details, role: 'user' })), requestorRole] : [] - const environment = projectInfos.environments[0] - projectInfos.environments[0].permissions = [...environment.permissions, getRandomPerm(environment.id, requestor)] - const requestorPermission = getRandomPerm(environment.id, requestor, 2) - const environmentInfos = { ...environment, project: projectInfos } - const permissionToDelete = environment.permissions[0] - - prisma.environment.findUnique.mockResolvedValue(environmentInfos) - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.user.findUnique.mockResolvedValue(getRequestor()) - prisma.role.findFirst.mockResolvedValue(requestorRole) - prisma.permission.findUnique.mockResolvedValue(requestorPermission) - prisma.permission.deleteMany.mockResolvedValue(permissionToDelete) - - const response = await app.inject() - .delete(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions/${permissionToDelete.userId}`) - .end() - - expect(response.body).toStrictEqual('') - expect(response.statusCode).toEqual(204) - }) - - it('Should not delete owner permission', async () => { - const projectInfos = createRandomDbSetup({ envs: ['dev'] }).project - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id, 'owner'), user: requestor } - projectInfos.roles = [...projectInfos.roles, requestorRole] - projectInfos.roles[0].role = 'owner' - const userToDelete = projectInfos.roles[0] - const environment = projectInfos.environments[0] - projectInfos.environments[0].permissions = [getRandomPerm(environment.id, requestor), ...environment.permissions] - const environmentInfos = { ...environment, project: projectInfos } - - prisma.environment.findUnique.mockResolvedValue(environmentInfos) - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.role.findFirst.mockResolvedValue(requestorRole) - - const response = await app.inject() - .delete(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions/${userToDelete.userId}`) - .end() - - expect(JSON.parse(response.body).error).toStrictEqual('La permission du owner du projet ne peut être supprimée') - expect(response.statusCode).toEqual(403) - }) - - it('Should not delete permission if not permitted on given environment', async () => { - const projectInfos = createRandomDbSetup({ envs: ['dev'] }).project - const requestorRole = { ...getRandomRole(getRequestor().id, projectInfos.id, 'user'), user: requestor } - projectInfos.roles = projectInfos.roles ? [...projectInfos.roles.map(({ role: _, ...details }) => ({ ...details, role: 'user' })), requestorRole] : [] - const environment = projectInfos.environments[0] - const environmentInfos = { ...environment, project: projectInfos } - const permissionToDelete = environment.permissions[0] - - prisma.environment.findUnique.mockResolvedValue(environmentInfos) - prisma.project.findUnique.mockResolvedValue(projectInfos) - prisma.role.findFirst.mockResolvedValue(requestorRole) - prisma.permission.findUnique.mockResolvedValue({ level: 0 }) - - const response = await app.inject() - .delete(`/api/v1/projects/${projectInfos.id}/environments/${environment.id}/permissions/${permissionToDelete.userId}`) - .end() - - expect(JSON.parse(response.body).error).toStrictEqual('Vous n\'avez pas de droits sur cet environnement') - expect(response.statusCode).toEqual(403) - }) - }) -}) diff --git a/apps/server/src/resources/permission/queries.ts b/apps/server/src/resources/permission/queries.ts deleted file mode 100644 index 9a8098c07..000000000 --- a/apps/server/src/resources/permission/queries.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Environment, Permission, User } from '@prisma/client' -import prisma from '@/prisma.js' - -// GET -export const getEnvironmentPermissions = (environmentId: Environment['id']) => - prisma.permission.findMany({ - where: { environmentId }, - }) - -export const getUserPermissions = (userId: User['id']) => - prisma.permission.findMany({ - where: { userId }, - }) - -export const getPermissionByUserIdAndEnvironmentId = ( - userId: User['id'], environmentId: Environment['id'], -) => - prisma.permission.findUnique({ - select: { level: true }, - where: { userId_environmentId: { userId, environmentId } }, - }) - -// CREATE -type UpsertPermissionsParams = { - userId: User['id'] - environmentId: Environment['id'] - level: Permission['level'] -} - -export const setPermission = ({ userId, environmentId, level }: UpsertPermissionsParams) => - prisma.permission.upsert({ - create: { userId, environmentId, level }, - update: { level }, - where: { userId_environmentId: { userId, environmentId } }, - }) - -// UPDATE -export const upsertPermission = ({ userId, environmentId, level }: UpsertPermissionsParams) => - prisma.permission.update({ - where: { - userId_environmentId: { - userId, - environmentId, - }, - }, - data: { - userId, - environmentId, - level, - }, - }) - -// DELETE -export const deletePermission = (userId: User['id'], environmentId: Environment['id']) => - prisma.permission.deleteMany({ where: { userId, environmentId } }) - -export const deletePermissionById = (permissionId: Permission['id']) => - prisma.permission.delete({ where: { id: permissionId } }) - -// TECH -export const _dropPermissionsTable = prisma.permission.deleteMany diff --git a/apps/server/src/resources/permission/router.ts b/apps/server/src/resources/permission/router.ts deleted file mode 100644 index 981a035b9..000000000 --- a/apps/server/src/resources/permission/router.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { addReqLogs } from '@/utils/logger.js' -import { - deletePermission, - getEnvironmentPermissions, - upsertPermission, -} from './business.js' -import { permissionContract } from '@cpn-console/shared' -import { serverInstance } from '@/app.js' - -export const permissionRouter = () => serverInstance.router(permissionContract, { - - // Récupérer toutes les permissions d'un environnement - listPermissions: async ({ request: req, params }) => { - const userId = req.session.user.id - const environmentId = params.environmentId - const projectId = params.projectId - - const permissions = await getEnvironmentPermissions(userId, projectId, environmentId) - - addReqLogs({ - req, - message: 'Permissions de l\'environnement récupérées avec succès', - infos: { - projectId, - environmentId, - }, - }) - return { - status: 200, - body: permissions, - } - }, - - // Mettre à jour le level d'une permission - upsertPermission: async ({ request: req, params, body: data }) => { - const requestorId = req.session.user.id - const environmentId = params.environmentId - const projectId = params.projectId - - if (typeof data.level === 'string') data.level = parseInt(data.level) - const permission = await upsertPermission(projectId, requestorId, data.userId, environmentId, data.level, req.id) - - addReqLogs({ - req, - message: 'Permission mise à jour avec succès', - infos: { - projectId, - environmentId, - }, - }) - return { - status: 200, - body: permission, - } - }, - - // Supprimer une permission - deletePermission: async ({ request: req, params }) => { - const requestorId = req.session.user.id - const environmentId = params.environmentId - const projectId = params.projectId - const userId = params.userId - - await deletePermission(userId, environmentId, requestorId, req.id) - - addReqLogs({ - req, - message: 'Permissions supprimée avec succès', - infos: { - projectId, - environmentId, - }, - }) - return { - status: 204, - body: null, - } - }, -}) diff --git a/apps/server/src/resources/project-member/business.ts b/apps/server/src/resources/project-member/business.ts new file mode 100644 index 000000000..94d34b27d --- /dev/null +++ b/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,56 @@ +import type { Project, User } from '@prisma/client' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { projectMemberContract, UserSchema, XOR } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400 } from '@/utils/controller.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logUser } from '../user/business.js' + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export const addMember = async (projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) => { + let userInDb: User | undefined | null + + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email } }) + } else { + return new BadRequest400('Veuillez spéecifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs('Retrieve User By Email', hookReply, requestorId, requestId) + if (hookReply.failed) { + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Impossible de toruver l\'utilisateur en base ou sur keycloak') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + userInDb = await logUser({ ...userValidated.data, groups: [] }) + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + return listMembers(projectId) +} + +export const patchMembers = async (projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) => { + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + } + return listMembers(projectId) +} + +export const removeMember = async (projectId: Project['id'], userId: User['id']) => { + await deleteMember({ projectId, userId }) + return listMembers(projectId) +} diff --git a/apps/server/src/resources/project-member/queries.ts b/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 000000000..8ae348f0b --- /dev/null +++ b/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,30 @@ +import { + Prisma, + type Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export const upsertMember = (data: Prisma.ProjectMembersUncheckedCreateInput) => + prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) + +export const deleteMember = (data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) => + prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) diff --git a/apps/server/src/resources/project-member/router.ts b/apps/server/src/resources/project-member/router.ts new file mode 100644 index 000000000..837b42abe --- /dev/null +++ b/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser, NotFound404, Forbidden403, ErrorResType } from '@/utils/controller.js' + +export const projectMemberRouter = () => serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.ManageMembers(perms)) return new NotFound404() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, +}) diff --git a/apps/server/src/resources/project-role/business.ts b/apps/server/src/resources/project-role/business.ts new file mode 100644 index 000000000..b4945f076 --- /dev/null +++ b/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,73 @@ +import type { Project, ProjectRole } from '@prisma/client' +import { + listRoles as listRolesQuery, + deleteRole as deleteRoleQuery, + updateRole, + listMembers, +} from '@/resources/queries-index.js' +import { projectRoleContract } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/controller.js' +import prisma from '@/prisma.js' + +export const listRoles = async (projectId: Project['id']) => listRolesQuery(projectId) + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString() }))) + +export const patchRoles = async (projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) => { + const dbRoles = await listRoles(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles = dbRoles.map((dbRole) => { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole?.position && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + return { + id: matchingRole?.id ?? dbRole.id, + name: matchingRole?.name ?? dbRole.name, + permissions: matchingRole?.permissions ? BigInt(matchingRole?.permissions) : BigInt(dbRole.permissions), + position: matchingRole?.position ?? dbRole.position, + } + }) + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + } + + return listRoles(projectId) +} + +export const createRole = async (projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) => { + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position + + await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole ? dbMaxPosRole + 1 : 0, + permissions: BigInt(role.permissions), + }, + }) + + return listRoles(projectId) +} + +export const countRolesMembers = async (projectId: Project['id']) => { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export const deleteRole = async (roleId: Project['id']) => { + await deleteRoleQuery(roleId) + return null +} diff --git a/apps/server/src/resources/project-role/queries.ts b/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 000000000..37d3617a3 --- /dev/null +++ b/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,51 @@ +import { + Prisma, + ProjectRole, + type Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export const createRole = (data: Pick) => + prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + }, + }) + +export const updateRole = (id: ProjectRole['id'], data: Pick) => + prisma.projectRole.updateMany({ + where: { id }, + data, + }) + +export const deleteRole = async (id: ProjectRole['id']) => { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} diff --git a/apps/server/src/resources/project-role/router.ts b/apps/server/src/resources/project-role/router.ts new file mode 100644 index 000000000..67889ce8a --- /dev/null +++ b/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,91 @@ +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' +import { AdminAuthorized, ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser, NotFound404, Forbidden403, ErrorResType } from '@/utils/controller.js' + +export const projectRoleRouter = () => serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions && !AdminAuthorized.ListProjects(perms.adminPermissions)) return new NotFound404() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions && !AdminAuthorized.ListProjects(perms.adminPermissions)) return new NotFound404() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 200, + body: resBody, + } + }, +}) diff --git a/apps/server/src/resources/project-service/business.ts b/apps/server/src/resources/project-service/business.ts index 8bc6eb74d..233002e2a 100644 --- a/apps/server/src/resources/project-service/business.ts +++ b/apps/server/src/resources/project-service/business.ts @@ -1,20 +1,16 @@ import type { Project } from '@prisma/client' import { type PluginsUpdateBody, - adminGroupPath, PermissionTarget, ServiceUrl, } from '@cpn-console/shared' import { getAdminPlugin, - getProjectInfosAndRepos, - getProjectInfosById, + getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore, } from '@/resources/queries-index.js' -import { ForbiddenError, NotFoundError } from '@/utils/errors.js' -import type { KeycloakPayload } from 'fastify-keycloak-adapter' import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' export type ConfigRecords = { @@ -37,16 +33,9 @@ export const objToDb = (obj: PluginsUpdateBody): ConfigRecords => Object.entries .map(([key, value]) => ({ pluginName, key, value }))) .flat() -export const getProjectServices = async (projectId: Project['id'], requestor: KeycloakPayload, permissionTarget: PermissionTarget = 'user') => { +export const getProjectServices = async (projectId: Project['id'], permissionTarget: PermissionTarget) => { // Pré-requis - const project = await getProjectInfosById(projectId) - if (!project) throw new NotFoundError('Projet introuvable') - - const isAdmin = requestor.groups?.includes(adminGroupPath) - if (!isAdmin) permissionTarget = 'user' - if (!isAdmin && !project.roles.find(role => role.userId === requestor.id)) { - throw new ForbiddenError('Vous n\'êtes ni admin, ni membre du projet') - } + const project = await getProjectInfosByIdOrThrow(projectId) const [projectStore, globalConfig] = await Promise.all([ getProjectStore(projectId), @@ -91,24 +80,11 @@ export const getProjectServices = async (projectId: Project['id'], requestor: Ke }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) } -export const updateProjectServices = async (projectId: Project['id'], data: PluginsUpdateBody, requestor: KeycloakPayload) => { - // Pré-requis - const project = await getProjectInfosAndRepos(projectId) - if (!project) throw new NotFoundError('Projet introuvable') - - const stripperRoles: Array<'user' | 'admin'> = [] - if (requestor.groups?.includes(adminGroupPath)) { - stripperRoles.push('admin') - } - if (project.roles.find(role => role.userId === requestor.id)) { - stripperRoles.push('user') - } - if (!stripperRoles.length) { - throw new ForbiddenError('Vous n\'êtes ni admin, ni membre du projet') - } +export const updateProjectServices = async (projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) => { for (const role of stripperRoles) { const parsedData = editStrippers.project[role].safeParse(data) if (!parsedData.success) continue await saveProjectStore(objToDb(parsedData.data), projectId) } + return null } diff --git a/apps/server/src/resources/project-service/router.ts b/apps/server/src/resources/project-service/router.ts index 8da50a89f..9e5c4c954 100644 --- a/apps/server/src/resources/project-service/router.ts +++ b/apps/server/src/resources/project-service/router.ts @@ -1,32 +1,35 @@ -import { addReqLogs } from '@/utils/logger.js' -import { projectServiceContract } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' import { getProjectServices, updateProjectServices } from './business.js' +import { authUser, Forbidden403, NotFound404 } from '@/utils/controller.js' export const projectServiceRouter = () => serverInstance.router(projectServiceContract, { // Récupérer les services d'un projet getServices: async ({ request: req, params: { projectId } }) => { - const requestor = req.session.user - const services = await getProjectServices(projectId, requestor) - addReqLogs({ - req, - message: 'Services de projet récupérés avec succès', - infos: { - userId: requestor.id, - }, - }) + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions && !AdminAuthorized.ListProjects(perms.adminPermissions)) return new NotFound404() + + const body = await getProjectServices(projectId, AdminAuthorized.ManageProjects(perms.adminPermissions) ? 'admin' : 'user') + return { status: 200, - body: services, + body, } }, + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!ProjectAuthorized.Manage(perms)) return new NotFound404() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.ManageProjects(perms.adminPermissions) ? ['user', 'admin'] : ['user'] - await updateProjectServices(projectId, body, user) + const resBody = await updateProjectServices(projectId, body, allowedRoles) return { status: 204, - body: null, + body: resBody, } }, }) diff --git a/apps/server/src/resources/project/business.ts b/apps/server/src/resources/project/business.ts index fbd59bac6..61c043770 100644 --- a/apps/server/src/resources/project/business.ts +++ b/apps/server/src/resources/project/business.ts @@ -1,13 +1,8 @@ -import type { Organization, Project, Role, User } from '@prisma/client' +import type { Project, User } from '@prisma/client' import type { KeycloakPayload } from 'fastify-keycloak-adapter' import { ProjectStatusSchema, - ProjectSchemaV2, - adminGroupPath, - exclude, projectContract, - projectIsLockedInfo, - type AsyncReturnType, type CreateProjectBody, type UpdateProjectBody, } from '@cpn-console/shared' @@ -16,122 +11,55 @@ import { addLogs, deleteAllEnvironmentForProject, deleteAllRepositoryForProject, - deleteAllRoleNonOwnerForProject, getOrganizationById, getProjectByNames, getProjectInfosAndRepos, getProjectInfos as getProjectInfosQuery, getProjectInfosOrThrow as getProjectInfosOrThrowQuery, getPublicClusters, - getUserProjects as getUserProjectsQuery, initializeProject, listProjects as listProjectsQuery, lockProject, removeClusterFromProject, updateProject as updateProjectQuery, - getOrCreateUser, getAllProjectsDataForExport, unlockProject, } from '@/resources/queries-index.js' -import { type UserDto } from '@/resources/user/business.js' -import { validateSchema } from '@/utils/business.js' -import { checkInsufficientPermissionInEnvironment, checkInsufficientRoleInProject, hasGroupAdmin, whereBuilder } from '@/utils/controller.js' -import { BadRequestError, DsoError, ForbiddenError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' +import { BadRequest400, NotFound404, Unprocessable422, whereBuilder } from '@/utils/controller.js' import { hook } from '@/utils/hook-wrapper.js' -import { filterObjectByKeys } from '@/utils/queries-tools.js' import { UserDetails } from '@/types/index.js' import { json2csv } from 'json-2-csv' - -export const rolesToMembers = (roles: (Role & { user: User })[]) => roles.map(({ role, user: { id, ...user } }) => ({ ...user, userId: id, role })) +import { logUser } from '../user/business.js' // Fetch infos export const getProjectInfosAndClusters = async (projectId: string) => { const project = await getProjectInfosQuery(projectId) - if (!project) throw new NotFoundError('Projet introuvable') + if (!project) return new NotFound404() const projectClusters = project.clusters ? [...await getPublicClusters(), ...project.clusters] : [...await getPublicClusters()] return { project, projectClusters } } -export const getUserProjects = async (userId: User['id']) => { - const projects = await getUserProjectsQuery(userId) - const publicClusterIds = (await getPublicClusters()).map(({ id }) => id) - return projects.map(({ description, clusters, ...project }) => { - const projectClusterIds = clusters.map(({ id }) => id) - return { - clusterIds: publicClusterIds.concat(projectClusterIds), - description: description || '', - ...project, - } - }) -} - const projectStatus = ProjectStatusSchema._def.values -export const listProjects = async ({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, user: UserDetails) => { - const isAdmin = hasGroupAdmin(user.groups) - if (!isAdmin && filter === 'all') { - filter = 'member' - } - - return listProjectsQuery({ - ...query, - status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), - filter, - userId: user.id, - }).then(projects => projects - .map(({ clusters, roles, ...project }) => ({ - ...project, - clusterIds: clusters.map(({ id }) => id), - members: rolesToMembers(roles), - }))) -} - -// Check logic -export const checkCreateProject = async ( - organizationName: Organization['name'], - data: CreateProjectBody, -) => { - const schemaValidation = ProjectSchemaV2.pick({ name: true, organizationId: true, description: true }).safeParse(data) - validateSchema(schemaValidation) - - const projectSearch = await getProjectByNames({ name: data.name, organizationName }) - if (projectSearch.length > 0) { - throw new BadRequestError(`Le projet "${data.name}" existe déjà`, { extras: {}, description: `Le projet "${data.name}" existe déjà` }) - } -} - -const filterProject = ( +export const listProjects = async ( + { status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'], - project: AsyncReturnType, -) => { - if (!project) throw new NotFoundError('Projet introuvable') - if (!checkInsufficientRoleInProject(userId, { roles: project.roles, minRole: 'owner' })) return project - // @ts-ignore - project = exclude(project, ['clusters']) - // TODO définir les clés disponibles des environnements par niveau d'autorisation - // @ts-ignore - project.environments = project.environments.filter(env => !checkInsufficientPermissionInEnvironment(userId, env.permissions, 0)).map(({ ...env }) => env) - return project -} - -// Routes logic -export const getProject = async (projectId: string, userId: User['id']) => { - const project = await getProjectInfosQuery(projectId) - if (!project) throw new NotFoundError('Projet introuvable', undefined) - const insufficientRoleErrorMessage = checkInsufficientRoleInProject(userId, { roles: project.roles, minRole: 'user' }) - if (insufficientRoleErrorMessage) throw new ForbiddenError('Vous ne faites pas partie de ce projet', { description: '', extras: { userId, projectId: project.id } }) - - return filterProject(userId, project) -} - -export const getProjectSecrets = async (projectId: string, userId: User['id']) => { - const project = await getProjectInfosQuery(projectId) - if (!project) throw new NotFoundError('Projet introuvable', undefined) - const insufficientRoleErrorMessage = checkInsufficientRoleInProject(userId, { roles: project.roles, minRole: 'owner' }) - if (insufficientRoleErrorMessage) throw new ForbiddenError(insufficientRoleErrorMessage, { description: '', extras: { userId, projectId: project.id } }) - - const hookReply = await hook.project.getSecrets(project.id) +) => listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, +}).then(projects => projects + .map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString() })), + everyonePerms: project.everyonePerms.toString(), + }))) + +export const getProjectSecrets = async (projectId: string, _userId: User['id']) => { + const hookReply = await hook.project.getSecrets(projectId) if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la récupération des secrets du projet') + return new Unprocessable422('Echec des services à la récupération des secrets du projet') } return Object.fromEntries( @@ -142,162 +70,123 @@ export const getProjectSecrets = async (projectId: string, userId: User['id']) = .map(([key, value]) => [servicesInfos[key]?.title, value.secrets])) } -export const createProject = async (dataDto: CreateProjectBody, requestor: UserDto, requestId: string) => { +export const createProject = async (dataDto: CreateProjectBody, { groups, ...requestor }: UserDetails, requestId: string) => { // Pré-requis - const owner = await getOrCreateUser(requestor) + const owner = await logUser({ groups, ...requestor }) const organization = await getOrganizationById(dataDto.organizationId) - if (!organization) throw new NotFoundError('Organisation introuvable') - await checkCreateProject(organization.name, dataDto) + if (!organization) return new BadRequest400('Organisation introuvable') + + const projectSearch = await getProjectByNames({ name: dataDto.name, organizationName: organization.name }) + if (projectSearch.length > 0) { + return new BadRequest400(`Le projet "${dataDto.name}" existe déjà`) + } // Actions const project = await initializeProject({ ...dataDto, ownerId: owner.id }) - try { - const { results } = await hook.project.upsert(project.id) - await addLogs('Create Project', results, owner.id, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la création du projet') - } + const { results } = await hook.project.upsert(project.id) + await addLogs('Create Project', results, owner.id, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } - const projectInfos = await getProjectInfosOrThrowQuery(project.id) + const projectInfos = await getProjectInfosOrThrowQuery(project.id) - return { - ...projectInfos, - clusterIds: projectInfos.clusters.map(({ id }) => id), - members: rolesToMembers(projectInfos.roles), - } - } catch (error) { - throw new Error(error?.message) + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString() })), } } -export const updateProject = async (data: UpdateProjectBody, projectId: Project['id'], requestor: UserDto, requestId: string) => { - const keysAllowedForUpdate = ['description'] - const dataFiltered = filterObjectByKeys(data, keysAllowedForUpdate) - - // Pré-requis - const project = await getProject(projectId, requestor.id) - if (!project) throw new NotFoundError('Projet introuvable') - if (project.locked) throw new ForbiddenError(projectIsLockedInfo) - Object.keys(data).forEach((key) => { - // @ts-ignore - project[key] = data[key] - }) - - const schemaValidation = ProjectSchemaV2.pick({ description: true }).safeParse(data) - validateSchema(schemaValidation) - +export const updateProject = async ({ description, ownerId, everyonePerms }: UpdateProjectBody, projectId: Project['id'], requestor: UserDetails, requestId: string) => { // Actions - try { - await updateProjectQuery(projectId, dataFiltered) + await updateProjectQuery(projectId, { + description, + ownerId, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + }) - const { results } = await hook.project.upsert(project.id) - await addLogs('Update Project', results, requestor.id, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la mise à jour du projet') - } + const { results } = await hook.project.upsert(projectId) + await addLogs('Update Project', results, requestor.id, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } - const projectInfos = await getProjectInfosOrThrowQuery(projectId) + const projectInfos = await getProjectInfosOrThrowQuery(projectId) - return { - ...projectInfos, - clusterIds: projectInfos.clusters.map(({ id }) => id), - members: rolesToMembers(projectInfos.roles), - } - } catch (error) { - throw new Error(error?.message) + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString() })), } } export const replayHooks = async (projectId: Project['id'], requestor: KeycloakPayload, requestId: string) => { - try { - // Pré-requis - const project = await getProjectInfosQuery(projectId) - if (!project || project.status === 'archived') throw new NotFoundError('Projet introuvable') - - if (!hasGroupAdmin(requestor.groups)) { - const insufficientRoleErrorMessage = checkInsufficientRoleInProject(requestor.id, { roles: project.roles, minRole: 'user' }) - if (insufficientRoleErrorMessage) throw new ForbiddenError(insufficientRoleErrorMessage) - } + // Pré-requis + const project = await getProjectInfosQuery(projectId) + if (!project || project.status === 'archived') return new NotFound404() - // Actions - const { results } = await hook.project.upsert(project.id) - await addLogs('Replay hooks for Project', results, requestor.id, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services au reprovisionnement du projet') - } - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error(error?.message) + // Actions + const { results } = await hook.project.upsert(project.id) + await addLogs('Replay hooks for Project', results, requestor.id, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') } + return null } export const archiveProject = async (projectId: Project['id'], requestor: KeycloakPayload, requestId: string) => { // Pré-requis const project = await getProjectInfosAndRepos(projectId) - if (!project) throw new NotFoundError('Projet introuvable') - - if (!requestor.groups?.includes(adminGroupPath)) { - const insufficientRoleErrorMessage = checkInsufficientRoleInProject(requestor.id, { roles: project.roles, minRole: 'owner' }) - if (insufficientRoleErrorMessage) throw new ForbiddenError(insufficientRoleErrorMessage) - } + if (!project) return new NotFound404() // Actions - try { - // Empty the project first - await Promise.all([ - lockProject(projectId), - deleteAllRepositoryForProject(projectId), - deleteAllEnvironmentForProject(projectId), - deleteAllRoleNonOwnerForProject(projectId), - ]) - - const { results: upsertResults } = await hook.project.upsert(project.id) - await addLogs('Delete all project resources', upsertResults, requestor.id, requestId) - if (upsertResults.failed) { - throw new UnprocessableContentError('Echec des services à la suppression des ressources du projet') - } - - // -- début - Suppression projet -- - const { results } = await hook.project.delete(project.id) - await addLogs('Archive Project', results, requestor.id, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la suppression du projet') - } + // Empty the project first + await Promise.all([ + lockProject(projectId), + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + const { results: upsertResults } = await hook.project.upsert(project.id) + await addLogs('Delete all project resources', upsertResults, requestor.id, requestId) + if (upsertResults.failed) { + return new Unprocessable422('Echec des services à la suppression des ressources du projet') + } - // -- début - Retrait clusters -- - for (const cluster of project.clusters) { - await removeClusterFromProject(cluster.id, project.id) - } - // -- fin - Retrait clusters cibles -- + // -- début - Suppression projet -- + const { results } = await hook.project.delete(project.id) + await addLogs('Archive Project', results, requestor.id, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } - // -- fin - Suppression projet -- - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error(error?.message) + // -- début - Retrait clusters -- + for (const cluster of project.clusters) { + await removeClusterFromProject(cluster.id, project.id) } + // -- fin - Retrait clusters cibles -- + + // -- fin - Suppression projet -- + return null } export const handleProjectLocking = async (projectId: Project['id'], lock: Project['locked']) => { - try { - if (lock) { - await lockProject(projectId) - } else { - await unlockProject(projectId) - } - } catch (error) { - throw new BadRequestError(error.message) + if (lock) { + await lockProject(projectId) + } else { + await unlockProject(projectId) } + return null } export const generateProjectsData = async () => { - try { - const projects = await getAllProjectsDataForExport() + const projects = await getAllProjectsDataForExport() - return json2csv(projects, { - emptyFieldValue: '', - }) - } catch (error) { - throw new BadRequestError(error.message) - } + return json2csv(projects, { + emptyFieldValue: '', + }) } diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts index 963ba53c6..b61067bc6 100644 --- a/apps/server/src/resources/project/queries.ts +++ b/apps/server/src/resources/project/queries.ts @@ -3,13 +3,12 @@ import { ProjectStatus, type Organization, type Project, - type Role, type User, } from '@prisma/client' -import { ClusterPrivacy, projectContract, XOR, type AsyncReturnType } from '@cpn-console/shared' +import { projectContract, XOR } from '@cpn-console/shared' import prisma from '@/prisma.js' -type ProjectUpdate = Partial> +type ProjectUpdate = Partial> export const updateProject = (id: Project['id'], data: ProjectUpdate) => prisma.project.update({ where: { id }, @@ -48,34 +47,34 @@ export const listProjects = async ({ ...organizationName && { organization: { name: organizationName } }, } if (filter === 'owned') { - where.roles = { some: { AND: [{ role: 'owner' }, { userId }] } } + where.ownerId = userId } else if (filter === 'member') { - where.roles = { some: { userId } } + where.OR = [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] } return prisma.project.findMany({ where, include: { - roles: { - include: { - user: true, - }, - }, clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, }, }) } -export const getProjectInfosById = (projectId: Project['id']) => - prisma.project.findUnique({ +export const getProjectInfosByIdOrThrow = (projectId: Project['id']) => + prisma.project.findUniqueOrThrow({ where: { id: projectId, }, select: { name: true, - roles: { - select: { userId: true }, - }, + members: { include: { user: true } }, organization: { select: { name: true }, }, @@ -91,7 +90,6 @@ export const getProjectInfosById = (projectId: Project['id']) => }, }, clusters: { - where: { privacy: ClusterPrivacy.DEDICATED }, select: { id: true, label: true, @@ -104,74 +102,25 @@ export const getProjectInfosById = (projectId: Project['id']) => }, }) -export const getProjectUsers = (projectId: Project['id']) => - prisma.user.findMany({ - where: { - roles: { - some: { - projectId, - }, - }, - }, - }) - -export const getUserProjects = (userId: User['id']) => - prisma.project.findMany({ +export const getProjectMembers = (projectId: Project['id']) => + prisma.projectMembers.findMany({ where: { - roles: { - some: { - userId, - }, - }, - status: { - not: ProjectStatus.archived, - }, - }, - orderBy: { - name: 'asc', - }, - include: { - organization: true, - environments: { - include: { - permissions: true, - }, - }, - repositories: true, - roles: true, - clusters: { - where: { - privacy: ClusterPrivacy.DEDICATED, - }, - select: { - id: true, - label: true, - privacy: true, - clusterResources: true, - infos: true, - zoneId: true, - }, - }, + projectId, }, + include: { user: true }, }) -export type DsoProject = AsyncReturnType[0] & { services: any } - export const getProjectById = (id: Project['id']) => prisma.project.findUnique({ where: { id } }) -const baseProjectIncludes: Parameters[0]['include'] = { +const baseProjectIncludes = { organization: true, - roles: true, - environments: { - include: { - permissions: true, - stage: true, - quota: true, - }, - }, + members: { include: { user: true } }, clusters: true, -} + roles: true, + owner: true, +} as const + export const getProjectInfos = (id: Project['id']) => prisma.project.findUnique({ where: { id }, @@ -181,13 +130,7 @@ export const getProjectInfos = (id: Project['id']) => export const getProjectInfosOrThrow = (id: Project['id']) => prisma.project.findUniqueOrThrow({ where: { id }, - include: { - organization: true, - roles: { include: { user: true } }, - environments: { include: { permissions: true, stage: true, quota: true } }, - clusters: true, - repositories: true, - }, + include: baseProjectIncludes, }) export const getProjectInfosAndRepos = (id: Project['id']) => @@ -240,21 +183,13 @@ export const getAllProjectsDataForExport = () => }, }, }, - roles: { - select: { - role: true, - user: { - select: { email: true }, - }, - }, - }, + owner: true, }, }) export const getRolesByProjectId = (projectId: Project['id']) => - prisma.role.findMany({ + prisma.projectRole.findMany({ where: { projectId }, - include: { user: true }, }) const clusterInfosSelect = { @@ -275,52 +210,29 @@ const clusterInfosSelect = { export const getHookProjectInfos = (id: Project['id']) => prisma.project.findUniqueOrThrow({ where: { id }, - select: { - id: true, - name: true, - status: true, - description: true, - organization: { - select: { - id: true, - label: true, - name: true, - }, - }, - roles: { - select: { - user: true, - role: true, - userId: true, - }, - }, - clusters: { - select: clusterInfosSelect, - }, + include: { + organization: true, + members: { include: { user: true } }, + clusters: { select: clusterInfosSelect }, environments: { include: { - permissions: true, quota: true, stage: true, - cluster: { select: clusterInfosSelect }, - }, - }, - repositories: { - select: { - id: true, - externalRepoUrl: true, - isInfra: true, - isPrivate: true, - internalRepoName: true, + cluster: { + select: clusterInfosSelect, + }, }, }, - projectPlugin: { + repositories: true, + plugins: { select: { key: true, pluginName: true, value: true, }, }, + owner: true, + roles: true, }, }) @@ -342,12 +254,7 @@ export const initializeProject = ( description, status: ProjectStatus.created, locked: false, - roles: { - create: { - role: 'owner', - userId: ownerId, - }, - }, + ownerId, }, }) @@ -373,28 +280,21 @@ export const updateProjectFailed = (id: Project['id']) => }) export const addUserToProject = ( - { project, user, role }: { project: Project, user: User, role: Role['role'] }, + { project, user }: { project: Project, user: User }, ) => - prisma.role.create({ + prisma.projectMembers.create({ data: { - user: { - connect: { id: user.id }, - }, - role, - project: { - connect: { - id: project.id, - }, - }, + userId: user.id, + projectId: project.id, }, }) export const removeUserFromProject = ( { projectId, userId }: { projectId: Project['id'], userId: User['id'] }, ) => - prisma.role.delete({ + prisma.projectMembers.delete({ where: { - userId_projectId: { + projectId_userId: { projectId, userId, }, @@ -419,5 +319,3 @@ export const archiveProject = async (id: Project['id']) => { // TECH export const _initializeProject = (data: Parameters[0]['create']) => prisma.project.upsert({ where: { id: data.id }, create: data, update: data }) - -export const _dropProjectsTable = prisma.project.deleteMany diff --git a/apps/server/src/resources/project/router.ts b/apps/server/src/resources/project/router.ts index 2f51edd89..51053cb61 100644 --- a/apps/server/src/resources/project/router.ts +++ b/apps/server/src/resources/project/router.ts @@ -1,4 +1,3 @@ -import { addReqLogs } from '@/utils/logger.js' import { createProject, updateProject, @@ -9,165 +8,154 @@ import { handleProjectLocking, generateProjectsData, } from './business.js' -import { projectContract } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, NotFound404, Forbidden403, ErrorResType, BadRequest400 } from '@/utils/controller.js' export const projectRouter = () => serverInstance.router(projectContract, { // Récupérer des projets listProjects: async ({ request: req, query }) => { - try { - const user = req.session.user - - const allProjects = await listProjects( - query, - user, - ) + const user = req.session.user + const perms = await authUser(user) + if (query.filter === 'all' && !AdminAuthorized.ListProjects(perms.adminPermissions)) { + return new BadRequest400('Seul les admins avec les droits de visionnage de projet peuvent utiliser le filtre \'all\'') + } + const body = await listProjects( + query, + user.id, + ) - addReqLogs({ - req, - message: 'Ensemble des projets récupérés avec succès', - }) - return { - status: 200, - body: allProjects, - } - } catch (error) { - throw new Error(error.message) + return { + status: 200, + body, } }, // Récupérer les secrets d'un projet getProjectSecrets: async ({ request: req, params }) => { - const userId = req.session.user.id const projectId = params.projectId + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId, user.id) - const projectSecrets = await getProjectSecrets(projectId, userId) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Secrets du projet récupérés avec succès', - infos: { - projectId, - userId, - }, - }) return { status: 200, - body: projectSecrets, + body, } }, // Créer un projet createProject: async ({ request: req, body: data }) => { - const requestor = req.session.user - // @ts-ignore - delete requestor.groups - - const project = await createProject(data, requestor, req.id) - - addReqLogs({ - req, - message: 'Projet créé avec succès', - infos: { - projectId: project.id, - }, - }) + const user = req.session.user + const body = await createProject(data, user, req.id) + + if (body instanceof ErrorResType) return body + return { status: 201, - body: project, + body, } }, // Mettre à jour un projet updateProject: async ({ request: req, params, body: data }) => { - const requestor = req.session.user const projectId = params.projectId + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if ( + data.ownerId && perms.projectOwnerId !== data.ownerId // Il essaye de changer le owner + && ( + perms.projectOwnerId !== user.id // mais il n'est ni owner + || !AdminAuthorized.ManageProjects(perms.adminPermissions) // ni authorisé comme admin + ) + ) return new Forbidden403('Seul le owner du projet peut transférer le projet') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (!perms.projectPermissions) return new NotFound404() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') - const project = await updateProject(data, projectId, requestor, req.id) - addReqLogs({ - req, - message: 'Projet mis à jour avec succès', - infos: { - projectId, - }, - }) + const body = await updateProject(data, projectId, user, req.id) + + if (body instanceof ErrorResType) return body return { status: 200, - body: project, + body, } }, // Reprovisionner un projet replayHooksForProject: async ({ request: req, params }) => { - const requestor = req.session.user const projectId = params.projectId + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + if (!perms.projectPermissions) return new NotFound404() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await replayHooks(projectId, user, req.id) - await replayHooks(projectId, requestor, req.id) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Projet reprovisionné avec succès', - infos: { - projectId, - }, - }) return { status: 204, - body: null, + body, } }, // Archiver un projet archiveProject: async ({ request: req, params }) => { - const requestor = req.session.user const projectId = params.projectId + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (!perms.projectPermissions) return new NotFound404() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') - await archiveProject(projectId, requestor, req.id) + const body = await archiveProject(projectId, user, req.id) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Projet en cours de suppression', - infos: { - projectId, - }, - }) return { status: 204, - body: null, + body, } }, // Récupérer les données de tous les projets pour export getProjectsData: async ({ request: req }) => { - assertIsAdmin(req.session.user) - const generatedProjectsData = await generateProjectsData() + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ListProjects(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() - addReqLogs({ - req, - message: 'Données des projets rassemblées pour export', - }) return { status: 200, - body: generatedProjectsData, + body, } }, // (Dé)verrouiller un projet patchProject: async ({ request: req, params, body: data }) => { - assertIsAdmin(req.session.user) + const user = req.session.user const projectId = params.projectId + const perms = await authUser(user, { id: projectId }) + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + const lock = data.lock - await handleProjectLocking(projectId, lock) + const body = await handleProjectLocking(projectId, lock) - addReqLogs({ - req, - message: `Projet ${lock ? 'verrouillé' : 'déverrouillé'} avec succès`, - }) return { status: 200, - body: null, + body, } }, }) diff --git a/apps/server/src/resources/queries-index.ts b/apps/server/src/resources/queries-index.ts index fe46fb729..d7f9fc937 100644 --- a/apps/server/src/resources/queries-index.ts +++ b/apps/server/src/resources/queries-index.ts @@ -1,13 +1,14 @@ +export * from '@/resources/admin-role/queries.js' export * from '@/resources/cluster/queries.js' export * from '@/resources/environment/queries.js' export * from '@/resources/log/queries.js' export * from '@/resources/organization/queries.js' -export * from '@/resources/permission/queries.js' +// export * from '@/resources/permission/queries.js' export * from '@/resources/project/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' export * from '@/resources/project-service/queries.js' export * from '@/resources/repository/queries.js' -export * from '@/resources/user/role-queries.js' -export * from '@/resources/system/db/queries.js' export * from '@/resources/user/queries.js' export * from '@/resources/stage/queries.js' export * from '@/resources/quota/queries.js' diff --git a/apps/server/src/resources/quota/business.ts b/apps/server/src/resources/quota/business.ts index dc999b9b3..10b4f0fb1 100644 --- a/apps/server/src/resources/quota/business.ts +++ b/apps/server/src/resources/quota/business.ts @@ -1,6 +1,5 @@ -import { type Quota } from '@prisma/client' +import { User, type Quota } from '@prisma/client' import { type CreateQuotaBody, QuotaSchema, type UpdateQuotaBody, type Quota as QuotaDto } from '@cpn-console/shared' -import { BadRequestError, DsoError, NotFoundError } from '@/utils/errors.js' import { getQuotaByName, createQuota as createQuotaQuery, @@ -19,52 +18,47 @@ import { listQuotas as listQuotasQuery, getAllQuotas, } from '../queries-index.js' -import { UserProfile, adminGroupPath } from '@cpn-console/shared' +import { ErrorResType, BadRequest400, NotFound404 } from '@/utils/controller.js' +import prisma from '@/prisma.js' export const getQuotaAssociatedEnvironments = async (quotaId: string) => { try { const quota = await getQuotaById(quotaId) - if (!quota) throw new NotFoundError('Quota introuvable') + if (!quota) return new NotFound404() const environments = await getQuotaAssociatedEnvironmentById(quotaId) return environments.map(env => ({ name: env.name, project: env.project.name, organization: env.project.organization.name, stage: env.stage.name, - owner: env.project.roles?.[0].user.email, + owner: env.project.owner.email, })) } catch (error) { throw new Error(error?.message) } } -export const createQuota = async (data: CreateQuotaBody): Promise => { - try { - const schemaValidation = QuotaSchema.omit({ id: true }).safeParse(data) - validateSchema(schemaValidation) +export const createQuota = async (data: CreateQuotaBody): Promise => { + const schemaValidation = QuotaSchema.omit({ id: true }).safeParse(data) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult - const isNameTaken = await getQuotaByName(data.name) - if (isNameTaken) throw new BadRequestError('Un quota portant ce nom existe déjà') + const isNameTaken = await getQuotaByName(data.name) + if (isNameTaken) return new BadRequest400('Un quota portant ce nom existe déjà') - const quota = await createQuotaQuery(data) + const quota = await createQuotaQuery(data) - if (data.stageIds?.length) { - await linkQuotaToStages(quota.id, data.stageIds) - } + if (data.stageIds?.length) { + await linkQuotaToStages(quota.id, data.stageIds) + } - return { - id: quota.id, - name: quota.name, - cpu: quota.cpu, - memory: quota.memory, - isPrivate: quota.isPrivate, - stageIds: data.stageIds ?? [], - } - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + return { + id: quota.id, + name: quota.name, + cpu: quota.cpu, + memory: quota.memory, + isPrivate: quota.isPrivate, + stageIds: data.stageIds ?? [], } } @@ -76,68 +70,58 @@ export const updateQuota = async ( name, stageIds, }: UpdateQuotaBody, -): Promise => { - try { - const dbQuota = await getQuotaById(id) +) => { + const dbQuota = await getQuotaById(id) - if (!dbQuota) throw new NotFoundError('Quota introuvable') - const dbStageIds = dbQuota.stages.map(({ id }) => id) - if (name === dbQuota.name) { - await updateQuotaName(id, name) - } - if (typeof isPrivate === 'boolean') { - await updateQuotaPrivacyQuery(id, isPrivate) - } - if (cpu && memory) { - await updateQuotaLimits(id, { - cpu, - memory, - }) - } - if (stageIds) { - const dbStages = dbQuota.stages - const stageIdsToRemove = dbStages - .filter(({ id }) => !stageIds.includes(id)) - .map(({ id }) => id) + if (!dbQuota) return new NotFound404() + const dbStageIds = dbQuota.stages.map(({ id }) => id) + if (name === dbQuota.name) { + await updateQuotaName(id, name) + } + if (typeof isPrivate === 'boolean') { + await updateQuotaPrivacyQuery(id, isPrivate) + } + if (cpu && memory) { + await updateQuotaLimits(id, { + cpu, + memory, + }) + } + if (stageIds) { + const dbStages = dbQuota.stages + const stageIdsToRemove = dbStages + .filter(({ id }) => !stageIds.includes(id)) + .map(({ id }) => id) - if (stageIdsToRemove.length) { - await unlinkQuotaFromStages(id, stageIdsToRemove) - } - if (stageIds?.length) { - await linkQuotaToStages(id, stageIds) - } + if (stageIdsToRemove.length) { + await unlinkQuotaFromStages(id, stageIdsToRemove) } - return { - id, - name: name ?? dbQuota.name, - cpu: cpu ?? dbQuota.cpu, - memory: memory ?? dbQuota.memory, - isPrivate: isPrivate ?? dbQuota.isPrivate, - stageIds: stageIds ?? dbStageIds, + if (stageIds?.length) { + await linkQuotaToStages(id, stageIds) } - } catch (error) { - throw new Error(error?.message) + } + return { + id, + name: name ?? dbQuota.name, + cpu: cpu ?? dbQuota.cpu, + memory: memory ?? dbQuota.memory, + isPrivate: isPrivate ?? dbQuota.isPrivate, + stageIds: stageIds ?? dbStageIds, } } export const deleteQuota = async (quotaId: string) => { - try { - const environments = await getQuotaAssociatedEnvironments(quotaId) - if (environments.length) throw new BadRequestError('Impossible de supprimer le quota, des environnements en activité y ont souscrit', { extras: environments }) + const attachedEnvironment = await prisma.environment.findFirst({ where: { quotaId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le quota, des environnements en activité y ont souscrit') - await deleteQuotaQuery(quotaId) - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) - } + await deleteQuotaQuery(quotaId) + return null } -export const listQuotas = async (kcUser: UserProfile) => { - const quotas = kcUser.groups?.includes(adminGroupPath) - ? await getAllQuotas() - : await listQuotasQuery(kcUser.id) +export const listQuotas = async (userId?: User['id']) => { + const quotas = userId + ? await listQuotasQuery(userId) + : await getAllQuotas() return quotas.map(({ stages, ...quota }) => { return { diff --git a/apps/server/src/resources/quota/queries.ts b/apps/server/src/resources/quota/queries.ts index 2695034ae..ede8c3c3d 100644 --- a/apps/server/src/resources/quota/queries.ts +++ b/apps/server/src/resources/quota/queries.ts @@ -7,7 +7,7 @@ export const listQuotas = (userId: User['id']) => OR: [{ isPrivate: false, }, { - environments: { some: { project: { roles: { some: { userId } } } } }, + environments: { some: { project: { members: { some: { userId } } } } }, }], }, include: { @@ -47,15 +47,7 @@ export const getQuotaAssociatedEnvironmentById = (id: Quota['id']) => organization: { select: { name: true }, }, - roles: { - where: { - role: 'owner', - }, - select: { - user: true, - role: true, - }, - }, + owner: true, }, }, stage: true, diff --git a/apps/server/src/resources/quota/router.ts b/apps/server/src/resources/quota/router.ts index 3712d10bd..cdbbd62e9 100644 --- a/apps/server/src/resources/quota/router.ts +++ b/apps/server/src/resources/quota/router.ts @@ -1,19 +1,18 @@ -import { quotaContract } from '@cpn-console/shared' +import { AdminAuthorized, quotaContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' -import { addReqLogs } from '@/utils/logger.js' -import { assertIsAdmin } from '@/utils/controller.js' + +import { authUser, ErrorResType, Forbidden403 } from '@/utils/controller.js' import { listQuotas, createQuota, deleteQuota, getQuotaAssociatedEnvironments, updateQuota } from './business.js' export const quotaRouter = () => serverInstance.router(quotaContract, { // Récupérer les quotas disponibles listQuotas: async ({ request: req }) => { const user = req.session.user - const quotas = await listQuotas(user) + const perms = await authUser(user) + const quotas = AdminAuthorized.ManageQuotas(perms.adminPermissions) + ? await listQuotas() + : await listQuotas(user.id) - addReqLogs({ - req, - message: 'Quotas récupérés avec succès', - }) return { status: 200, body: quotas, @@ -22,83 +21,67 @@ export const quotaRouter = () => serverInstance.router(quotaContract, { // Récupérer les environnements associés au quota listQuotaEnvironments: async ({ request: req, params }) => { - const quotaId = params.quotaId - - assertIsAdmin(req.session.user) - const environments = await getQuotaAssociatedEnvironments(quotaId) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) return new Forbidden403() - addReqLogs({ - req, - message: 'Environnements associés au quota récupérés', - infos: { - quotaId, - }, - }) + const quotaId = params.quotaId + const body = await getQuotaAssociatedEnvironments(quotaId) + if (body instanceof ErrorResType) return body return { status: 200, - body: environments, + body, } }, // Créer un quota createQuota: async ({ request: req, body: data }) => { - assertIsAdmin(req.session.user) - const quota = await createQuota(data) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) delete data.stageIds - addReqLogs({ - req, - message: 'Quota créé avec succès', - infos: { - quotaId: quota.id, - }, - }) + const body = await createQuota(data) + + if (body instanceof ErrorResType) return body return { status: 201, - body: quota, + body, } }, // Modifier la confidentialité d'un quota updateQuota: async ({ request: req, params, body: data }) => { - const quotaId = params.quotaId - - assertIsAdmin(req.session.user) - const quota = await updateQuota(quotaId, data) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) delete data.stageIds - addReqLogs({ - req, - message: 'Confidentialité du quota mise à jour avec succès', - infos: { - quotaId: quota.id, - }, - }) + const quotaId = params.quotaId + const body = await updateQuota(quotaId, data) + if (body instanceof ErrorResType) return body return { status: 200, - body: quota, + body, } }, // Supprimer un quota deleteQuota: async ({ request: req, params }) => { - const quotaId = params.quotaId - - assertIsAdmin(req.session.user) - await deleteQuota(quotaId) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) return new Forbidden403() - addReqLogs({ - req, - message: 'Quota supprimé avec succès', - infos: { - quotaId, - }, - }) + const quotaId = params.quotaId + const body = await deleteQuota(quotaId) + if (body instanceof ErrorResType) return body return { status: 204, - body: null, + body, } }, }) diff --git a/apps/server/src/resources/repository/business.ts b/apps/server/src/resources/repository/business.ts index 59a5a892b..a874e6f05 100644 --- a/apps/server/src/resources/repository/business.ts +++ b/apps/server/src/resources/repository/business.ts @@ -1,38 +1,32 @@ -import type { Repository, User, Role } from '@prisma/client' -import type { Project, CreateRepositoryBody, ProjectRoles, UpdateRepositoryBody } from '@cpn-console/shared' -import { addLogs, deleteRepository as deleteRepositoryQuery, getRepositoryById as getRepositoryByIdQuery, getProjectInfosAndRepos, getUserById, initializeRepository, updateRepository as updateRepositoryQuery, getProjectRepositories as getProjectRepositoriesQuery, getProjectInfosOrThrow } from '@/resources/queries-index.js' -import { checkInsufficientRoleInProject, checkRoleAndLocked } from '@/utils/controller.js' -import { BadRequestError, DsoError, ForbiddenError, NotFoundError, UnauthorizedError, UnprocessableContentError } from '@/utils/errors.js' +import type { Repository, User } from '@prisma/client' +import type { Project, CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import { addLogs, deleteRepository as deleteRepositoryQuery, getRepositoryById as getRepositoryByIdQuery, getProjectInfosAndRepos, initializeRepository, updateRepository as updateRepositoryQuery, getProjectRepositories as getProjectRepositoriesQuery, getProjectInfosOrThrow } from '@/resources/queries-index.js' +import { checkLocked, ErrorResType, BadRequest400, NotFound404, Unprocessable422 } from '@/utils/controller.js' import { hook } from '@/utils/hook-wrapper.js' export const getRepositoryById = async ( - userId: User['id'], projectId: Project['id'], repositoryId: Repository['id'], ) => { - const project = await getProjectAndCheckRole(userId, projectId) + const project = await getProjectAndCheckRole(projectId) + if (project instanceof ErrorResType) return project + const repository = project.repositories?.find(repo => repo.id === repositoryId) - if (!repository) throw new NotFoundError('Dépôt introuvable') + if (!repository) return new NotFound404() return repository } export const getProjectRepositories = async ( - userId: User['id'], - isAdmin: boolean, projectId: Project['id'], ) => { - return isAdmin ? await getProjectRepositoriesQuery(projectId) : (await getProjectAndCheckRole(userId, projectId)).repositories + return getProjectRepositoriesQuery(projectId) } export const getProjectAndCheckRole = async ( - userId: User['id'], projectId: Project['id'], - minRole: ProjectRoles = 'user', ) => { const project = await getProjectInfosAndRepos(projectId) - if (!project) throw new BadRequestError(`Le projet ayant pour id ${projectId} n'existe pas`) - const errorMessage = checkInsufficientRoleInProject(userId, { roles: project.roles, minRole }) - if (errorMessage) throw new ForbiddenError(errorMessage, undefined) + if (!project) return new BadRequest400(`Le projet ayant pour id ${projectId} n'existe pas`) return project } @@ -48,30 +42,21 @@ export const syncRepository = async ( branchName: string requestId: string }) => { - try { - const repository = await getRepositoryByIdQuery(repositoryId) - const project = await getProjectInfosOrThrow(repository.projectId) - await checkUpsertRepository({ project, userId }) - - const hookReply = await hook.misc.syncRepository(repositoryId, { branchName }) - await addLogs('Sync Repository', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la synchronisation du dépôt') - } - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error('Echec de la synchronisation du dépôt') + const repository = await getRepositoryByIdQuery(repositoryId) + const project = await getProjectInfosOrThrow(repository.projectId) + await checkUpsertRepository(project) + + const hookReply = await hook.misc.syncRepository(repositoryId, { branchName }) + await addLogs('Sync Repository', hookReply, userId, requestId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') } + return null } -export const checkUpsertRepository = async ( - { - project, - userId, - minRole = 'user', - }: { userId: User['id'], project: { locked: Project['locked'], roles: Role[] }, minRole?: ProjectRoles }) => { - const errorMessage = checkRoleAndLocked(project, userId, minRole) - if (errorMessage) throw new ForbiddenError(errorMessage) +export const checkUpsertRepository = async ({ locked }: { locked: Project['locked'] }) => { + const errorMessage = checkLocked({ locked }) + if (errorMessage) return new BadRequest400(errorMessage) } export const createRepository = async ( @@ -84,38 +69,32 @@ export const createRepository = async ( userId: User['id'] requestId: string }) => { - const user = await getUserById(userId) - if (!user) throw new UnauthorizedError('Veuillez vous identifier') - const project = await getProjectInfosAndRepos(data.projectId) - if (!project) throw new BadRequestError(`Le projet ayant pour id ${data.projectId} n'existe pas`) - await checkUpsertRepository({ project, userId, minRole: 'owner' }) + if (!project) return new BadRequest400(`Le projet ayant pour id ${data.projectId} n'existe pas`) + const checkResult = await checkUpsertRepository(project) + + if (checkResult instanceof ErrorResType) return checkResult - if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) throw new BadRequestError(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`, undefined) + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } delete dbData.externalToken const repo = await initializeRepository(dbData) - try { - const { results } = await hook.project.upsert(project.id, data.isPrivate - ? { - [repo.internalRepoName]: { - token: data.externalToken ?? '', - username: data.externalUserName ?? '', - }, - } - : undefined, - ) - await addLogs('Create Repository', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services lors de la création du dépôt') - } - - return repo - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error('Echec de la création du dépôt') + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined, + ) + await addLogs('Create Repository', results, userId, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') } + + return repo } export const updateRepository = async ( @@ -130,31 +109,27 @@ export const updateRepository = async ( userId: User['id'] requestId: string }) => { - try { - const repository = await getRepositoryByIdQuery(repositoryId) - const project = await getProjectInfosOrThrow(repository.projectId) - await checkUpsertRepository({ project, userId }) - - const dbData = { ...data } - delete dbData.externalToken - const repo = await updateRepositoryQuery(repositoryId, dbData) - - const { results } = await hook.project.upsert(project.id, { - [repo.internalRepoName]: { - username: repo.externalUserName ?? '', - token: data.externalToken ?? '', - }, - }) - await addLogs('Update Repository', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la mise à jour du dépôt') - } - - return repo - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error('Echec de la mise à jour du dépôt') + const repository = await getRepositoryByIdQuery(repositoryId) + const project = await getProjectInfosOrThrow(repository.projectId) + const checkResult = await checkUpsertRepository(project) + if (checkResult instanceof ErrorResType) return checkResult + + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(project.id, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs('Update Repository', results, userId, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') } + + return repo } export const deleteRepository = async ({ @@ -167,21 +142,18 @@ export const deleteRepository = async ({ requestId: string }, ) => { - try { - const repository = await getRepositoryByIdQuery(repositoryId) - const project = await getProjectInfosOrThrow(repository.projectId) - - await checkUpsertRepository({ project, userId, minRole: 'owner' }) - - await deleteRepositoryQuery(repositoryId) - - const { results } = await hook.project.upsert(project.id) - await addLogs('Delete Repository', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à la suppression du dépôt') - } - } catch (error) { - if (error instanceof DsoError) throw error - throw new Error('Echec de la mise à jour du dépôt') + const repository = await getRepositoryByIdQuery(repositoryId) + const project = await getProjectInfosOrThrow(repository.projectId) + + const checkResult = await checkUpsertRepository(project) + if (checkResult instanceof ErrorResType) return checkResult + + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(project.id) + await addLogs('Delete Repository', results, userId, requestId) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') } + return null } diff --git a/apps/server/src/resources/repository/queries.ts b/apps/server/src/resources/repository/queries.ts index 2cd6026c0..a0147b1fa 100644 --- a/apps/server/src/resources/repository/queries.ts +++ b/apps/server/src/resources/repository/queries.ts @@ -53,8 +53,5 @@ export const deleteRepository = async (id: Repository['id']) => { export const deleteAllRepositoryForProject = (id: Project['id']) => prisma.repository.deleteMany({ where: { projectId: id } }) -// TECH -export const _dropRepositoriesTable = prisma.repository.deleteMany - export const _createRepository = (data: Parameters[0]['create']) => prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) diff --git a/apps/server/src/resources/repository/router.ts b/apps/server/src/resources/repository/router.ts index aa8f5470a..b6cc8e28c 100644 --- a/apps/server/src/resources/repository/router.ts +++ b/apps/server/src/resources/repository/router.ts @@ -1,7 +1,6 @@ -import { adminGroupPath, repositoryContract } from '@cpn-console/shared' +import { ProjectAuthorized, repositoryContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' -import { BadRequestError } from '@/utils/errors.js' -import { addReqLogs } from '@/utils/logger.js' + import { filterObjectByKeys } from '@/utils/queries-tools.js' import { createRepository, @@ -10,69 +9,76 @@ import { syncRepository, updateRepository, } from './business.js' +import { authUser, ErrorResType, Forbidden403, BadRequest400, NotFound404 } from '@/utils/controller.js' export const repositoryRouter = () => serverInstance.router(repositoryContract, { // Récupérer tous les repositories d'un projet listRepositories: async ({ request: req, query }) => { const projectId = query.projectId - const userId = req.session.user.id - const isAdmin = req.session.user.groups?.includes(adminGroupPath) + const user = req.session.user + const perms = await authUser(user, { id: projectId }) - const repositories = await getProjectRepositories(userId, isAdmin, projectId) + if (perms.projectPermissions && !ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + + const body = await getProjectRepositories(projectId) - addReqLogs({ - req, - message: 'Dépôts du projet récupérés avec succès', - infos: { - projectId, - repositoriesId: repositories.map(({ id }) => id).join(', '), - }, - }) return { status: 200, - body: repositories, + body, } }, // Synchroniser un repository syncRepository: async ({ request: req, params, body }) => { - const userId = req.session.user.id const { repositoryId } = params + const user = req.session.user + const perms = await authUser(user, { id: repositoryId }) + if (!perms.projectPermissions) return new NotFound404() + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const userId = req.session.user.id const { branchName } = body - await syncRepository({ repositoryId, userId, branchName, requestId: req.id }) + const resBody = await syncRepository({ repositoryId, userId, branchName, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody return { - body: null, status: 204, + body: resBody, } }, // Créer un repository createRepository: async ({ request: req, body: data }) => { - const userId = req.session.user.id const projectId = data.projectId + const userId = req.session.user.id + const user = req.session.user + const perms = await authUser(user, { id: projectId }) + if (!perms.projectPermissions) return new Forbidden403() - const repository = await createRepository({ data, userId, requestId: req.id }) + if (!ProjectAuthorized.ManageRepositories(perms)) return new NotFound404() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId, requestId: req.id }) + if (body instanceof ErrorResType) return body - addReqLogs({ - req, - message: 'Dépôt créé avec succès', - infos: { - projectId, - repositoryId: repository.id, - }, - }) return { status: 201, - body: repository, + body, } }, // Mettre à jour un repository updateRepository: async ({ request: req, params, body }) => { - const userId = req.session.user.id const repositoryId = params.repositoryId + const user = req.session.user + const perms = await authUser(user, { repositoryId }) + if (!ProjectAuthorized.ListRepositories(perms)) return new NotFound404() + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') const keysAllowedForUpdate = [ 'externalRepoUrl', @@ -83,26 +89,19 @@ export const repositoryRouter = () => serverInstance.router(repositoryContract, ] const data = filterObjectByKeys(body, keysAllowedForUpdate) - if (data.isPrivate && !data.externalToken) throw new BadRequestError('Le token est requis', undefined) - if (data.isPrivate && !data.externalUserName) throw new BadRequestError('Le nom d\'utilisateur est requis', undefined) + if (data.isPrivate && !data.externalToken) return new BadRequest400('Le token est requis') + if (data.isPrivate && !data.externalUserName) return new BadRequest400('Le nom d\'utilisateur est requis') if (!data.isPrivate) { data.externalToken = undefined data.externalUserName = '' } - const repository = await updateRepository({ repositoryId, data, userId, requestId: req.id }) + const resBody = await updateRepository({ repositoryId, data, userId: user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody - const message = 'Dépôt mis à jour avec succès' - addReqLogs({ - req, - message, - infos: { - repositoryId, - }, - }) return { status: 200, - body: repository, + body: resBody, } }, @@ -110,24 +109,23 @@ export const repositoryRouter = () => serverInstance.router(repositoryContract, deleteRepository: async ({ request: req, params }) => { const repositoryId = params.repositoryId const userId = req.session.user.id - - await deleteRepository({ + const user = req.session.user + const perms = await authUser(user, { repositoryId }) + if (ProjectAuthorized.ListRepositories(perms)) return new NotFound404() + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ repositoryId, userId, requestId: req.id, }) + if (body instanceof ErrorResType) return body - const message = 'Dépôt en cours de suppression' - addReqLogs({ - req, - message, - infos: { - repositoryId, - }, - }) return { status: 204, - body: null, + body, } }, }) diff --git a/apps/server/src/resources/service-monitor/router.ts b/apps/server/src/resources/service-monitor/router.ts index 141749402..29ff011f1 100644 --- a/apps/server/src/resources/service-monitor/router.ts +++ b/apps/server/src/resources/service-monitor/router.ts @@ -1,15 +1,11 @@ -import { addReqLogs } from '@/utils/logger.js' import { checkServicesHealth } from './business.js' import { serviceContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' export const serviceMonitorRouter = () => serverInstance.router(serviceContract, { - getServiceHealth: async ({ request: req }) => { + getServiceHealth: async () => { const serviceData = await checkServicesHealth() - addReqLogs({ - req, - message: 'Etats des services récupérés avec succès', - }) + return { status: 200, body: serviceData, diff --git a/apps/server/src/resources/stage/business.ts b/apps/server/src/resources/stage/business.ts index c838f63ee..73419f89c 100644 --- a/apps/server/src/resources/stage/business.ts +++ b/apps/server/src/resources/stage/business.ts @@ -1,6 +1,5 @@ -import type { Stage, Cluster, User } from '@prisma/client' +import type { Stage, Cluster } from '@prisma/client' import { type CreateStageBody, UpdateStageBody } from '@cpn-console/shared' -import { BadRequestError, DsoError, NotFoundError, UnauthorizedError } from '@/utils/errors.js' import { getStageByName, createStage as createStageQuery, @@ -10,129 +9,105 @@ import { removeClusterFromStage, linkStageToQuotas, getStageAssociatedEnvironmentById, - getStageAssociatedEnvironmentLengthById, updateStageName, unlinkStageFromQuotas, } from '@/resources/queries-index.js' - import { - getUserById, listStages as listStagesQuery, linkClusterToStages as linkClusterToStagesQuery, getAllStageIds, } from '../queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/controller.js' +import prisma from '@/prisma.js' export const getStageAssociatedEnvironments = async (stageId: Stage['id']) => { - try { - const stage = await getStageById(stageId) - if (!stage) throw new BadRequestError(`Le stage ${stageId} n'existe pas`) - const environments = await getStageAssociatedEnvironmentById(stageId) - return environments.map(env => ({ - organization: env.project.organization.name, - project: env.project.name, - name: env.name, - quota: env.quota.name, - cluster: env.cluster.label, - owner: env.project.roles?.[0].user.email, - })) - } catch (error) { - throw new Error(error?.message) - } + const stage = await getStageById(stageId) + if (!stage) return new BadRequest400(`Le stage ${stageId} n'existe pas`) + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + organization: env.project.organization.name, + project: env.project.name, + name: env.name, + quota: env.quota.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) } export const createStage = async ({ clusterIds = [], name, quotaIds = [] }: CreateStageBody) => { - try { - const isNameTaken = await getStageByName(name) - if (isNameTaken) throw new BadRequestError('Un type d\'environnement portant ce nom existe déjà') + const isNameTaken = await getStageByName(name) + if (isNameTaken) throw new BadRequest400('Un type d\'environnement portant ce nom existe déjà') - const stage = await createStageQuery({ name }) + const stage = await createStageQuery({ name }) - if (quotaIds.length) { - await linkStageToQuotas(stage.id, quotaIds) - } + if (quotaIds.length) { + await linkStageToQuotas(stage.id, quotaIds) + } - if (clusterIds.length) { - await linkStageToClusters(stage.id, clusterIds) - } + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } - return { - id: stage.id, - name: stage.name, - clusterIds, - quotaIds, - } - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) + return { + id: stage.id, + name: stage.name, + clusterIds, + quotaIds, } } export const updateStage = async (stageId: Stage['id'], { clusterIds, name, quotaIds }: UpdateStageBody) => { - try { - const dbStage = await getStageById(stageId) - if (!dbStage) throw new NotFoundError('Stage introuvable') - if (name === dbStage.name) { - await updateStageName(stageId, name) - } - // Remove clusters - if (clusterIds) { - const dbClusters = dbStage.clusters - if (dbClusters?.length) { - const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) - for (const clusterToRemove of clustersToRemove) { - await removeClusterFromStage(clusterToRemove.id, stageId) - } + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name === dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + if (clusterIds) { + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) } - // Add clusters - await linkStageToClusters(stageId, clusterIds) } + // Add clusters + await linkStageToClusters(stageId, clusterIds) + } - if (quotaIds) { - const dbQuotas = dbStage.quotas - const quotaIdsToRemove = dbQuotas - .filter(({ id }) => !quotaIds.includes(id)) - .map(({ id }) => id) + if (quotaIds) { + const dbQuotas = dbStage.quotas + const quotaIdsToRemove = dbQuotas + .filter(({ id }) => !quotaIds.includes(id)) + .map(({ id }) => id) - if (quotaIdsToRemove.length) { - await unlinkStageFromQuotas(stageId, quotaIdsToRemove) - } - const quotaIdsToAdd = quotaIds - .filter(quotaIdToAdd => !dbQuotas.find(({ id }) => id === quotaIdToAdd)) - if (quotaIdsToAdd.length) { - await linkStageToQuotas(stageId, quotaIdsToAdd) - } + if (quotaIdsToRemove.length) { + await unlinkStageFromQuotas(stageId, quotaIdsToRemove) } - - return { - id: stageId, - name: name ?? dbStage.name, - clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), - quotaIds: quotaIds ?? dbStage.quotas.map(({ id }) => id), + const quotaIdsToAdd = quotaIds + .filter(quotaIdToAdd => !dbQuotas.find(({ id }) => id === quotaIdToAdd)) + if (quotaIdsToAdd.length) { + await linkStageToQuotas(stageId, quotaIdsToAdd) } - } catch (error) { - throw new Error(error?.message) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + quotaIds: quotaIds ?? dbStage.quotas.map(({ id }) => id), } } export const deleteStage = async (stageId: Stage['id']) => { - try { - const environments = await getStageAssociatedEnvironmentLengthById(stageId) - if (environments) throw new BadRequestError('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') - await deleteStageQuery(stageId) - } catch (error) { - if (error instanceof DsoError) { - throw error - } - throw new Error(error?.message) - } + await deleteStageQuery(stageId) + return null } -export const listStages = async (userId: User['id']) => { - const user = await getUserById(userId) - if (!user) throw new UnauthorizedError('Vous n\'êtes pas connecté') +export const listStages = async () => { const stages = await listStagesQuery() return stages.map((stage) => { diff --git a/apps/server/src/resources/stage/queries.ts b/apps/server/src/resources/stage/queries.ts index 18582839a..8e7b6c724 100644 --- a/apps/server/src/resources/stage/queries.ts +++ b/apps/server/src/resources/stage/queries.ts @@ -52,15 +52,7 @@ export const getStageAssociatedEnvironmentById = (id: Stage['id']) => organization: { select: { name: true }, }, - roles: { - where: { - role: 'owner', - }, - select: { - user: true, - role: true, - }, - }, + owner: true, }, }, quota: true, diff --git a/apps/server/src/resources/stage/router.ts b/apps/server/src/resources/stage/router.ts index 8a9e0dcfd..a6224eb16 100644 --- a/apps/server/src/resources/stage/router.ts +++ b/apps/server/src/resources/stage/router.ts @@ -1,6 +1,6 @@ -import { stageContract } from '@cpn-console/shared' +import { AdminAuthorized, stageContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' -import { addReqLogs } from '@/utils/logger.js' + import { listStages, createStage, @@ -8,58 +8,45 @@ import { deleteStage, updateStage, } from './business.js' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, ErrorResType, Forbidden403 } from '@/utils/controller.js' export const stageRouter = () => serverInstance.router(stageContract, { // Récupérer les types d'environnement disponibles - listStages: async ({ request: req }) => { - const userId = req.session.user.id - const stages = await listStages(userId) - - addReqLogs({ - req, - message: 'Stages récupérés avec succès', - }) + listStages: async () => { + const body = await listStages() + return { status: 200, - body: stages, + body, } }, // Récupérer les environnements associés au stage getStageEnvironments: async ({ request: req, params }) => { - const stageId = params.stageId + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() - assertIsAdmin(req.session.user) - const environments = await getStageAssociatedEnvironments(stageId) - - addReqLogs({ - req, - message: 'Environnements associés au type d\'environnement récupérés', - infos: { - stageId, - }, - }) + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body return { status: 200, - body: environments, + body, } }, // Créer un stage createStage: async ({ request: req, body: data }) => { - assertIsAdmin(req.session.user) - const stage = await createStage(data) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) delete data.quotaIds + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) delete data.clusterIds - addReqLogs({ - req, - message: 'Type d\'environnement créé avec succès', - infos: { - stageId: stage.id, - }, - }) + const stage = await createStage(data) return { status: 201, @@ -69,43 +56,37 @@ export const stageRouter = () => serverInstance.router(stageContract, { // Modifier une association stage / clusters updateStage: async ({ request: req, params, body: data }) => { - const stageId = params.stageId + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + if (!AdminAuthorized.ManageQuotas(perms.adminPermissions)) delete data.quotaIds + if (!AdminAuthorized.ManageClusters(perms.adminPermissions)) delete data.clusterIds - assertIsAdmin(req.session.user) - const stage = await updateStage(stageId, data) + const stageId = params.stageId - addReqLogs({ - req, - message: 'Clusters associés au type d\'environnement mis à jour avec succès', - infos: { - stageId, - }, - }) + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body return { status: 200, - body: stage, + body, } }, // Supprimer un stage deleteStage: async ({ request: req, params }) => { - const stageId = params.stageId + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() - assertIsAdmin(req.session.user) - await deleteStage(stageId) + const stageId = params.stageId - addReqLogs({ - req, - message: 'Type d\'environnement supprimé avec succès', - infos: { - stageId, - }, - }) + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body return { status: 204, - body: null, + body, } }, }) diff --git a/apps/server/src/resources/system/config/business.ts b/apps/server/src/resources/system/config/business.ts index 90671b80c..2018c6cd0 100644 --- a/apps/server/src/resources/system/config/business.ts +++ b/apps/server/src/resources/system/config/business.ts @@ -1,14 +1,12 @@ import { type PluginsUpdateBody, - adminGroupPath, } from '@cpn-console/shared' import { getAdminPlugin, } from '@/resources/queries-index.js' -import { ForbiddenError } from '@/utils/errors.js' -import type { KeycloakPayload } from 'fastify-keycloak-adapter' import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' import { savePluginsConfig } from './queries.js' +import { BadRequest400 } from '@/utils/controller.js' export type ConfigRecords = { key: string @@ -21,14 +19,7 @@ export const objToDb = (obj: PluginsUpdateBody): ConfigRecords => Object.entries .map(([key, value]) => ({ pluginName, key, value }))) .flat() -export const getPluginsConfig = async (requestor: KeycloakPayload) => { - // Pré-requis - const isAdmin = requestor.groups?.includes(adminGroupPath) - - if (!isAdmin) { - throw new ForbiddenError('Vous n\'êtes pas admin') - } - +export const getPluginsConfig = async () => { const globalConfig = await getAdminPlugin() return Object.values(servicesInfos).map(({ name, title, imgSrc }) => { @@ -49,7 +40,8 @@ export const getPluginsConfig = async (requestor: KeycloakPayload) => { export const updatePluginConfig = async (data: PluginsUpdateBody) => { const parsedData = editStrippers.global.safeParse(data) - if (!parsedData.success) return + if (!parsedData.success) return new BadRequest400(parsedData.error.message) const records = objToDb(parsedData.data) await savePluginsConfig(records) + return null } diff --git a/apps/server/src/resources/system/config/router.ts b/apps/server/src/resources/system/config/router.ts index cee434b90..5d879c376 100644 --- a/apps/server/src/resources/system/config/router.ts +++ b/apps/server/src/resources/system/config/router.ts @@ -1,41 +1,34 @@ -import { addReqLogs } from '@/utils/logger.js' -import { systemPluginContract } from '@cpn-console/shared' +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' import { serverInstance } from '@/app.js' import { getPluginsConfig, updatePluginConfig } from './business.js' -import { assertIsAdmin } from '@/utils/controller.js' +import { authUser, Forbidden403, ErrorResType } from '@/utils/controller.js' export const pluginConfigRouter = () => serverInstance.router(systemPluginContract, { // Récupérer les configurations plugins getPluginsConfig: async ({ request: req }) => { - const requestor = req.session.user - const services = await getPluginsConfig(requestor) - addReqLogs({ - req, - message: 'Configurations des plugins récupérées avec succès', - infos: { - userId: requestor.id, - }, - }) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManagePlugins(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + return { status: 200, body: services, + } }, // Mettre à jour les configurations plugins updatePluginsConfig: async ({ request: req, body }) => { - const requestor = req.session.user - assertIsAdmin(requestor) - await updatePluginConfig(body) - addReqLogs({ - req, - message: 'Configurations des plugins mises à jour avec succès', - infos: { - userId: requestor.id, - }, - }) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManagePlugins(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody return { status: 204, - body: null, + body: resBody, } }, }) diff --git a/apps/server/src/resources/system/db/business.ts b/apps/server/src/resources/system/db/business.ts deleted file mode 100644 index ead92864f..000000000 --- a/apps/server/src/resources/system/db/business.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { dumpDb } from './queries.js' - -export const getDb = dumpDb diff --git a/apps/server/src/resources/system/db/controllers.ts b/apps/server/src/resources/system/db/controllers.ts deleted file mode 100644 index a4cc7b77f..000000000 --- a/apps/server/src/resources/system/db/controllers.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { sendOk } from '@/utils/response.js' -import { addReqLogs } from '@/utils/logger.js' -import type { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify' -import { getDb } from './business.js' -import { FastifyRouteConfig } from 'fastify/types/route.js' - -// TODO revoir -const router = async (app: FastifyInstance, _opt: FastifyRouteConfig) => { - app.get('/', async (req: FastifyRequest, res: FastifyReply) => { - const db = await getDb() - addReqLogs({ - req, - message: 'Export de la BDD avec succès', - }) - sendOk(res, db) - }) -} - -export default router diff --git a/apps/server/src/resources/system/db/queries.ts b/apps/server/src/resources/system/db/queries.ts deleted file mode 100644 index 98d094e7c..000000000 --- a/apps/server/src/resources/system/db/queries.ts +++ /dev/null @@ -1,45 +0,0 @@ -import prisma from '@/prisma.js' - -export const dumpDb = async () => { - return ({ - organization: await prisma.organization.findMany({}), - project: await prisma.project.findMany({}), - user: await prisma.user.findMany({}), - repository: await prisma.repository.findMany({}), - environment: await prisma.environment.findMany({}), - permission: await prisma.permission.findMany({}), - role: await prisma.role.findMany({}), - log: await prisma.log.findMany({}), - cluster: await prisma.cluster.findMany({ - select: { - id: true, - label: true, - privacy: true, - secretName: true, - clusterResources: true, - createdAt: true, - updatedAt: true, - kubeconfig: { - select: { - cluster: true, - user: true, - }, - }, - }, - }), - associates: { - cluster: await prisma.cluster.findMany({ - select: { - id: true, - environments: { select: { id: true } }, - projects: { select: { id: true } }, - }, - }), - }, - }) -} -// how to dump quickly... -// writeFileSync( -// './test.json', -// JSON.stringify(await dumpDb()), -// ) diff --git a/apps/server/src/resources/user/business.ts b/apps/server/src/resources/user/business.ts index 7d18b2064..bf3ad2d99 100644 --- a/apps/server/src/resources/user/business.ts +++ b/apps/server/src/resources/user/business.ts @@ -1,165 +1,72 @@ -import { type KeycloakPayload } from 'fastify-keycloak-adapter' -import type { Project, User } from '@prisma/client' -import { UserSchema, instanciateSchema, projectIsLockedInfo, type AsyncReturnType, adminGroupPath } from '@cpn-console/shared' -import { addLogs, addUserToProject as addUserToProjectQuery, createUser, deletePermission, getMatchingUsers as getMatchingUsersQuery, getProjectInfos as getProjectInfosQuery, getProjectUsers as getProjectUsersQuery, getRolesByProjectId, getUserByEmail, getUserById, removeUserFromProject as removeUserFromProjectQuery, transferProjectOwnership as transferProjectOwnershipQuery, getUsers as getUsersQuery, setPermission } from '@/resources/queries-index.js' -import { validateSchema } from '@/utils/business.js' -import { checkInsufficientRoleInProject, type SearchOptions } from '@/utils/controller.js' -import { BadRequestError, ForbiddenError, NotFoundError, UnprocessableContentError } from '@/utils/errors.js' -import { hook } from '@/utils/hook-wrapper.js' -import { rolesToMembers } from '../project/business.js' - -export const getUsers = async () => { - const users = await getUsersQuery() - - const hookReply = await hook.user.retrieveAdminUsers() - if (hookReply.failed) { - throw new UnprocessableContentError('Echec de récupération des administrateurs') +import type { Prisma, Project } from '@prisma/client' +import { getMatchingUsers as getMatchingUsersQuery, getProjectMembers as getProjectUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { userContract } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { UserDetails } from '@/types/index.js' + +export const getUsers = (query: typeof userContract.getAllUsers.query._type) => { + const where: Prisma.UserWhereInput = {} + if (query.adminRoleId) { + where.adminRoleIds = { has: query.adminRoleId } } - - const adminIds: string[] = hookReply.results.keycloak?.adminIds - - if (!adminIds?.length) return users.map(user => ({ ...user, isAdmin: false })) - - return users.map(user => ({ ...user, isAdmin: adminIds.includes(user.id) })) -} - -export const updateUserAdminRole = async ({ userId, isAdmin }: { userId: string, isAdmin: boolean }, requestId: string) => { - const hookReply = await hook.user.updateUserAdminGroupMembership(userId, { isAdmin }) - await addLogs('Update User Admin Role', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la mise à jour du rôle de l\'utilisateur') - } -} - -export type UserDto = Pick - -export const checkProjectRole = (userId: User['id'], { userList = undefined, roles = undefined, minRole }: SearchOptions) => { - // @ts-ignore - const insufficientRoleErrorMessage = checkInsufficientRoleInProject(userId, { userList, minRole, roles }) - if (insufficientRoleErrorMessage) throw new ForbiddenError(insufficientRoleErrorMessage, undefined) + return getUsersQuery(where) } -export const checkProjectLocked = (project: Project) => { - if (project.locked) throw new ForbiddenError(projectIsLockedInfo, undefined) -} - -export const getProjectInfos = async (projectId: Project['id']) => getProjectInfosQuery(projectId) - export const getProjectUsers = async (projectId: Project['id']) => getProjectUsersQuery(projectId) -export const getMatchingUsers = async (letters: string) => getMatchingUsersQuery(letters) - -export const addUserToProject = async ( - project: AsyncReturnType, - email: User['email'], - userId: User['id'], - requestId: string, -) => { - if (!project) throw new BadRequestError('Le projet n\'existe pas') - - let userToAdd = await getUserByEmail(email) - - // Retrieve user from keycloak if does not exist in db - if (!userToAdd) { - const hookReply = await hook.user.retrieveUserByEmail(email) - await addLogs('Retrieve User By Email', hookReply, userId, requestId) - if (hookReply.failed) { - throw new UnprocessableContentError('Echec des services à la récupération des administrateurs') - } - - const retrievedUser = hookReply.results.keycloak?.user - if (!retrievedUser) throw new NotFoundError('Utilisateur introuvable') - - // keep only keys allowed in model - const userFromModel = instanciateSchema(UserSchema, undefined) - Object.keys(userFromModel).forEach((modelKey) => { - userFromModel[modelKey] = retrievedUser[modelKey] +export const getMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ ProjectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], }) - - const schemaValidation = UserSchema.safeParse(userFromModel) - validateSchema(schemaValidation) - await createUser(retrievedUser) - userToAdd = await getUserByEmail(email) - if (!userToAdd) throw new BadRequestError('L\'utilisateur n\'existe pas') } - const insufficientRoleErrorMessageUserToAdd = checkInsufficientRoleInProject(userToAdd.id, { roles: project.roles, minRole: 'user' }) - if (!insufficientRoleErrorMessageUserToAdd) throw new BadRequestError('L\'utilisateur est déjà membre du projet', undefined) - - try { - await addUserToProjectQuery({ project, user: userToAdd, role: 'user' }) - - const { results } = await hook.project.upsert(project.id) - await addLogs('Add Project Member', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services à l\'ajout de l\'utilisateur au projet') - } - - return rolesToMembers(await getRolesByProjectId(project.id)) - } catch (error) { - throw new Error('Echec d\'ajout de l\'utilisateur au projet') - } + return getMatchingUsersQuery({ + AND, + }) } -export const transferProjectOwnership = async ( - requestor: KeycloakPayload, - userToUpdateId: User['id'], - projectId: Project['id'], -) => { - const project = await getProjectInfos(projectId) - if (!project) throw new BadRequestError(`Le projet ayant pour id ${projectId} n'existe pas`) - - checkProjectLocked(project) - - if (!project.roles.some(projectUser => projectUser.userId === userToUpdateId)) throw new BadRequestError('L\'utilisateur ne fait pas partie du projet') - - const owner = project.roles.find(role => role.role === 'owner') - if (!owner) throw new BadRequestError('Impossible de trouver le souscripteur actuel du projet') - - if (!requestor.groups?.includes(adminGroupPath)) { - checkProjectRole(requestor.id, { roles: project.roles, minRole: 'owner' }) +export const patchUsers = async (users: typeof userContract.patchUsers.body._type) => { + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) } - await transferProjectOwnershipQuery(projectId, userToUpdateId, owner.userId) - await Promise.all( - project.environments - .map(environment => setPermission({ - userId: userToUpdateId, - environmentId: environment.id, - level: 2, - })), - ) - return rolesToMembers(await getRolesByProjectId(project.id)) + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) } -export const removeUserFromProject = async ( - userToRemoveId: User['id'], - project: AsyncReturnType, - userId: User['id'], - requestId: string, -) => { - if (!project) throw new BadRequestError('Le projet n\'existe pas') - - const userToRemove = await getUserById(userToRemoveId) - if (!userToRemove) throw new Error('L\'utilisateur n\'existe pas') - - const insufficientRoleErrorMessageUserToRemove = checkInsufficientRoleInProject(userToRemoveId, { roles: project.roles }) - if (insufficientRoleErrorMessageUserToRemove) throw new BadRequestError('L\'utilisateur n\'est pas membre du projet') - - try { - for (const environment of project.environments) { - await deletePermission(userToRemoveId, environment.id) - } - await removeUserFromProjectQuery({ projectId: project.id, userId: userToRemoveId }) - - const { results } = await hook.project.upsert(project.id) - await addLogs('Remove User from Project', results, userId, requestId) - if (results.failed) { - throw new UnprocessableContentError('Echec des services au retrait de l\'utilisateur au projet') - } - - return rolesToMembers(await getRolesByProjectId(project.id)) - } catch (error) { - throw new Error('Echec du retrait de l\'utilisateur du projet') - } +export const logUser = async ({ id, email, groups, ...user }: UserDetails) => { + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { oidcGroup: { in: groups } }, + }) + + const adminRoleIds = matchingAdminRoles.filter(({ oidcGroup }) => groups.includes(oidcGroup ?? '')).map(({ id }) => id) + const userDb = await prisma.user.findUnique({ + where: { id }, + }) + return userDb + ? prisma.user.update({ where: { id }, data: { ...user, adminRoleIds } }) + : prisma.user.create({ data: { email, id, ...user, adminRoleIds } }) } diff --git a/apps/server/src/resources/user/queries.ts b/apps/server/src/resources/user/queries.ts index 8c652308e..5e8f03b4c 100644 --- a/apps/server/src/resources/user/queries.ts +++ b/apps/server/src/resources/user/queries.ts @@ -1,4 +1,4 @@ -import type { User } from '@prisma/client' +import type { Prisma, User } from '@prisma/client' import { exclude } from '@cpn-console/shared' import prisma from '@/prisma.js' import { dbKeysExcluded } from '@/utils/queries-tools.js' @@ -6,40 +6,27 @@ import { dbKeysExcluded } from '@/utils/queries-tools.js' type UserCreate = Omit // SELECT -export const getUsers = prisma.user.findMany +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) export const getUserInfos = async (id: User['id']) => { const usr = await prisma.user.findMany({ where: { id }, include: { logs: true, - permissions: true, - roles: true, }, }) return exclude(usr, dbKeysExcluded) } -export const getMatchingUsers = (letters: string) => +export const getMatchingUsers = (where: Prisma.UserWhereInput) => prisma.user.findMany({ - where: { - email: { - contains: letters, - }, - }, + where, take: 5, }) export const getUserById = (id: User['id']) => prisma.user.findUnique({ where: { id } }) -export const getOrCreateUser = (user: Parameters[0]['create']) => - prisma.user.upsert({ - where: { id: user.id }, - update: user, - create: user, - }) - export const getUserOrThrow = (id: User['id']) => prisma.user.findUniqueOrThrow({ where: { id }, @@ -67,7 +54,5 @@ export const updateUserById = async ({ id, email, firstName, lastName }: UserCre } // TECH -export const _dropUsersTable = prisma.user.deleteMany - -export const _createUser = (data: Parameters[0]['data']) => +export const _createUser = (data: Prisma.UserCreateInput) => prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) diff --git a/apps/server/src/resources/user/role-queries.ts b/apps/server/src/resources/user/role-queries.ts deleted file mode 100644 index be016f32a..000000000 --- a/apps/server/src/resources/user/role-queries.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Project, User } from '@prisma/client' -import prisma from '@/prisma.js' - -// SELECT -export const getRoleByUserIdAndProjectId = async (userId: User['id'], projectId: Project['id']) => { - return await prisma.role.findFirst({ select: { role: true }, where: { userId, projectId } }) -} - -export const getSingleOwnerByProjectId = async (projectId: Project['id']) => { - return (await prisma.role.findFirst({ - select: { user: true }, - where: { role: 'owner', projectId }, - }))?.user -} - -// UPDATE -export const transferProjectOwnership = async (projectId: Project['id'], userToUpdateId: User['id'], ownerId: User['id']) => { - await prisma.role.update({ - where: { - userId_projectId: { - userId: userToUpdateId, - projectId, - }, - }, - data: { role: 'owner' }, - }) - await prisma.role.update({ - where: { - userId_projectId: { - userId: ownerId, - projectId, - }, - }, - data: { role: 'user' }, - }) -} - -// DELETE -export const deleteRoleByUserIdAndProjectId = async (userId: User['id'], projectId: Project['id']) => { - return prisma.role.delete({ - where: { - userId_projectId: { - userId, - projectId, - }, - }, - }) -} - -export const deleteAllRoleNonOwnerForProject = async (id: Project['id']) => { - return prisma.role.deleteMany({ - where: { - AND: { - role: { - not: 'owner', - }, - projectId: id, - }, - }, - }) -} -// TECH -export const _dropRolesTable = async () => { - await prisma.role.deleteMany({}) -} - -export const _createRole = async (data: Parameters[0]['create']) => { - await prisma.role.upsert({ - create: data, - update: data, - where: { - userId_projectId: { - // @ts-ignore - projectId: data.projectId, - // @ts-ignore - userId: data.userId, - }, - }, - }) -} diff --git a/apps/server/src/resources/user/router.ts b/apps/server/src/resources/user/router.ts index 499f5b6fc..8550f47a7 100644 --- a/apps/server/src/resources/user/router.ts +++ b/apps/server/src/resources/user/router.ts @@ -1,190 +1,58 @@ -import { userContract, adminGroupPath } from '@cpn-console/shared' -import { addReqLogs } from '@/utils/logger.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' import { - addUserToProject, - checkProjectLocked, - checkProjectRole, getMatchingUsers, - getProjectInfos, - getProjectUsers, - removeUserFromProject, - transferProjectOwnership as transferProjectOwnershipBusiness, getUsers, - updateUserAdminRole as updateUserAdminRoleBusiness, + patchUsers, + logUser, } from './business.js' import '@/types/index.js' -import { BadRequestError } from '@/utils/errors.js' import { serverInstance } from '@/app.js' -import { getOrCreateUser } from './queries.js' -import { assertIsAdmin, hasGroupAdmin } from '@/utils/controller.js' +import { authUser, Forbidden403 } from '@/utils/controller.js' +// TODO tout revoir export const userRouter = () => serverInstance.router(userContract, { - getProjectUsers: async ({ request: req, params }) => { - const user = req.session.user - const projectId = params.projectId - - const users = await getProjectUsers(projectId) - - if (!user.groups?.includes(adminGroupPath)) { - checkProjectRole(user.id, { userList: users, minRole: 'user' }) - } + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) - addReqLogs({ - req, - message: 'Membres du projet récupérés avec succès', - infos: { - projectId, - }, - }) - return { - status: 200, - body: users, - } - }, - - getMatchingUsers: async ({ request: req, params, query }) => { - const user = req.session.user - const projectId = params.projectId - const { letters } = query - - const users = await getProjectUsers(projectId) - - if (!hasGroupAdmin(user.groups)) { - checkProjectRole(user.id, { userList: users, minRole: 'user' }) - } - - const usersMatching = await getMatchingUsers(letters) - - addReqLogs({ - req, - message: 'Utilisateurs récupérés avec succès', - infos: { - projectId, - }, - }) return { status: 200, body: usersMatching, } }, - createUserRoleInProject: async ({ request: req, params, body: data }) => { - const user = req.session.user - const projectId = params.projectId - - const project = await getProjectInfos(projectId) - if (!project) throw new BadRequestError(`Le projet ayant pour id ${projectId} n'existe pas`) - - checkProjectLocked(project) - - if (!hasGroupAdmin(user.groups)) { - checkProjectRole(user.id, { roles: project.roles, minRole: 'owner' }) - } - - const newRoles = await addUserToProject(project, data.email, user.id, req.id) - - addReqLogs({ - req, - message: 'Utilisateur ajouté au projet avec succès', - infos: { - projectId, - email: data.email, - }, - }) - return { - status: 201, - body: newRoles, - } - }, - - transferProjectOwnership: async ({ request: req, params }) => { - const requestor = req.session.user - const userToUpdateId = params.userId - const projectId = params.projectId - - const newRoles = await transferProjectOwnershipBusiness(requestor, userToUpdateId, projectId) + auth: async ({ request }) => { + const user = request.session.user + const body = await logUser(user) - addReqLogs({ - req, - message: 'Rôle de l\'utilisateur mis à jour avec succès', - infos: { - projectId, - userId: userToUpdateId, - }, - }) return { status: 200, - body: newRoles, + body, } }, - deleteUserRoleInProject: async ({ request: req, params }) => { + getAllUsers: async ({ request: req, query }) => { const user = req.session.user - const projectId = params.projectId - const userToRemoveId = params.userId + const perms = await authUser(user) + if (!perms.adminPermissions) return new Forbidden403() - const project = await getProjectInfos(projectId) - if (!project) throw new BadRequestError(`Le projet ayant pour id ${projectId} n'existe pas`) + const body = await getUsers(query) - if (!hasGroupAdmin(user.groups) && user.id !== userToRemoveId) { - checkProjectRole(user.id, { roles: project.roles, minRole: 'owner' }) - } - - checkProjectLocked(project) - - const newRoles = await removeUserFromProject(userToRemoveId, project, user.id, req.id) - - addReqLogs({ - req, - message: 'Utilisateur retiré du projet avec succès', - infos: { - projectId, - userId: userToRemoveId, - }, - }) return { status: 200, - body: newRoles, + body, } }, - auth: async ({ request }) => { - const { groups: _g, ...requestor } = request.session.user - await getOrCreateUser(requestor) - return { - status: 200, - body: null, - } - }, + patchUsers: async ({ request: req, body }) => { + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() - getAllUsers: async ({ request: req }) => { - assertIsAdmin(req.session.user) - const users = await getUsers() + const users = await patchUsers(body) - addReqLogs({ - req, - message: 'Ensemble des utilisateurs récupérés avec succès', - }) return { status: 200, body: users, } }, - - updateUserAdminRole: async ({ request: req, params, body }) => { - const userId = params.userId - const isAdmin = body.isAdmin - - assertIsAdmin(req.session.user) - await updateUserAdminRoleBusiness({ userId, isAdmin }, req.id) - - addReqLogs({ - req, - message: 'Rôle administrateur de l\'utilisateur mis à jour avec succès', - }) - return { - status: 204, - body: null, - } - }, }) diff --git a/apps/server/src/resources/zone/business.ts b/apps/server/src/resources/zone/business.ts index fb6783304..56f3ab23c 100644 --- a/apps/server/src/resources/zone/business.ts +++ b/apps/server/src/resources/zone/business.ts @@ -1,14 +1,14 @@ import { type Zone } from '@cpn-console/shared' -import { BadRequestError, ForbiddenError } from '@/utils/errors.js' import { listZones as listZonesQuery, createZone as createZoneQuery, updateZone as updateZoneQuery, deleteZone as deleteZoneQuery, linkZoneToClusters, - getZoneById, getZoneBySlug, } from './queries.js' +import { BadRequest400 } from '@/utils/controller.js' +import prisma from '@/prisma.js' export const listZones = async () => { const zones = await listZonesQuery() @@ -24,7 +24,7 @@ export const createZone = async ( const { slug, label, description, clusterIds } = data const existingZone = await getZoneBySlug(slug) - if (existingZone) throw new BadRequestError(`Une zone portant le nom ${slug} existe déjà.`) + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) const zone = await createZoneQuery({ slug, label, description }) if (clusterIds) { await linkZoneToClusters(zone.id, clusterIds) @@ -40,8 +40,9 @@ export const updateZone = async (zoneId: Zone['id'], data: Pick { - const zone = await getZoneById(zoneId) - if (zone?.clusters?.length) throw new ForbiddenError('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') await deleteZoneQuery(zoneId) + return null } diff --git a/apps/server/src/resources/zone/queries.ts b/apps/server/src/resources/zone/queries.ts index aeb537815..6ae0d7b1c 100644 --- a/apps/server/src/resources/zone/queries.ts +++ b/apps/server/src/resources/zone/queries.ts @@ -68,5 +68,3 @@ export const deleteZone = (zoneId: Zone['id']) => id: zoneId, }, }) - -export const _dropZoneTable = prisma.zone.deleteMany diff --git a/apps/server/src/resources/zone/router.ts b/apps/server/src/resources/zone/router.ts index d9f08e8d1..6ff1796cf 100644 --- a/apps/server/src/resources/zone/router.ts +++ b/apps/server/src/resources/zone/router.ts @@ -1,14 +1,13 @@ import { serverInstance } from '@/app.js' -import { addReqLogs } from '@/utils/logger.js' + import { createZone, deleteZone, listZones, updateZone } from './business.js' -import { zoneContract } from '@cpn-console/shared' -import { assertIsAdmin } from '@/utils/controller.js' +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { authUser, ErrorResType, Forbidden403 } from '@/utils/controller.js' export const zoneRouter = () => serverInstance.router(zoneContract, { - listZones: async ({ request: req }) => { + listZones: async () => { const zones = await listZones() - addReqLogs({ req, message: 'Zones récupérées avec succès' }) return { status: 200, body: zones, @@ -16,38 +15,45 @@ export const zoneRouter = () => serverInstance.router(zoneContract, { }, createZone: async ({ request: req, body: data }) => { - assertIsAdmin(req.session.user) - const zone = await createZone(data) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageZones(perms.adminPermissions)) return new Forbidden403() + + const body = await createZone(data) + if (body instanceof ErrorResType) return body - addReqLogs({ req, message: 'Zone créée avec succès', infos: { zoneId: zone.id } }) return { status: 201, - body: zone, + body, } }, updateZone: async ({ request: req, params, body: data }) => { - assertIsAdmin(req.session.user) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageZones(perms.adminPermissions)) return new Forbidden403() + const zoneId = params.zoneId - const zone = await updateZone(zoneId, data) - addReqLogs({ req, message: 'Zone mise à jour avec succès', infos: { zoneId: zone.id } }) + const body = await updateZone(zoneId, data) return { status: 201, - body: zone, + body, } }, deleteZone: async ({ request: req, params }) => { - assertIsAdmin(req.session.user) + const user = req.session.user + const perms = await authUser(user) + if (!AdminAuthorized.ManageZones(perms.adminPermissions)) return new Forbidden403() const zoneId = params.zoneId - await deleteZone(zoneId) + const body = await deleteZone(zoneId) + if (body instanceof ErrorResType) return body - addReqLogs({ req, message: 'Zone supprimée avec succès', infos: { zoneId } }) return { status: 204, - body: null, + body, } }, }) diff --git a/apps/server/src/utils/business.ts b/apps/server/src/utils/business.ts index 5e5159a77..905ad206f 100644 --- a/apps/server/src/utils/business.ts +++ b/apps/server/src/utils/business.ts @@ -1,6 +1,6 @@ import { type SharedSafeParseReturnType, parseZodError } from '@cpn-console/shared' -import { BadRequestError } from './errors.js' +import { BadRequest400 } from './controller.js' export const validateSchema = (schemaValidation: SharedSafeParseReturnType) => { - if (!schemaValidation.success) throw new BadRequestError(parseZodError(schemaValidation.error)) + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) } diff --git a/apps/server/src/utils/controller.ts b/apps/server/src/utils/controller.ts index 847445d47..0152477c7 100644 --- a/apps/server/src/utils/controller.ts +++ b/apps/server/src/utils/controller.ts @@ -1,24 +1,10 @@ -import type { Permission, User, Role, Cluster } from '@prisma/client' -import { DoneFuncWithErrOrRes, FastifyReply, FastifyRequest } from 'fastify' -import { type ProjectRoles, adminGroupPath, projectIsLockedInfo, type Project } from '@cpn-console/shared' -import { ForbiddenError } from './errors.js' +import type { Cluster, ProjectRole, ProjectMembers, Project, Prisma } from '@prisma/client' +import { adminGroupPath, projectIsLockedInfo, PROJECT_PERMS as PP, XOR } from '@cpn-console/shared' import { UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' export const hasGroupAdmin = (groups: UserDetails['groups']) => groups.includes(adminGroupPath) -export const assertIsAdmin = (user: UserDetails) => { - if (!hasGroupAdmin(user.groups)) { - throw new ForbiddenError('Vous n\'avez pas les droits administrateur') - } -} - -export const checkAdminGroup = (req: FastifyRequest, _res: FastifyReply, done: DoneFuncWithErrOrRes) => { - if (!req.session.user.groups?.includes(adminGroupPath)) { - throw new ForbiddenError('Vous n\'avez pas les droits administrateur') - } - done() -} - export type RequireOnlyOne = Pick> & { @@ -27,14 +13,6 @@ export type RequireOnlyOne = & Partial, undefined>> }[Keys] -type IsAllowed = { - userList: User[] | Pick[] - roles: Role[] - minRole?: ProjectRoles -} - -export type SearchOptions = RequireOnlyOne - type ErrorMessagePredicate = () => string | undefined export const getErrorMessage = (...fns: ErrorMessagePredicate[]) => { for (const f of fns) { @@ -54,53 +32,15 @@ export const checkProjectLocked = ( ? projectIsLockedInfo : '' -/** - * Renvoie une erreur si l'utilisateur n'a pas le rôle suffisant dans un projet - * @param {number} userId Id du user dont il faut vérifier le rôle - * @param {Object} SearchOptions - * @param {string} SearchOptions.usersList - Liste d'utilisateurs, check si ids incluent userId - * @param {string?} SearchOptions.minRole - Optionnel, role minimum requis, 'user' ou 'owner'. Si `undefined` : 'user' - * @return {string} message d'erreur si rôle insuffisant / absence de rôle, sinon chaîne vide - */ -export const checkInsufficientRoleInProject = ( - userId: User['id'], - { userList, roles, minRole }: SearchOptions, -): string => { - if (roles) { - // if minRole is 'owner' filter and assign to userList - // else assign to userList - userList = minRole === 'owner' - ? roles - .filter(role => role.role === 'owner') - .map(({ userId }) => ({ id: userId })) - : roles.map(({ userId }) => ({ id: userId })) - } - return userList?.some(user => user?.id === userId) - ? '' - : 'Vous n’avez pas les permissions suffisantes dans le projet' -} - -export const checkRoleAndLocked = ( - project: { locked: Project['locked'], roles: Role[] }, - userId: string, - minRole: ProjectRoles = 'user', -): string => checkProjectLocked(project) || checkInsufficientRoleInProject(userId, { minRole, roles: project.roles }) +export const checkLocked = ( + project: { locked: Project['locked'] }, +): string => checkProjectLocked(project) export const checkClusterUnavailable = (clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string => authorizedClusterIds.some(authorizedClusterId => authorizedClusterId === clusterId) ? '' : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' -export const checkInsufficientPermissionInEnvironment = (userId: User['id'], permissions: Permission[], minLevel: Permission['level']) => { - // get project by id, assign result to projectUsers - const { level } = permissions.find(perm => perm.userId === userId) ?? { level: -1 } - return level >= minLevel - ? '' - : 'Vous n\'avez pas les droits suffisants pour requêter cet environnement' -} - -export const filterOwners = (roles: Role[]) => roles.filter(({ role }) => role === 'owner') - export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T type StringArray = string[] @@ -120,3 +60,117 @@ export const whereBuilder = ({ enumValues, eqValue, inVal return { notIn: splitStringsFilterArray(enumValues, notInValues) } } } + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +type UserProfile = { user: UserDetails, adminPermissions: bigint } +type UserProjectProfile = UserProfile & { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } + +type ProjectUniqueFinder = XOR< + { name: string, organizationName: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>>> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(user: UserDetails): Promise +export async function authUser(user: UserDetails, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(user: UserDetails, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions = 0n + const dbUser = await prisma.user.findUnique({ where: { id: user.id } }) + if (dbUser) { + adminPermissions = await prisma.adminRole.findMany({ + where: { + id: { in: dbUser.adminRoleIds }, + }, + }).then(role => role.reduce((acc, curr) => acc | curr.permissions, 0n)) + } + + if (!projectUnique) { + return { + user, + adminPermissions, + } + } + let project: ProjectMinimalPerms + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUniqueOrThrow({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + })).project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUniqueOrThrow({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + })).project + } else if (projectUnique.id) { + project = await prisma.project.findUniqueOrThrow({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + } else { + project = await prisma.project.findFirstOrThrow({ + where: { name: projectUnique.name, organization: { name: projectUnique.organizationName } }, + select: projectPermsSelect, + }) + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +const getProjectPermissions = (project: ProjectMinimalPerms, user: UserDetails): bigint | undefined => { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms) +} + +export class ErrorResType { + status: 400 | 403 | 404 | 422 + body: { message: string } = { message: '' } + constructor(code: 400 | 403 | 404 | 422) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + status = 400 as const + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad request' + } +} + +export class Forbidden403 extends ErrorResType { + status = 403 as const + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + status = 404 as const + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + status = 422 as const + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} diff --git a/apps/server/src/utils/errors.ts b/apps/server/src/utils/errors.ts index 0b6d45666..e69de29bb 100644 --- a/apps/server/src/utils/errors.ts +++ b/apps/server/src/utils/errors.ts @@ -1,54 +0,0 @@ -type DsoErrorData = { - description?: string - extras?: Record -} -class DsoError extends Error { - public description: string - public extras: Record - public statusCode: number - - constructor(message: string, data?: DsoErrorData) { - super(message) - this.description = data?.description || message - this.extras = data?.extras || {} - } -} - -class BadRequestError extends DsoError { - statusCode = 400 -} - -class UnauthorizedError extends DsoError { - statusCode = 401 -} - -class ForbiddenError extends DsoError { - statusCode = 403 -} - -class NotFoundError extends DsoError { - statusCode = 404 -} - -class ConflictError extends DsoError { - statusCode = 409 -} - -class UnprocessableContentError extends DsoError { - statusCode = 422 -} - -class TooManyRequestError extends DsoError { - statusCode = 429 -} - -export { - DsoError, - BadRequestError, - UnauthorizedError, - ForbiddenError, - NotFoundError, - ConflictError, - UnprocessableContentError, - TooManyRequestError, -} diff --git a/apps/server/src/utils/hook-wrapper.ts b/apps/server/src/utils/hook-wrapper.ts index 7c53d9b27..904963073 100644 --- a/apps/server/src/utils/hook-wrapper.ts +++ b/apps/server/src/utils/hook-wrapper.ts @@ -1,7 +1,7 @@ -import type { Cluster, Kubeconfig, Project, Zone } from '@prisma/client' +import type { Cluster, Kubeconfig, Project, ProjectRole, Zone } from '@prisma/client' import type { ClusterObject, Config, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository } from '@cpn-console/hooks' import { hooks } from '@cpn-console/hooks' -import { AsyncReturnType } from '@cpn-console/shared' +import { AsyncReturnType, resourceListToDict, ProjectAuthorized, getPermsByUserRoles } from '@cpn-console/shared' import { archiveProject, getClusterByIdOrThrow, getAdminPlugin, getHookProjectInfos, getHookRepository, getProjectStore, saveProjectStore, updateProjectCreated, updateProjectFailed, getClustersAssociatedWithProject, updateProjectClusterHistory } from '@/resources/queries-index.js' import { genericProxy } from './proxy.js' import { ConfigRecords, dbToObj } from '@/resources/project-service/business.js' @@ -146,9 +146,13 @@ const misc = { } export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction project: genericProxy(project, { upsert: ['delete'], delete: ['upsert'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction cluster: genericProxy(cluster, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction user: genericProxy(user, {}), } @@ -161,28 +165,38 @@ const formatClusterInfos = ( ...cluster, privacy: cluster.privacy, }) +export type RolesById = Record export const transformToHookProject = (project: ProjectInfos, store: Config, reposCreds: ReposCreds = {}): ProjectPayload => { const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) return ({ ...project, - users: project.roles.map(role => role.user), - roles: project.roles.map(role => ({ role: role.role as 'owner' | 'user', userId: role.userId })), clusters, - environments: project.environments.map(({ permissions, quota, stage, ...environment }) => ({ + environments: project.environments.map(({ quota, stage, ...environment }) => ({ quota, stage: stage.name, - permissions: permissions.map(permission => ({ - userId: permission.userId, + permissions: project.members.map(member => ({ + userId: member.userId, permissions: { - ro: permission.level >= 0, - rw: permission.level >= 1, + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), }, })), ...environment, })), repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), store, + users: project.members.map(({ user }) => user), + roles: project.members.map(member => ({ + userId: member.userId, + role: ProjectAuthorized.Manage({ + adminPermissions: 0n, + projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms), + }) + ? 'owner' + : 'user', + })), }) } diff --git a/apps/server/src/utils/logger.ts b/apps/server/src/utils/logger.ts index 0206391eb..f3dc35a6c 100644 --- a/apps/server/src/utils/logger.ts +++ b/apps/server/src/utils/logger.ts @@ -1,6 +1,7 @@ -import { FastifyRequest } from 'fastify' +import { FastifyLoggerOptions, FastifyRequest, RawServerBase } from 'fastify' +import { FastifyBaseLogger, PinoLoggerOptions } from 'fastify/types/logger.js' -export const loggerConf = { +export const loggerConf: Record & PinoLoggerOptions | FastifyBaseLogger> = { development: { transport: { target: 'pino-pretty', @@ -12,7 +13,7 @@ export const loggerConf = { }, }, }, - production: true, + production: {}, test: false, } diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index e5c911ea7..8c5cf38fa 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -11,6 +11,7 @@ "@/*": ["src/*"] }, "plugins": [{ "transform": "typescript-transform-paths" }], + "useUnknownInCatchVariables": false }, "include": [ "./src/**/*.ts", @@ -21,5 +22,5 @@ "./src/**/__mocks__", "./src/mocks/utils.ts", "./src/utils/mocks.ts", - ], + ] } \ No newline at end of file diff --git a/packages/hooks/src/hooks/hook-project.ts b/packages/hooks/src/hooks/hook-project.ts index 8aefc1690..2cc8e34a8 100644 --- a/packages/hooks/src/hooks/hook-project.ts +++ b/packages/hooks/src/hooks/hook-project.ts @@ -1,4 +1,3 @@ -import type { Role as RoleFromSchema } from '@cpn-console/shared' import { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' import { Hook, createHook } from './hook.js' @@ -13,8 +12,8 @@ export type RepoCreds = { } export type Role = { - userId: UserObject['id'] - role: RoleFromSchema['role'] + userId: string + role: 'owner' | 'user' } export type Environment = { @@ -61,6 +60,7 @@ export type Project = { users: UserObject[] roles: Role[] store: ProjectStore + owner: UserObject } export const upsertProject: Hook = createHook() diff --git a/packages/shared/src/api-client.ts b/packages/shared/src/api-client.ts index 9af8102ff..db7fdfb3f 100644 --- a/packages/shared/src/api-client.ts +++ b/packages/shared/src/api-client.ts @@ -6,19 +6,21 @@ export const apiPrefix: string = '/api/v1' export const getContract = async () => contractInstance.router( { + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, Clusters: (await import('./contracts/index.js')).clusterContract, Environments: (await import('./contracts/index.js')).environmentContract, Logs: (await import('./contracts/index.js')).logContract, Organizations: (await import('./contracts/index.js')).organizationContract, Permissions: (await import('./contracts/index.js')).permissionContract, Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')).projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, ProjectServices: (await import('./contracts/index.js')).projectServiceContract, Quotas: (await import('./contracts/index.js')).quotaContract, Repositories: (await import('./contracts/index.js')).repositoryContract, Stages: (await import('./contracts/index.js')).stageContract, Services: (await import('./contracts/index.js')).serviceContract, Users: (await import('./contracts/index.js')).userContract, - Files: (await import('./contracts/index.js')).filesContract, Zones: (await import('./contracts/index.js')).zoneContract, System: (await import('./contracts/index.js')).systemContract, SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, diff --git a/packages/shared/src/contracts/admin-role.ts b/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 000000000..5f59565bb --- /dev/null +++ b/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { AdminRoleSchema, apiPrefix, contractInstance } from '../index.js' +import { ErrorSchema } from '../schemas/utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: `${apiPrefix}/admin/roles`, + responses: { + 200: z.lazy(() => AdminRoleSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: `${apiPrefix}/admin/roles`, + body: z.lazy(() => AdminRoleSchema.pick({ name: true })), + responses: { + 200: z.lazy(() => AdminRoleSchema), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchAdminRoles: { + method: 'PATCH', + path: `${apiPrefix}/admin/roles`, + body: z.lazy(() => AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true }).array()), + responses: { + 200: z.lazy(() => AdminRoleSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + adminRoleMemberCounts: { + method: 'GET', + path: `${apiPrefix}/admin/roles/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteAdminRole: { + method: 'DELETE', + path: `${apiPrefix}/admin/roles/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: null, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}) diff --git a/packages/shared/src/contracts/files.ts b/packages/shared/src/contracts/files.ts deleted file mode 100644 index da4915ffb..000000000 --- a/packages/shared/src/contracts/files.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod' -import { ClientInferRequest } from '@ts-rest/core' -import { apiPrefix, contractInstance } from '../api-client.js' -import { ErrorSchema } from '../schemas/utils.js' - -export const filesContract = contractInstance.router({ - generateCIFiles: { - method: 'POST', - path: `${apiPrefix}/ci-files`, - summary: 'Generate ci files', - description: 'Generate ci files.', - body: z.object({ - typeLanguage: z.string(), - isJava: z.boolean().optional(), - isNode: z.boolean().optional(), - isPython: z.boolean().optional(), - artefactDir: z.string().optional(), - internalRepoName: z.string().optional(), - javaVersion: z.string().optional(), - nodeBuildCommand: z.string().optional(), - nodeInstallCommand: z.string().optional(), - nodeVersion: z.string().optional(), - projectName: z.string().optional(), - workingDir: z.string().optional(), - }), - responses: { - 201: z.object({}), - 500: ErrorSchema, - }, - }, -}) - -export type GenerateCIFilesBody = ClientInferRequest['body'] diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 3d7048704..45143c139 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,13 +1,15 @@ +export * from './admin-role.js' export * from './cluster.js' export * from './environment.js' -export * from './files.js' export * from './log.js' +export * from './project-member.js' export * from './organization.js' export * from './permission.js' export * from './project.js' export * from './project-service.js' export * from './quota.js' export * from './repository.js' +export * from './project-role.js' export * from './service-monitor.js' export * from './stage.js' export * from './system.js' diff --git a/packages/shared/src/contracts/project-member.ts b/packages/shared/src/contracts/project-member.ts new file mode 100644 index 000000000..91cec96ca --- /dev/null +++ b/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance, MemberSchema } from '../index.js' +import { ErrorSchema } from '../schemas/utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: `${apiPrefix}/projects/:projectId/members`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.lazy(() => MemberSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: `${apiPrefix}/projects/:projectId/members`, + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: z.lazy(() => MemberSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: `${apiPrefix}/projects/:projectId/members`, + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.lazy(() => MemberSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `${apiPrefix}/projects/:projectId/members/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: null, + responses: { + 204: z.lazy(() => MemberSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}) diff --git a/packages/shared/src/contracts/project-role.ts b/packages/shared/src/contracts/project-role.ts new file mode 100644 index 000000000..3d90aed3c --- /dev/null +++ b/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance, RoleSchema } from '../index.js' +import { ErrorSchema } from '../schemas/utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: `${apiPrefix}/projects/:projectId/roles`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.lazy(() => RoleSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: `${apiPrefix}/projects/:projectId/roles`, + body: z.lazy(() => RoleSchema.omit({ position: true, id: true })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 200: z.lazy(() => RoleSchema), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: `${apiPrefix}/projects/:projectId/roles`, + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: z.lazy(() => RoleSchema.partial({ name: true, permissions: true, position: true }).array()), + responses: { + // 200: z.any(), + 200: z.lazy(() => RoleSchema.array()), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `${apiPrefix}/projects/:projectId/roles/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `${apiPrefix}/projects/:projectId/roles/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: null, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}) diff --git a/packages/shared/src/contracts/project.ts b/packages/shared/src/contracts/project.ts index 1c4b2b444..ba083e004 100644 --- a/packages/shared/src/contracts/project.ts +++ b/packages/shared/src/contracts/project.ts @@ -48,6 +48,7 @@ export const projectContract = contractInstance.router({ 200: z.record(z.record(z.string())), 401: ErrorSchema, 403: ErrorSchema, + 404: ErrorSchema, 500: ErrorSchema, }, }, diff --git a/packages/shared/src/contracts/user.ts b/packages/shared/src/contracts/user.ts index ac40141dc..0d1d05e38 100644 --- a/packages/shared/src/contracts/user.ts +++ b/packages/shared/src/contracts/user.ts @@ -1,65 +1,24 @@ import { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' import { apiPrefix, contractInstance } from '../api-client.js' import { - CreateUserRoleInProjectSchema, - GetAllUsersSchema, GetMatchingUsersSchema, - GetProjectUsersSchema, - UpdateUserAdminRoleSchema, - TransferProjectOwnershipSchema, - LoginSchema, + UserSchema, } from '../schemas/index.js' +import { z } from 'zod' +import { ErrorSchema } from '../schemas/utils.js' export const userContract = contractInstance.router({ - getProjectUsers: { - method: 'GET', - path: `${apiPrefix}/projects/:projectId/users`, - pathParams: GetProjectUsersSchema.params, - summary: 'Get project users', - description: 'Retrieved all project users.', - responses: GetProjectUsersSchema.responses, - }, - getMatchingUsers: { method: 'GET', - path: `${apiPrefix}/projects/:projectId/users/match`, - pathParams: GetMatchingUsersSchema.params, + path: `${apiPrefix}/users/matching`, query: GetMatchingUsersSchema.query, - summary: 'Get project users by letters matching', - description: 'Retrieved all project users by letters matching.', - responses: GetMatchingUsersSchema.responses, - }, - - createUserRoleInProject: { - method: 'POST', - path: `${apiPrefix}/projects/:projectId/users`, - pathParams: GetProjectUsersSchema.params, - body: CreateUserRoleInProjectSchema.body, - contentType: 'application/json', - summary: 'Create user role in project', - description: 'Create user role in project.', - responses: CreateUserRoleInProjectSchema.responses, - }, - - transferProjectOwnership: { - method: 'PUT', - path: `${apiPrefix}/projects/:projectId/users/:userId`, - pathParams: TransferProjectOwnershipSchema.params, - body: null, - contentType: 'application/json', - summary: 'Update user role in project', - description: 'Update user role in project.', - responses: TransferProjectOwnershipSchema.responses, - }, - - deleteUserRoleInProject: { - method: 'DELETE', - path: `${apiPrefix}/projects/:projectId/users/:userId`, - pathParams: TransferProjectOwnershipSchema.params, - body: null, - summary: 'Delete user role in project', - description: 'Delete user role in project.', - responses: TransferProjectOwnershipSchema.responses, + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: z.lazy(() => UserSchema.array()), + 400: ErrorSchema, + 403: ErrorSchema, + }, }, auth: { @@ -67,7 +26,11 @@ export const userContract = contractInstance.router({ path: `${apiPrefix}/auth`, summary: 'Login', description: 'OIDC callback to signin or signup', - responses: LoginSchema.responses, + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, }, getAllUsers: { @@ -75,24 +38,30 @@ export const userContract = contractInstance.router({ path: `${apiPrefix}/users`, summary: 'Get all users', description: 'Get all users.', - responses: GetAllUsersSchema.responses, + query: z.object({ + adminRoleId: z.string().uuid().optional(), + }), + responses: { + 200: z.lazy(() => UserSchema.array()), + 400: ErrorSchema, + 403: ErrorSchema, + }, }, - updateUserAdminRole: { - method: 'PUT', - path: `${apiPrefix}/users/:userId`, - summary: 'Update user admin role', - pathParams: UpdateUserAdminRoleSchema.params, - body: UpdateUserAdminRoleSchema.body, + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: z.lazy(() => UserSchema.pick({ adminRoleIds: true, id: true }).array()), description: 'Update user admin role.', - responses: UpdateUserAdminRoleSchema.responses, + responses: { + 200: z.lazy(() => UserSchema.array()), + 400: ErrorSchema, + 403: ErrorSchema, + }, }, }) -export type AddUserToProjectBody = ClientInferRequest['body'] - export type LettersQuery = ClientInferRequest['query'] -export type Users = ClientInferResponseBody - export type AllUsers = ClientInferResponseBody diff --git a/packages/shared/src/schemas/environment.ts b/packages/shared/src/schemas/environment.ts index 8ce9867b5..c78c33b2f 100644 --- a/packages/shared/src/schemas/environment.ts +++ b/packages/shared/src/schemas/environment.ts @@ -38,6 +38,7 @@ export const GetEnvironmentsSchema = { }), responses: { 200: z.array(EnvironmentSchema), + 404: ErrorSchema, 500: ErrorSchema, }, } diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 9c665d5fd..cef5cf7ab 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -4,6 +4,7 @@ export * from './environment.js' export * from './organization.js' export * from './permission.js' export * from './project.js' +export * from './role.js' export * from './quota.js' export * from './repository.js' export * from './stage.js' diff --git a/packages/shared/src/schemas/project.ts b/packages/shared/src/schemas/project.ts index 5db901639..ecbc7f516 100644 --- a/packages/shared/src/schemas/project.ts +++ b/packages/shared/src/schemas/project.ts @@ -2,9 +2,11 @@ import { z } from 'zod' import { longestEnvironmentName, projectStatus } from '../utils/const.js' import { AtDatesToStringExtend, ErrorSchema } from './utils.js' import { RepoSchema } from './repository.js' -import { RoleSchema, UserSchema, UserWithRoleSchema } from './user.js' +import { MemberSchema, UserSchema } from './user.js' import { OrganizationSchema } from './organization.js' import { CoerceBooleanSchema } from '../utils/schemas.js' +import { permissionLevelSchema } from '../utils/permissions.js' +import { RoleSchema } from './role.js' export const descriptionMaxLength = 280 export const projectNameMaxLength = 20 @@ -56,7 +58,6 @@ export const ProjectSchema = z.object({ // ProjectInfos organization: OrganizationSchema.optional(), - roles: z.array(RoleSchema.and(z.object({ user: UserSchema.optional() }))).optional(), clusterIds: z.string().uuid().array(), repositories: RepoSchema.array(), environments: ProjectEnvironmentSchema.array(), @@ -77,7 +78,11 @@ export const ProjectSchemaV2 = ProjectSchema updatedAt: true, }) .extend({ - members: UserWithRoleSchema.array(), + members: MemberSchema.array(), + ownerId: z.string().uuid(), + owner: UserSchema.omit({ adminRoleIds: true }), + roles: RoleSchema.array(), + everyonePerms: permissionLevelSchema, }) .required({ description: true }) diff --git a/packages/shared/src/schemas/role.ts b/packages/shared/src/schemas/role.ts new file mode 100644 index 000000000..0c5259e03 --- /dev/null +++ b/packages/shared/src/schemas/role.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { permissionLevelSchema } from '../utils/permissions.js' + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: z.string().max(30), + permissions: permissionLevelSchema, + position: z.number().min(0), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), +}) + +export type Role = Zod.infer +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer diff --git a/packages/shared/src/schemas/user.ts b/packages/shared/src/schemas/user.ts index fe402d134..ce3ff03a6 100644 --- a/packages/shared/src/schemas/user.ts +++ b/packages/shared/src/schemas/user.ts @@ -1,57 +1,44 @@ import { z } from 'zod' import { AtDatesToStringExtend, ErrorSchema } from './utils.js' -import { projectRoles } from '../utils/const.js' export const UserSchema = z.object({ id: z.string() .uuid(), - firstName: z.string() - .min(1, { message: 'must be at least 1 character long' }) - .max(50, { message: 'must not exceed 50 characters' }), - lastName: z.string() - .min(1, { message: 'must be 1 at least characters long' }) - .max(50, { message: 'must not exceed 50 characters' }), - email: z.string() - .email(), - groups: z.string().array().optional(), -}) + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), +}).extend(AtDatesToStringExtend) -export const UserWithRoleSchema = z.object({ +export const MemberSchema = z.object({ userId: z.string() .uuid(), - firstName: z.string() - .min(1, { message: 'must be at least 1 character long' }) - .max(50, { message: 'must not exceed 50 characters' }), - lastName: z.string() - .min(1, { message: 'must be 1 at least characters long' }) - .max(50, { message: 'must not exceed 50 characters' }), + firstName: z.string(), + lastName: z.string(), email: z.string() .email(), - role: z.enum(projectRoles), + roleIds: z.string().uuid().array(), }) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })) + , + ) export type User = Zod.infer -export type UserWithRole = Zod.infer - -export const RoleSchema = z.object({ - userId: z.string() - .uuid(), - projectId: z.string() - .uuid(), - role: z.enum(projectRoles), -}) - -export type Role = Zod.infer +export type Member = Zod.infer const projectIdParams = z.object({ projectId: z.string() .uuid(), }) -export const GetProjectUsersSchema = { +export const GetProjectMembersSchema = { params: projectIdParams, responses: { - 200: z.array(UserSchema.omit({ groups: true })), + 200: z.array(MemberSchema), 403: ErrorSchema, 500: ErrorSchema, }, @@ -60,7 +47,6 @@ export const GetProjectUsersSchema = { export const GetAllUsersSchema = { responses: { 200: UserSchema - .omit({ groups: true }) .extend({ ...AtDatesToStringExtend, isAdmin: z.boolean(), @@ -72,12 +58,12 @@ export const GetAllUsersSchema = { } export const GetMatchingUsersSchema = { - params: projectIdParams, query: z.object({ letters: z.string(), + notInProjectId: z.string().uuid().optional(), }), responses: { - 200: z.array(UserSchema.omit({ groups: true })), + 200: z.array(UserSchema), 403: ErrorSchema, 500: ErrorSchema, }, @@ -87,7 +73,7 @@ export const CreateUserRoleInProjectSchema = { params: projectIdParams, body: UserSchema.pick({ email: true }), responses: { - 201: z.array(UserWithRoleSchema), + 201: z.array(MemberSchema), 400: ErrorSchema, 403: ErrorSchema, 500: ErrorSchema, @@ -102,7 +88,7 @@ export const TransferProjectOwnershipSchema = { .uuid(), }), responses: { - 200: z.array(UserWithRoleSchema), + 200: z.array(MemberSchema), 400: ErrorSchema, 403: ErrorSchema, 500: ErrorSchema, @@ -122,11 +108,3 @@ export const UpdateUserAdminRoleSchema = { 500: ErrorSchema, }, } - -export const LoginSchema = { - responses: { - 200: null, - 307: null, - 500: ErrorSchema, - }, -} diff --git a/packages/shared/src/schemas/utils.ts b/packages/shared/src/schemas/utils.ts index eb4b53edc..4eec0b4fc 100644 --- a/packages/shared/src/schemas/utils.ts +++ b/packages/shared/src/schemas/utils.ts @@ -1,11 +1,13 @@ import { z } from 'zod' -export const ErrorSchema = z.object({ - message: z.string() - .optional(), - error: z.unknown(), - stack: z.unknown(), -}) +// export const ErrorSchema = z.object({ +// message: z.string() +// .optional(), +// error: z.unknown().optional(), +// stack: z.unknown().optional(), +// }) + +export const ErrorSchema = z.unknown() const dateToString = z.string().or(z.date().transform(date => date.toISOString())) export const AtDatesToStringExtend = { diff --git a/packages/shared/src/utils/functions.ts b/packages/shared/src/utils/functions.ts index e6faedf57..e8ffaeadf 100644 --- a/packages/shared/src/utils/functions.ts +++ b/packages/shared/src/utils/functions.ts @@ -94,3 +94,20 @@ export const resourceListToDict = (resList: Array): [curr.id]: curr, } }, {} as ResourceById) + +export const shallowEqual = (object1: Record, object2: Record) => { + const keys1 = Object.keys(object1) + const keys2 = Object.keys(object2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (object1[key] !== object2[key]) { + return false + } + } + + return true +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 4f269fd72..91eeb86c5 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -2,5 +2,6 @@ export * from './const.js' export * from './date.js' export * from './functions.js' export * from './plugins.js' +export * from './permissions.js' export * from './schemas.js' export * from './types.js' diff --git a/packages/shared/src/utils/permissions.ts b/packages/shared/src/utils/permissions.ts new file mode 100644 index 000000000..29efc2f77 --- /dev/null +++ b/packages/shared/src/utils/permissions.ts @@ -0,0 +1,177 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import { z } from 'zod' +import { ResourceById } from './types.js' + +export const getPermsByUserRoles = ( + userRoles: string[], + rolesById: ResourceById<{ id: string, permissions: bigint | string }>, + basePerms?: bigint | string, +) => userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + console.trace(`Unable to find role: ${curr}, database needs to be inspected`) + } + return acc | BigInt(rolesById[curr].permissions) +}, basePerms ? BigInt(basePerms) : 0n) + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') throw Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) throw Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +export const permissionLevelSchema = z.coerce.string() + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + // GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + // GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_CLUSTERS: bit(2n), + MANAGE_ORGANIZATIONS: bit(3n), + MANAGE_PLUGINS: bit(4n), + MANAGE_PROJECTS: bit(5n), + MANAGE_QUOTAS: bit(6n), + MANAGE_ROLES: bit(7n), + MANAGE_STAGES: bit(8n), + MANAGE_ZONES: bit(9n), + VIEW_LOGS: bit(10n), + LIST_PROJECTS: bit(11n), + LIST_ALL_QUOTAS: bit(12n), +} + +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +type ProjectAuthorizedParams = { adminPermissions?: bigint | string, projectPermissions?: bigint | string } + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.ManageProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), +} as const + +export const AdminAuthorized = { + ListProjects: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE | ADMIN_PERMS.MANAGE_CLUSTERS)), + Manage: (perms?: bigint | string) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageClusters: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageOrganizations: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ORGANIZATIONS | ADMIN_PERMS.MANAGE)), + ManagePlugins: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PLUGINS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageQuotas: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_QUOTAS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ViewLogs: (perms?: bigint | string) => !!(toBigInt(perms) & (ADMIN_PERMS.VIEW_LOGS | ADMIN_PERMS.MANAGE)), +} as const + +export const toBigInt = (value?: bigint | number | string | undefined) => value ? BigInt(value) : 0n + +export const projectPermsLabels: Record = { + LIST_ENVIRONMENTS: 'Voir les environnements', + MANAGE: 'Gérer le projet', + MANAGE_ENVIRONMENTS: 'Gérer les environnements', + MANAGE_MEMBERS: 'Gérer les membres du projet', + LIST_REPOSITORIES: 'Voir les dépôts', + MANAGE_REPOSITORIES: 'Gérer les dépots', + MANAGE_ROLES: 'Gérer les rôles du projet', + REPLAY_HOOKS: 'Reprovisionner le projet', + SEE_SECRETS: 'Afficher les secrets', +} + +export const adminPermsLabels: Record = { + MANAGE: 'Administration globale', + LIST_PROJECTS: 'Lister tous les projets', + MANAGE_CLUSTERS: 'Gérer les clusters', + MANAGE_ORGANIZATIONS: 'Gérer les organisations', + MANAGE_PLUGINS: 'Gérer la configuration des plugins', + MANAGE_ROLES: 'Gérer les rôles d\'administration', + MANAGE_PROJECTS: 'Gérer tous les projets', + MANAGE_QUOTAS: 'Gérer les quotas et utiliser les quotas privés', + MANAGE_STAGES: 'Gérer les types d\'environment', + MANAGE_ZONES: 'Gérer les zones', + VIEW_LOGS: 'Visualiser les logs', + LIST_ALL_QUOTAS: 'Lister les quotas privés', +} + +export const projectPermsOrder: Array = [ + 'MANAGE', + 'MANAGE_MEMBERS', + 'MANAGE_ROLES', + 'LIST_ENVIRONMENTS', + 'MANAGE_ENVIRONMENTS', + 'LIST_REPOSITORIES', + 'MANAGE_REPOSITORIES', + 'SEE_SECRETS', + 'REPLAY_HOOKS', +] + +export const adminPermsOrder: Array = [ + 'MANAGE', + 'LIST_PROJECTS', + 'MANAGE_PROJECTS', + 'VIEW_LOGS', + 'MANAGE_ROLES', + 'MANAGE_ORGANIZATIONS', + 'MANAGE_ZONES', + 'MANAGE_CLUSTERS', + 'MANAGE_PLUGINS', + 'MANAGE_QUOTAS', + 'MANAGE_STAGES', + 'LIST_ALL_QUOTAS', +] diff --git a/packages/shared/src/utils/schemas.spec.ts b/packages/shared/src/utils/schemas.spec.ts index 14112cd11..5b502ef86 100644 --- a/packages/shared/src/utils/schemas.spec.ts +++ b/packages/shared/src/utils/schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ClusterDetailsSchema, ClusterPrivacy, EnvironmentSchema, OrganizationSchema, PermissionSchema, ProjectSchemaV2, QuotaSchema, RepoBusinessSchema, RepoSchema, StageSchema, UserSchema, descriptionMaxLength, instanciateSchema, parseZodError } from '../index.js' +import { ClusterDetailsSchema, ClusterPrivacy, EnvironmentSchema, OrganizationSchema, PermissionSchema, ProjectSchemaV2, ProjectV2, QuotaSchema, RepoBusinessSchema, RepoSchema, StageSchema, UserSchema, descriptionMaxLength, instanciateSchema, parseZodError } from '../index.js' import { faker } from '@faker-js/faker' import { ZodError } from 'zod' @@ -86,7 +86,7 @@ describe('Schemas utils', () => { }) it('Should validate a correct project schema', () => { - const toParse = { + const toParse: ProjectV2 = { id: faker.string.uuid(), name: faker.lorem.word({ length: { min: 2, max: 10 } }), description: '', @@ -94,15 +94,34 @@ describe('Schemas utils', () => { status: 'created', locked: false, clusterIds: [], + // @ts-ignore la date doit être transformé en string updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string createdAt: new Date(), members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], } const parsed = structuredClone(toParse) // @ts-ignore la date doit être transformé en string parsed.createdAt = parsed.createdAt.toISOString() // @ts-ignore parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) }) @@ -112,9 +131,16 @@ describe('Schemas utils', () => { email: faker.internet.email(), firstName: faker.person.firstName(), lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), } - - expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) }) it('Should validate a correct quota schema', () => { diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index cb93ce365..07b40d0a1 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -1,83 +1,12 @@ export const data = { - organization: [ - { - id: '2368a61e-f243-42f6-b471-a85b056ee131', - source: 'dso-console', - name: 'dinum', - label: 'DINUM', - active: true, - createdAt: '2023-07-03T14:46:56.758Z', - updatedAt: '2023-07-03T14:46:56.758Z', - }, - { - id: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', - source: 'dso-console', - name: 'mi', - label: 'Ministère de l\'Intérieur', - active: true, - createdAt: '2023-07-03T14:46:56.764Z', - updatedAt: '2023-07-03T14:46:56.764Z', - }, - { - id: '94e5b24b-ba73-4169-af09-e2df4b83a60f', - source: 'dso-console', - name: 'mj', - label: 'Ministère de la Justice', - active: true, - createdAt: '2023-07-03T14:46:56.765Z', - updatedAt: '2023-07-03T14:46:56.765Z', - }, - ], - project: [ - { - id: '22e7044f-8414-435d-9c4a-2df42a65034b', - name: 'betaapp', - organizationId: '2368a61e-f243-42f6-b471-a85b056ee131', - description: '', - status: 'created', - locked: false, - createdAt: '2023-07-03T14:46:56.814Z', - updatedAt: '2023-07-03T14:46:56.817Z', - }, - { - id: '9dabf3f9-6c86-4358-8598-65007d78df65', - name: 'projecttoarchive', - organizationId: '2368a61e-f243-42f6-b471-a85b056ee131', - description: '', - status: 'created', - locked: false, - createdAt: '2023-07-03T14:46:56.824Z', - updatedAt: '2023-07-03T14:46:56.830Z', - }, - { - id: '011e7860-04d7-461f-912d-334c622d38b3', - name: 'candilib', - organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', - description: 'Application de réservation de places à l\'examen du permis B.', - status: 'created', - locked: false, - createdAt: '2023-07-03T14:46:56.778Z', - updatedAt: '2023-07-03T14:46:56.783Z', - }, - { - id: '011e7860-04d7-461f-912d-334c622d38c5', - name: 'basegun', - organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', - description: 'Application d\'aide à la catégorisation d\'armes à feu.', - status: 'created', - locked: false, - createdAt: '2023-07-10T14:46:56.778Z', - updatedAt: '2023-07-10T14:46:56.783Z', - }, + adminPlugin: [], + adminRole: [ { - id: '83833faf-f654-40dd-bcd5-cf2e944fc702', - name: 'psijfailed', - organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', - description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', - status: 'failed', - locked: true, - createdAt: '2023-07-03T14:46:56.799Z', - updatedAt: '2023-07-03T14:46:56.806Z', + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '2n', + position: 0, + oidcGroup: '/admin', + name: 'Admin', }, ], kubeconfig: [ @@ -160,11 +89,11 @@ export const data = { privacy: 'public', secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', clusterResources: false, - zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', createdAt: '2023-07-10T19:32:13.385Z', updatedAt: '2023-07-10T19:32:13.385Z', infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', }, { id: '126ac57f-263c-4463-87bb-d4e9017056b2', @@ -172,11 +101,11 @@ export const data = { privacy: 'dedicated', secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', clusterResources: true, - zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', createdAt: '2023-07-10T19:49:31.691Z', updatedAt: '2024-07-24T16:54:15.234Z', infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', }, { id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', @@ -184,11 +113,11 @@ export const data = { privacy: 'dedicated', secretName: '94d52618-7869-4192-b33e-85dd0959e815', clusterResources: false, - zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', createdAt: '2023-07-10T19:49:31.697Z', updatedAt: '2024-07-24T16:54:15.249Z', infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', }, { id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', @@ -196,144 +125,41 @@ export const data = { privacy: 'public', secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', clusterResources: false, - zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', createdAt: '2023-07-10T19:32:13.385Z', updatedAt: '2024-07-24T16:54:15.261Z', infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', }, ], - quota: [ - { - id: '5a57b62f-2465-4fb6-a853-5a751d099199', - memory: '4Gi', - cpu: 2, - name: 'small', - isPrivate: false, - }, - { - id: '08770663-3b76-4af6-8978-9f75eda4faa7', - memory: '8Gi', - cpu: 4, - name: 'medium', - isPrivate: false, - }, - { - id: 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', - memory: '12Gi', - cpu: 6, - name: 'large', - isPrivate: false, - }, - { - id: '97b851e8-9067-4a3d-a0e8-c3a6820c49be', - memory: '16Gi', - cpu: 8, - name: 'xlarge', - isPrivate: false, - }, - ], - stage: [ - { - id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', - name: 'dev', - }, - { - id: '38fa869d-6267-441d-af7f-e0548fd06b7e', - name: 'staging', - }, - { - id: 'd434310e-7850-4d59-b47f-0772edf50582', - name: 'integration', - }, - { - id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', - name: 'prod', - }, - ], - environment: [ - { - id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', - name: 'dev', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - createdAt: '2023-07-03T14:46:56.819Z', - updatedAt: '2023-07-03T14:46:56.826Z', - clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', - }, - { - id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', - name: 'staging', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - createdAt: '2023-07-03T14:46:56.819Z', - updatedAt: '2023-07-03T14:46:56.829Z', - clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', - }, - { - id: '8d4503eb-64c7-407e-89db-6ab80865071f', - name: 'dev', - projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', - createdAt: '2023-07-03T14:46:56.834Z', - updatedAt: '2023-07-03T14:46:56.855Z', - clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', - }, - { - id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', - name: 'staging', - projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', - createdAt: '2023-07-03T14:46:56.834Z', - updatedAt: '2023-07-03T14:46:56.859Z', - clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', - }, - { - id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', - name: 'dev', - projectId: '011e7860-04d7-461f-912d-334c622d38b3', - createdAt: '2023-07-03T14:46:56.787Z', - updatedAt: '2023-07-03T14:46:56.803Z', - clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', - }, + user: [ { - id: '1c654f00-4798-4a80-929f-960ddb37885a', - name: 'integration', - projectId: '011e7860-04d7-461f-912d-334c622d38b3', - createdAt: '2023-07-03T14:46:56.788Z', - updatedAt: '2023-07-03T14:46:56.803Z', - clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], }, { - id: '1c654f00-4798-4a80-929f-960ddb36774b', - name: 'integration', - projectId: '011e7860-04d7-461f-912d-334c622d38c5', - createdAt: '2023-07-03T14:46:56.788Z', - updatedAt: '2023-07-03T14:46:56.803Z', - clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + id: '89e5d1ca-3194-4b0a-b226-75a5f4fe6a34', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [], }, { - id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', - name: 'integration', - projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', - createdAt: '2023-07-03T14:46:56.808Z', - updatedAt: '2023-07-03T14:46:56.815Z', - clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', - quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', - stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], }, - ], - user: [ { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', firstName: 'Arnaud', @@ -341,14 +167,7 @@ export const data = { email: 'arnaud.tardif@test.com', createdAt: '2023-07-03T14:46:56.773Z', updatedAt: '2023-07-03T14:46:56.773Z', - }, - { - id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - firstName: 'Thibault', - lastName: 'COLIN', - email: 'thibault.colin@test.com', - createdAt: '2023-07-03T14:46:56.772Z', - updatedAt: '2023-07-03T14:46:56.772Z', + adminRoleIds: [], }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', @@ -357,22 +176,18 @@ export const data = { email: 'test@test.com', createdAt: '2023-07-03T14:46:56.770Z', updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], }, { - id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - firstName: 'Claire', - lastName: 'NOLLET', - email: 'claire.nollet@test.com', - createdAt: '2023-07-03T14:46:56.771Z', - updatedAt: '2023-07-03T14:46:56.771Z', - }, - { - id: '89e5d1ca-3194-4b0a-b226-75a5f4fe6a34', - firstName: 'Admin', - lastName: 'ADMIN', - email: 'admin@test.com', - createdAt: '2023-07-03T18:01:52.884Z', - updatedAt: '2023-07-06T12:53:39.183Z', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], }, ], log: [ @@ -432,9 +247,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.809Z', updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', @@ -492,9 +308,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.819Z', updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', @@ -549,9 +366,10 @@ export const data = { }, action: 'Create Repository', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.819Z', updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', @@ -606,9 +424,10 @@ export const data = { }, action: 'Create Repository', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', @@ -752,9 +571,10 @@ export const data = { }, action: 'Create Environment', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', @@ -808,9 +628,10 @@ export const data = { }, action: 'Delete Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', @@ -868,9 +689,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', @@ -928,9 +750,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', @@ -988,9 +811,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', @@ -1048,9 +872,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', @@ -1108,9 +933,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', @@ -1168,9 +994,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', @@ -1228,9 +1055,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', @@ -1288,9 +1116,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', @@ -1348,9 +1177,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', @@ -1408,9 +1238,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', @@ -1468,9 +1299,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', @@ -1528,9 +1360,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', @@ -1588,9 +1421,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', @@ -1648,9 +1482,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', @@ -1708,9 +1543,10 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', @@ -1768,147 +1604,231 @@ export const data = { }, action: 'create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - requestId: null, createdAt: '2023-07-03T14:46:56.788Z', updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, }, ], - permission: [ + organization: [ { - id: '1b5266b4-73b3-4c4e-95bd-cd54f0f22df4', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '1c654f00-4798-4a80-929f-960ddb36774b', - level: 2, - createdAt: '2023-07-03T14:46:56.831Z', - updatedAt: '2023-07-03T14:46:56.831Z', + id: '2368a61e-f243-42f6-b471-a85b056ee131', + source: 'dso-console', + name: 'dinum', + label: 'DINUM', + active: true, + createdAt: '2023-07-03T14:46:56.758Z', + updatedAt: '2023-07-03T14:46:56.758Z', }, { - id: '1b5266b4-73b3-4c4e-95bd-cd54f0f22df5', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: 'bc06ace5-ddf6-4f00-97fa-872922baf078', - level: 2, - createdAt: '2023-07-03T14:46:56.831Z', - updatedAt: '2023-07-03T14:46:56.831Z', + id: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', + source: 'dso-console', + name: 'mi', + label: 'Ministère de l\'Intérieur', + active: true, + createdAt: '2023-07-03T14:46:56.764Z', + updatedAt: '2023-07-03T14:46:56.764Z', }, { - id: 'ddd062b7-d94a-43f0-8204-1ac4156b90b4', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - environmentId: 'bc06ace5-ddf6-4f00-97fa-872922baf078', - level: 0, - createdAt: '2023-07-03T14:46:56.831Z', - updatedAt: '2023-07-03T14:46:56.831Z', + id: '94e5b24b-ba73-4169-af09-e2df4b83a60f', + source: 'dso-console', + name: 'mj', + label: 'Ministère de la Justice', + active: true, + createdAt: '2023-07-03T14:46:56.765Z', + updatedAt: '2023-07-03T14:46:56.765Z', }, + ], + project: [ { - id: '5d21568d-013c-4f03-bfaf-16bbafbd2e85', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '95ef0d9b-945e-4af6-851c-4c6685ceff20', - level: 2, - createdAt: '2023-07-03T14:46:56.832Z', - updatedAt: '2023-07-03T14:46:56.832Z', + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + organizationId: '2368a61e-f243-42f6-b471-a85b056ee131', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', }, { - id: '4587f2d7-f253-4bcb-8eb4-8c6b854b852a', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - environmentId: '95ef0d9b-945e-4af6-851c-4c6685ceff20', - level: 1, - createdAt: '2023-07-03T14:46:56.832Z', - updatedAt: '2023-07-03T14:46:56.832Z', + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + organizationId: '2368a61e-f243-42f6-b471-a85b056ee131', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', }, { - id: '0c71a970-fdbb-4243-81b5-494ca03eabc5', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - environmentId: '95ef0d9b-945e-4af6-851c-4c6685ceff20', - level: 1, - createdAt: '2023-07-03T14:46:56.832Z', - updatedAt: '2023-07-03T14:46:56.832Z', + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', }, { - id: '383bfec5-5603-4c84-bd61-b8597ad3fd4d', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', - environmentId: '95ef0d9b-945e-4af6-851c-4c6685ceff20', - level: 0, - createdAt: '2023-07-03T14:46:56.832Z', - updatedAt: '2023-07-03T14:46:56.832Z', + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', }, { - id: 'ee520417-4baa-415e-9bfd-5b272c470cbd', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '8d4503eb-64c7-407e-89db-6ab80865071f', - level: 2, - createdAt: '2023-07-03T14:46:56.861Z', - updatedAt: '2023-07-03T14:46:56.861Z', + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + organizationId: 'b644c07f-193c-47ed-ae10-b88a8f63d20b', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', }, + ], + quota: [ { - id: '5598f092-4c2b-45df-ae68-06181e4ef235', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - environmentId: '8d4503eb-64c7-407e-89db-6ab80865071f', - level: 0, - createdAt: '2023-07-03T14:46:56.861Z', - updatedAt: '2023-07-03T14:46:56.861Z', + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, }, { - id: '863641ef-7a81-4ba6-b110-fb6dcf131d8d', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', - level: 2, - createdAt: '2023-07-03T14:46:56.863Z', - updatedAt: '2023-07-03T14:46:56.863Z', + id: '08770663-3b76-4af6-8978-9f75eda4faa7', + memory: '8Gi', + cpu: 4, + name: 'medium', + isPrivate: false, }, { - id: '75f3c6db-7729-4b04-ad59-a3a661b355b6', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - environmentId: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', - level: 1, - createdAt: '2023-07-03T14:46:56.863Z', - updatedAt: '2023-07-03T14:46:56.863Z', + id: 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', + memory: '12Gi', + cpu: 6, + name: 'large', + isPrivate: false, }, { - id: 'af8f87d4-fca2-4c8d-b39f-230ccbc7308f', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - environmentId: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', - level: 1, - createdAt: '2023-07-03T14:46:56.863Z', - updatedAt: '2023-07-03T14:46:56.863Z', + id: '97b851e8-9067-4a3d-a0e8-c3a6820c49be', + memory: '16Gi', + cpu: 8, + name: 'xlarge', + isPrivate: false, }, + ], + stage: [ { - id: 'f24773dc-c3c6-479d-9405-ee4a25227c66', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', - level: 2, - createdAt: '2023-07-03T14:46:56.807Z', - updatedAt: '2023-07-03T14:46:56.807Z', + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', }, { - id: '1390ac0b-7895-4f69-bc1a-3d4d534564a7', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '1c654f00-4798-4a80-929f-960ddb37885a', - level: 2, - createdAt: '2023-07-03T14:46:56.808Z', - updatedAt: '2023-07-03T14:46:56.808Z', + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', }, { - id: '9666026d-205a-456e-a226-a2e04433e4f8', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - environmentId: '1c654f00-4798-4a80-929f-960ddb37885a', - level: 0, - createdAt: '2023-07-03T14:46:56.808Z', - updatedAt: '2023-07-03T14:46:56.808Z', + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', }, { - id: 'c607501f-e54b-4550-8714-9c8eed6ae956', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', - environmentId: '1c654f00-4798-4a80-929f-960ddb37885a', - level: 0, - createdAt: '2023-07-03T14:46:56.808Z', - updatedAt: '2023-07-03T14:46:56.808Z', + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', }, { - id: '2843d23c-c2ac-4a35-a4ca-12afdb237fc6', - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - environmentId: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', - level: 2, - createdAt: '2023-07-03T14:46:56.818Z', - updatedAt: '2023-07-03T14:46:56.818Z', + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', }, ], projectClusterHistory: [ @@ -1937,6 +1857,9 @@ export const data = { clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', }, ], + projectMembers: [], + projectPlugin: [], + projectRole: [], repository: [ { id: '53891549-e628-4893-8bd3-92abcb71068a', @@ -2016,147 +1939,46 @@ export const data = { updatedAt: '2023-07-03T14:46:56.816Z', }, ], - role: [ - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', - role: 'user', - createdAt: '2023-07-03T14:46:56.826Z', - updatedAt: '2023-07-03T14:46:56.826Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - role: 'owner', - createdAt: '2023-07-03T14:46:56.826Z', - updatedAt: '2023-07-03T14:46:56.826Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - role: 'user', - createdAt: '2023-07-03T14:46:56.828Z', - updatedAt: '2023-07-03T14:46:56.828Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - role: 'user', - createdAt: '2023-07-03T14:46:56.828Z', - updatedAt: '2023-07-03T14:46:56.828Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', - projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', - role: 'user', - createdAt: '2023-07-03T14:46:56.829Z', - updatedAt: '2023-07-03T14:46:56.829Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', - role: 'user', - createdAt: '2023-07-03T14:46:56.856Z', - updatedAt: '2023-07-03T14:46:56.856Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', - projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', - role: 'user', - createdAt: '2023-07-03T14:46:56.857Z', - updatedAt: '2023-07-03T14:46:56.857Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', - role: 'owner', - createdAt: '2023-07-03T14:46:56.858Z', - updatedAt: '2023-07-03T14:46:56.858Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - projectId: '011e7860-04d7-461f-912d-334c622d38b3', - role: 'owner', - createdAt: '2023-07-03T14:46:56.804Z', - updatedAt: '2023-07-03T14:46:56.804Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - projectId: '011e7860-04d7-461f-912d-334c622d38c5', - role: 'owner', - createdAt: '2023-07-03T14:46:56.804Z', - updatedAt: '2023-07-03T14:46:56.804Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', - projectId: '011e7860-04d7-461f-912d-334c622d38b3', - role: 'user', - createdAt: '2023-07-03T14:46:56.805Z', - updatedAt: '2023-07-03T14:46:56.805Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', - projectId: '011e7860-04d7-461f-912d-334c622d38b3', - role: 'user', - createdAt: '2023-07-03T14:46:56.806Z', - updatedAt: '2023-07-03T14:46:56.806Z', - }, - { - userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', - projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', - role: 'owner', - createdAt: '2023-07-03T14:46:56.816Z', - updatedAt: '2023-07-03T14:46:56.816Z', - }, - ], - projectPlugin: [], - adminPlugin: [], associations: [ [ - 'project', + 'cluster', [ { - id: '22e7044f-8414-435d-9c4a-2df42a65034b', - clusters: [ - { - id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', - }, - ], + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], }, { - id: '9dabf3f9-6c86-4358-8598-65007d78df65', - clusters: [ + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, { - id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', }, ], }, { - id: '011e7860-04d7-461f-912d-334c622d38b3', - clusters: [ + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ { - id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + id: '011e7860-04d7-461f-912d-334c622d38b3', }, { - id: '126ac57f-263c-4463-87bb-d4e9017056b2', + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', }, - ], - }, - { - id: '011e7860-04d7-461f-912d-334c622d38c5', - clusters: [], - }, - { - id: '83833faf-f654-40dd-bcd5-cf2e944fc702', - clusters: [ { - id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + id: '22e7044f-8414-435d-9c4a-2df42a65034b', }, { - id: '126ac57f-263c-4463-87bb-d4e9017056b2', + id: '9dabf3f9-6c86-4358-8598-65007d78df65', }, ], }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, ], ], [ diff --git a/plugins/gitlab/src/repositories.ts b/plugins/gitlab/src/repositories.ts index 65417d59c..1c96b34bd 100644 --- a/plugins/gitlab/src/repositories.ts +++ b/plugins/gitlab/src/repositories.ts @@ -3,7 +3,8 @@ import type { GitlabProjectApi } from './class.js' import { provisionMirror } from './project.js' import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/class.js' import { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' -import { infraAppsRepoName, internalMirrorRepoName, shallowEqual } from './utils.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' +import { shallowEqual } from '@cpn-console/shared' type ProjectMirrorCreds = { botAccount: string diff --git a/plugins/gitlab/src/utils.ts b/plugins/gitlab/src/utils.ts index 2af843e03..d38d2fba1 100644 --- a/plugins/gitlab/src/utils.ts +++ b/plugins/gitlab/src/utils.ts @@ -55,23 +55,6 @@ export const getConfig = (): { export const infraAppsRepoName = 'infra-apps' export const internalMirrorRepoName = 'mirror' -export const shallowEqual = (object1: Record, object2: Record) => { - const keys1 = Object.keys(object1) - const keys2 = Object.keys(object2) - - if (keys1.length !== keys2.length) { - return false - } - - for (const key of keys1) { - if (object1[key] !== object2[key]) { - return false - } - } - - return true -} - export type VaultSecrets = { GITLAB: { ORGANIZATION_NAME: string diff --git a/plugins/kubernetes/src/class.ts b/plugins/kubernetes/src/class.ts index ccd84ffd1..29dc910c8 100644 --- a/plugins/kubernetes/src/class.ts +++ b/plugins/kubernetes/src/class.ts @@ -112,13 +112,7 @@ export class KubernetesProjectApi extends PluginApi { public namespaces: Record = {} constructor(project: GProject) { super() - const ownerId = (project.roles.find(role => role.role === 'owner'))?.userId - const owner = project.users.find(user => user.id === ownerId) ?? { - id: 'owner-not-found', - email: 'owner@not.found', - firstName: 'Owner', - lastName: 'Not found', - } + const owner = project.owner this.namespaces = project.environments.reduce((acc, env) => { const cluster = project.clusters.find(cluster => cluster.id === env.clusterId) as ClusterObject acc[env.name] = new KubernetesNamespace(project.organization.name, project.name, env.name, owner, cluster) diff --git a/plugins/nexus/src/project.ts b/plugins/nexus/src/project.ts index c842c123e..f99469cc8 100644 --- a/plugins/nexus/src/project.ts +++ b/plugins/nexus/src/project.ts @@ -8,10 +8,9 @@ const getAxiosInstance = () => axios.create(getAxiosOptions()) export const createNexusProject: StepCall = async (payload) => { const axiosInstance = getAxiosInstance() try { - // const { organization, project, owner } = payload.project const organization = payload.args.organization.name const project = payload.args.name - const owner = payload.args.users[0] + const owner = payload.args.owner const projectName = `${organization}-${project}` const res: any = {}
- {{ usersStore.users[permission.userId]?.email }} -