From 2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Wed, 6 Nov 2024 16:33:36 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20PAT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cypress/e2e/specs/admin/roles.e2e.ts | 2 +- .../cypress/e2e/specs/admin/tokens.e2e.ts | 2 +- .../cypress/e2e/specs/admin/users.e2e.ts | 6 +- apps/client/src/views/admin/AdminTokens.vue | 3 +- .../20241104232541_add_pat/migration.sql | 44 ++++++ apps/server/src/prisma/schema/token.prisma | 16 ++- apps/server/src/prisma/schema/user.prisma | 28 ++-- .../resources/admin-token/business.spec.ts | 7 +- .../src/resources/admin-token/business.ts | 25 ++-- .../src/resources/admin-token/router.spec.ts | 2 +- .../src/resources/admin-token/router.ts | 2 +- .../src/resources/project-member/business.ts | 5 +- .../src/resources/project/business.spec.ts | 23 ++-- apps/server/src/resources/project/business.ts | 24 ++-- apps/server/src/resources/project/queries.ts | 2 +- apps/server/src/resources/project/router.ts | 2 +- .../src/resources/user/business.spec.ts | 55 ++++---- apps/server/src/resources/user/business.ts | 125 ++++++++++++------ apps/server/src/resources/user/router.spec.ts | 6 +- apps/server/src/resources/user/router.ts | 4 +- apps/server/src/utils/controller.ts | 38 +++--- packages/shared/src/schemas/token.ts | 4 +- packages/test-utils/src/imports/data.ts | 14 ++ 23 files changed, 292 insertions(+), 147 deletions(-) create mode 100644 apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql diff --git a/apps/client/cypress/e2e/specs/admin/roles.e2e.ts b/apps/client/cypress/e2e/specs/admin/roles.e2e.ts index 48dfaa0b6..dac371ef3 100644 --- a/apps/client/cypress/e2e/specs/admin/roles.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/roles.e2e.ts @@ -2,7 +2,7 @@ import type { Role, User } from '@cpn-console/shared' import { getModel } from '../../support/func.js' const roles: Role[] = getModel('adminRole') -const users: User[] = getModel('user') +const users: User[] = getModel('user').filter(user => user.type === 'human') const newRole = { name: 'les copains locaux', users, diff --git a/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts b/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts index 272895a4a..a26087b2b 100644 --- a/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts @@ -63,7 +63,7 @@ describe('Administration tokens', () => { cy.get(`tbody tr:nth-of-type(2)`).within(() => { cy.get('td:nth-of-type(1)').should('contain', 'test2') cy.get('td:nth-of-type(2)').should('contain', 'Administration globale') - cy.get('td:nth-of-type(3)').should('contain', 'thibault.colin') + cy.get('td:nth-of-type(3)').should('contain.text', '@bot.io') cy.get('td:nth-of-type(4)').should('exist') cy.get('td:nth-of-type(5)').should('contain', 'Jamais') cy.get('td:nth-of-type(6)').should('contain', 'Jamais') diff --git a/apps/client/cypress/e2e/specs/admin/users.e2e.ts b/apps/client/cypress/e2e/specs/admin/users.e2e.ts index 97f8212a6..015453199 100644 --- a/apps/client/cypress/e2e/specs/admin/users.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/users.e2e.ts @@ -18,10 +18,10 @@ describe('Administration users', () => { users.forEach((user) => { cy.getByDataTestid(`user-${user.id}`) .should('contain.text', user.email) - .should('contain.text', '2023') .should('contain.text', user.lastName) .should('contain.text', user.firstName) - .should('not.contain.text', user.id) + .parent() + .should('contain.text', '202') // test que la date s'affiche }) cy.getByDataTestid('input-checkbox-tableAdministrationUsersDisplayId') .should('exist') @@ -43,7 +43,7 @@ describe('Administration users', () => { cy.getByDataTestid('tableAdministrationUsers') .find('tbody') .find('tr') - .should('have.length', users.length) + .should('have.length.at.least', users.length) cy.getByDataTestid('tableAdministrationUsersSearch') .clear() .type(anonUser.email) diff --git a/apps/client/src/views/admin/AdminTokens.vue b/apps/client/src/views/admin/AdminTokens.vue index 28a1a2d0f..3cee1f7c8 100644 --- a/apps/client/src/views/admin/AdminTokens.vue +++ b/apps/client/src/views/admin/AdminTokens.vue @@ -7,6 +7,7 @@ import { useAdminTokenStore } from '@/stores/admin-token.js' const statusWording: Record = { active: 'Actif', revoked: 'Révoqué', + inactive: 'Inactif', } const headers = [ 'Nom', @@ -32,7 +33,7 @@ const rows = computed(() => tokens.value.length ? tokens.value.map(token => ([ token.name, getAdminPermLabelsByValue(token.permissions).join(', '), - token.createdBy?.email ?? '-', + token.owner?.email ?? '-', (new Date(token.createdAt)).toLocaleString(), token.expirationDate ? (new Date(token.expirationDate)).toLocaleString() : 'Jamais', token.lastUse ? (new Date(token.lastUse)).toLocaleString() : 'Jamais', diff --git a/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 000000000..7eedbfd9b --- /dev/null +++ b/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + +-- AddForeignKey +ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name" + FROM public."AdminToken" + LOOP + user_uuid := gen_random_uuid(); + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + +-- DropForeignKey +ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/server/src/prisma/schema/token.prisma b/apps/server/src/prisma/schema/token.prisma index d532bd44d..c0c55751c 100644 --- a/apps/server/src/prisma/schema/token.prisma +++ b/apps/server/src/prisma/schema/token.prisma @@ -2,13 +2,25 @@ model AdminToken { id String @id @unique @default(uuid()) @db.Uuid name String permissions BigInt - userId String? @db.Uuid + userId String @db.Uuid expirationDate DateTime? lastUse DateTime? createdAt DateTime @default(now()) status TokenStatus @default(active) hash String - createdBy User? @relation(fields: [userId], references: [id]) + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) } enum TokenStatus { diff --git a/apps/server/src/prisma/schema/user.prisma b/apps/server/src/prisma/schema/user.prisma index fb329e6a0..e90fb69f8 100644 --- a/apps/server/src/prisma/schema/user.prisma +++ b/apps/server/src/prisma/schema/user.prisma @@ -1,17 +1,19 @@ model User { - id String @id @db.Uuid - firstName String - lastName String - email String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastLogin DateTime? - adminRoleIds String[] - type UserType - AdminToken AdminToken[] - logs Log[] - projectsOwned Project[] - ProjectMembers ProjectMembers[] + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] } enum UserType { diff --git a/apps/server/src/resources/admin-token/business.spec.ts b/apps/server/src/resources/admin-token/business.spec.ts index 584f2454a..b08773f19 100644 --- a/apps/server/src/resources/admin-token/business.spec.ts +++ b/apps/server/src/resources/admin-token/business.spec.ts @@ -36,10 +36,13 @@ describe('test admin-token business', () => { name: 'test', hash: expect.any(String), permissions: 2n, - userId, - expirationDate: null, + userId: expect.any(String), + expirationDate: undefined, }, omit: expect.any(Object), + include: { + owner: true, + }, }) }) }) diff --git a/apps/server/src/resources/admin-token/business.ts b/apps/server/src/resources/admin-token/business.ts index 9f07cfc90..c5af30a43 100644 --- a/apps/server/src/resources/admin-token/business.ts +++ b/apps/server/src/resources/admin-token/business.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { type adminTokenContract, generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' import type { $Enums, AdminToken, Prisma } from '@prisma/client' import prisma from '../../prisma.js' @@ -15,7 +15,7 @@ export async function listTokens(query: typeof adminTokenContract.listAdminToken return prisma.adminToken.findMany({ omit: { hash: true }, - include: { createdBy: true }, + include: { owner: true }, orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], where, }).then(tokens => @@ -23,25 +23,32 @@ export async function listTokens(query: typeof adminTokenContract.listAdminToken ) } -export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type, userId: string | null | undefined, tokenId: string | undefined) { - if (!userId) { - const originalToken = await prisma.adminToken.findUniqueOrThrow({ where: { id: tokenId } }) - userId = originalToken.userId - } +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { return new BadRequest400('Date d\'expiration trop courte') } const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) const token = await prisma.adminToken.create({ data: { ...data, hash, permissions: BigInt(data.permissions), - expirationDate: data.expirationDate ? new Date(data.expirationDate) : null, - userId, + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, }, omit: { hash: true }, + include: { owner: true }, }) return { ...token, diff --git a/apps/server/src/resources/admin-token/router.spec.ts b/apps/server/src/resources/admin-token/router.spec.ts index e9dbab457..301cf6204 100644 --- a/apps/server/src/resources/admin-token/router.spec.ts +++ b/apps/server/src/resources/admin-token/router.spec.ts @@ -86,7 +86,7 @@ describe('test adminTokenContract', () => { .body(tokenData) .end() - expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData, user.user.id, undefined) + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) expect(response.json()).toEqual(newToken) expect(response.statusCode).toEqual(201) }) diff --git a/apps/server/src/resources/admin-token/router.ts b/apps/server/src/resources/admin-token/router.ts index 5ccb4f002..c5a630e8d 100644 --- a/apps/server/src/resources/admin-token/router.ts +++ b/apps/server/src/resources/admin-token/router.ts @@ -21,7 +21,7 @@ export function adminTokenRouter() { const perms = await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() - const body = await createToken(data, perms.user?.id, perms.tokenId) + const body = await createToken(data) if (body instanceof ErrorResType) return body return { diff --git a/apps/server/src/resources/project-member/business.ts b/apps/server/src/resources/project-member/business.ts index f04f41f93..7cc35389a 100644 --- a/apps/server/src/resources/project-member/business.ts +++ b/apps/server/src/resources/project-member/business.ts @@ -1,7 +1,7 @@ import type { Project, User } from '@prisma/client' import type { XOR, projectMemberContract } from '@cpn-console/shared' import { UserSchema } from '@cpn-console/shared' -import { logUser } from '../user/business.js' +import { logViaSession } from '../user/business.js' import { addLogs, deleteMember, @@ -37,7 +37,8 @@ export async function addMember(projectId: Project['id'], user: XOR<{ userId: st if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') 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: [] }) + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user } else { return new NotFound404() } diff --git a/apps/server/src/resources/project/business.spec.ts b/apps/server/src/resources/project/business.spec.ts index c23409b86..280fb1191 100644 --- a/apps/server/src/resources/project/business.spec.ts +++ b/apps/server/src/resources/project/business.spec.ts @@ -15,7 +15,7 @@ vi.mock('../../utils/hook-wrapper.ts', async () => ({ hook, })) -const logUserMock = vi.spyOn(userBusiness, 'logUser') +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') const projectId = faker.string.uuid() @@ -27,6 +27,8 @@ const user: User = { firstName: faker.person.firstName(), lastName: faker.person.lastName(), adminRoleIds: [], + type: 'human', + lastLogin: null, } const project: Project & { clusters: Pick[] @@ -114,22 +116,23 @@ describe('test project business logic', () => { describe('createProject', () => { it('should create project', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue({ id: project.organizationId, active: true }) prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) prisma.project.findFirst.mockResolvedValue(undefined) hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) - await createProject(project, user, reqId) + const projectRes = await createProject(project, user, reqId) + expect(projectRes.name).toEqual(project.name) expect(prisma.project.create).toHaveBeenCalledTimes(1) expect(prisma.log.create).toHaveBeenCalledTimes(1) expect(hook.project.upsert).toHaveBeenCalledTimes(1) }) it('should not create project, cause missing organization', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue(undefined) @@ -140,7 +143,7 @@ describe('test project business logic', () => { }) it('should not create project, cause inactive organization', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue({ id: project.organizationId, active: false }) @@ -151,7 +154,7 @@ describe('test project business logic', () => { }) it('should not create project, cause confilct', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue({ id: project.organizationId }) prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) @@ -164,7 +167,7 @@ describe('test project business logic', () => { }) it('should return plugins failed', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue({ id: project.organizationId, active: true }) prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) @@ -185,7 +188,7 @@ describe('test project business logic', () => { everyonePerms: '5', } const reqId = faker.string.uuid() - const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [] }] + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] it('should update project', async () => { prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) prisma.project.update.mockResolvedValue(project) @@ -212,7 +215,7 @@ describe('test project business logic', () => { it('should not update project, cause missing member', async () => { hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) @@ -225,7 +228,7 @@ describe('test project business logic', () => { }) it('should return plugins failed', async () => { - logUserMock.mockResolvedValue({ user }) + logViaSessionMock.mockResolvedValue({ user }) prisma.organization.findUnique.mockResolvedValue({ id: project.organizationId }) hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) diff --git a/apps/server/src/resources/project/business.ts b/apps/server/src/resources/project/business.ts index 63730fe49..5d72557ae 100644 --- a/apps/server/src/resources/project/business.ts +++ b/apps/server/src/resources/project/business.ts @@ -3,7 +3,6 @@ import { servicesInfos } from '@cpn-console/hooks' import type { Project, User } from '@prisma/client' import type { projectContract } from '@cpn-console/shared' import { ProjectStatusSchema } from '@cpn-console/shared' -import { logUser } from '../user/business.js' import { addLogs, deleteAllEnvironmentForProject, @@ -54,9 +53,8 @@ export async function getProjectSecrets(projectId: string) { ) } -export async function createProject(dataDto: typeof projectContract.createProject.body._type, { groups, ...requestor }: UserDetails, requestId: string) { - // Pré-requis - const owner = await logUser({ groups, ...requestor }) +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') const organization = await getOrganizationById(dataDto.organizationId) if (!organization) return new BadRequest400('Organisation introuvable') if (!organization.active) return new BadRequest400('Organisation inactive') @@ -67,10 +65,10 @@ export async function createProject(dataDto: typeof projectContract.createProjec } // Actions - const project = await initializeProject({ ...dataDto, ownerId: owner.id }) + const project = await initializeProject({ ...dataDto, ownerId: requestor.id }) const { results, project: projectInfos } = await hook.project.upsert(project.id) - await addLogs({ action: 'Create Project', data: results, userId: owner.id, requestId, projectId: project.id }) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) if (results.failed) { return new Unprocessable422('Echec des services à la création du projet') } @@ -92,7 +90,7 @@ export async function getProject(projectId: Project['id']) { })) } export async function updateProject( - { description, ownerId, everyonePerms, locked }: typeof projectContract.updateProject.body._type, + { description, ownerId: ownerIdCandidate, everyonePerms, locked }: typeof projectContract.updateProject.body._type, projectId: Project['id'], requestor: UserDetails, requestId: string, @@ -109,13 +107,15 @@ export async function updateProject( // Actions const project = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, - include: { members: true }, + include: { members: { include: { user: true } } }, }) - if (ownerId && ownerId !== project.ownerId) { - if (!project.members.find(member => member.userId === ownerId)) { + if (ownerIdCandidate && ownerIdCandidate !== project.ownerId) { + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') if (!project.members.find(member => member.userId === project.ownerId)) { await prisma.projectMembers.create({ data: { userId: project.ownerId, projectId }, @@ -123,9 +123,9 @@ export async function updateProject( } await prisma.$transaction([ prisma.projectMembers.delete({ - where: { projectId_userId: { userId: ownerId, projectId } }, + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, }), - prisma.project.update({ where: { id: projectId }, data: { ownerId } }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), ]) } diff --git a/apps/server/src/resources/project/queries.ts b/apps/server/src/resources/project/queries.ts index 7db684c10..2eef3b02d 100644 --- a/apps/server/src/resources/project/queries.ts +++ b/apps/server/src/resources/project/queries.ts @@ -239,7 +239,7 @@ export function getHookProjectInfos(id: Project['id']) { where: { id }, include: { organization: true, - members: { include: { user: true } }, + members: { include: { user: true }, where: { user: { type: 'human' } } }, clusters: { select: clusterInfosSelect }, environments: { include: { diff --git a/apps/server/src/resources/project/router.ts b/apps/server/src/resources/project/router.ts index d3c63715e..f7a260b05 100644 --- a/apps/server/src/resources/project/router.ts +++ b/apps/server/src/resources/project/router.ts @@ -61,7 +61,7 @@ export function projectRouter() { // Créer un projet createProject: async ({ request: req, body: data }) => { const perms = await authUser(req) - if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (perms.user?.type !== 'human') return new Unauthorized401('Require to be requested from user not api key') const body = await createProject(data, perms.user, req.id) if (body instanceof ErrorResType) return body diff --git a/apps/server/src/resources/user/business.spec.ts b/apps/server/src/resources/user/business.spec.ts index 65d57ce30..836cdb76f 100644 --- a/apps/server/src/resources/user/business.spec.ts +++ b/apps/server/src/resources/user/business.spec.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker' import { beforeEach, describe, expect, it, vi } from 'vitest' import prisma from '../../__mocks__/prisma.js' import type { UserDetails } from '../../types/index.ts' -import { TokenSearchResult, getMatchingUsers, getUsers, logAdminToken, logUser, patchUsers } from './business.ts' +import { TokenInvalidReason, getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers } from './business.ts' import * as queries from './queries.js' const getUsersQueryMock = vi.spyOn(queries, 'getUsers') @@ -110,7 +110,7 @@ describe('test users business', () => { expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [{ - ProjectMembers: { + projectMembers: { none: { projectId, }, @@ -124,7 +124,7 @@ describe('test users business', () => { }].concat(AND) }) }) }) - describe('logUser', () => { + describe('logViaSession', () => { // ça ne teste pas tout mais c'est déjà bien hein const adminRoles = [{ id: faker.string.uuid(), @@ -148,33 +148,23 @@ describe('test users business', () => { } it('should create user and return adminPerms', async () => { prisma.adminRole.findMany.mockResolvedValue(adminRoles) - const response = await logUser(userToLog, true) + prisma.user.findUnique.mockResolvedValue(undefined) + const response = await logViaSession(userToLog) expect(response.adminPerms).toBe(0n) - }) - it('should create user and not return adminPerms', async () => { - prisma.adminRole.findMany.mockResolvedValue(adminRoles) - prisma.user.create.mockResolvedValue(user) - const response = await logUser(userToLog, false) - expect(response.adminPerms).toBeUndefined() + expect(prisma.user.create).toHaveBeenCalledTimes(1) }) it('should update user and return adminPerms', async () => { prisma.adminRole.findMany.mockResolvedValue(adminRoles) prisma.user.findUnique.mockResolvedValue(user) prisma.user.update.mockResolvedValue(user) - const response = await logUser(userToLog, true) - expect(response.adminPerms).toBe(0n) - }) - it('should update user and not return adminPerms', async () => { - prisma.adminRole.findMany.mockResolvedValue(adminRoles) - prisma.user.findUnique.mockResolvedValue(user) - prisma.user.update.mockResolvedValue(user) - const response = await logUser(userToLog, false) - expect(response.adminPerms).toBeUndefined() + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) }) }) }) -describe('logAdminToken', () => { +describe('logViaToken', () => { const nextYear = new Date() const lastYear = new Date() nextYear.setFullYear((new Date()).getFullYear() + 1) @@ -191,31 +181,40 @@ describe('logAdminToken', () => { it('should return identity', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) - const identity = await logAdminToken('test') + const identity = await logViaToken('test', true) expect(identity.adminPerms).toBe(2n) }) + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test', true) + expect(identity.adminPerms).toBe(0n) + }) + it('should return identity, with expirationDate', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) - const identity = await logAdminToken('test') + const identity = await logViaToken('test', true) expect(identity.adminPerms).toBe(2n) }) it('should return cause revoked', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) - const identity = await logAdminToken('test') - expect(identity).toBe(TokenSearchResult.INACTIVE) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) }) it('should return cause expired', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) - const identity = await logAdminToken('test') - expect(identity).toBe(TokenSearchResult.EXPIRED) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) }) it('should return cause not found', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) - const identity = await logAdminToken('test') - expect(identity).toBe(TokenSearchResult.NOT_FOUND) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) }) }) diff --git a/apps/server/src/resources/user/business.ts b/apps/server/src/resources/user/business.ts index 137d8d8b4..20c02c369 100644 --- a/apps/server/src/resources/user/business.ts +++ b/apps/server/src/resources/user/business.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import type { Prisma, User } from '@prisma/client' -import type { userContract } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { XOR, userContract } from '@cpn-console/shared' import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' import prisma from '@/prisma.js' import type { UserDetails } from '@/types/index.js' @@ -39,7 +39,7 @@ export async function getUsers(query: typeof userContract.getAllUsers.query._typ export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { const AND: Prisma.UserWhereInput[] = [] if (query.notInProjectId) { - AND.push({ ProjectMembers: { none: { projectId: 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 @@ -80,10 +80,14 @@ export async function patchUsers(users: typeof userContract.patchUsers.body._typ }) } +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + type UserTrial = Omit -export async function logUser({ id, email, groups, ...user }: UserTrial): Promise -export async function logUser({ id, email, groups, ...user }: UserTrial, withAdminPerms: boolean): Promise<{ user: User, adminPerms: bigint }> -export async function logUser({ id, email, groups, ...user }: UserTrial, withAdminPerms?: boolean): Promise { +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { const userDb = await prisma.user.findUnique({ where: { id }, }) @@ -97,13 +101,10 @@ export async function logUser({ id, email, groups, ...user }: UserTrial, withAdm if (!userDb) { const createdUser = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) - if (withAdminPerms) { - return { - user: createdUser, - adminPerms: matchingAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n), - } + return { + user: createdUser, + adminPerms: sumAdminPerms(matchingAdminRoles), } - return createdUser } const nonOidcRoleIds = matchingAdminRoles @@ -112,40 +113,90 @@ export async function logUser({ id, email, groups, ...user }: UserTrial, withAdm const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, adminRoleIds: nonOidcRoleIds, lastLogin: (new Date()).toISOString() } }) // on enregistre en bdd uniquement les roles de l'utilisateurs qui ne viennent pas de keycloak .then(user => ({ ...user, adminRoleIds: [...user.adminRoleIds, ...oidcRoleIds] })) - if (withAdminPerms) { - return { - user: updatedUser, - adminPerms: matchingAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n), - } + return { + user: updatedUser, + adminPerms: matchingAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n), } - return updatedUser // mais on lui retourne tous ceux auxquels il est aussi attaché par oidc } -export enum TokenSearchResult { - NOT_FOUND = 'Not Found', - INACTIVE = 'Not active', - EXPIRED = 'Expired', -} +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') -export async function logAdminToken(token: string): Promise<{ adminPerms: bigint, id: string, user: User | null } | TokenSearchResult> { - const calculatedHash = createHash('sha256').update(token).digest('hex') - const tokenRecord = await prisma.adminToken.findFirst({ where: { hash: calculatedHash }, include: { createdBy: true } }) + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } - if (!tokenRecord) { - return TokenSearchResult.NOT_FOUND + if (typeof token === 'string') { + return token } - if (tokenRecord.status !== 'active') { - return TokenSearchResult.INACTIVE + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds), + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE } const currentDate = new Date() - if (tokenRecord.expirationDate && currentDate.getTime() > tokenRecord.expirationDate?.getTime()) { - return TokenSearchResult.EXPIRED + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED } +} - await prisma.adminToken.update({ where: { id: tokenRecord.id }, data: { lastUse: new Date() } }) - return { - adminPerms: tokenRecord.permissions, - id: tokenRecord.id, - user: tokenRecord.createdBy, +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token } diff --git a/apps/server/src/resources/user/router.spec.ts b/apps/server/src/resources/user/router.spec.ts index 35761f142..ffa817afe 100644 --- a/apps/server/src/resources/user/router.spec.ts +++ b/apps/server/src/resources/user/router.spec.ts @@ -9,7 +9,7 @@ import * as business from './business.js' vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) const authUserMock = vi.spyOn(utilsController, 'authUser') const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') -const businessLogMock = vi.spyOn(business, 'logUser') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') const businessGetUsersMock = vi.spyOn(business, 'getUsers') const businessPatchMock = vi.spyOn(business, 'patchUsers') @@ -47,13 +47,13 @@ describe('test userContract', () => { lastName: faker.person.lastName(), } setRequestor(user) - businessLogMock.mockResolvedValueOnce(user) + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) const response = await app.inject() .get(userContract.auth.path) .end() - expect(businessLogMock).toHaveBeenCalledTimes(1) + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) expect(response.json()).toEqual(user) expect(response.statusCode).toEqual(200) }) diff --git a/apps/server/src/resources/user/router.ts b/apps/server/src/resources/user/router.ts index 3af00c529..c95d0620c 100644 --- a/apps/server/src/resources/user/router.ts +++ b/apps/server/src/resources/user/router.ts @@ -2,7 +2,7 @@ import { AdminAuthorized, userContract } from '@cpn-console/shared' import { getMatchingUsers, getUsers, - logUser, + logViaSession, patchUsers, } from './business.js' import '@/types/index.js' @@ -26,7 +26,7 @@ export function userRouter() { if (!user) return new Unauthorized401() - const body = await logUser(user) + const { user: body } = await logViaSession(user) return { status: 200, diff --git a/apps/server/src/utils/controller.ts b/apps/server/src/utils/controller.ts index 79096fdfe..48f0f1b08 100644 --- a/apps/server/src/utils/controller.ts +++ b/apps/server/src/utils/controller.ts @@ -2,9 +2,10 @@ import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@pri import type { XOR } from '@cpn-console/shared' import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' import type { FastifyRequest } from 'fastify' +import { Unauthorized401 } from './errors.js' import type { UserDetails } from '@/types/index.js' import prisma from '@/prisma.js' -import { logAdminToken, logUser } from '@/resources/user/business.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' export type RequireOnlyOne = Pick> @@ -80,25 +81,32 @@ export async function authUser(req: FastifyRequest, projectUnique: ProjectUnique export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { let adminPermissions: bigint = 0n let tokenId: string | undefined - let user = req.session?.user - - const tokenHeader = req.headers[tokenHeaderName] - if (typeof tokenHeader === 'string') { - const token = await logAdminToken(tokenHeader) - if (typeof token !== 'string') { - adminPermissions = token.adminPerms - tokenId = token.id - if (!user && token.user) { - user = { ...token.user, groups: [] } - } + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(req.session.user) + user = { + ...loginResult.user, + groups: req.session.user.groups, } - } else if (req.session.user) { - const loginResult = await logUser(req.session.user, true) adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } } const baseReturnInfos = { - user: req.session.user, + user, adminPermissions, tokenId, } diff --git a/packages/shared/src/schemas/token.ts b/packages/shared/src/schemas/token.ts index 34a32847e..c683db6ea 100644 --- a/packages/shared/src/schemas/token.ts +++ b/packages/shared/src/schemas/token.ts @@ -11,8 +11,8 @@ export const TokenSchema = z.object({ lastUse: dateToString.nullable(), expirationDate: dateToString.nullable(), createdAt: dateToString, - createdBy: UserSchema - .pick({ email: true, firstName: true, lastName: true, id: true }) + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) .optional() .nullable(), status: z.enum(['active', 'revoked', 'inactive']), diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index a967cfbf9..9227bedf9 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -1,16 +1,19 @@ export const data = { + personalAccessToken: [], adminToken: [ { id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', name: 'test', hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', }, { id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', name: 'test', hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', status: 'revoked', }, ], @@ -157,6 +160,17 @@ export const data = { }, ], user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, { id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', firstName: 'Claire', From 7179a68bc9402810f807fe0494793bb4c2c2f551 Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:00:21 +0100 Subject: [PATCH 2/6] refactor: :sparkles: nexus internal url --- plugins/nexus/README.md | 13 +++++++++++++ plugins/nexus/src/config.ts | 29 +++++++++++++++++++++++++++++ plugins/nexus/src/functions.ts | 18 ++---------------- plugins/nexus/src/maven.ts | 4 ++-- plugins/nexus/src/npm.ts | 4 ++-- 5 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 plugins/nexus/README.md create mode 100644 plugins/nexus/src/config.ts diff --git a/plugins/nexus/README.md b/plugins/nexus/README.md new file mode 100644 index 000000000..20efcc0c6 --- /dev/null +++ b/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +# Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/plugins/nexus/src/config.ts b/plugins/nexus/src/config.ts new file mode 100644 index 000000000..aecdc3795 --- /dev/null +++ b/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/plugins/nexus/src/functions.ts b/plugins/nexus/src/functions.ts index 1730169e1..5841f1c01 100644 --- a/plugins/nexus/src/functions.ts +++ b/plugins/nexus/src/functions.ts @@ -1,18 +1,4 @@ -import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' - -const config: { - url?: string - user?: string - password?: string -} = {} - -export function getConfig(): Required { - config.password = requiredEnv('NEXUS_ADMIN_PASSWORD') - config.user = requiredEnv('NEXUS_ADMIN') - config.url = removeTrailingSlash(requiredEnv('NEXUS_URL')) - // @ts-ignore - return config -} +import getConfig from './config.js' let axiosOptions: { baseURL: string @@ -28,7 +14,7 @@ let axiosOptions: { export function getAxiosOptions(): Required { if (!axiosOptions) { axiosOptions = { - baseURL: `${getConfig().url}/service/rest/v1/`, + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, auth: { username: getConfig().user, password: getConfig().password, diff --git a/plugins/nexus/src/maven.ts b/plugins/nexus/src/maven.ts index 1172ad780..a5129b3e3 100644 --- a/plugins/nexus/src/maven.ts +++ b/plugins/nexus/src/maven.ts @@ -1,7 +1,7 @@ import type { AxiosInstance } from 'axios' import type { WritePolicy } from './utils.js' import { deleteIfExists } from './utils.js' -import { getConfig } from './functions.js' +import getConfig from './config.js' // Retro-compatibilty, maven is a special case with bad name formats function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo @@ -186,7 +186,7 @@ export function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: strin } export function getMavenUrls(projectName: string) { - const nexusUrl = getConfig().url + const nexusUrl = getConfig().secretExposedUrl const names = getRepoNames(projectName) return { MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, diff --git a/plugins/nexus/src/npm.ts b/plugins/nexus/src/npm.ts index 9d020cfb2..8fd51f118 100644 --- a/plugins/nexus/src/npm.ts +++ b/plugins/nexus/src/npm.ts @@ -1,7 +1,7 @@ import type { AxiosInstance } from 'axios' import type { WritePolicy } from './utils.js' import { deleteIfExists } from './utils.js' -import { getConfig } from './functions.js' +import getConfig from './config.js' function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo return { @@ -159,7 +159,7 @@ export function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) } export function getNpmUrls(projectName: string) { - const nexusUrl = getConfig().url + const nexusUrl = getConfig().secretExposedUrl const names = getRepoNames(projectName) return { NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, From f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c Mon Sep 17 00:00:00 2001 From: this-is-tobi Date: Thu, 3 Oct 2024 20:42:37 +0200 Subject: [PATCH 3/6] feat: :sparkles: build project kv with config for projects --- apps/client/public/img/vault.svg | 2 +- plugins/argocd/package.json | 2 +- plugins/argocd/src/functions.ts | 2 +- plugins/kubernetes/package.json | 2 +- plugins/kubernetes/src/api.ts | 25 ++- plugins/kubernetes/src/class.ts | 6 +- plugins/vault/README.md | 12 ++ plugins/vault/package.json | 3 +- plugins/vault/src/class.ts | 290 +++++++++++++++++++++---------- plugins/vault/src/config.ts | 28 +++ plugins/vault/src/env.d.ts | 1 + plugins/vault/src/functions.ts | 77 +++++++- plugins/vault/src/index.ts | 14 +- plugins/vault/src/infos.ts | 8 +- plugins/vault/src/monitor.ts | 7 +- plugins/vault/src/utils.ts | 15 ++ plugins/vault/src/vso.ts | 55 ++++++ pnpm-lock.yaml | 3 + 18 files changed, 442 insertions(+), 110 deletions(-) create mode 100644 plugins/vault/README.md create mode 100644 plugins/vault/src/config.ts create mode 100644 plugins/vault/src/env.d.ts create mode 100644 plugins/vault/src/utils.ts create mode 100644 plugins/vault/src/vso.ts diff --git a/apps/client/public/img/vault.svg b/apps/client/public/img/vault.svg index 0eeb10fb0..d5be8c74d 100644 --- a/apps/client/public/img/vault.svg +++ b/apps/client/public/img/vault.svg @@ -1 +1 @@ - \ No newline at end of file +Vault \ No newline at end of file diff --git a/plugins/argocd/package.json b/plugins/argocd/package.json index 77ee2e246..19d44cf59 100644 --- a/plugins/argocd/package.json +++ b/plugins/argocd/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/argocd-plugin", "type": "module", - "version": "2.1.1", + "version": "2.1.2", "private": false, "description": "", "main": "dist/index.js", diff --git a/plugins/argocd/src/functions.ts b/plugins/argocd/src/functions.ts index 235848ed9..da5a8ad78 100644 --- a/plugins/argocd/src/functions.ts +++ b/plugins/argocd/src/functions.ts @@ -219,7 +219,7 @@ async function ensureInfraEnvValues(project: Project, environment: Environment, const cluster = getCluster(project, environment) const infraProject = await gitlabApi.getProjectById(repoId) const valueFilePath = getValueFilePath(project, cluster, environment) - const vaultCredentials = await vaultApi.getAppRoleCredentials() + const vaultCredentials = await vaultApi.Role.getCredentials() const repositories: ArgoRepoSource[] = await Promise.all([ getArgoRepoSource('infra-apps', environment.name, gitlabApi), ...sourceRepos.map(repo => getArgoRepoSource(repo.internalRepoName, environment.name, gitlabApi)), diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 9fb498f16..9c3665c9b 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/kubernetes-plugin", "type": "module", - "version": "2.0.6", + "version": "2.0.7", "private": false, "description": "", "main": "dist/index.js", diff --git a/plugins/kubernetes/src/api.ts b/plugins/kubernetes/src/api.ts index 8a41db90f..b1f85ee54 100644 --- a/plugins/kubernetes/src/api.ts +++ b/plugins/kubernetes/src/api.ts @@ -1,4 +1,4 @@ -import { CoreV1Api, KubeConfig } from '@kubernetes/client-node' +import { ApisApi, CoreV1Api, KubeConfig } from '@kubernetes/client-node' import type { ClusterObject } from '@cpn-console/hooks' import { AnyObjectsApi } from './customApiClass.js' @@ -25,6 +25,29 @@ export function createCoreV1Api(cluster: ClusterObject) { return kc.makeApiClient(CoreV1Api) } +export function createApisApi(cluster: ClusterObject) { + if (!cluster.user.keyData && !cluster.user.token) { + // Special case: disable direct calls to the cluster + console.log(`Direct kubernetes API calls are disabled for cluster ${cluster.label}`) + return + } + const kc = new KubeConfig() + const clusterConfig = { + ...cluster.cluster, + skipTLSVerify: cluster.cluster.skipTLSVerify ?? false, + name: 'You should pass !', + } + const userConfig = { + ...cluster.user, + name: cluster.id, + } + if (cluster.cluster.skipTLSVerify) { + delete clusterConfig.caData + } + kc.loadFromClusterAndUser(clusterConfig, userConfig) + return kc.makeApiClient(ApisApi) +} + export function createCoreV1Apis(clusters: ClusterObject[]) { return clusters.map(createCoreV1Api) } diff --git a/plugins/kubernetes/src/class.ts b/plugins/kubernetes/src/class.ts index 8db89bdb4..2149bdd5b 100644 --- a/plugins/kubernetes/src/class.ts +++ b/plugins/kubernetes/src/class.ts @@ -1,9 +1,9 @@ import type { ClusterObject, Environment, Project, ResourceQuotaType, UserObject } from '@cpn-console/hooks' import { PluginApi } from '@cpn-console/hooks' -import type { CoreV1Api, V1Namespace, V1ObjectMeta } from '@kubernetes/client-node' +import type { ApisApi, CoreV1Api, V1Namespace, V1ObjectMeta } from '@kubernetes/client-node' import { objectValues, shallowMatch } from '@cpn-console/shared' import { getNsObject } from './namespace.js' -import { createCoreV1Api, createCustomObjectApi } from './api.js' +import { createApisApi, createCoreV1Api, createCustomObjectApi } from './api.js' import { getQuotaObject } from './quota.js' import type { AnyObjectsApi } from './customApiClass.js' import { patchOptions } from './misc.js' @@ -38,9 +38,11 @@ class KubernetesNamespace { nsObject: V1NamespacePopulated coreV1Api: CoreV1Api | undefined anyObjectApi: AnyObjectsApi | undefined + apisApi: ApisApi | undefined constructor(organizationName: string, projectName: string, environmentName: string, owner: UserObject, cluster: ClusterObject, projectId: string) { this.coreV1Api = createCoreV1Api(cluster) + this.apisApi = createApisApi(cluster) this.anyObjectApi = createCustomObjectApi(cluster) this.nsObjectExpected = getNsObject(organizationName, projectName, environmentName, owner, cluster.zone.slug, projectId) this.nsObject = getNsObject(organizationName, projectName, environmentName, owner, cluster.zone.slug, projectId) diff --git a/plugins/vault/README.md b/plugins/vault/README.md new file mode 100644 index 000000000..48b66f4b6 --- /dev/null +++ b/plugins/vault/README.md @@ -0,0 +1,12 @@ +# Introduction + +Plugin de gestion du plugin Vault + +# Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | diff --git a/plugins/vault/package.json b/plugins/vault/package.json index 8a1838994..1732dc9b6 100644 --- a/plugins/vault/package.json +++ b/plugins/vault/package.json @@ -1,7 +1,7 @@ { "name": "@cpn-console/vault-plugin", "type": "module", - "version": "2.0.5", + "version": "2.2.0", "private": false, "description": "", "main": "dist/index.js", @@ -26,6 +26,7 @@ }, "devDependencies": { "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/kubernetes-plugin": "workspace:^", "@cpn-console/ts-config": "workspace:^", "@types/node": "^22.8.2", "@vitest/coverage-v8": "^1.6.0", diff --git a/plugins/vault/src/class.ts b/plugins/vault/src/class.ts index 3452cbd03..e849cb57b 100644 --- a/plugins/vault/src/class.ts +++ b/plugins/vault/src/class.ts @@ -2,14 +2,15 @@ import type { AxiosInstance } from 'axios' import axios from 'axios' import type { ProjectLite } from '@cpn-console/hooks' import { PluginApi } from '@cpn-console/hooks' -import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import getConfig from './config.js' +import { getAuthMethod, isAppRoleEnabled } from './utils.js' interface ReadOptions { throwIfNoEntry: boolean } -interface AppRoleCredentials { +export interface AppRoleCredentials { url: string - kvName: string + coreKvName: string roleId: string secretId: string } @@ -17,26 +18,38 @@ interface AppRoleCredentials { export class VaultProjectApi extends PluginApi { private token: string | undefined = undefined private readonly axios: AxiosInstance - private readonly kvName: string = 'forge-dso' private readonly basePath: string private readonly roleName: string - private readonly projectRootDir: string | undefined + private readonly projectRootDir: string private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } constructor(project: ProjectLite) { super() this.basePath = `${project.organization.name}/${project.name}` this.roleName = `${project.organization.name}-${project.name}` - this.projectRootDir = removeTrailingSlash(requiredEnv('PROJECTS_ROOT_DIR')) + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = `${project.organization.name}-${project.name}` + this.groupName = `${project.organization.name}-${project.name}` + this.policyName = { + techRO: `tech--${project.organization.name}-${project.name}--ro`, + appFull: `app--${project.organization.name}-${project.name}--admin`, + } this.axios = axios.create({ - baseURL: requiredEnv('VAULT_URL'), + baseURL: getConfig().internalUrl, headers: { - 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + 'X-Vault-Token': getConfig().token, }, }) this.defaultAppRoleCredentials = { - url: removeTrailingSlash(requiredEnv('VAULT_URL')), - kvName: this.kvName, + url: getConfig().publicUrl, + coreKvName: this.coreKvName, roleId: 'none', secretId: 'none', } @@ -56,7 +69,7 @@ export class VaultProjectApi extends PluginApi { const listSecretPath: string[] = [] const response = await this.axios({ - url: `/v1/${this.kvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, headers: { 'X-Vault-Token': await this.getToken(), }, @@ -82,7 +95,7 @@ export class VaultProjectApi extends PluginApi { if (path.startsWith('/')) path = path.slice(1) const response = await this.axios.get( - `/v1/${this.kvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, { headers: { 'X-Vault-Token': await this.getToken() }, validateStatus: status => (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), @@ -95,7 +108,7 @@ export class VaultProjectApi extends PluginApi { if (path.startsWith('/')) path = path.slice(1) const response = await this.axios.post( - `/v1/${this.kvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, { headers: { 'X-Vault-Token': await this.getToken() }, data: body, @@ -104,96 +117,197 @@ export class VaultProjectApi extends PluginApi { return await response.data } - async upsertPolicy() { - await this.axios.put( - `/v1/sys/policies/acl/${this.roleName}`, - { policy: `path "${this.kvName}/data/${this.projectRootDir}/${this.basePath}/*" { capabilities = ["create", "read", "update", "delete", "list"] }` }, - { + public async destroy(path: string = '/') { + if (path.startsWith('/')) + path = path.slice(1) + return this.axios({ + method: 'delete', + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + + }) + } + + Project = { + upsert: async () => { + const token = await this.getToken() + let kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${this.projectKvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + kvRes = await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${this.projectKvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + }, + }) + } + // TODO faire une vérif des paramétrages sur 200 + + await this.Policy.ensureAll() + await this.Group.upsert() + await this.Role.upsert() + }, + delete: async () => { + const token = await this.getToken() + await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${this.projectKvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + await this.Policy.deleteAll() + await this.Group.delete() + await this.Role.delete() + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, headers: { 'X-Vault-Token': await this.getToken(), 'Content-Type': 'application/json', }, - }, - ) + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + ensureAll: async () => { + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + }, + deleteAll: async () => { + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + }, } - async isAppRoleEnabled() { - const response = await this.axios.get( - '/v1/sys/auth', - { + Group = { + upsert: async () => { + const existingGroup = await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - return Object.keys(response.data).includes('approle/') - } + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + console.log(existingGroup.data) - public async upsertRole() { - const appRoleEnabled = await this.isAppRoleEnabled() - if (!appRoleEnabled) return - this.upsertPolicy() - this.axios.post( - `/v1/auth/approle/role/${this.roleName}`, - { - secret_id_num_uses: '40', - secret_id_ttl: '10m', - token_max_ttl: '30m', - token_num_uses: '0', - token_ttl: '20m', - token_type: 'batch', - token_policies: this.roleName, - }, - { + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - } + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) - public async getAppRoleCredentials(): Promise { - const appRoleEnabled = await this.isAppRoleEnabled() - if (!appRoleEnabled) return this.defaultAppRoleCredentials - const { data: dataRole } = await this.axios.get( - `/v1/auth/approle/role/${this.roleName}/role-id`, - { + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - const { data: dataSecret } = await this.axios.put( - `/v1/auth/approle/role/${this.roleName}/secret-id`, - 'null', // Force empty data - { + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + const existingGroup = await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - return { - ...this.defaultAppRoleCredentials, - roleId: dataRole.data?.role_id, - secretId: dataSecret.data?.secret_id, - } + }) + console.log({ existingGroup }) + }, } - public async destroy(path: string = '/') { - if (path.startsWith('/')) - path = path.slice(1) - return this.axios.delete( - `/v1/${this.kvName}/metadata/${this.projectRootDir}/${this.basePath}/${path}`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - } + Role = { + upsert: async () => { + const appRoleEnabled = await isAppRoleEnabled(this.axios, await this.getToken()) + if (!appRoleEnabled) return - public async destroyRole() { - await this.axios.delete( - `/v1/auth/approle/role/${this.roleName}`, - { - headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) - await this.axios.delete( - `/v1/sys/policies/acl/${this.roleName}`, - { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${this.roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: [this.policyName.techRO, this.policyName.appFull], + }, headers: { 'X-Vault-Token': await this.getToken() }, - }, - ) + }) + }, + + getCredentials: async (): Promise => { + const appRoleEnabled = await isAppRoleEnabled(this.axios, await this.getToken()) + if (!appRoleEnabled) return this.defaultAppRoleCredentials + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${this.roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${this.roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + ...this.defaultAppRoleCredentials, + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + delete: async () => { + await this.axios.delete( + `/v1/auth/approle/role/${this.roleName}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + }, } } diff --git a/plugins/vault/src/config.ts b/plugins/vault/src/config.ts new file mode 100644 index 000000000..130248614 --- /dev/null +++ b/plugins/vault/src/config.ts @@ -0,0 +1,28 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/plugins/vault/src/env.d.ts b/plugins/vault/src/env.d.ts new file mode 100644 index 000000000..d3a888a61 --- /dev/null +++ b/plugins/vault/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/plugins/vault/src/functions.ts b/plugins/vault/src/functions.ts index e9b421bc2..effbc006a 100644 --- a/plugins/vault/src/functions.ts +++ b/plugins/vault/src/functions.ts @@ -1,11 +1,68 @@ -import { type Project, type StepCall, parseError } from '@cpn-console/hooks' +import { parseError } from '@cpn-console/hooks' +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import { generateVaultAuth, generateVsoSecret, generateVsoVaultConnection } from './vso.js' -export const upsertProjectAppRole: StepCall = async (payload) => { +export const upsertProject: StepCall = async (payload) => { try { if (!payload.apis.vault) { throw new Error('no Vault available') } - await payload.apis.vault.upsertRole() + await payload.apis.vault.Project.upsert() + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: parseError(error), + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deployAuth: StepCall = async (payload) => { + try { + const kubeApi = payload.apis.kubernetes + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + const appRoleCreds = await payload.apis.vault.Role.getCredentials() + + // loop on each env to verify if vault CRDs are installed on the cluster + for (const ns of Object.values(kubeApi.namespaces)) { + // verify if vault CRDs are installed on the cluster + if ((await ns.apisApi?.getAPIVersions())?.body.groups.filter(group => group.name === 'secrets.hashicorp.com')) { + const vaultConnectionObject = generateVsoVaultConnection(appRoleCreds) + await ns.createOrPatchRessource({ + body: vaultConnectionObject, + name: vaultConnectionObject.metadata.name, + plural: 'vaultconnections', + version: 'v1beta1', + group: 'secrets.hashicorp.com', + }) + + const vaultSecretObject = generateVsoSecret(appRoleCreds) + await ns.createOrPatchRessource({ + body: vaultSecretObject, + name: vaultSecretObject.metadata.name, + plural: 'secrets', + version: 'v1', + group: '', + }) + + const vaultAuthObject = generateVaultAuth(appRoleCreds) + await ns.createOrPatchRessource({ + body: vaultAuthObject, + name: vaultAuthObject.metadata.name, + plural: 'vaultauths', + version: 'v1beta1', + group: 'secrets.hashicorp.com', + }) + } + } return { status: { result: 'OK', @@ -26,7 +83,7 @@ export const archiveDsoProject: StepCall = async (payload) => { try { if (!payload.apis.vault) throw new Error('no Vault available') - await payload.apis.vault.destroyRole() + await payload.apis.vault.Project.delete() const allSecrets = await payload.apis.vault.list('/') const promisesDestroy = allSecrets.map((path) => { return payload.apis.vault.destroy(path) @@ -49,3 +106,15 @@ export const archiveDsoProject: StepCall = async (payload) => { } } } + +export const getSecrets: StepCall = async (payload) => { + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.organization.name}-${payload.args.name}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} diff --git a/plugins/vault/src/index.ts b/plugins/vault/src/index.ts index 6fed3ef99..4621f3e97 100644 --- a/plugins/vault/src/index.ts +++ b/plugins/vault/src/index.ts @@ -1,5 +1,5 @@ import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' -import { archiveDsoProject, upsertProjectAppRole } from './functions.js' +import { archiveDsoProject, deployAuth, getSecrets, upsertProject } from './functions.js' import infos from './infos.js' import monitor from './monitor.js' import { VaultProjectApi } from './class.js' @@ -9,10 +9,18 @@ const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } export const plugin: Plugin = { infos, subscribedHooks: { - getProjectSecrets: onlyApi, + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, upsertProject: { ...onlyApi, - steps: { main: upsertProjectAppRole }, + steps: { + main: upsertProject, + post: deployAuth, + }, }, deleteProject: { ...onlyApi, diff --git a/plugins/vault/src/infos.ts b/plugins/vault/src/infos.ts index db7403390..8be1fa246 100644 --- a/plugins/vault/src/infos.ts +++ b/plugins/vault/src/infos.ts @@ -1,11 +1,11 @@ import type { ServiceInfos } from '@cpn-console/hooks' - -export const vaultUrl = process.env.VAULT_URL +import getConfig from './config.js' const infos: ServiceInfos = { name: 'vault', - // TODO wait for vault to be connected to oidc - // to: ({ project, organization }) => `${vaultUrl}/ui/vault/secrets/forge-dso/list/${projectRootDir}/${organization}/${project}`, + to: ({ project, organization }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${organization}-${project}`, title: 'Vault', imgSrc: '/img/vault.svg', description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', diff --git a/plugins/vault/src/monitor.ts b/plugins/vault/src/monitor.ts index 687db9382..12be7e7fd 100644 --- a/plugins/vault/src/monitor.ts +++ b/plugins/vault/src/monitor.ts @@ -1,5 +1,6 @@ -import { Monitor, type MonitorInfos, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' import axios from 'axios' +import getConfig from './config.js' const vaultStatusCode = [200, 429, 472, 473, 501, 503] @@ -17,9 +18,9 @@ type VaultRes = { async function monitor(instance: Monitor): Promise { instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() try { - const res = await axios.get(`${requiredEnv('VAULT_URL')}v1/sys/health?standbyok=true`, { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { headers: { - 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + 'X-Vault-Token': getConfig().token, }, validateStatus: res => vaultStatusCode.includes(res), }) diff --git a/plugins/vault/src/utils.ts b/plugins/vault/src/utils.ts new file mode 100644 index 000000000..9afcd6d4a --- /dev/null +++ b/plugins/vault/src/utils.ts @@ -0,0 +1,15 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export async function isAppRoleEnabled(axiosInstance: AxiosInstance, token: string) { + const methods = await getAuthMethod(axiosInstance, token) + return Object.keys(methods).includes('approle/') +} diff --git a/plugins/vault/src/vso.ts b/plugins/vault/src/vso.ts new file mode 100644 index 000000000..1fec54dbe --- /dev/null +++ b/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './class.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef: 'default', + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7ec1263f..1e18a5878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,6 +869,9 @@ importers: '@cpn-console/eslint-config': specifier: workspace:^ version: link:../../packages/eslintconfig + '@cpn-console/kubernetes-plugin': + specifier: workspace:^ + version: link:../kubernetes '@cpn-console/ts-config': specifier: workspace:^ version: link:../../packages/tsconfig From 0d9db0f9bf3274eae8c37efcc0d3fa91f2387c9c Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:47:51 +0100 Subject: [PATCH 4/6] refactor: :children_crossing: publish personnal access tokens feature to users --- ...dmin-token-form.ct.ts => token-form.ct.ts} | 39 +++--- .../cypress/e2e/specs/02-profile.e2e.ts | 85 +++++++++++++ .../cypress/e2e/specs/admin/tokens.e2e.ts | 8 +- apps/client/cypress/e2e/specs/home.e2e.ts | 2 +- .../client/cypress/e2e/specs/not-found.e2e.ts | 4 +- apps/client/src/components/SideMenu.vue | 74 +++++++---- .../{AdminTokenForm.vue => TokenForm.vue} | 63 ++++----- apps/client/src/router/index.ts | 20 ++- apps/client/src/stores/token.ts | 26 ++++ apps/client/src/views/admin/AdminTokens.vue | 56 +++++++- .../views/profile/PersonalAccessTokens.vue | 120 ++++++++++++++++++ .../src/views/profile/ProfileWrapper.vue | 7 + .../{UserProfile.vue => profile/UserInfo.vue} | 26 ++-- apps/server/src/resources/index.ts | 2 + .../src/resources/user/business.spec.ts | 6 +- .../src/resources/user/tokens/business.ts | 51 ++++++++ .../src/resources/user/tokens/router.ts | 48 +++++++ packages/shared/src/api-client.ts | 1 + packages/shared/src/contracts/index.ts | 1 + .../src/contracts/personal-access-token.ts | 47 +++++++ packages/shared/src/schemas/token.ts | 21 ++- 21 files changed, 601 insertions(+), 106 deletions(-) rename apps/client/cypress/components/specs/{admin-token-form.ct.ts => token-form.ct.ts} (67%) create mode 100644 apps/client/cypress/e2e/specs/02-profile.e2e.ts rename apps/client/src/components/{AdminTokenForm.vue => TokenForm.vue} (68%) create mode 100644 apps/client/src/stores/token.ts create mode 100644 apps/client/src/views/profile/PersonalAccessTokens.vue create mode 100644 apps/client/src/views/profile/ProfileWrapper.vue rename apps/client/src/views/{UserProfile.vue => profile/UserInfo.vue} (74%) create mode 100644 apps/server/src/resources/user/tokens/business.ts create mode 100644 apps/server/src/resources/user/tokens/router.ts create mode 100644 packages/shared/src/contracts/personal-access-token.ts diff --git a/apps/client/cypress/components/specs/admin-token-form.ct.ts b/apps/client/cypress/components/specs/token-form.ct.ts similarity index 67% rename from apps/client/cypress/components/specs/admin-token-form.ct.ts rename to apps/client/cypress/components/specs/token-form.ct.ts index bb63dd331..965b7d8e5 100644 --- a/apps/client/cypress/components/specs/admin-token-form.ct.ts +++ b/apps/client/cypress/components/specs/token-form.ct.ts @@ -6,10 +6,10 @@ import '@gouvfr/dsfr/dist/utility/utility.main.min.css' import '@gouvminint/vue-dsfr/styles' import '@/main.css' -import AdminTokenForm from '@/components/AdminTokenForm.vue' +import TokenForm from '@/components/TokenForm.vue' import { useSnackbarStore } from '@/stores/snackbar.js' -describe('AdminTokenForm.vue', () => { +describe('TokenForm.vue', () => { let pinia: Pinia beforeEach(() => { @@ -18,22 +18,12 @@ describe('AdminTokenForm.vue', () => { setActivePinia(pinia) }) - it('Should mount a AdminTokenForm', () => { - const password = 'dfvbjfdbvjkdbvdfb' - cy.intercept('GET', 'api/v1/admin/tokens', { - body: [], - }).as('listTokens') - cy.intercept('POST', 'api/v1/admin/tokens', { - body: { password }, - statusCode: 201, - }).as('createToken') - + it('Should mount a TokenForm', () => { useSnackbarStore() // @ts-ignore - cy.mount(AdminTokenForm, { props: {} }) - - cy.getByDataTestid('showNewTokenFormBtn') - .click() + cy.mount(TokenForm, { props: { + exposedToken: undefined, + } }) cy.getByDataTestid('saveBtn') .should('be.disabled') @@ -44,8 +34,17 @@ describe('AdminTokenForm.vue', () => { cy.getByDataTestid('saveBtn') .should('be.enabled') .click() + }) + + it('Should mount a TokenForm', () => { + const password = 'dfvbjfdbvjkdbvdfb' + + useSnackbarStore() + // @ts-ignore + cy.mount(TokenForm, { props: { + exposedToken: password, + } }) - cy.wait('@createToken') cy.getByDataTestid('newTokenPassword') .get('input') .should('be.visible') @@ -68,11 +67,5 @@ describe('AdminTokenForm.vue', () => { cy.getByDataTestid('showNewTokenFormBtn') .click() - - cy.getByDataTestid('newTokenPassword') - .should('not.exist') - - cy.getByDataTestid('newTokenName') - .should('have.value', '') }) }) diff --git a/apps/client/cypress/e2e/specs/02-profile.e2e.ts b/apps/client/cypress/e2e/specs/02-profile.e2e.ts new file mode 100644 index 000000000..a75635046 --- /dev/null +++ b/apps/client/cypress/e2e/specs/02-profile.e2e.ts @@ -0,0 +1,85 @@ +import type { User } from '@cpn-console/shared' +import { getModelById } from '../support/func.js' + +const userClaire = getModelById('user', 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567') as User +const userTibo = getModelById('user', 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566') as User + +describe('Header', () => { + it('Should display name once logged', () => { + cy.kcLogin((userClaire.firstName.slice(0, 1) + userClaire.lastName).toLowerCase()) + .visit('/') + .getByDataTestid('menuUserList') + .should('contain', `${userClaire.firstName} ${userClaire.lastName}`) + }) + + it('Should display profile infos', () => { + cy.kcLogin('tcolin') + .visit('/') + .getByDataTestid('menuUserList') + .click() + cy.getByDataTestid('menuUserInfo') + .click() + cy.url().should('contain', '/profile/info') + cy.getByDataTestid('profileInfos') + .should('contain.text', `${userTibo.lastName}, ${userTibo.firstName}`) + .should('contain.text', userTibo.id) + .should('contain.text', 'Admin') + .should('contain.text', userTibo.email) + }) + + it('Should create pat', () => { + cy.intercept('GET', 'api/v1/user/tokens*').as('listTokens') + cy.intercept('POST', 'api/v1/user/tokens').as('createToken') + cy.intercept('DELETE', 'api/v1/user/tokens/*').as('deleteToken') + + cy.kcLogin('tcolin') + .visit('/') + .getByDataTestid('menuUserList') + .click() + cy.getByDataTestid('menuUserTokens') + .click() + cy.wait('@listTokens', { timeout: 15_000 }) + cy.url().should('contain', '/profile/tokens') + cy.getByDataTestid('showNewTokenFormBtn') + .click() + + cy.getByDataTestid('newTokenName') + .click() + .clear() + .type('test2') + cy.getByDataTestid('expirationDateInput') + .click() + .clear() + .type('2100-11-22') + cy.getByDataTestid('saveBtn') + .click() + cy.wait('@createToken') + cy.getByDataTestid('newTokenPassword') + .should('be.visible') + + // Réinitialiser le formulaire + cy.getByDataTestid('showNewTokenFormBtn') + .click() + cy.getByDataTestid('newTokenPassword').should('not.exist') + + cy.getByDataTestid('tokenTable').within(() => { + cy.get(`tbody tr:nth-of-type(1)`).within(() => { + cy.get('td:nth-of-type(1)').should('contain', 'test2') + cy.get('td:nth-of-type(2)').should('contain', (new Date()).getFullYear()) + cy.get('td:nth-of-type(3)').should('contain', 2100) + cy.get('td:nth-of-type(4)').should('contain', 'Jamais') + cy.get('td:nth-of-type(5)').should('contain', 'Actif') + cy.get('td:nth-of-type(6)') + .click() + }) + }) + + cy.getByDataTestid('confirmDeletionBtn') + .click() + cy.wait('@deleteToken') + cy.getByDataTestid('tokenTable').within(() => { + cy.get(`tbody tr`) + .should('have.length', 1) + }) + }) +}) diff --git a/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts b/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts index a26087b2b..87715183d 100644 --- a/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/tokens.e2e.ts @@ -14,7 +14,7 @@ describe('Administration tokens', () => { }) it('Should display tokens list, loggedIn as admin', () => { - cy.getByDataTestid('adminTokenTable').within(() => { + cy.getByDataTestid('tokenTable').within(() => { tokens.forEach((token) => { cy.get(`tbody tr:nth-of-type(1)`).within(() => { cy.get('td:nth-of-type(1)').should('contain', token.name) @@ -49,7 +49,7 @@ describe('Administration tokens', () => { .click() cy.getByDataTestid('newTokenPassword').should('not.exist') - cy.getByDataTestid('adminTokenTable').within(() => { + cy.getByDataTestid('tokenTable').within(() => { cy.get(`tbody tr:nth-of-type(1)`).within(() => { cy.get('td:nth-of-type(1)').should('contain', 'test') cy.get('td:nth-of-type(2)').should('contain', 'Administration globale') @@ -72,8 +72,10 @@ describe('Administration tokens', () => { .click() }) }) + cy.getByDataTestid('confirmDeletionBtn') + .click() cy.wait('@deleteToken') - cy.getByDataTestid('adminTokenTable').within(() => { + cy.getByDataTestid('tokenTable').within(() => { cy.get(`tbody tr`) .should('have.length', 1) }) diff --git a/apps/client/cypress/e2e/specs/home.e2e.ts b/apps/client/cypress/e2e/specs/home.e2e.ts index 0cf13a89a..367e07ed6 100644 --- a/apps/client/cypress/e2e/specs/home.e2e.ts +++ b/apps/client/cypress/e2e/specs/home.e2e.ts @@ -14,7 +14,7 @@ describe('Header', () => { it('Should display name once logged', () => { cy.kcLogin((user.firstName.slice(0, 1) + user.lastName).toLowerCase()) .visit('/') - .getByDataTestid('whoami-hint') + .getByDataTestid('menuUserList') .should('contain', `${user.firstName} ${user.lastName}`) }) diff --git a/apps/client/cypress/e2e/specs/not-found.e2e.ts b/apps/client/cypress/e2e/specs/not-found.e2e.ts index 0d931c38d..b03034444 100644 --- a/apps/client/cypress/e2e/specs/not-found.e2e.ts +++ b/apps/client/cypress/e2e/specs/not-found.e2e.ts @@ -6,14 +6,14 @@ describe('Redirect to 404 if page not found', () => { it('should redirect loggedout user to 404 if page not found', () => { cy.visit('/nowhere') cy.url().should('contain', '/404') - cy.getByDataTestid('whoami-hint') + cy.getByDataTestid('menuUserList') .should('not.exist') }) it('should redirect loggedin user to 404 if page not found', () => { cy.kcLogin('test') cy.visit('/nowhere') cy.url().should('contain', '/404') - cy.getByDataTestid('whoami-hint') + cy.getByDataTestid('menuUserList') .should('contain', `${user.firstName} ${user.lastName}`) }) }) diff --git a/apps/client/src/components/SideMenu.vue b/apps/client/src/components/SideMenu.vue index 3ddf6c413..e694c1e32 100644 --- a/apps/client/src/components/SideMenu.vue +++ b/apps/client/src/components/SideMenu.vue @@ -32,6 +32,7 @@ const isExpanded = ref({ mainMenu: false, projects: false, administration: false, + profile: false, }) function toggleExpand(key: keyof typeof isExpanded.value) { @@ -39,18 +40,9 @@ function toggleExpand(key: keyof typeof isExpanded.value) { } watch(route, (currentRoute) => { - if (isInProject.value) { - isExpanded.value.projects = true - isExpanded.value.administration = false - return - } - if (currentRoute.matched.some(match => match.name === 'ParentAdmin')) { - isExpanded.value.projects = false - isExpanded.value.administration = true - return - } - isExpanded.value.projects = false - isExpanded.value.administration = false + isExpanded.value.projects = isInProject.value + isExpanded.value.profile = currentRoute.matched.some(match => match.name === 'Profile') + isExpanded.value.administration = currentRoute.matched.some(match => match.name === 'ParentAdmin') }) onMounted(() => { @@ -75,18 +67,6 @@ onMounted(() => { id="menuList" :expanded="isExpanded.mainMenu" > -

-

{ Accueil + + + + + {{ userStore.userProfile?.firstName }} {{ userStore.userProfile?.lastName }} + + +
+ + + + Mon profil + + + + + + Jetons personnels + + +
+
+
+ -import type { ExposedAdminToken, SharedZodError } from '@cpn-console/shared' -import { AdminTokenSchema, isAtLeastTomorrow } from '@cpn-console/shared' +import { TokenSchema, isAtLeastTomorrow } from '@cpn-console/shared' import { useSnackbarStore } from '@/stores/snackbar.js' -import { useAdminTokenStore } from '@/stores/admin-token.js' import { copyContent } from '@/utils/func.js' +export interface SimpleToken { name: string, expirationDate: string } +const props = defineProps<{ + exposedToken?: string + mandatoryExpiration: boolean +}>() + const emits = defineEmits<{ - create: [] + create: [SimpleToken] + reset: [] }>() const snackbarStore = useSnackbarStore() -const adminTokenStore = useAdminTokenStore() -const exposedToken = ref() -const showNewTokenPassword = ref(true) +const showNewTokenPassword = ref(false) const isCreatingToken = ref(false) const newToken = ref<{ name: string - permissions: bigint expirationDate: string }>({ name: '', - permissions: 2n, expirationDate: '', }) const showNewTokenForm = ref(false) @@ -29,15 +30,14 @@ const showNewTokenForm = ref(false) async function createToken() { try { snackbarStore.hideMessage() + if (!newToken.value.name) { + return + } isCreatingToken.value = true - exposedToken.value = await adminTokenStore.createToken({ - name: newToken.value.name, - permissions: newToken.value.permissions.toString(), - expirationDate: newToken.value.expirationDate ?? null, - }) - showNewTokenForm.value = false + emits('create', newToken.value) newToken.value.name = '' - emits('create') + newToken.value.expirationDate = '' + showNewTokenForm.value = false } catch (error) { if (error instanceof Error) { snackbarStore.setMessage(error.message, 'error') @@ -46,6 +46,12 @@ async function createToken() { isCreatingToken.value = false } +function reset() { + showNewTokenForm.value = true + showNewTokenPassword.value = false + emits('reset') +} + const invalidExpirationDate = computed(() => { if (!newToken.value.expirationDate) { return undefined @@ -56,23 +62,21 @@ const invalidExpirationDate = computed(() => { : 'La durée de vie du token est trop courte' }) -const errorSchema = computed(() => { - const schemaValidation = AdminTokenSchema.partial().safeParse(newToken.value) - - return schemaValidation.success ? undefined : schemaValidation.error +const schema = computed(() => { + return TokenSchema.partial().safeParse(newToken.value) }) diff --git a/apps/client/src/views/profile/PersonalAccessTokens.vue b/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 000000000..355878bb7 --- /dev/null +++ b/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,120 @@ + + + diff --git a/apps/client/src/views/profile/ProfileWrapper.vue b/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 000000000..d31d761df --- /dev/null +++ b/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/client/src/views/UserProfile.vue b/apps/client/src/views/profile/UserInfo.vue similarity index 74% rename from apps/client/src/views/UserProfile.vue rename to apps/client/src/views/profile/UserInfo.vue index 8cc302e64..c3b9e4a1b 100644 --- a/apps/client/src/views/UserProfile.vue +++ b/apps/client/src/views/profile/UserInfo.vue @@ -4,6 +4,8 @@ import { useUserStore } from '@/stores/user.js' const userStore = useUserStore() +const displayAllGroups = ref(false) +const groupsLengthDisplayed = 10 const groups = computed(() => userStore.userProfile?.groups?.length ? userStore.userProfile.groups : ['-']) const adminRoles = computed(() => userStore.myAdminRoles.map(({ name }) => name)) @@ -14,6 +16,7 @@ const adminRoles = computed(() => userStore.myAdminRoles.map(({ name } > @@ -26,29 +29,36 @@ const adminRoles = computed(() => userStore.myAdminRoles.map(({ name } Id Keycloak{{ userStore.userProfile.id }} - Groupes Keycloak + Roles Admins
  • - {{ group }} + {{ role }}
- Roles Admins + Groupes Keycloak
  • - {{ role }} + {{ group }}
+ + {{ displayAllGroups ? 'Masquer' : 'Afficher plus...' }} +
diff --git a/apps/server/src/resources/index.ts b/apps/server/src/resources/index.ts index 0b3d034d8..12f520a0e 100644 --- a/apps/server/src/resources/index.ts +++ b/apps/server/src/resources/index.ts @@ -18,6 +18,7 @@ import { userRouter } from './user/router.js' import { zoneRouter } from './zone/router.js' import { systemSettingsRouter } from './system/settings/router.js' import { adminTokenRouter } from './admin-token/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' import { serverInstance } from '@/app.js' // relax validation schema if NO_VALIDATION env var is set to true. @@ -31,6 +32,7 @@ export function apiRouter() { 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(personalAccessTokenRouter()), validateTrue) await app.register(serverInstance.plugin(projectRouter()), validateTrue) await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) diff --git a/apps/server/src/resources/user/business.spec.ts b/apps/server/src/resources/user/business.spec.ts index 836cdb76f..28a57c5c0 100644 --- a/apps/server/src/resources/user/business.spec.ts +++ b/apps/server/src/resources/user/business.spec.ts @@ -181,7 +181,7 @@ describe('logViaToken', () => { it('should return identity', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) - const identity = await logViaToken('test', true) + const identity = await logViaToken('test') expect(identity.adminPerms).toBe(2n) }) @@ -190,13 +190,13 @@ describe('logViaToken', () => { delete pat.permissions pat.owner = { adminRoleIds: null } prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) - const identity = await logViaToken('test', true) + const identity = await logViaToken('test') expect(identity.adminPerms).toBe(0n) }) it('should return identity, with expirationDate', async () => { prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) - const identity = await logViaToken('test', true) + const identity = await logViaToken('test') expect(identity.adminPerms).toBe(2n) }) diff --git a/apps/server/src/resources/user/tokens/business.ts b/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 000000000..4e6da3e6e --- /dev/null +++ b/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import { createHash } from 'node:crypto' +import type { personalAccessTokenContract } from '@cpn-console/shared' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import prisma from '../../../prisma.js' +import { BadRequest400 } from '@/utils/errors.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/apps/server/src/resources/user/tokens/router.ts b/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 000000000..4dfdc134d --- /dev/null +++ b/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import '@/types/index.js' +import { createToken, deleteToken, listTokens } from './business.js' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/packages/shared/src/api-client.ts b/packages/shared/src/api-client.ts index 3092f2419..119f49a23 100644 --- a/packages/shared/src/api-client.ts +++ b/packages/shared/src/api-client.ts @@ -14,6 +14,7 @@ export async function getContract() { Environments: (await import('./contracts/index.js')).environmentContract, Logs: (await import('./contracts/index.js')).logContract, Organizations: (await import('./contracts/index.js')).organizationContract, + PersonalAccessTokens: (await import('./contracts/index.js')).personalAccessTokenContract, Projects: (await import('./contracts/index.js')).projectContract, ProjectsMembers: (await import('./contracts/index.js')).projectMemberContract, ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 0d986867f..85b0c7416 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -7,6 +7,7 @@ export * from './project-member.js' export * from './organization.js' export * from './project.js' export * from './project-service.js' +export * from './personal-access-token.js' export * from './quota.js' export * from './repository.js' export * from './project-role.js' diff --git a/packages/shared/src/contracts/personal-access-token.ts b/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 000000000..0c05ef676 --- /dev/null +++ b/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema, apiPrefix, contractInstance } from '../index.js' +import { ErrorSchema, baseHeaders } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: null, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/packages/shared/src/schemas/token.ts b/packages/shared/src/schemas/token.ts index c683db6ea..889b67813 100644 --- a/packages/shared/src/schemas/token.ts +++ b/packages/shared/src/schemas/token.ts @@ -9,7 +9,6 @@ export const TokenSchema = z.object({ .min(2, { message: 'Ne peut faire moins de 2 caractères' }) .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), lastUse: dateToString.nullable(), - expirationDate: dateToString.nullable(), createdAt: dateToString, owner: UserSchema .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) @@ -18,9 +17,12 @@ export const TokenSchema = z.object({ status: z.enum(['active', 'revoked', 'inactive']), }) -export const AdminTokenSchema = TokenSchema.extend({ - permissions: permissionLevelSchema, -}) +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ password: z.string(), @@ -28,3 +30,14 @@ export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ export type AdminToken = Zod.infer export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer From 5b12e6ebd31973b46787c98188c8d96ba8a748cb Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:16:21 +0100 Subject: [PATCH 5/6] refactor: :sparkles: option to disable Nexus --- plugins/nexus/src/infos.ts | 12 ++++++++++++ plugins/nexus/src/project.ts | 17 ++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/nexus/src/infos.ts b/plugins/nexus/src/infos.ts index c39b7a631..83abdb71a 100644 --- a/plugins/nexus/src/infos.ts +++ b/plugins/nexus/src/infos.ts @@ -97,6 +97,18 @@ const infos = { value: 'disabled', description: 'Défaut au niveau global signifie: Désactivé', }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, ], }, } as const satisfies ServiceInfos diff --git a/plugins/nexus/src/project.ts b/plugins/nexus/src/project.ts index 21ba62309..977434834 100644 --- a/plugins/nexus/src/project.ts +++ b/plugins/nexus/src/project.ts @@ -1,6 +1,6 @@ import axios from 'axios' import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' -import { generateRandomPassword, parseError } from '@cpn-console/hooks' +import { generateRandomPassword, parseError, specificallyDisabled } from '@cpn-console/hooks' import { getAxiosOptions } from './functions.js' import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' @@ -46,6 +46,14 @@ export const deleteNexusProject: StepCall = async ({ args: project }) = export const createNexusProject: StepCall = async (payload) => { try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } if (!payload.apis.vault) throw new Error('no Vault available') const axiosInstance = getAxiosInstance() @@ -203,6 +211,13 @@ export const createNexusProject: StepCall = async (payload) => { } export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + return { + status: { + result: 'OK', + }, + } + } const projectName = `${payload.args.organization.name}-${payload.args.name}` const techUsed = getTechUsed(payload) From e06cf117c0fef4f49749d80a7ac5b8de813c6ed7 Mon Sep 17 00:00:00 2001 From: ArnaudTa <33383276+ArnaudTA@users.noreply.github.com> Date: Wed, 20 Nov 2024 04:10:32 +0100 Subject: [PATCH 6/6] fix: :bug: fix weird front behviours --- .../cypress/e2e/specs/02-profile.e2e.ts | 7 +- .../e2e/specs/admin/organizations.e2e.ts | 2 +- .../client/cypress/e2e/specs/not-found.e2e.ts | 15 +- apps/client/src/components/OperationPanel.vue | 25 ++++ .../src/components/ProjectLogsViewer.vue | 18 ++- apps/client/src/components/ReplayButton.vue | 49 ++++++ apps/client/src/router/index.ts | 19 +-- apps/client/src/stores/project.ts | 3 +- apps/client/src/utils/project-utils.ts | 139 +++++++++--------- apps/client/src/views/admin/AdminProject.vue | 47 +++--- .../src/views/projects/DsoDashboard.vue | 53 ++----- .../src/views/projects/DsoProjectWrapper.vue | 29 ++-- apps/client/src/views/projects/DsoRepos.vue | 12 +- apps/client/src/views/projects/DsoTeam.vue | 2 +- .../src/views/projects/ManageEnvironments.vue | 28 ++-- .../src/resources/project-member/business.ts | 4 +- 16 files changed, 241 insertions(+), 211 deletions(-) create mode 100644 apps/client/src/components/OperationPanel.vue create mode 100644 apps/client/src/components/ReplayButton.vue diff --git a/apps/client/cypress/e2e/specs/02-profile.e2e.ts b/apps/client/cypress/e2e/specs/02-profile.e2e.ts index a75635046..28a94ab0f 100644 --- a/apps/client/cypress/e2e/specs/02-profile.e2e.ts +++ b/apps/client/cypress/e2e/specs/02-profile.e2e.ts @@ -14,12 +14,7 @@ describe('Header', () => { it('Should display profile infos', () => { cy.kcLogin('tcolin') - .visit('/') - .getByDataTestid('menuUserList') - .click() - cy.getByDataTestid('menuUserInfo') - .click() - cy.url().should('contain', '/profile/info') + .visit('/profile/info') cy.getByDataTestid('profileInfos') .should('contain.text', `${userTibo.lastName}, ${userTibo.firstName}`) .should('contain.text', userTibo.id) diff --git a/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts b/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts index d765f4d95..527ef5efe 100644 --- a/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/organizations.e2e.ts @@ -195,7 +195,7 @@ describe('Administration organizations', () => { cy.getByDataTestid('menuMyProjects').click() cy.getByDataTestid(`projectTile-${projectFailed.name}`) .click() - cy.getByDataTestid(`${projectFailed.id}-locked-badge`) + cy.getByDataTestid(`${projectFailed.id}-locked-badge`, 15_000) .should('not.exist') cy.getByDataTestid('menuMyProjects').click() diff --git a/apps/client/cypress/e2e/specs/not-found.e2e.ts b/apps/client/cypress/e2e/specs/not-found.e2e.ts index b03034444..c19bdb27f 100644 --- a/apps/client/cypress/e2e/specs/not-found.e2e.ts +++ b/apps/client/cypress/e2e/specs/not-found.e2e.ts @@ -1,19 +1,8 @@ -import { getModelById } from '../support/func.js' - -const user = getModelById('user', 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565') - describe('Redirect to 404 if page not found', () => { - it('should redirect loggedout user to 404 if page not found', () => { - cy.visit('/nowhere') - cy.url().should('contain', '/404') - cy.getByDataTestid('menuUserList') - .should('not.exist') - }) it('should redirect loggedin user to 404 if page not found', () => { cy.kcLogin('test') cy.visit('/nowhere') - cy.url().should('contain', '/404') - cy.getByDataTestid('menuUserList') - .should('contain', `${user.firstName} ${user.lastName}`) + cy.get('.fr-h1') + .should('contain.text', 'Page non trouvée') }) }) diff --git a/apps/client/src/components/OperationPanel.vue b/apps/client/src/components/OperationPanel.vue new file mode 100644 index 000000000..cfa3267da --- /dev/null +++ b/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,25 @@ + + + diff --git a/apps/client/src/components/ProjectLogsViewer.vue b/apps/client/src/components/ProjectLogsViewer.vue index 75d6236d0..0c5130b85 100644 --- a/apps/client/src/components/ProjectLogsViewer.vue +++ b/apps/client/src/components/ProjectLogsViewer.vue @@ -2,10 +2,9 @@ import type { CleanLog, ProjectV2 } from '@cpn-console/shared' import { ref, watch } from 'vue' import { useLogStore } from '../stores/log.js' -import { selectedProjectId } from '@/router/index.js' const props = defineProps<{ - projectId: ProjectV2['id'] + projectId?: ProjectV2['id'] }>() const logStore = useLogStore() @@ -22,6 +21,9 @@ async function showLogs(index?: number) { } async function getProjectLogs({ offset, limit }: { offset: number, limit: number }) { + if (!props.projectId) { + return + } isUpdating.value = true const res = await logStore.listLogs({ offset, limit, projectId: props.projectId, clean: true }) logs.value = res.logs as CleanLog[] @@ -41,18 +43,18 @@ watch(logStore, () => { } }) -watch(selectedProjectId, () => { - if (!selectedProjectId.value) { - logStore.needRefresh = false - logStore.displayProjectLogs = true - return +watch(props, (p) => { + console.log({ p }) + + if (p.projectId) { + logStore.needRefresh = true } - logStore.needRefresh = true })
diff --git a/apps/client/src/views/projects/DsoDashboard.vue b/apps/client/src/views/projects/DsoDashboard.vue index cb029c2ba..66808cec1 100644 --- a/apps/client/src/views/projects/DsoDashboard.vue +++ b/apps/client/src/views/projects/DsoDashboard.vue @@ -3,7 +3,6 @@ import { onBeforeMount, ref } from 'vue' import type { ProjectV2 } from '@cpn-console/shared' import { ProjectAuthorized, descriptionMaxLength, projectIsLockedInfo } from '@cpn-console/shared' import { useProjectStore } from '@/stores/project.js' -import { useSnackbarStore } from '@/stores/snackbar.js' import router from '@/router/index.js' import { copyContent } from '@/utils/func.js' import { useStageStore } from '@/stores/stage.js' @@ -12,7 +11,6 @@ import { useLogStore } from '@/stores/log.js' const props = defineProps<{ projectId: ProjectV2['id'] }>() const projectStore = useProjectStore() -const snackbarStore = useSnackbarStore() const stageStore = useStageStore() const project = computed(() => projectStore.projectsById[props.projectId]) @@ -26,32 +24,15 @@ const allStages = ref>([]) const logStore = useLogStore() async function updateProject() { - project.value.update({ description: description.value }) + project.value.Commands.update({ description: description.value }) isEditingDescription.value = false } -async function replayHooks() { - await project.value.replay() - switch (project.value.status) { - case 'created': - snackbarStore.setMessage('Le projet a été reprovisionné avec succès.', 'success') - break - case 'failed': - snackbarStore.setMessage('Le projet a été reprovisionné mais a rencontré une erreur bloquante.\nVeuillez consulter les journaux puis réessayer dans quelques instants.\nSi le problème persiste, vous pouvez contacter un administrateur.', 'error') - break - case 'warning': - snackbarStore.setMessage('Le projet a été reprovisionné et a rencontré une erreur non bloquante.\nVeuillez consulter les journaux puis réessayer dans quelques instants.\nSi le problème persiste, vous pouvez contacter un administrateur.', 'warning', 20_000) - break - default: - snackbarStore.setMessage('Le projet a été reprovisionné mais se trouve dans un état inconnu.', 'info') - break - } -} - async function archiveProject() { - await project.value.delete() + await project.value.Commands.delete() projectStore.lastSelectedProjectId = undefined router.push('/projects') + delete projectStore.projectsById[project.value.id] } function getDynamicTitle(locked?: ProjectV2['locked'], description?: ProjectV2['description']) { @@ -151,10 +132,10 @@ onMounted(() => { data-testid="saveDescriptionBtn" label="Enregistrer la description" secondary - :icon="project.operationsInProgress.has('update') + :icon="project.operationsInProgress.includes('update') ? { name: 'ri:refresh-fill', animation: 'spin' } : 'ri:send-plane-line'" - :disabled="project.operationsInProgress.has('update')" + :disabled="project.operationsInProgress.includes('update')" @click="updateProject()" /> { /> -
- -
+
@@ -221,10 +192,10 @@ onMounted(() => { data-testid="showSecretsBtn" :label="`${isSecretShown ? 'Cacher' : 'Afficher'} les secrets des services`" secondary - :icon="project.operationsInProgress.has('searchSecret') + :icon="project.operationsInProgress.includes('searchSecret') ? { name: 'ri:refresh-fill', animation: 'spin' } : isSecretShown ? 'ri:eye-off-line' : 'ri:eye-line'" - :disabled="project.operationsInProgress.has('searchSecret')" + :disabled="project.operationsInProgress.includes('searchSecret')" @click="handleSecretDisplay" />
{ import type { ProjectV2 } from '@cpn-console/shared' import { useProjectStore } from '../../stores/project.js' -import { isInProject } from '../../router/index.js' +import type { Project } from '@/utils/project-utils.js' const props = defineProps<{ projectId: ProjectV2['id'] }>() const projectStore = useProjectStore() -const project = computed(() => projectStore.projectsById[props.projectId]) + +const project = ref(undefined) + +watch(projectStore.projectsById, (store) => { + console.log({ store }) + + project.value = store[props.projectId] +}, { immediate: true }) diff --git a/apps/client/src/views/projects/DsoRepos.vue b/apps/client/src/views/projects/DsoRepos.vue index 2f60adab8..e0c1ec702 100644 --- a/apps/client/src/views/projects/DsoRepos.vue +++ b/apps/client/src/views/projects/DsoRepos.vue @@ -19,7 +19,7 @@ const isAllSyncing = ref(false) const repoFormId = 'repoFormId' const syncFormId = 'syncFormId' -const repositories = ref([]) +const repositories = computed(() => project.value.repositories) function setSelectedRepo(repo: Repo) { if (selectedRepo.value?.internalRepoName === repo.internalRepoName) { selectedRepo.value = undefined @@ -41,15 +41,17 @@ function cancel() { async function saveRepo(repo: Repo) { if (repo.id) { - await project.value.Repositories.update(repo.id, repo) + project.value.Repositories.update(repo.id, repo) + .catch(reason => snackbarStore.setMessage(reason, 'error')) } else { - await project.value.Repositories.create(repo) + project.value.Repositories.create(repo) + .catch(reason => snackbarStore.setMessage(reason, 'error')) } reload() } async function deleteRepo(repoId: Repo['id']) { - await project.value.Repositories.delete(repoId) + project.value.Repositories.delete(repoId) reload() } @@ -64,7 +66,7 @@ async function syncRepository() { const canManageRepos = ref(false) async function reload() { - repositories.value = await project.value.Repositories.list() ?? [] + await project.value.Repositories.list() canManageRepos.value = !project.value.locked && ProjectAuthorized.ManageRepositories({ projectPermissions: project.value.myPerms }) cancel() } diff --git a/apps/client/src/views/projects/DsoTeam.vue b/apps/client/src/views/projects/DsoTeam.vue index 6cc55f11e..500b5f7dd 100644 --- a/apps/client/src/views/projects/DsoTeam.vue +++ b/apps/client/src/views/projects/DsoTeam.vue @@ -30,7 +30,7 @@ async function removeUserFromProject(userId: string) { } async function transferOwnerShip(nextOwnerId: string) { - await project.value.update({ ownerId: nextOwnerId }) + await project.value.Commands.update({ ownerId: nextOwnerId }) teamKey.value = getRandomId('team') } diff --git a/apps/client/src/views/projects/ManageEnvironments.vue b/apps/client/src/views/projects/ManageEnvironments.vue index 214842e9b..38caaf8aa 100644 --- a/apps/client/src/views/projects/ManageEnvironments.vue +++ b/apps/client/src/views/projects/ManageEnvironments.vue @@ -15,15 +15,21 @@ import { useProjectStore } from '@/stores/project.js' import { useClusterStore } from '@/stores/cluster.js' import { useQuotaStore } from '@/stores/quota.js' import { useStageStore } from '@/stores/stage.js' +import { useSnackbarStore } from '@/stores/snackbar.js' const props = defineProps<{ projectId: ProjectV2['id'] }>() +const snackbarStore = useSnackbarStore() const projectStore = useProjectStore() const clusterStore = useClusterStore() const project = computed(() => projectStore.projectsById[props.projectId]) -const environments = ref([]) -const environmentNames = computed(() => project.value.environments?.map(env => env.name) ?? []) +const environments = computed(() => +// @ts-ignore + project.value.environments as Environment[], +) + +const environmentNames = computed(() => environments.value?.map(env => env.name) ?? []) const allClusters = computed(() => clusterStore.clusters) const selectedEnvironment = ref() @@ -46,36 +52,38 @@ function cancel() { async function addEnvironment(environment: Omit) { if (!project.value.locked) { - await project.value.Environments.create(environment) + project.value.Environments.create(environment) + .catch(reason => snackbarStore.setMessage(reason, 'error')) } reload() } async function putEnvironment(environment: UpdateEnvironmentBody) { if (!project.value.locked && selectedEnvironment.value?.id) { - await project.value.Environments.update(selectedEnvironment.value.id, environment) + project.value.Environments.update(selectedEnvironment.value.id, environment) + .catch(reason => snackbarStore.setMessage(reason, 'error')) } reload() } async function deleteEnvironment(environmentId: Environment['id']) { if (!project.value.locked) { - await project.value.Environments.delete(environmentId) + project.value.Environments.delete(environmentId) + .catch(reason => snackbarStore.setMessage(reason, 'error')) } reload() } const canManageEnvs = ref(false) async function reload() { - const [envs, _] = await Promise.all([ + await Promise.all([ project.value.Environments.list(), clusterStore.getClusters(), useQuotaStore().getAllQuotas(), useStageStore().getAllStages(), ]) - environments.value = envs ?? [] - canManageEnvs.value = !project.value.locked && ProjectAuthorized.ManageRepositories({ projectPermissions: project.value.myPerms }) + canManageEnvs.value = !project.value.locked && ProjectAuthorized.ManageEnvironments({ projectPermissions: project.value.myPerms }) cancel() } @@ -116,7 +124,7 @@ watch(project, reload, { immediate: true }) :title="project.locked ? projectIsLockedInfo : 'Ajouter un nouvel environnement'" class="fr-mt-2v mb-5" icon="ri:add-line" - @click="showNewEnvironmentForm()" + @click="showNewEnvironmentForm" />