diff --git a/api-collection/Secret Controller/Fetch all by project and environment.bru b/api-collection/Secret Controller/Fetch all by project and environment.bru new file mode 100644 index 000000000..649df0499 --- /dev/null +++ b/api-collection/Secret Controller/Fetch all by project and environment.bru @@ -0,0 +1,26 @@ +meta { + name: Fetch all by project and environment + type: http + seq: 5 +} + +get { + url: {{BASE_URL}}/api/secret/:project_slug/:environment_slug + body: none + auth: bearer +} + +params:path { + project_slug: + environment_slug: +} + +auth:bearer { + token: {{JWT}} +} + +docs { + ## Description + + Fetches all the secrets for a particular pair of project and environment. Used by the CLI to prefetch the existing secrets. +} \ No newline at end of file diff --git a/api-collection/Variable Controller/Fetch all by project and environment.bru b/api-collection/Variable Controller/Fetch all by project and environment.bru new file mode 100644 index 000000000..6dce34152 --- /dev/null +++ b/api-collection/Variable Controller/Fetch all by project and environment.bru @@ -0,0 +1,26 @@ +meta { + name: Fetch all by project and environment + type: http + seq: 5 +} + +get { + url: {{BASE_URL}}/api/variable/:project_slug/:environment_slug + body: none + auth: bearer +} + +params:path { + project_slug: project-1-uzukc + environment_slug: alpha-l7xvp +} + +auth:bearer { + token: {{JWT}} +} + +docs { + ## Description + + Fetches all the variables for a particular pair of project and environment. Used by the CLI to prefetch the existing variables. +} \ No newline at end of file diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index b984dd623..4b1c16add 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -154,8 +154,10 @@ describe('Project Controller Tests', () => { }) afterEach(async () => { - await prisma.user.deleteMany() - await prisma.workspace.deleteMany() + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.workspace.deleteMany() + ]) }) it('should be defined', async () => { diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index 7db44a553..4925f6a2d 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -462,7 +462,7 @@ export class ProjectService { accessLevel: project.accessLevel, isForked: true, forkedFromId: project.id, - workspaceId: workspaceId, + workspaceId, lastUpdatedById: userId } }) diff --git a/apps/api/src/secret/controller/secret.controller.ts b/apps/api/src/secret/controller/secret.controller.ts index 7f3e757eb..b4e0e12da 100644 --- a/apps/api/src/secret/controller/secret.controller.ts +++ b/apps/api/src/secret/controller/secret.controller.ts @@ -110,4 +110,18 @@ export class SecretController { order ) } + + @Get('/:projectSlug/:environmentSlug') + @RequiredApiKeyAuthorities(Authority.READ_SECRET) + async getAllSecretsOfEnvironment( + @CurrentUser() user: AuthenticatedUser, + @Param('projectSlug') projectSlug: string, + @Param('environmentSlug') environmentSlug: string + ) { + return await this.secretService.getAllSecretsOfProjectAndEnvironment( + user, + projectSlug, + environmentSlug + ) + } } diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index 917c0f25a..00370e32f 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -1160,4 +1160,102 @@ describe('Secret Controller Tests', () => { expect(event.title).toBe('Secret rotated') }) }) + + describe('Fetch All Secrets By Project And Environment Tests', () => { + it('should be able to fetch all secrets by project and environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) + + const secret = response.json()[0] + expect(secret.name).toBe('Secret 1') + expect(secret.value).toBe('Secret 1 value') + expect(secret.isPlaintext).toBe(true) + }) + + it('should not be able to fetch all secrets by project and environment if project does not exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/non-existing-project-slug/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to fetch all secrets by project and environment if environment does not exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/non-existing-environment-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to fetch all secrets by project and environment if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be sending the plaintext secret if project does not store the private key', async () => { + // Get the first environment of project 2 + const environment = await prisma.environment.findFirst({ + where: { + projectId: project2.id + } + }) + + // Create a secret in project 2 + await secretService.createSecret( + user1, + { + name: 'Secret 20', + entries: [ + { + environmentSlug: environment.slug, + value: 'Secret 20 value' + } + ], + rotateAfter: '24', + note: 'Secret 20 note' + }, + project2.slug + ) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${project2.slug}/${environment.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) + + const secret = response.json()[0] + expect(secret.name).toBe('Secret 20') + expect(secret.value).not.toBe('Secret 20 value') + expect(secret.isPlaintext).toBe(false) + }) + }) }) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index caf87540f..447a68fc1 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -24,7 +24,10 @@ import { AuthorizationService } from '@/auth/service/authorization.service' import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' import { CHANGE_NOTIFIER_RSC } from '@/socket/change-notifier.socket' -import { ChangeNotificationEvent } from 'src/socket/socket.types' +import { + ChangeNotification, + ChangeNotificationEvent +} from '@/socket/socket.types' import { paginate } from '@/common/paginate' import { addHoursToDate, @@ -770,6 +773,89 @@ export class SecretService { return { items, metadata } } + /** + * Gets all secrets of a project and environment + * @param user the user performing the action + * @param projectSlug the slug of the project + * @param environmentSlug the slug of the environment + * @returns an array of objects with the secret name and value + * @throws {NotFoundException} if the project or environment does not exist + * @throws {BadRequestException} if the user does not have the required role + */ + async getAllSecretsOfProjectAndEnvironment( + user: AuthenticatedUser, + projectSlug: Project['slug'], + environmentSlug: Environment['slug'] + ) { + // Fetch the project + const project = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_SECRET] + }) + const projectId = project.id + + // Check access to the environment + const environment = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + const environmentId = environment.id + + const secrets = await this.prisma.secret.findMany({ + where: { + projectId, + versions: { + some: { + environmentId + } + } + }, + include: { + lastUpdatedBy: { + select: { + id: true, + name: true + } + }, + versions: { + where: { + environmentId + }, + orderBy: { + version: 'desc' + }, + take: 1, + include: { + environment: { + select: { + id: true, + slug: true + } + } + } + } + } + }) + + const response: ChangeNotification[] = [] + + for (const secret of secrets) { + response.push({ + name: secret.name, + value: project.storePrivateKey + ? await decrypt(project.privateKey, secret.versions[0].value) + : secret.versions[0].value, + isPlaintext: project.storePrivateKey + }) + } + + return response + } + /** * Rotate values of secrets that have reached their rotation time * @param currentTime the current time diff --git a/apps/api/src/variable/controller/variable.controller.ts b/apps/api/src/variable/controller/variable.controller.ts index 1a15d29d7..fc967cbd4 100644 --- a/apps/api/src/variable/controller/variable.controller.ts +++ b/apps/api/src/variable/controller/variable.controller.ts @@ -106,4 +106,18 @@ export class VariableController { order ) } + + @Get('/:projectSlug/:environmentSlug') + @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) + async getAllVariablesOfEnvironment( + @CurrentUser() user: AuthenticatedUser, + @Param('projectSlug') projectSlug: string, + @Param('environmentSlug') environmentSlug: string + ) { + return await this.variableService.getAllVariablesOfProjectAndEnvironment( + user, + projectSlug, + environmentSlug + ) + } } diff --git a/apps/api/src/variable/service/variable.service.ts b/apps/api/src/variable/service/variable.service.ts index d54e48e9a..0abdcc557 100644 --- a/apps/api/src/variable/service/variable.service.ts +++ b/apps/api/src/variable/service/variable.service.ts @@ -22,7 +22,10 @@ import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' import { CHANGE_NOTIFIER_RSC } from '@/socket/change-notifier.socket' import { AuthorizationService } from '@/auth/service/authorization.service' -import { ChangeNotificationEvent } from 'src/socket/socket.types' +import { + ChangeNotification, + ChangeNotificationEvent +} from '@/socket/socket.types' import { paginate } from '@/common/paginate' import { getEnvironmentIdToSlugMap } from '@/common/environment' import generateEntitySlug from '@/common/slug-generator' @@ -715,6 +718,83 @@ export class VariableService { return { items, metadata } } + /** + * Gets all variables of a project and environment. + * @param user the user performing the action + * @param projectSlug the slug of the project to get the variables from + * @param environmentSlug the slug of the environment to get the variables from + * @returns an array of objects containing the name, value and whether the value is a plaintext + * @throws `NotFoundException` if the project or environment does not exist + * @throws `ForbiddenException` if the user does not have the required authority + */ + async getAllVariablesOfProjectAndEnvironment( + user: AuthenticatedUser, + projectSlug: Project['slug'], + environmentSlug: Environment['slug'] + ) { + // Check if the user has the required authorities in the project + const { id: projectId } = + await this.authorizationService.authorizeUserAccessToProject({ + user, + entity: { slug: projectSlug }, + authorities: [Authority.READ_VARIABLE] + }) + + // Check if the user has the required authorities in the environment + const { id: environmentId } = + await this.authorizationService.authorizeUserAccessToEnvironment({ + user, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT] + }) + + const variables = await this.prisma.variable.findMany({ + where: { + projectId, + versions: { + some: { + environmentId + } + } + }, + include: { + lastUpdatedBy: { + select: { + id: true, + name: true + } + }, + versions: { + where: { + environmentId + }, + select: { + value: true, + environment: { + select: { + id: true, + slug: true + } + } + }, + orderBy: { + version: 'desc' + }, + take: 1 + } + } + }) + + return variables.map( + (variable) => + ({ + name: variable.name, + value: variable.versions[0].value, + isPlaintext: true + }) as ChangeNotification + ) + } + /** * Checks if a variable with a given name already exists in a project. * Throws a ConflictException if the variable already exists. diff --git a/apps/api/src/variable/variable.e2e.spec.ts b/apps/api/src/variable/variable.e2e.spec.ts index 70872af83..b17e963a9 100644 --- a/apps/api/src/variable/variable.e2e.spec.ts +++ b/apps/api/src/variable/variable.e2e.spec.ts @@ -932,4 +932,60 @@ describe('Variable Controller Tests', () => { expect(response.statusCode).toBe(401) }) }) + + describe('Get All Variables By Project And Environment Tests', () => { + it('should be able to fetch all variables by project and environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) + + const variable = response.json()[0] + expect(variable.name).toBe('Variable 1') + expect(variable.value).toBe('Variable 1 value') + expect(variable.isPlaintext).toBe(true) + }) + + it('should not be able to fetch all variables by project and environment if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + }) + + it('should not be able to fetch all variables by project and environment if the project does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/non-existing-project-slug/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to fetch all variables by project and environment if the environment does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/non-existing-environment-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + }) }) diff --git a/apps/cli/src/commands/run.command.ts b/apps/cli/src/commands/run.command.ts index 43bdfec63..5b624543d 100644 --- a/apps/cli/src/commands/run.command.ts +++ b/apps/cli/src/commands/run.command.ts @@ -160,9 +160,10 @@ export default class RunCommand extends BaseCommand { } if (childProcess === null) { childProcess = spawn(command, { + // @ts-expect-error this just works stdio: ['inherit', 'pipe', 'pipe'], shell: true, - env: this.processEnvironmentalVariables, + env: { ...process.env, ...this.processEnvironmentalVariables }, detached: true }) diff --git a/packages/api-client/src/controllers/secret.ts b/packages/api-client/src/controllers/secret.ts index a4d961d9c..176f1cd70 100644 --- a/packages/api-client/src/controllers/secret.ts +++ b/packages/api-client/src/controllers/secret.ts @@ -1,5 +1,9 @@ import { APIClient } from '@api-client/core/client' -import { ClientResponse } from '@keyshade/schema' +import { + ClientResponse, + GetAllSecretsOfEnvironmentRequest, + GetAllSecretsOfEnvironmentResponse +} from '@keyshade/schema' import { parseResponse } from '@api-client/core/response-parser' import { CreateSecretRequest, @@ -106,4 +110,14 @@ export default class SecretController { return await parseResponse(response) } + + async getAllSecretsOfEnvironment( + request: GetAllSecretsOfEnvironmentRequest, + headers?: Record + ): Promise> { + const url = `/api/secret/${request.projectSlug}/${request.environmentSlug}` + const response = await this.apiClient.get(url, headers) + + return await parseResponse(response) + } } diff --git a/packages/api-client/src/controllers/variable.ts b/packages/api-client/src/controllers/variable.ts index de60d6eaa..d6ed0b23d 100644 --- a/packages/api-client/src/controllers/variable.ts +++ b/packages/api-client/src/controllers/variable.ts @@ -1,7 +1,11 @@ import { APIClient } from '@api-client/core/client' import { parsePaginationUrl } from '@api-client/core/pagination-parser' import { parseResponse } from '@api-client/core/response-parser' -import { ClientResponse } from '@keyshade/schema' +import { + ClientResponse, + GetAllVariablesOfEnvironmentRequest, + GetAllVariablesOfEnvironmentResponse +} from '@keyshade/schema' import { CreateVariableRequest, CreateVariableResponse, @@ -99,4 +103,14 @@ export default class VariableController { return await parseResponse(response) } + + async getAllVariablesOfEnvironment( + request: GetAllVariablesOfEnvironmentRequest, + headers: Record + ): Promise> { + const url = `/api/variable/${request.projectSlug}/${request.environmentSlug}` + const response = await this.apiClient.get(url, headers) + + return await parseResponse(response) + } } diff --git a/packages/schema/src/secret/index.ts b/packages/schema/src/secret/index.ts index e075354ba..0b1879d38 100644 --- a/packages/schema/src/secret/index.ts +++ b/packages/schema/src/secret/index.ts @@ -129,3 +129,16 @@ export const GetRevisionsOfSecretResponseSchema = PageResponseSchema( }) }) ) + +export const GetAllSecretsOfEnvironmentRequestSchema = z.object({ + projectSlug: BaseProjectSchema.shape.slug, + environmentSlug: EnvironmentSchema.shape.slug +}) + +export const GetAllSecretsOfEnvironmentResponseSchema = z.array( + z.object({ + name: z.string(), + value: z.string(), + isPlaintext: z.boolean() + }) +) diff --git a/packages/schema/src/secret/index.types.ts b/packages/schema/src/secret/index.types.ts index 43920ad29..990bf9cf7 100644 --- a/packages/schema/src/secret/index.types.ts +++ b/packages/schema/src/secret/index.types.ts @@ -12,7 +12,9 @@ import { GetAllSecretsOfProjectRequestSchema, GetAllSecretsOfProjectResponseSchema, GetRevisionsOfSecretRequestSchema, - GetRevisionsOfSecretResponseSchema + GetRevisionsOfSecretResponseSchema, + GetAllSecretsOfEnvironmentRequestSchema, + GetAllSecretsOfEnvironmentResponseSchema } from '.' export type Secret = z.infer @@ -50,3 +52,11 @@ export type GetRevisionsOfSecretRequest = z.infer< export type GetRevisionsOfSecretResponse = z.infer< typeof GetRevisionsOfSecretResponseSchema > + +export type GetAllSecretsOfEnvironmentRequest = z.infer< + typeof GetAllSecretsOfEnvironmentRequestSchema +> + +export type GetAllSecretsOfEnvironmentResponse = z.infer< + typeof GetAllSecretsOfEnvironmentResponseSchema +> diff --git a/packages/schema/src/variable/index.ts b/packages/schema/src/variable/index.ts index 810e5ba2a..d127db4bc 100644 --- a/packages/schema/src/variable/index.ts +++ b/packages/schema/src/variable/index.ts @@ -133,3 +133,16 @@ export const GetRevisionsOfVariableResponseSchema = PageResponseSchema( }) }) ) + +export const GetAllVariablesOfEnvironmentRequestSchema = z.object({ + projectSlug: BaseProjectSchema.shape.slug, + environmentSlug: EnvironmentSchema.shape.slug +}) + +export const GetAllVariablesOfEnvironmentResponseSchema = z.array( + z.object({ + name: z.string(), + value: z.string(), + isPlaintext: z.boolean() + }) +) diff --git a/packages/schema/src/variable/index.types.ts b/packages/schema/src/variable/index.types.ts index 609fbb24f..6bf88352b 100644 --- a/packages/schema/src/variable/index.types.ts +++ b/packages/schema/src/variable/index.types.ts @@ -12,7 +12,9 @@ import { GetAllVariablesOfProjectRequestSchema, GetAllVariablesOfProjectResponseSchema, GetRevisionsOfVariableRequestSchema, - GetRevisionsOfVariableResponseSchema + GetRevisionsOfVariableResponseSchema, + GetAllVariablesOfEnvironmentRequestSchema, + GetAllVariablesOfEnvironmentResponseSchema } from '.' export type Variable = z.infer @@ -58,3 +60,11 @@ export type GetRevisionsOfVariableRequest = z.infer< export type GetRevisionsOfVariableResponse = z.infer< typeof GetRevisionsOfVariableResponseSchema > + +export type GetAllVariablesOfEnvironmentRequest = z.infer< + typeof GetAllVariablesOfEnvironmentRequestSchema +> + +export type GetAllVariablesOfEnvironmentResponse = z.infer< + typeof GetAllVariablesOfEnvironmentResponseSchema +>