From c49962bf1f4441e95dbcbf1e10520f66c60496bb Mon Sep 17 00:00:00 2001 From: rajdip-b Date: Thu, 5 Sep 2024 12:00:56 +0530 Subject: [PATCH] feat(api): Add global search in workspace --- .../src/common/authority-checker.service.ts | 28 +- .../controller/workspace.controller.ts | 16 + .../workspace/service/workspace.service.ts | 151 +++++++++ apps/api/src/workspace/workspace.e2e.spec.ts | 290 +++++++++++++++++- 4 files changed, 471 insertions(+), 14 deletions(-) diff --git a/apps/api/src/common/authority-checker.service.ts b/apps/api/src/common/authority-checker.service.ts index 210e5c9d..d4d797d0 100644 --- a/apps/api/src/common/authority-checker.service.ts +++ b/apps/api/src/common/authority-checker.service.ts @@ -76,7 +76,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(permittedAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(permittedAuthorities, authorities, userId) return workspace } @@ -140,8 +140,14 @@ export class AuthorityCheckerService { const projectAccessLevel = project.accessLevel switch (projectAccessLevel) { case ProjectAccessLevel.GLOBAL: - if (!authorities.includes(Authority.READ_PROJECT)) { - this.checkHasPermission( + // In the global case, we check if the authorities being passed in + // contains just the READ_PROJECT authority. If not, we need to + // check if the user has access to the other authorities mentioned as well. + if ( + authorities.length !== 1 || + !authorities.includes(Authority.READ_PROJECT) + ) { + this.checkHasPermissionOverEntity( permittedAuthoritiesForWorkspace, authorities, userId @@ -149,14 +155,14 @@ export class AuthorityCheckerService { } break case ProjectAccessLevel.INTERNAL: - this.checkHasPermission( + this.checkHasPermissionOverEntity( permittedAuthoritiesForWorkspace, authorities, userId ) break case ProjectAccessLevel.PRIVATE: - this.checkHasPermission( + this.checkHasPermissionOverEntity( permittedAuthoritiesForProject, authorities, userId @@ -219,7 +225,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(permittedAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(permittedAuthorities, authorities, userId) return environment } @@ -278,7 +284,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(permittedAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(permittedAuthorities, authorities, userId) return variable } @@ -337,7 +343,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(permittedAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(permittedAuthorities, authorities, userId) return secret } @@ -388,7 +394,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(permittedAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(permittedAuthorities, authorities, userId) if (integration.projectId) { const project = await prisma.project.findUnique({ @@ -409,7 +415,7 @@ export class AuthorityCheckerService { prisma ) - this.checkHasPermission(projectAuthorities, authorities, userId) + this.checkHasPermissionOverEntity(projectAuthorities, authorities, userId) } return integration @@ -425,7 +431,7 @@ export class AuthorityCheckerService { * @returns void * @throws UnauthorizedException if the user does not have all the required authorities */ - private checkHasPermission( + private checkHasPermissionOverEntity( permittedAuthorities: Set, authorities: Authority[], userId: string diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index 4bbc7ca8..7768ce3f 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -211,4 +211,20 @@ export class WorkspaceController { search ) } + + @Get(':workspaceId/global-search/:searchTerm') + @RequiredApiKeyAuthorities( + Authority.READ_WORKSPACE, + Authority.READ_ENVIRONMENT, + Authority.READ_SECRET, + Authority.READ_VARIABLE, + Authority.READ_PROJECT + ) + async globalSearch( + @CurrentUser() user: User, + @Param('workspaceId') workspaceId: Workspace['id'], + @Param('searchTerm') searchTerm: string + ) { + return this.workspaceService.globalSearch(user, workspaceId, searchTerm) + } } diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index fb481d46..d157d3b5 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -10,9 +10,14 @@ import { import { PrismaService } from '../../prisma/prisma.service' import { Authority, + Environment, EventSource, EventType, + Project, + ProjectAccessLevel, + Secret, User, + Variable, Workspace, WorkspaceMember, WorkspaceRole @@ -31,6 +36,7 @@ import { v4 } from 'uuid' import createEvent from '../../common/create-event' import createWorkspace from '../../common/create-workspace' import { AuthorityCheckerService } from '../../common/authority-checker.service' +import getCollectiveProjectAuthorities from '../../common/get-collective-project-authorities' import { paginate } from '../../common/paginate' import { limitMaxItemsPerPage } from '../../common/limit-max-items-per-page' @@ -918,6 +924,151 @@ export class WorkspaceService { return data } + async globalSearch( + user: User, + workspaceId: string, + searchTerm: string + ): Promise<{ + projects: Partial[] + environments: Partial[] + secrets: Partial[] + variables: Partial[] + }> { + // Check authority over workspace + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { id: workspaceId }, + authorities: [ + Authority.READ_WORKSPACE, + Authority.READ_PROJECT, + Authority.READ_ENVIRONMENT, + Authority.READ_SECRET, + Authority.READ_VARIABLE + ], + prisma: this.prisma + }) + + // Get a list of project IDs that the user has access to READ + const accessibleProjectIds = await this.getAccessibleProjectIds( + user.id, + workspaceId + ) + + // Query all entities based on the search term and permissions + const projects = await this.queryProjects(accessibleProjectIds, searchTerm) + const environments = await this.queryEnvironments( + accessibleProjectIds, + searchTerm + ) + const secrets = await this.querySecrets(accessibleProjectIds, searchTerm) + const variables = await this.queryVariables( + accessibleProjectIds, + searchTerm + ) + + return { projects, environments, secrets, variables } + } + private async getAccessibleProjectIds( + userId: string, + workspaceId: string + ): Promise { + const projects = await this.prisma.project.findMany({ + where: { workspaceId } + }) + + const accessibleProjectIds: string[] = [] + for (const project of projects) { + if (project.accessLevel === ProjectAccessLevel.GLOBAL) { + accessibleProjectIds.push(project.id) + } + + const authorities = await getCollectiveProjectAuthorities( + userId, + project, + this.prisma + ) + if ( + authorities.has(Authority.READ_PROJECT) || + authorities.has(Authority.WORKSPACE_ADMIN) + ) { + accessibleProjectIds.push(project.id) + } + } + return accessibleProjectIds + } + + private async queryProjects( + projectIds: string[], + searchTerm: string + ): Promise[]> { + // Fetch projects where user has READ_PROJECT authority and match search term + return this.prisma.project.findMany({ + where: { + id: { in: projectIds }, + OR: [ + { name: { contains: searchTerm, mode: 'insensitive' } }, + { description: { contains: searchTerm, mode: 'insensitive' } } + ] + }, + select: { id: true, name: true, description: true } + }) + } + + private async queryEnvironments( + projectIds: string[], + searchTerm: string + ): Promise[]> { + return this.prisma.environment.findMany({ + where: { + project: { + id: { in: projectIds } + }, + OR: [ + { name: { contains: searchTerm, mode: 'insensitive' } }, + { description: { contains: searchTerm, mode: 'insensitive' } } + ] + }, + select: { id: true, name: true, description: true } + }) + } + + private async querySecrets( + projectIds: string[], + searchTerm: string + ): Promise[]> { + // Fetch secrets associated with projects user has READ_SECRET authority on + return await this.prisma.secret.findMany({ + where: { + project: { + id: { in: projectIds } + }, + OR: [ + { name: { contains: searchTerm, mode: 'insensitive' } }, + { note: { contains: searchTerm, mode: 'insensitive' } } + ] + }, + select: { id: true, name: true, note: true } + }) + } + + private async queryVariables( + projectIds: string[], + searchTerm: string + ): Promise[]> { + return this.prisma.variable.findMany({ + where: { + project: { + id: { in: projectIds } + }, + OR: [ + { name: { contains: searchTerm, mode: 'insensitive' } }, + { note: { contains: searchTerm, mode: 'insensitive' } } + ] + }, + select: { id: true, name: true, note: true } + }) + } + private async existsByName( name: string, userId: User['id'] diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 50591377..95b4b9ee 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -14,6 +14,7 @@ import { EventSource, EventTriggerer, EventType, + ProjectAccessLevel, User, Workspace, WorkspaceRole @@ -25,9 +26,19 @@ import { UserModule } from '../user/user.module' import { UserService } from '../user/service/user.service' import { WorkspaceService } from './service/workspace.service' import { QueryTransformPipe } from '../common/query.transform.pipe' +import { ProjectModule } from '../project/project.module' +import { EnvironmentModule } from '../environment/environment.module' +import { SecretModule } from '../secret/secret.module' +import { VariableModule } from '../variable/variable.module' +import { ProjectService } from '../project/service/project.service' +import { EnvironmentService } from '../environment/service/environment.service' +import { SecretService } from '../secret/service/secret.service' +import { VariableService } from '../variable/service/variable.service' +import { WorkspaceRoleService } from '../workspace-role/service/workspace-role.service' +import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' const createMembership = async ( - adminRoleId: string, + roleId: string, userId: string, workspaceId: string, prisma: PrismaService @@ -40,7 +51,7 @@ const createMembership = async ( create: { role: { connect: { - id: adminRoleId + id: roleId } } } @@ -55,6 +66,11 @@ describe('Workspace Controller Tests', () => { let eventService: EventService let userService: UserService let workspaceService: WorkspaceService + let projectService: ProjectService + let environmentService: EnvironmentService + let secretService: SecretService + let variableService: VariableService + let workspaceRoleService: WorkspaceRoleService let user1: User, user2: User, user3: User let workspace1: Workspace, workspace2: Workspace @@ -62,7 +78,17 @@ describe('Workspace Controller Tests', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - imports: [AppModule, WorkspaceModule, EventModule, UserModule] + imports: [ + AppModule, + WorkspaceModule, + EventModule, + UserModule, + ProjectModule, + EnvironmentModule, + SecretModule, + VariableModule, + WorkspaceRoleModule + ] }) .overrideProvider(MAIL_SERVICE) .useClass(MockMailService) @@ -75,6 +101,11 @@ describe('Workspace Controller Tests', () => { eventService = moduleRef.get(EventService) userService = moduleRef.get(UserService) workspaceService = moduleRef.get(WorkspaceService) + projectService = moduleRef.get(ProjectService) + environmentService = moduleRef.get(EnvironmentService) + secretService = moduleRef.get(SecretService) + variableService = moduleRef.get(VariableService) + workspaceRoleService = moduleRef.get(WorkspaceRoleService) app.useGlobalPipes(new QueryTransformPipe()) @@ -143,6 +174,11 @@ describe('Workspace Controller Tests', () => { expect(eventService).toBeDefined() expect(userService).toBeDefined() expect(workspaceService).toBeDefined() + expect(projectService).toBeDefined() + expect(environmentService).toBeDefined() + expect(secretService).toBeDefined() + expect(variableService).toBeDefined() + expect(workspaceRoleService).toBeDefined() }) it('should be able to create a new workspace', async () => { @@ -1363,4 +1399,252 @@ describe('Workspace Controller Tests', () => { message: `You cannot transfer ownership of default workspace ${workspace1.name} (${workspace1.id})` }) }) + + describe('Global Search Tests', () => { + beforeEach(async () => { + // Assign member role to user 2 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + + // Create projects + const project1Response = await projectService.createProject( + user1, + workspace1.id, + { + name: 'Project 1', + description: 'Project 1 description', + environments: [ + { + name: 'Dev' + } + ] + } + ) + const project2Response = await projectService.createProject( + user1, + workspace1.id, + { + name: 'Project 2', + description: 'Project 2 description', + environments: [ + { + name: 'Dev' + } + ] + } + ) + const project3Response = await projectService.createProject( + user1, + workspace1.id, + { + name: 'Project 3', + description: 'Project 3 description', + accessLevel: ProjectAccessLevel.GLOBAL, + environments: [ + { + name: 'Dev' + } + ] + } + ) + + // Update member role to include project 2 + await workspaceRoleService.updateWorkspaceRole(user1, memberRole.id, { + authorities: [ + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT, + Authority.READ_SECRET, + Authority.READ_VARIABLE, + Authority.READ_WORKSPACE + ], + projectIds: [project2Response.id] + }) + + const project1DevEnv = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project1Response.id, + name: 'Dev' + } + } + }) + const project2DevEnv = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project2Response.id, + name: 'Dev' + } + } + }) + const project3DevEnv = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project3Response.id, + name: 'Dev' + } + } + }) + + // Create secrets + await secretService.createSecret( + user1, + { + name: 'API_KEY', + entries: [ + { + environmentId: project1DevEnv.id, + value: 'test' + } + ] + }, + project1Response.id + ) + + await secretService.createSecret( + user1, + { + name: 'API_TOKEN', + entries: [ + { + environmentId: project3DevEnv.id, + value: 'test' + } + ] + }, + project3Response.id + ) + + // Create variables + await variableService.createVariable( + user1, + { + name: 'PORT', + entries: [ + { + environmentId: project1DevEnv.id, + value: '3000' + } + ] + }, + project1Response.id + ) + + await variableService.createVariable( + user1, + { + name: 'PORT_NUMBER', + entries: [ + { + environmentId: project2DevEnv.id, + value: '4000' + } + ] + }, + project2Response.id + ) + }) + + it('should be able to search for projects', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.id}/global-search/project` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().projects).toHaveLength(3) + }) + + it('should be able to search for secrets', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.id}/global-search/api` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().secrets).toHaveLength(2) + }) + + it('should be able to search for variables', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.id}/global-search/port` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().variables).toHaveLength(2) + }) + + it('should be able to search for environments', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.id}/global-search/dev` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().environments).toHaveLength(3) + }) + + it('should restrict search to projects the user has access to', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/global-search/project` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().projects).toHaveLength(2) + }) + + it('should restrict search to secrets the user has access to', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/global-search/api` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().secrets).toHaveLength(1) + }) + + it('should restrict search to variables the user has access to', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/global-search/port` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().variables).toHaveLength(1) + }) + + it('should restrict search to environments the user has access to', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/global-search/dev` + }) + + expect(response.statusCode).toBe(200) + expect(response.json().environments).toHaveLength(2) + }) + }) })