diff --git a/.github/workflows/build-staging-and-deploy-aws.yml b/.github/workflows/build-staging-and-deploy-aws.yml index 5c699c9d4..347b6f5ee 100644 --- a/.github/workflows/build-staging-and-deploy-aws.yml +++ b/.github/workflows/build-staging-and-deploy-aws.yml @@ -6,9 +6,15 @@ permissions: contents: read jobs: + infisical-tests: + name: Run tests before deployment + # https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview + uses: ./.github/workflows/run-backend-tests.yml + infisical-image: name: Build backend image runs-on: ubuntu-latest + needs: [infisical-tests] steps: - name: ☁️ Checkout source uses: actions/checkout@v3 diff --git a/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts b/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts new file mode 100644 index 000000000..3234503e5 --- /dev/null +++ b/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts @@ -0,0 +1,35 @@ +import { seedData1 } from "@app/db/seed-data"; + +const createPolicy = async (dto: { name: string; secretPath: string; approvers: string[]; approvals: number }) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/secret-approvals`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: dto.name, + secretPath: dto.secretPath, + approvers: dto.approvers, + approvals: dto.approvals + } + }); + + expect(res.statusCode).toBe(200); + return res.json().approval; +}; + +describe("Secret approval policy router", async () => { + test("Create policy", async () => { + const policy = await createPolicy({ + secretPath: "/", + approvals: 1, + approvers: [seedData1.id], + name: "test-policy" + }); + + expect(policy.name).toBe("test-policy"); + }); +}); diff --git a/backend/e2e-test/routes/v1/secret-import.spec.ts b/backend/e2e-test/routes/v1/secret-import.spec.ts index c184e44e5..dd4f9c4ca 100644 --- a/backend/e2e-test/routes/v1/secret-import.spec.ts +++ b/backend/e2e-test/routes/v1/secret-import.spec.ts @@ -1,73 +1,61 @@ +import { createFolder, deleteFolder } from "e2e-test/testUtils/folders"; +import { createSecretImport, deleteSecretImport } from "e2e-test/testUtils/secret-imports"; +import { createSecretV2, deleteSecretV2, getSecretByNameV2, getSecretsV2 } from "e2e-test/testUtils/secrets"; + import { seedData1 } from "@app/db/seed-data"; -const createSecretImport = async (importPath: string, importEnv: string) => { - const res = await testServer.inject({ - method: "POST", - url: `/api/v1/secret-imports`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - path: "/", - import: { - environment: importEnv, - path: importPath - } - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("secretImport"); - return payload.secretImport; -}; - -const deleteSecretImport = async (id: string) => { - const res = await testServer.inject({ - method: "DELETE", - url: `/api/v1/secret-imports/${id}`, - headers: { - authorization: `Bearer ${jwtAuthToken}` - }, - body: { - workspaceId: seedData1.project.id, - environment: seedData1.environment.slug, - path: "/" - } - }); - - expect(res.statusCode).toBe(200); - const payload = JSON.parse(res.payload); - expect(payload).toHaveProperty("secretImport"); - return payload.secretImport; -}; - describe("Secret Import Router", async () => { test.each([ { importEnv: "prod", importPath: "/" }, // one in root { importEnv: "staging", importPath: "/" } // then create a deep one creating intermediate ones ])("Create secret import $importEnv with path $importPath", async ({ importPath, importEnv }) => { // check for default environments - const payload = await createSecretImport(importPath, importEnv); + const payload = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath, + importEnv + }); expect(payload).toEqual( expect.objectContaining({ id: expect.any(String), - importPath: expect.any(String), + importPath, importEnv: expect.objectContaining({ name: expect.any(String), - slug: expect.any(String), + slug: importEnv, id: expect.any(String) }) }) ); - await deleteSecretImport(payload.id); + + await deleteSecretImport({ + id: payload.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); }); test("Get secret imports", async () => { - const createdImport1 = await createSecretImport("/", "prod"); - const createdImport2 = await createSecretImport("/", "staging"); + const createdImport1 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: "/", + importEnv: "prod" + }); + const createdImport2 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: "/", + importEnv: "staging" + }); const res = await testServer.inject({ method: "GET", url: `/api/v1/secret-imports`, @@ -89,25 +77,60 @@ describe("Secret Import Router", async () => { expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - importPath: expect.any(String), + importPath: "/", importEnv: expect.objectContaining({ name: expect.any(String), - slug: expect.any(String), + slug: "prod", + id: expect.any(String) + }) + }), + expect.objectContaining({ + id: expect.any(String), + importPath: "/", + importEnv: expect.objectContaining({ + name: expect.any(String), + slug: "staging", id: expect.any(String) }) }) ]) ); - await deleteSecretImport(createdImport1.id); - await deleteSecretImport(createdImport2.id); + await deleteSecretImport({ + id: createdImport1.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); + await deleteSecretImport({ + id: createdImport2.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); }); test("Update secret import position", async () => { const prodImportDetails = { path: "/", envSlug: "prod" }; const stagingImportDetails = { path: "/", envSlug: "staging" }; - const createdImport1 = await createSecretImport(prodImportDetails.path, prodImportDetails.envSlug); - const createdImport2 = await createSecretImport(stagingImportDetails.path, stagingImportDetails.envSlug); + const createdImport1 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: prodImportDetails.path, + importEnv: prodImportDetails.envSlug + }); + const createdImport2 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: stagingImportDetails.path, + importEnv: stagingImportDetails.envSlug + }); const updateImportRes = await testServer.inject({ method: "PATCH", @@ -161,22 +184,55 @@ describe("Secret Import Router", async () => { expect(secretImportList.secretImports[1].id).toEqual(createdImport1.id); expect(secretImportList.secretImports[0].id).toEqual(createdImport2.id); - await deleteSecretImport(createdImport1.id); - await deleteSecretImport(createdImport2.id); + await deleteSecretImport({ + id: createdImport1.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); + await deleteSecretImport({ + id: createdImport2.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); }); test("Delete secret import position", async () => { - const createdImport1 = await createSecretImport("/", "prod"); - const createdImport2 = await createSecretImport("/", "staging"); - const deletedImport = await deleteSecretImport(createdImport1.id); + const createdImport1 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: "/", + importEnv: "prod" + }); + const createdImport2 = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: "/", + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.project.id, + importPath: "/", + importEnv: "staging" + }); + const deletedImport = await deleteSecretImport({ + id: createdImport1.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); + // check for default environments expect(deletedImport).toEqual( expect.objectContaining({ id: expect.any(String), - importPath: expect.any(String), + importPath: "/", importEnv: expect.objectContaining({ name: expect.any(String), - slug: expect.any(String), + slug: "prod", id: expect.any(String) }) }) @@ -201,6 +257,552 @@ describe("Secret Import Router", async () => { expect(secretImportList.secretImports.length).toEqual(1); expect(secretImportList.secretImports[0].position).toEqual(1); - await deleteSecretImport(createdImport2.id); + await deleteSecretImport({ + id: createdImport2.id, + workspaceId: seedData1.project.id, + environmentSlug: seedData1.environment.slug, + secretPath: "/", + authToken: jwtAuthToken + }); }); }); + +// dev <- stage <- prod +describe.each([{ path: "/" }, { path: "/deep" }])( + "Secret import waterfall pattern testing - %path", + ({ path: testSuitePath }) => { + beforeAll(async () => { + let prodFolder: { id: string }; + let stagingFolder: { id: string }; + let devFolder: { id: string }; + + if (testSuitePath !== "/") { + prodFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + stagingFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + devFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + } + + const devImportFromStage = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "staging" + }); + + const stageImportFromProd = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "prod" + }); + + return async () => { + await deleteSecretImport({ + id: stageImportFromProd.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging", + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + await deleteSecretImport({ + id: devImportFromStage.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + if (prodFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod" + }); + } + + if (stagingFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: stagingFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging" + }); + } + + if (devFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: devFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug + }); + } + }; + }); + + test("Check one level imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY", + value: "stage-value" + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + + expect(secret.secretKey).toBe("STAGING_KEY"); + expect(secret.secretValue).toBe("stage-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "STAGING_KEY", + secretValue: "stage-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + }); + + test("Check two level imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY", + value: "prod-value" + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + + expect(secret.secretKey).toBe("PROD_KEY"); + expect(secret.secretValue).toBe("prod-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "PROD_KEY", + secretValue: "prod-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + }); + } +); + +// dev <- stage, dev <- prod +describe.each([{ path: "/" }, { path: "/deep" }])( + "Secret import multiple destination to one source pattern testing - %path", + ({ path: testSuitePath }) => { + beforeAll(async () => { + let prodFolder: { id: string }; + let stagingFolder: { id: string }; + let devFolder: { id: string }; + + if (testSuitePath !== "/") { + prodFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + stagingFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + devFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + } + + const devImportFromStage = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "staging" + }); + + const devImportFromProd = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "prod" + }); + + return async () => { + await deleteSecretImport({ + id: devImportFromProd.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + await deleteSecretImport({ + id: devImportFromStage.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + if (prodFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod" + }); + } + + if (stagingFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: stagingFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging" + }); + } + + if (devFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: devFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug + }); + } + }; + }); + + test("Check imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY", + value: "stage-value" + }); + + await createSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY", + value: "prod-value" + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + + expect(secret.secretKey).toBe("STAGING_KEY"); + expect(secret.secretValue).toBe("stage-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "STAGING_KEY", + secretValue: "stage-value" + }) + ]) + }), + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "PROD_KEY", + secretValue: "prod-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + await deleteSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + }); + } +); + +// dev -> stage, prod +describe.each([{ path: "/" }, { path: "/deep" }])( + "Secret import one source to multiple destination pattern testing - %path", + ({ path: testSuitePath }) => { + beforeAll(async () => { + let prodFolder: { id: string }; + let stagingFolder: { id: string }; + let devFolder: { id: string }; + + if (testSuitePath !== "/") { + prodFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + stagingFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + devFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + } + + const stageImportFromDev = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: seedData1.environment.slug + }); + + const prodImportFromDev = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: seedData1.environment.slug + }); + + return async () => { + await deleteSecretImport({ + id: prodImportFromDev.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod", + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + await deleteSecretImport({ + id: stageImportFromDev.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging", + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + if (prodFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod" + }); + } + + if (stagingFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: stagingFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging" + }); + } + + if (devFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: devFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug + }); + } + }; + }); + + test("Check imported secret exist", async () => { + await createSecretV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY", + value: "stage-value" + }); + + await createSecretV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY", + value: "prod-value" + }); + + const stagingSecret = await getSecretByNameV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + + expect(stagingSecret.secretKey).toBe("STAGING_KEY"); + expect(stagingSecret.secretValue).toBe("stage-value"); + + const prodSecret = await getSecretByNameV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + + expect(prodSecret.secretKey).toBe("PROD_KEY"); + expect(prodSecret.secretValue).toBe("prod-value"); + + await deleteSecretV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + await deleteSecretV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + }); + } +); diff --git a/backend/e2e-test/routes/v1/secret-replication.spec.ts b/backend/e2e-test/routes/v1/secret-replication.spec.ts new file mode 100644 index 000000000..f762165a2 --- /dev/null +++ b/backend/e2e-test/routes/v1/secret-replication.spec.ts @@ -0,0 +1,406 @@ +import { createFolder, deleteFolder } from "e2e-test/testUtils/folders"; +import { createSecretImport, deleteSecretImport } from "e2e-test/testUtils/secret-imports"; +import { createSecretV2, deleteSecretV2, getSecretByNameV2, getSecretsV2 } from "e2e-test/testUtils/secrets"; + +import { seedData1 } from "@app/db/seed-data"; + +// dev <- stage <- prod +describe.each([{ secretPath: "/" }, { secretPath: "/deep" }])( + "Secret replication waterfall pattern testing - %secretPath", + ({ secretPath: testSuitePath }) => { + beforeAll(async () => { + let prodFolder: { id: string }; + let stagingFolder: { id: string }; + let devFolder: { id: string }; + + if (testSuitePath !== "/") { + prodFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + stagingFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + devFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + } + + const devImportFromStage = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "staging", + isReplication: true + }); + + const stageImportFromProd = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "prod", + isReplication: true + }); + + return async () => { + await deleteSecretImport({ + id: stageImportFromProd.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging", + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + await deleteSecretImport({ + id: devImportFromStage.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + if (prodFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod" + }); + } + + if (stagingFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: stagingFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging" + }); + } + + if (devFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: devFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug + }); + } + }; + }); + + test("Check one level imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY", + value: "stage-value" + }); + + // wait for 5 second for replication to finish + await new Promise((resolve) => { + setTimeout(resolve, 5000); // time to breathe for db + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + + expect(secret.secretKey).toBe("STAGING_KEY"); + expect(secret.secretValue).toBe("stage-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "STAGING_KEY", + secretValue: "stage-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + }); + + test("Check two level imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY", + value: "prod-value" + }); + + // wait for 5 second for replication to finish + await new Promise((resolve) => { + setTimeout(resolve, 5000); // time to breathe for db + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + + expect(secret.secretKey).toBe("PROD_KEY"); + expect(secret.secretValue).toBe("prod-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "PROD_KEY", + secretValue: "prod-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + }); + }, + { timeout: 30000 } +); + +// dev <- stage, dev <- prod +describe.each([{ path: "/" }, { path: "/deep" }])( + "Secret replication 1-N pattern testing - %path", + ({ path: testSuitePath }) => { + beforeAll(async () => { + let prodFolder: { id: string }; + let stagingFolder: { id: string }; + let devFolder: { id: string }; + + if (testSuitePath !== "/") { + prodFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + stagingFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + + devFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: "/", + name: "deep" + }); + } + + const devImportFromStage = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "staging", + isReplication: true + }); + + const devImportFromProd = await createSecretImport({ + authToken: jwtAuthToken, + secretPath: testSuitePath, + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + importPath: testSuitePath, + importEnv: "prod", + isReplication: true + }); + + return async () => { + await deleteSecretImport({ + id: devImportFromProd.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + await deleteSecretImport({ + id: devImportFromStage.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + + if (prodFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "prod" + }); + } + + if (stagingFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: stagingFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: "staging" + }); + } + + if (devFolder) { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: devFolder.id, + workspaceId: seedData1.projectV3.id, + environmentSlug: seedData1.environment.slug + }); + } + }; + }); + + test("Check imported secret exist", async () => { + await createSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY", + value: "stage-value" + }); + + await createSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY", + value: "prod-value" + }); + + // wait for 5 second for replication to finish + await new Promise((resolve) => { + setTimeout(resolve, 5000); // time to breathe for db + }); + + const secret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + + expect(secret.secretKey).toBe("STAGING_KEY"); + expect(secret.secretValue).toBe("stage-value"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "STAGING_KEY", + secretValue: "stage-value" + }) + ]) + }), + expect.objectContaining({ + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "PROD_KEY", + secretValue: "prod-value" + }) + ]) + }) + ]) + ); + + await deleteSecretV2({ + environmentSlug: "staging", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "STAGING_KEY" + }); + await deleteSecretV2({ + environmentSlug: "prod", + workspaceId: seedData1.projectV3.id, + secretPath: testSuitePath, + authToken: jwtAuthToken, + key: "PROD_KEY" + }); + }); + }, + { timeout: 30000 } +); diff --git a/backend/e2e-test/routes/v3/secret-reference.spec.ts b/backend/e2e-test/routes/v3/secret-reference.spec.ts new file mode 100644 index 000000000..fb23cdc25 --- /dev/null +++ b/backend/e2e-test/routes/v3/secret-reference.spec.ts @@ -0,0 +1,330 @@ +import { createFolder, deleteFolder } from "e2e-test/testUtils/folders"; +import { createSecretImport, deleteSecretImport } from "e2e-test/testUtils/secret-imports"; +import { createSecretV2, deleteSecretV2, getSecretByNameV2, getSecretsV2 } from "e2e-test/testUtils/secrets"; + +import { seedData1 } from "@app/db/seed-data"; + +describe("Secret expansion", () => { + const projectId = seedData1.projectV3.id; + + beforeAll(async () => { + const prodRootFolder = await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/", + name: "deep" + }); + + await createFolder({ + authToken: jwtAuthToken, + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep", + name: "nested" + }); + + return async () => { + await deleteFolder({ + authToken: jwtAuthToken, + secretPath: "/", + id: prodRootFolder.id, + workspaceId: projectId, + environmentSlug: "prod" + }); + }; + }); + + test("Local secret reference", async () => { + const secrets = [ + { + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "HELLO", + value: "world" + }, + { + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "TEST", + // eslint-disable-next-line + value: "hello ${HELLO}" + } + ]; + + await Promise.all(secrets.map((el) => createSecretV2(el))); + + const expandedSecret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "TEST" + }); + expect(expandedSecret.secretValue).toBe("hello world"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken + }); + expect(listSecrets.secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: "TEST", + secretValue: "hello world" + }) + ]) + ); + + await Promise.all(secrets.map((el) => deleteSecretV2(el))); + }); + + test("Cross environment secret reference", async () => { + const secrets = [ + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep", + authToken: jwtAuthToken, + key: "DEEP_KEY_1", + value: "testing" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_1", + value: "reference" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_2", + // eslint-disable-next-line + value: "secret ${NESTED_KEY_1}" + }, + { + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "KEY", + // eslint-disable-next-line + value: "hello ${prod.deep.DEEP_KEY_1} ${prod.deep.nested.NESTED_KEY_2}" + } + ]; + + await Promise.all(secrets.map((el) => createSecretV2(el))); + + const expandedSecret = await getSecretByNameV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "KEY" + }); + expect(expandedSecret.secretValue).toBe("hello testing secret reference"); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken + }); + expect(listSecrets.secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretKey: "KEY", + secretValue: "hello testing secret reference" + }) + ]) + ); + + await Promise.all(secrets.map((el) => deleteSecretV2(el))); + }); + + test("Non replicated secret import secret expansion on local reference and nested reference", async () => { + const secrets = [ + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep", + authToken: jwtAuthToken, + key: "DEEP_KEY_1", + value: "testing" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_1", + value: "reference" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_2", + // eslint-disable-next-line + value: "secret ${NESTED_KEY_1} ${prod.deep.DEEP_KEY_1}" + }, + { + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "KEY", + // eslint-disable-next-line + value: "hello world" + } + ]; + + await Promise.all(secrets.map((el) => createSecretV2(el))); + const secretImportFromProdToDev = await createSecretImport({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + importEnv: "prod", + importPath: "/deep/nested" + }); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretPath: "/deep/nested", + environment: "prod", + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "NESTED_KEY_1", + secretValue: "reference" + }), + expect.objectContaining({ + secretKey: "NESTED_KEY_2", + secretValue: "secret reference testing" + }) + ]) + }) + ]) + ); + + await Promise.all(secrets.map((el) => deleteSecretV2(el))); + await deleteSecretImport({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + authToken: jwtAuthToken, + id: secretImportFromProdToDev.id, + secretPath: "/" + }); + }); + + test( + "Replicated secret import secret expansion on local reference and nested reference", + async () => { + const secrets = [ + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep", + authToken: jwtAuthToken, + key: "DEEP_KEY_1", + value: "testing" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_1", + value: "reference" + }, + { + environmentSlug: "prod", + workspaceId: projectId, + secretPath: "/deep/nested", + authToken: jwtAuthToken, + key: "NESTED_KEY_2", + // eslint-disable-next-line + value: "secret ${NESTED_KEY_1} ${prod.deep.DEEP_KEY_1}" + }, + { + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + key: "KEY", + // eslint-disable-next-line + value: "hello world" + } + ]; + + await Promise.all(secrets.map((el) => createSecretV2(el))); + const secretImportFromProdToDev = await createSecretImport({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken, + importEnv: "prod", + importPath: "/deep/nested", + isReplication: true + }); + + // wait for 5 second for replication to finish + await new Promise((resolve) => { + setTimeout(resolve, 5000); // time to breathe for db + }); + + const listSecrets = await getSecretsV2({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + secretPath: "/", + authToken: jwtAuthToken + }); + expect(listSecrets.imports).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + secretPath: `/__reserve_replication_${secretImportFromProdToDev.id}`, + environment: seedData1.environment.slug, + secrets: expect.arrayContaining([ + expect.objectContaining({ + secretKey: "NESTED_KEY_1", + secretValue: "reference" + }), + expect.objectContaining({ + secretKey: "NESTED_KEY_2", + secretValue: "secret reference testing" + }) + ]) + }) + ]) + ); + + await Promise.all(secrets.map((el) => deleteSecretV2(el))); + await deleteSecretImport({ + environmentSlug: seedData1.environment.slug, + workspaceId: projectId, + authToken: jwtAuthToken, + id: secretImportFromProdToDev.id, + secretPath: "/" + }); + }, + { timeout: 10000 } + ); +}); diff --git a/backend/e2e-test/routes/v3/secrets-v2.spec.ts b/backend/e2e-test/routes/v3/secrets-v2.spec.ts index 70066c061..dc02587cd 100644 --- a/backend/e2e-test/routes/v3/secrets-v2.spec.ts +++ b/backend/e2e-test/routes/v3/secrets-v2.spec.ts @@ -8,6 +8,7 @@ type TRawSecret = { secretComment?: string; version: number; }; + const createSecret = async (dto: { path: string; key: string; value: string; comment: string; type?: SecretType }) => { const createSecretReqBody = { workspaceId: seedData1.projectV3.id, diff --git a/backend/e2e-test/testUtils/folders.ts b/backend/e2e-test/testUtils/folders.ts new file mode 100644 index 000000000..a5484a22e --- /dev/null +++ b/backend/e2e-test/testUtils/folders.ts @@ -0,0 +1,73 @@ +type TFolder = { + id: string; + name: string; +}; + +export const createFolder = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + name: string; + authToken: string; +}) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + name: dto.name, + path: dto.secretPath + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder as TFolder; +}; + +export const deleteFolder = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + id: string; + authToken: string; +}) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/folders/${dto.id}`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + path: dto.secretPath + } + }); + expect(res.statusCode).toBe(200); + return res.json().folder as TFolder; +}; + +export const listFolders = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + authToken: string; +}) => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + path: dto.secretPath + } + }); + expect(res.statusCode).toBe(200); + return res.json().folders as TFolder[]; +}; diff --git a/backend/e2e-test/testUtils/secret-imports.ts b/backend/e2e-test/testUtils/secret-imports.ts new file mode 100644 index 000000000..370a8dfb8 --- /dev/null +++ b/backend/e2e-test/testUtils/secret-imports.ts @@ -0,0 +1,93 @@ +type TSecretImport = { + id: string; + importEnv: { + name: string; + slug: string; + id: string; + }; + importPath: string; +}; + +export const createSecretImport = async (dto: { + workspaceId: string; + environmentSlug: string; + isReplication?: boolean; + secretPath: string; + importPath: string; + importEnv: string; + authToken: string; +}) => { + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/secret-imports`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + isReplication: dto.isReplication, + path: dto.secretPath, + import: { + environment: dto.importEnv, + path: dto.importPath + } + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("secretImport"); + return payload.secretImport as TSecretImport; +}; + +export const deleteSecretImport = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + authToken: string; + id: string; +}) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/secret-imports/${dto.id}`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + path: dto.secretPath + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("secretImport"); + return payload.secretImport as TSecretImport; +}; + +export const listSecretImport = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + authToken: string; +}) => { + const res = await testServer.inject({ + method: "GET", + url: `/api/v1/secret-imports`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + query: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + path: dto.secretPath + } + }); + + expect(res.statusCode).toBe(200); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("secretImports"); + return payload.secretImports as TSecretImport[]; +}; diff --git a/backend/e2e-test/testUtils/secrets.ts b/backend/e2e-test/testUtils/secrets.ts new file mode 100644 index 000000000..96ecc91c6 --- /dev/null +++ b/backend/e2e-test/testUtils/secrets.ts @@ -0,0 +1,128 @@ +import { SecretType } from "@app/db/schemas"; + +type TRawSecret = { + secretKey: string; + secretValue: string; + secretComment?: string; + version: number; +}; + +export const createSecretV2 = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + key: string; + value: string; + comment?: string; + authToken: string; + type?: SecretType; +}) => { + const createSecretReqBody = { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + type: dto.type || SecretType.Shared, + secretPath: dto.secretPath, + secretKey: dto.key, + secretValue: dto.value, + secretComment: dto.comment + }; + const createSecRes = await testServer.inject({ + method: "POST", + url: `/api/v3/secrets/raw/${dto.key}`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: createSecretReqBody + }); + expect(createSecRes.statusCode).toBe(200); + const createdSecretPayload = JSON.parse(createSecRes.payload); + expect(createdSecretPayload).toHaveProperty("secret"); + return createdSecretPayload.secret as TRawSecret; +}; + +export const deleteSecretV2 = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + key: string; + authToken: string; +}) => { + const deleteSecRes = await testServer.inject({ + method: "DELETE", + url: `/api/v3/secrets/raw/${dto.key}`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + body: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + secretPath: dto.secretPath + } + }); + expect(deleteSecRes.statusCode).toBe(200); + const updatedSecretPayload = JSON.parse(deleteSecRes.payload); + expect(updatedSecretPayload).toHaveProperty("secret"); + return updatedSecretPayload.secret as TRawSecret; +}; + +export const getSecretByNameV2 = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + key: string; + authToken: string; +}) => { + const response = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/raw/${dto.key}`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + query: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + secretPath: dto.secretPath, + expandSecretReferences: "true", + include_imports: "true" + } + }); + expect(response.statusCode).toBe(200); + const payload = JSON.parse(response.payload); + expect(payload).toHaveProperty("secret"); + return payload.secret as TRawSecret; +}; + +export const getSecretsV2 = async (dto: { + workspaceId: string; + environmentSlug: string; + secretPath: string; + authToken: string; +}) => { + const getSecretsResponse = await testServer.inject({ + method: "GET", + url: `/api/v3/secrets/raw`, + headers: { + authorization: `Bearer ${dto.authToken}` + }, + query: { + workspaceId: dto.workspaceId, + environment: dto.environmentSlug, + secretPath: dto.secretPath, + expandSecretReferences: "true", + include_imports: "true" + } + }); + expect(getSecretsResponse.statusCode).toBe(200); + const getSecretsPayload = JSON.parse(getSecretsResponse.payload); + expect(getSecretsPayload).toHaveProperty("secrets"); + expect(getSecretsPayload).toHaveProperty("imports"); + return getSecretsPayload as { + secrets: TRawSecret[]; + imports: { + secretPath: string; + environment: string; + folderId: string; + secrets: TRawSecret[]; + }[]; + }; +}; diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index cc1f9afc2..7be0b860f 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -11,10 +11,11 @@ import { initLogger } from "@app/lib/logger"; import { main } from "@app/server/app"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; -import { mockQueue } from "./mocks/queue"; import { mockSmtpServer } from "./mocks/smtp"; -import { mockKeyStore } from "./mocks/keystore"; import { initDbConnection } from "@app/db"; +import { queueServiceFactory } from "@app/queue"; +import { keyStoreFactory } from "@app/keystore/keystore"; +import { Redis } from "ioredis"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -28,19 +29,31 @@ export default { dbRootCert: cfg.DB_ROOT_CERT }); + const redis = new Redis(cfg.REDIS_URL); + await redis.flushdb("SYNC"); + try { + await db.migrate.rollback( + { + directory: path.join(__dirname, "../src/db/migrations"), + extension: "ts", + tableName: "infisical_migrations" + }, + true + ); await db.migrate.latest({ directory: path.join(__dirname, "../src/db/migrations"), extension: "ts", tableName: "infisical_migrations" }); + await db.seed.run({ directory: path.join(__dirname, "../src/db/seeds"), extension: "ts" }); const smtp = mockSmtpServer(); - const queue = mockQueue(); - const keyStore = mockKeyStore(); + const queue = queueServiceFactory(cfg.REDIS_URL); + const keyStore = keyStoreFactory(cfg.REDIS_URL); const server = await main({ db, smtp, logger, queue, keyStore }); // @ts-expect-error type globalThis.testServer = server; @@ -58,10 +71,12 @@ export default { { expiresIn: cfg.JWT_AUTH_LIFETIME } ); } catch (error) { + // eslint-disable-next-line console.log("[TEST] Error setting up environment", error); await db.destroy(); throw error; } + // custom setup return { async teardown() { @@ -80,6 +95,9 @@ export default { }, true ); + + await redis.flushdb("ASYNC"); + redis.disconnect(); await db.destroy(); } }; diff --git a/backend/package-lock.json b/backend/package-lock.json index a4b3fa3cb..8128b0258 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", + "@elastic/elasticsearch": "^8.15.0", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", @@ -81,6 +82,8 @@ "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", + "scim-patch": "^0.8.3", + "scim2-parse-filter": "^0.2.10", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", @@ -136,7 +139,6 @@ "tsup": "^8.0.1", "tsx": "^4.4.0", "typescript": "^5.3.2", - "vite-tsconfig-paths": "^4.2.2", "vitest": "^1.2.2" } }, @@ -301,16 +303,16 @@ } }, "node_modules/@aws-sdk/client-cloudwatch-logs": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.635.0.tgz", - "integrity": "sha512-M2SGf0B/WmHYNxUhUWKIYI5NW4Si7cyokB6Lt3RtDof3WVHA8L0LLl+EEo1URUkpxH8/F8VH2fTES7ODm2c+7g==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.632.0.tgz", + "integrity": "sha512-QrG04Ss2/KDsvGmoBH9QHjaC/wx7Gf9U2F5o8gYbHVU5ZGDW+zMX2Sj/6jjSyZ4qLD4sxK7sRHwK+fYA21OQQA==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.635.0", - "@aws-sdk/client-sts": "3.635.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/client-sso-oidc": "3.632.0", + "@aws-sdk/client-sts": "3.632.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -321,7 +323,7 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/eventstream-serde-browser": "^3.0.6", "@smithy/eventstream-serde-config-resolver": "^3.0.3", "@smithy/eventstream-serde-node": "^3.0.5", @@ -330,20 +332,20 @@ "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -561,6 +563,45 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/core": { + "version": "3.635.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.635.0.tgz", + "integrity": "sha512-i1x/E/sgA+liUE1XJ7rj1dhyXpAKO1UKFUcTTHXok2ARjWTvszHnSXMOsB77aPbmn0fUp1JTx2kHUAZ1LVt5Bg==", + "dependencies": { + "@smithy/core": "^2.4.0", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/property-provider": "^3.1.3", + "@smithy/protocol-http": "^4.1.0", + "@smithy/signature-v4": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/util-middleware": "^3.0.3", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.635.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.635.0.tgz", + "integrity": "sha512-iJyRgEjOCQlBMXqtwPLIKYc7Bsc6nqjrZybdMDenPDa+kmLg7xh8LxHsu9088e+2/wtLicE34FsJJIfzu3L82g==", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/property-provider": "^3.1.3", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/util-stream": "^3.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/credential-provider-ini": { "version": "3.637.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.637.0.tgz", @@ -659,16 +700,16 @@ } }, "node_modules/@aws-sdk/client-iam": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.635.0.tgz", - "integrity": "sha512-sflTv6XcwO5UX+U9x31+T6TBEgVIzG61giLgRV51kkFGErni++GpxUcc6O1mpDwb3jpbntJf7QPjrkkj9wsTPA==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.632.0.tgz", + "integrity": "sha512-iwivASUliVxCEbT/mu5s03SCyqQKNXbJUpG17ywT4taA2xvLisGRI5iNV3OYT1qDmK9DOLMSJYpeX2GWCijPxw==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.635.0", - "@aws-sdk/client-sts": "3.635.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/client-sso-oidc": "3.632.0", + "@aws-sdk/client-sts": "3.632.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -679,26 +720,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -711,16 +752,16 @@ } }, "node_modules/@aws-sdk/client-kms": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.635.0.tgz", - "integrity": "sha512-H2qJVXiz3WbBQwtxqfEvuJ9pCKJdqEWkzQ8I4knkXbQkyy78GktfMwBWqFyw3eap/s9rQmsyXbuuhBIozbemOg==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.632.0.tgz", + "integrity": "sha512-uMm1fAIdImaBKwKXnpcD1cpRlTAbLisbRbNJqzJdH+snN0jAkukLNUMUheb0XKaczk7eQrp5w4inlWrRvEmjSA==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.635.0", - "@aws-sdk/client-sts": "3.635.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/client-sso-oidc": "3.632.0", + "@aws-sdk/client-sts": "3.632.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -731,26 +772,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -762,16 +803,16 @@ } }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.635.0.tgz", - "integrity": "sha512-taa+sa8xFym7ZYzybqkOVy5MAdedcIt2pKEVOReEaNkUuOwMUo+wF4QhJeyhaLPTs2l0rHR1bnwYOG+0fW0Kvg==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.632.0.tgz", + "integrity": "sha512-WsQhPHHK1yPfALcP1B7nBSGDzky6vFTUEXnUdfzb5Xy2cT+JTBTS6ChtQGqqOuGHDP/3t/9soqZ+L6rUCYBb/Q==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.635.0", - "@aws-sdk/client-sts": "3.635.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/client-sso-oidc": "3.632.0", + "@aws-sdk/client-sts": "3.632.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -782,26 +823,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -814,13 +855,13 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.635.0.tgz", - "integrity": "sha512-/Hl69+JpFUo9JNVmh2gSvMgYkE4xjd+1okiRoPBbQqjI7YBP2JWCUDP8IoEkNq3wj0vNTq0OWfn6RpZycIkAXQ==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.632.0.tgz", + "integrity": "sha512-iYWHiKBz44m3chCFvtvHnvCpL2rALzyr1e6tOZV3dLlOKtQtDUlPy6OtnXDu4y+wyJCniy8ivG3+LAe4klzn1Q==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.635.0", + "@aws-sdk/core": "3.629.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -831,26 +872,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -862,14 +903,14 @@ } }, "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.635.0.tgz", - "integrity": "sha512-RIwDlhzAFttB1vbpznewnPqz7h1H/2UhQLwB38yfZBwYQOxyxVfLV5j5VoUUX3jY4i4qH9wiHc7b02qeAOZY6g==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz", + "integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -880,26 +921,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -910,19 +951,19 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.635.0" + "@aws-sdk/client-sts": "^3.632.0" } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.635.0.tgz", - "integrity": "sha512-Al2ytE69+cbA44qHlelqhzWwbURikfF13Zkal9utIG5Q6T2c7r8p6sePN92n8l/x1v0FhJ5VTxKak+cPTE0CZQ==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz", + "integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.635.0", - "@aws-sdk/core": "3.635.0", - "@aws-sdk/credential-provider-node": "3.635.0", + "@aws-sdk/client-sso-oidc": "3.632.0", + "@aws-sdk/core": "3.629.0", + "@aws-sdk/credential-provider-node": "3.632.0", "@aws-sdk/middleware-host-header": "3.620.0", "@aws-sdk/middleware-logger": "3.609.0", "@aws-sdk/middleware-recursion-detection": "3.620.0", @@ -933,26 +974,26 @@ "@aws-sdk/util-user-agent-browser": "3.609.0", "@aws-sdk/util-user-agent-node": "3.614.0", "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/hash-node": "^3.0.3", "@smithy/invalid-dependency": "^3.0.3", "@smithy/middleware-content-length": "^3.0.5", "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-retry": "^3.0.14", "@smithy/middleware-serde": "^3.0.3", "@smithy/middleware-stack": "^3.0.3", "@smithy/node-config-provider": "^3.1.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/url-parser": "^3.0.3", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.15", - "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-defaults-mode-browser": "^3.0.14", + "@smithy/util-defaults-mode-node": "^3.0.14", "@smithy/util-endpoints": "^2.0.5", "@smithy/util-middleware": "^3.0.3", "@smithy/util-retry": "^3.0.3", @@ -964,16 +1005,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.635.0.tgz", - "integrity": "sha512-i1x/E/sgA+liUE1XJ7rj1dhyXpAKO1UKFUcTTHXok2ARjWTvszHnSXMOsB77aPbmn0fUp1JTx2kHUAZ1LVt5Bg==", + "version": "3.629.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.629.0.tgz", + "integrity": "sha512-+/ShPU/tyIBM3oY1cnjgNA/tFyHtlWq+wXF9xEKRv19NOpYbWQ+xzNwVjGq8vR07cCRqy/sDQLWPhxjtuV/FiQ==", "dependencies": { - "@smithy/core": "^2.4.0", + "@smithy/core": "^2.3.2", "@smithy/node-config-provider": "^3.1.4", "@smithy/property-provider": "^3.1.3", "@smithy/protocol-http": "^4.1.0", "@smithy/signature-v4": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/util-middleware": "^3.0.3", "fast-xml-parser": "4.4.1", @@ -998,16 +1039,16 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.635.0.tgz", - "integrity": "sha512-iJyRgEjOCQlBMXqtwPLIKYc7Bsc6nqjrZybdMDenPDa+kmLg7xh8LxHsu9088e+2/wtLicE34FsJJIfzu3L82g==", + "version": "3.622.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.622.0.tgz", + "integrity": "sha512-VUHbr24Oll1RK3WR8XLUugLpgK9ZuxEm/NVeVqyFts1Ck9gsKpRg1x4eH7L7tW3SJ4TDEQNMbD7/7J+eoL2svg==", "dependencies": { "@aws-sdk/types": "3.609.0", "@smithy/fetch-http-handler": "^3.2.4", "@smithy/node-http-handler": "^3.1.4", "@smithy/property-provider": "^3.1.3", "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.2.0", + "@smithy/smithy-client": "^3.1.12", "@smithy/types": "^3.3.0", "@smithy/util-stream": "^3.1.3", "tslib": "^2.6.2" @@ -1017,14 +1058,14 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.635.0.tgz", - "integrity": "sha512-+OqcNhhOFFY08YHLjO9/Y1n37RKAO7LADnsJ7VTXca7IfvYh27BVBn+FdlqnyEb1MQ5ArHTY4pq3pKRIg6RW4Q==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.632.0.tgz", + "integrity": "sha512-m6epoW41xa1ajU5OiHcmQHoGVtrbXBaRBOUhlCLZmcaqMLYsboM4iD/WZP8aatKEON5tTnVXh/4StV8D/+wemw==", "dependencies": { "@aws-sdk/credential-provider-env": "3.620.1", - "@aws-sdk/credential-provider-http": "3.635.0", + "@aws-sdk/credential-provider-http": "3.622.0", "@aws-sdk/credential-provider-process": "3.620.1", - "@aws-sdk/credential-provider-sso": "3.635.0", + "@aws-sdk/credential-provider-sso": "3.632.0", "@aws-sdk/credential-provider-web-identity": "3.621.0", "@aws-sdk/types": "3.609.0", "@smithy/credential-provider-imds": "^3.2.0", @@ -1037,19 +1078,19 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.635.0" + "@aws-sdk/client-sts": "^3.632.0" } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.635.0.tgz", - "integrity": "sha512-bmd23mnb94S6AxmWPgqJTnvT9ONKlTx7EPafE1RNO+vUl6mHih4iyqX6ZPaRcSfaPx4U1R7H1RM8cSnafXgaBg==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.632.0.tgz", + "integrity": "sha512-cL8fuJWm/xQBO4XJPkeuZzl3XinIn9EExWgzpG48NRMKR5us1RI/ucv7xFbBBaG+r/sDR2HpYBIA3lVIpm1H3Q==", "dependencies": { "@aws-sdk/credential-provider-env": "3.620.1", - "@aws-sdk/credential-provider-http": "3.635.0", - "@aws-sdk/credential-provider-ini": "3.635.0", + "@aws-sdk/credential-provider-http": "3.622.0", + "@aws-sdk/credential-provider-ini": "3.632.0", "@aws-sdk/credential-provider-process": "3.620.1", - "@aws-sdk/credential-provider-sso": "3.635.0", + "@aws-sdk/credential-provider-sso": "3.632.0", "@aws-sdk/credential-provider-web-identity": "3.621.0", "@aws-sdk/types": "3.609.0", "@smithy/credential-provider-imds": "^3.2.0", @@ -1078,11 +1119,11 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.635.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.635.0.tgz", - "integrity": "sha512-hO/fKyvUaGpK9zyvCnmJz70EputvGWDr2UTOn/RzvcR6UB4yXoFf0QcCMubEsE3v67EsAv6PadgOeJ0vz6IazA==", + "version": "3.632.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.632.0.tgz", + "integrity": "sha512-P/4wB6j7ym5QCPTL2xlMfvf2NcXSh+z0jmsZP4WW/tVwab4hvgabPPbLeEZDSWZ0BpgtxKGvRq0GSHuGeirQbA==", "dependencies": { - "@aws-sdk/client-sso": "3.635.0", + "@aws-sdk/client-sso": "3.632.0", "@aws-sdk/token-providers": "3.614.0", "@aws-sdk/types": "3.609.0", "@smithy/property-provider": "^3.1.3", @@ -3725,6 +3766,60 @@ "node": ">=12" } }, + "node_modules/@elastic/elasticsearch": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-8.15.0.tgz", + "integrity": "sha512-mG90EMdTDoT6GFSdqpUAhWK9LGuiJo6tOWqs0Usd/t15mPQDj7ZqHXfCBqNkASZpwPZpbAYVjd57S6nbUBINCg==", + "license": "Apache-2.0", + "dependencies": { + "@elastic/transport": "^8.7.0", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@elastic/transport": { + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/@elastic/transport/-/transport-8.7.1.tgz", + "integrity": "sha512-2eeMVkz57Ayxv+UAZkIKzzrUu7nm96jr3+N3kLfbBqALYe2jwDpLr9pR0jc/x9HyJKAM909YGaNlHFDZeb0+Mw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "1.x", + "debug": "^4.3.4", + "hpagent": "^1.0.0", + "ms": "^2.1.3", + "secure-json-parse": "^2.4.0", + "tslib": "^2.4.0", + "undici": "^6.12.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@elastic/transport/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@elastic/transport/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -5221,6 +5316,15 @@ "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz", "integrity": "sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@peculiar/asn1-cms": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", @@ -11371,12 +11475,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, "node_modules/google-auth-library": { "version": "9.9.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.9.0.tgz", @@ -11668,6 +11766,15 @@ "node": ">= 6" } }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -13129,12 +13236,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -15643,6 +15750,34 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" }, + "node_modules/scim-patch": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/scim-patch/-/scim-patch-0.8.3.tgz", + "integrity": "sha512-3d0wD4THAt03Zgi08kCQwc+lvPJ2v4wwk41b0xViVa4gLYSgRUCmGkJNBsaE+yoKg0fufTOJCcSrufZaqYn/og==", + "dependencies": { + "@types/node": "^22.0.0", + "fast-deep-equal": "3.1.3", + "scim2-parse-filter": "0.2.10" + } + }, + "node_modules/scim-patch/node_modules/@types/node": { + "version": "22.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", + "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/scim-patch/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/scim2-parse-filter": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/scim2-parse-filter/-/scim2-parse-filter-0.2.10.tgz", + "integrity": "sha512-k5TgGSuQEbR4jXRgw/GPAYVL9fMp1pWA2abLF5z3q9IGWSuZTqbrZBOSUezvc+rtViXr+czSZjg3eAN4QSTvxQ==" + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -16721,26 +16856,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/tsconfck": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.1.2.tgz", - "integrity": "sha512-ghqN1b0puy3MhhviwO2kGF8SeMDNhEbnKxjK7h6+fvY9JAxqvXi8y5NAHSQv687OVboS2uZIByzGd45/YxrRHg==", - "dev": true, - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^14.13.1 || ^16 || >=18" - }, - "peerDependencies": { - "typescript": "^4.3.5 || ^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -17560,6 +17675,15 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/undici": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -17897,48 +18021,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/vite-tsconfig-paths": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.2.2.tgz", - "integrity": "sha512-dq0FjyxHHDnp0uS3P12WEOX2W7NeuLzX9AWP38D7Zw2CTbFErapwQVlCiT5DMJcVWKQ1MMdTe92PZl/rBQ7qcw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "globrex": "^0.1.2", - "tsconfck": "^2.1.0" - }, - "peerDependencies": { - "vite": "*" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/vite-tsconfig-paths/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vite-tsconfig-paths/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/vite/node_modules/@esbuild/android-arm": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", diff --git a/backend/package.json b/backend/package.json index dd81bd579..e1e624b6a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,7 @@ "migration:down": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:down", "migration:list": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:list", "migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest", + "migration:status": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:status", "migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback", "seed:new": "tsx ./scripts/create-seed-file.ts", "seed": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", @@ -102,7 +103,6 @@ "tsup": "^8.0.1", "tsx": "^4.4.0", "typescript": "^5.3.2", - "vite-tsconfig-paths": "^4.2.2", "vitest": "^1.2.2" }, "dependencies": { @@ -112,6 +112,7 @@ "@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-sts": "^3.600.0", "@casl/ability": "^6.5.0", + "@elastic/elasticsearch": "^8.15.0", "@fastify/cookie": "^9.3.1", "@fastify/cors": "^8.5.0", "@fastify/etag": "^5.1.0", @@ -178,6 +179,8 @@ "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", + "scim-patch": "^0.8.3", + "scim2-parse-filter": "^0.2.10", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", diff --git a/backend/src/db/migrations/20240702131735_secret-approval-groups.ts b/backend/src/db/migrations/20240702131735_secret-approval-groups.ts index 537230b47..84824ac65 100644 --- a/backend/src/db/migrations/20240702131735_secret-approval-groups.ts +++ b/backend/src/db/migrations/20240702131735_secret-approval-groups.ts @@ -115,7 +115,14 @@ export async function down(knex: Knex): Promise { // eslint-disable-next-line // @ts-ignore because generate schema happens after this approverId: knex(TableName.ProjectMembership) - .select("id") + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyApprover}.policyId` + ) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretApprovalPolicy}.envId`) + .select(knex.ref("id").withSchema(TableName.ProjectMembership)) + .where(`${TableName.ProjectMembership}.projectId`, knex.raw("??", [`${TableName.Environment}.projectId`])) .where("userId", knex.raw("??", [`${TableName.SecretApprovalPolicyApprover}.approverUserId`])) }); await knex.schema.alterTable(TableName.SecretApprovalPolicyApprover, (tb) => { @@ -147,13 +154,27 @@ export async function down(knex: Knex): Promise { // eslint-disable-next-line // @ts-ignore because generate schema happens after this committerId: knex(TableName.ProjectMembership) - .select("id") - .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.committerUserId`])), + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalRequest}.policyId` + ) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretApprovalPolicy}.envId`) + .where(`${TableName.ProjectMembership}.projectId`, knex.raw("??", [`${TableName.Environment}.projectId`])) + .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.committerUserId`])) + .select(knex.ref("id").withSchema(TableName.ProjectMembership)), // eslint-disable-next-line // @ts-ignore because generate schema happens after this statusChangeBy: knex(TableName.ProjectMembership) - .select("id") + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalRequest}.policyId` + ) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretApprovalPolicy}.envId`) + .where(`${TableName.ProjectMembership}.projectId`, knex.raw("??", [`${TableName.Environment}.projectId`])) .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequest}.statusChangedByUserId`])) + .select(knex.ref("id").withSchema(TableName.ProjectMembership)) }); await knex.schema.alterTable(TableName.SecretApprovalRequest, (tb) => { @@ -177,8 +198,20 @@ export async function down(knex: Knex): Promise { // eslint-disable-next-line // @ts-ignore because generate schema happens after this member: knex(TableName.ProjectMembership) - .select("id") + .join( + TableName.SecretApprovalRequest, + `${TableName.SecretApprovalRequest}.id`, + `${TableName.SecretApprovalRequestReviewer}.requestId` + ) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalRequest}.policyId` + ) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretApprovalPolicy}.envId`) + .where(`${TableName.ProjectMembership}.projectId`, knex.raw("??", [`${TableName.Environment}.projectId`])) .where("userId", knex.raw("??", [`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`])) + .select(knex.ref("id").withSchema(TableName.ProjectMembership)) }); await knex.schema.alterTable(TableName.SecretApprovalRequestReviewer, (tb) => { tb.uuid("member").notNullable().alter(); diff --git a/backend/src/db/migrations/20240806083221_secret-sharing-password.ts b/backend/src/db/migrations/20240806083221_secret-sharing-password.ts new file mode 100644 index 000000000..7e0f5f30f --- /dev/null +++ b/backend/src/db/migrations/20240806083221_secret-sharing-password.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesPasswordExist = await knex.schema.hasColumn(TableName.SecretSharing, "password"); + if (!doesPasswordExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("password").nullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesPasswordExist = await knex.schema.hasColumn(TableName.SecretSharing, "password"); + if (doesPasswordExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("password"); + }); + } + } +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index de75fbafc..be5643e2b 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -21,6 +21,7 @@ export const SecretSharingSchema = z.object({ expiresAfterViews: z.number().nullable().optional(), accessType: z.string().default("anyone"), name: z.string().nullable().optional(), + password: z.string().nullable().optional(), lastViewedAt: z.date().nullable().optional() }); diff --git a/backend/src/ee/routes/v1/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index fbf1af43b..b19faaf70 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -// TODO(akhilmhdh): Fix this when licence service gets it type +// TODO(akhilmhdh): Fix this when license service gets it type import { z } from "zod"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 45e89de4a..427c77fa7 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -5,22 +5,47 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -export const registerScimRouter = async (server: FastifyZodProvider) => { - server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { - try { - const strBody = body instanceof Buffer ? body.toString() : body; - if (!strBody) { - done(null, undefined); - return; - } - const json: unknown = JSON.parse(strBody); - done(null, json); - } catch (err) { - const error = err as Error; - done(error, undefined); - } - }); +const ScimUserSchema = z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), + displayName: z.string().trim(), + active: z.boolean() +}); +const ScimGroupSchema = z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z + .array( + z.object({ + value: z.string(), + display: z.string().optional() + }) + ) + .optional(), + meta: z.object({ + resourceType: z.string().trim() + }) +}); + +export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/scim-tokens", method: "POST", @@ -127,25 +152,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - Resources: z.array( - z.object({ - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean() - }) - ), + Resources: z.array(ScimUserSchema), itemsPerPage: z.number(), schemas: z.array(z.string()), startIndex: z.number(), @@ -173,30 +180,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { orgMembershipId: z.string().trim() }), response: { - 201: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean(), - groups: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim() - }) - ) - }) + 200: ScimUserSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -216,10 +200,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { body: z.object({ schemas: z.array(z.string()), userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), emails: z .array( z.object({ @@ -229,28 +215,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }) ) .optional(), - // displayName: z.string().trim(), - active: z.boolean() + active: z.boolean().default(true) }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string().email(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean() - }) + 200: ScimUserSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -260,8 +228,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const user = await req.server.services.scim.createScimUser({ externalId: req.body.userName, email: primaryEmail, - firstName: req.body.name.givenName, - lastName: req.body.name.familyName, + firstName: req.body?.name?.givenName, + lastName: req.body?.name?.familyName, orgId: req.permission.orgId }); @@ -291,6 +259,116 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/Users/:orgMembershipId", + method: "PUT", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), + displayName: z.string().trim(), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), + active: z.boolean() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; + const user = await req.server.services.scim.replaceScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId, + firstName: req.body?.name?.givenName, + lastName: req.body?.name?.familyName, + active: req.body?.active, + email: primaryEmail, + externalId: req.body.userName + }); + return user; + } + }); + + server.route({ + url: "/Users/:orgMembershipId", + method: "PATCH", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.union([ + z.object({ + op: z.union([z.literal("remove"), z.literal("Remove")]), + path: z.string().trim(), + value: z + .object({ + value: z.string() + }) + .array() + .optional() + }), + z.object({ + op: z.union([z.literal("add"), z.literal("Add"), z.literal("replace"), z.literal("Replace")]), + path: z.string().trim().optional(), + value: z.any().optional() + }) + ]) + ) + }), + response: { + 200: ScimUserSchema + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.updateScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId, + operations: req.body.Operations + }); + + return user; + } + }); server.route({ url: "/Groups", method: "POST", @@ -305,25 +383,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { display: z.string() }) ) - .optional() // okta-specific + .optional() }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - displayName: z.string().trim(), - members: z - .array( - z.object({ - value: z.string(), - display: z.string() - }) - ) - .optional(), - meta: z.object({ - resourceType: z.string().trim() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -344,26 +407,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { querystring: z.object({ startIndex: z.coerce.number().default(1), count: z.coerce.number().default(20), - filter: z.string().trim().optional() + filter: z.string().trim().optional(), + excludedAttributes: z.string().trim().optional() }), response: { 200: z.object({ - Resources: z.array( - z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - displayName: z.string().trim(), - members: z.array( - z.object({ - value: z.string(), - display: z.string() - }) - ), - meta: z.object({ - resourceType: z.string().trim() - }) - }) - ), + Resources: z.array(ScimGroupSchema), itemsPerPage: z.number(), schemas: z.array(z.string()), startIndex: z.number(), @@ -377,7 +426,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { orgId: req.permission.orgId, startIndex: req.query.startIndex, filter: req.query.filter, - limit: req.query.count + limit: req.query.count, + isMembersExcluded: req.query.excludedAttributes === "members" }); return groups; @@ -392,20 +442,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { groupId: z.string().trim() }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - displayName: z.string().trim(), - members: z.array( - z.object({ - value: z.string(), - display: z.string() - }) - ), - meta: z.object({ - resourceType: z.string().trim() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -414,6 +451,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { groupId: req.params.groupId, orgId: req.permission.orgId }); + return group; } }); @@ -437,25 +475,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { ) }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - displayName: z.string().trim(), - members: z.array( - z.object({ - value: z.string(), - display: z.string() - }) - ), - meta: z.object({ - resourceType: z.string().trim() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const group = await req.server.services.scim.updateScimGroupNamePut({ + const group = await req.server.services.scim.replaceScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId, ...req.body @@ -476,55 +501,35 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { schemas: z.array(z.string()), Operations: z.array( z.union([ - z.object({ - op: z.union([z.literal("replace"), z.literal("Replace")]), - value: z.object({ - id: z.string().trim(), - displayName: z.string().trim() - }) - }), z.object({ op: z.union([z.literal("remove"), z.literal("Remove")]), - path: z.string().trim() + path: z.string().trim(), + value: z + .object({ + value: z.string() + }) + .array() + .optional() }), z.object({ - op: z.union([z.literal("add"), z.literal("Add")]), - path: z.string().trim(), - value: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim().optional() - }) - ) + op: z.union([z.literal("add"), z.literal("Add"), z.literal("replace"), z.literal("Replace")]), + path: z.string().trim().optional(), + value: z.any() }) ]) ) }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - displayName: z.string().trim(), - members: z.array( - z.object({ - value: z.string(), - display: z.string() - }) - ), - meta: z.object({ - resourceType: z.string().trim() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const group = await req.server.services.scim.updateScimGroupNamePatch({ + const group = await req.server.services.scim.updateScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId, operations: req.body.Operations }); - return group; } }); @@ -550,60 +555,4 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { return group; } }); - - server.route({ - url: "/Users/:orgMembershipId", - method: "PUT", - schema: { - params: z.object({ - orgMembershipId: z.string().trim() - }), - body: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - displayName: z.string().trim(), - active: z.boolean() - }), - response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string().email(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean(), - groups: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim() - }) - ) - }) - } - }, - onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), - handler: async (req) => { - const user = await req.server.services.scim.replaceScimUser({ - orgMembershipId: req.params.orgMembershipId, - orgId: req.permission.orgId, - active: req.body.active - }); - return user; - } - }); }; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index 249e239c5..bcb76e6a5 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -5,6 +5,7 @@ import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, stripUndefinedInWhere } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TAuditLogDALFactory = ReturnType; @@ -62,7 +63,9 @@ export const auditLogDALFactory = (db: TDbClient) => { const today = new Date(); let deletedAuditLogIds: { id: string }[] = []; let numberOfRetryOnFailure = 0; + let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); do { try { const findExpiredLogSubQuery = (tx || db)(TableName.AuditLog) @@ -84,7 +87,9 @@ export const auditLogDALFactory = (db: TDbClient) => { setTimeout(resolve, 10); // time to breathe for db }); } - } while (deletedAuditLogIds.length > 0 || numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE); + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; return { ...auditLogOrm, pruneAuditLog, find }; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts new file mode 100644 index 000000000..2ead58bef --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -0,0 +1,126 @@ +import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; + +const generatePassword = () => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 64)(); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const ElasticSearchDatabaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const appCfg = getConfig(); + const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not + + const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); + if ( + isCloud && + // localhost + // internal ips + (providerInputs.host === "host.docker.internal" || + providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || + providerInputs.host.match(/^192\.168\.\d+\.\d+/)) + ) { + throw new BadRequestError({ message: "Invalid db host" }); + } + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { + throw new BadRequestError({ message: "Invalid db host" }); + } + + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const connection = new ElasticSearchClient({ + node: { + url: new URL(`${providerInputs.host}:${providerInputs.port}`), + ...(providerInputs.ca && { + ssl: { + rejectUnauthorized: false, + ca: providerInputs.ca + } + }) + }, + auth: { + ...(providerInputs.auth.type === ElasticSearchAuthTypes.ApiKey + ? { + apiKey: { + api_key: providerInputs.auth.apiKey, + id: providerInputs.auth.apiKeyId + } + } + : { + username: providerInputs.auth.username, + password: providerInputs.auth.password + }) + } + }); + + return connection; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const infoResponse = await connection + .info() + .then(() => true) + .catch(() => false); + + return infoResponse; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + + await connection.security.putUser({ + username, + password, + full_name: "Managed by Infisical.com", + roles: providerInputs.roles + }); + + await connection.close(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + await connection.security.deleteUser({ + username: entityId + }); + + await connection.close(); + return { entityId }; + }; + + const renew = async (inputs: unknown, entityId: string) => { + // Do nothing + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 1ee020625..23769df32 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,7 +1,9 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; +import { ElasticSearchDatabaseProvider } from "./elastic-search"; import { DynamicSecretProviders } from "./models"; +import { MongoAtlasProvider } from "./mongo-atlas"; import { RedisDatabaseProvider } from "./redis"; import { SqlDatabaseProvider } from "./sql-database"; @@ -10,5 +12,7 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), [DynamicSecretProviders.Redis]: RedisDatabaseProvider(), - [DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider() + [DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider(), + [DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(), + [DynamicSecretProviders.ElasticSearch]: ElasticSearchDatabaseProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 60cab6f80..06069c981 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -7,6 +7,11 @@ export enum SqlProviders { MsSQL = "mssql" } +export enum ElasticSearchAuthTypes { + User = "user", + ApiKey = "api-key" +} + export const DynamicSecretRedisDBSchema = z.object({ host: z.string().trim().toLowerCase(), port: z.number(), @@ -30,6 +35,28 @@ export const DynamicSecretAwsElastiCacheSchema = z.object({ ca: z.string().optional() }); +export const DynamicSecretElasticSearchSchema = z.object({ + host: z.string().trim().min(1), + port: z.number(), + roles: z.array(z.string().trim().min(1)).min(1), + + // two auth types "user, apikey" + auth: z.discriminatedUnion("type", [ + z.object({ + type: z.literal(ElasticSearchAuthTypes.User), + username: z.string().trim(), + password: z.string().trim() + }), + z.object({ + type: z.literal(ElasticSearchAuthTypes.ApiKey), + apiKey: z.string().trim(), + apiKeyId: z.string().trim() + }) + ]), + + ca: z.string().optional() +}); + export const DynamicSecretSqlDBSchema = z.object({ client: z.nativeEnum(SqlProviders), host: z.string().trim().toLowerCase(), @@ -67,12 +94,51 @@ export const DynamicSecretAwsIamSchema = z.object({ policyArns: z.string().trim().optional() }); +export const DynamicSecretMongoAtlasSchema = z.object({ + adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"), + adminPrivateKey: z.string().trim().min(1).describe("Admin user private api key"), + groupId: z + .string() + .trim() + .min(1) + .describe("Unique 24-hexadecimal digit string that identifies your project. This is same as project id"), + roles: z + .object({ + collectionName: z.string().optional().describe("Collection on which this role applies."), + databaseName: z.string().min(1).describe("Database to which the user is granted access privileges."), + roleName: z + .string() + .min(1) + .describe( + ' Enum: "atlasAdmin" "backup" "clusterMonitor" "dbAdmin" "dbAdminAnyDatabase" "enableSharding" "read" "readAnyDatabase" "readWrite" "readWriteAnyDatabase" "".Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role.' + ) + }) + .array() + .min(1), + scopes: z + .object({ + name: z + .string() + .min(1) + .describe( + "Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access." + ), + type: z + .string() + .min(1) + .describe("Category of resource that this database user can access. Enum: CLUSTER, DATA_LAKE, STREAM") + }) + .array() +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", AwsIam = "aws-iam", Redis = "redis", - AwsElastiCache = "aws-elasticache" + AwsElastiCache = "aws-elasticache", + MongoAtlas = "mongo-db-atlas", + ElasticSearch = "elastic-search" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -80,7 +146,9 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }), z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }), - z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }) + z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }), + z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }), + z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts new file mode 100644 index 000000000..69f54ce77 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -0,0 +1,146 @@ +import axios, { AxiosError } from "axios"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const MongoAtlasProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretMongoAtlasSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = axios.create({ + baseURL: "https://cloud.mongodb.com/api/atlas", + headers: { + Accept: "application/vnd.atlas.2023-02-01+json", + "Content-Type": "application/json" + } + }); + const digestAuth = createDigestAuthRequestInterceptor( + client, + providerInputs.adminPublicKey, + providerInputs.adminPrivateKey + ); + return digestAuth; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client({ + method: "GET", + url: `v2/groups/${providerInputs.groupId}/databaseUsers`, + params: { itemsPerPage: 1 } + }) + .then(() => true) + .catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + return isConnected; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + await client({ + method: "POST", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers`, + data: { + roles: providerInputs.roles, + scopes: providerInputs.scopes, + deleteAfterDate: expiration, + username, + password, + databaseName: "admin", + groupId: providerInputs.groupId + } + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const isExisting = await client({ + method: "GET", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` + }).catch((err) => { + if ((err as AxiosError).response?.status === 404) return false; + throw err; + }); + if (isExisting) { + await client({ + method: "DELETE", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + } + + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + + await client({ + method: "PATCH", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`, + data: { + deleteAfterDate: expiration, + databaseName: "admin", + groupId: providerInputs.groupId + } + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 23eb454c5..1338e62e3 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import handlebars from "handlebars"; import { Redis } from "ioredis"; import { customAlphabet } from "nanoid"; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts similarity index 89% rename from backend/src/ee/services/license/__mocks__/licence-fns.ts rename to backend/src/ee/services/license/__mocks__/license-fns.ts index a8b3b351d..360b39f28 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -26,8 +26,10 @@ export const getDefaultOnPremFeatures = () => { status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: true, caCrl: false }; }; + +export const setupLicenseRequestWithStore = () => {}; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/license-fns.ts similarity index 84% rename from backend/src/ee/services/license/licence-fns.ts rename to backend/src/ee/services/license/license-fns.ts index beb981a5a..fa67b72d1 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -49,15 +49,15 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ pkiEst: false }); -export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { +export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { let token: string; - const licenceReq = axios.create({ + const licenseReq = axios.create({ baseURL, timeout: 35 * 1000 // signal: AbortSignal.timeout(60 * 1000) }); - const refreshLicence = async () => { + const refreshLicense = async () => { const appCfg = getConfig(); const { data: { token: authToken } @@ -75,7 +75,7 @@ export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string return token; }; - licenceReq.interceptors.request.use( + licenseReq.interceptors.request.use( (config) => { if (token && config.headers) { // eslint-disable-next-line no-param-reassign @@ -86,7 +86,7 @@ export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string (err) => Promise.reject(err) ); - licenceReq.interceptors.response.use( + licenseReq.interceptors.response.use( (response) => response, async (err) => { const originalRequest = (err as AxiosError).config; @@ -97,15 +97,15 @@ export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string (originalRequest as any)._retry = true; // injected // refresh - await refreshLicence(); + await refreshLicense(); - licenceReq.defaults.headers.common.Authorization = `Bearer ${token}`; - return licenceReq(originalRequest!); + licenseReq.defaults.headers.common.Authorization = `Bearer ${token}`; + return licenseReq(originalRequest!); } return Promise.reject(err); } ); - return { request: licenceReq, refreshLicence }; + return { request: licenseReq, refreshLicense }; }; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index f5360bbdf..28a9af445 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -16,8 +16,8 @@ import { TOrgDALFactory } from "@app/services/org/org-dal"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { getDefaultOnPremFeatures, setupLicenceRequestWithStore } from "./licence-fns"; import { TLicenseDALFactory } from "./license-dal"; +import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns"; import { InstanceType, TAddOrgPmtMethodDTO, @@ -64,13 +64,13 @@ export const licenseServiceFactory = ({ let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); const appCfg = getConfig(); - const licenseServerCloudApi = setupLicenceRequestWithStore( + const licenseServerCloudApi = setupLicenseRequestWithStore( appCfg.LICENSE_SERVER_URL || "", LICENSE_SERVER_CLOUD_LOGIN, appCfg.LICENSE_SERVER_KEY || "" ); - const licenseServerOnPremApi = setupLicenceRequestWithStore( + const licenseServerOnPremApi = setupLicenseRequestWithStore( appCfg.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, appCfg.LICENSE_KEY || "" @@ -79,7 +79,7 @@ export const licenseServiceFactory = ({ const init = async () => { try { if (appCfg.LICENSE_SERVER_KEY) { - const token = await licenseServerCloudApi.refreshLicence(); + const token = await licenseServerCloudApi.refreshLicense(); if (token) instanceType = InstanceType.Cloud; logger.info(`Instance type: ${InstanceType.Cloud}`); isValidLicense = true; @@ -87,7 +87,7 @@ export const licenseServiceFactory = ({ } if (appCfg.LICENSE_KEY) { - const token = await licenseServerOnPremApi.refreshLicence(); + const token = await licenseServerOnPremApi.refreshLicense(); if (token) { const { data: { currentPlan } diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index b7454dc90..3ade1a117 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -44,19 +44,18 @@ export const buildScimUser = ({ email, firstName, lastName, - groups = [], - active + active, + createdAt, + updatedAt }: { orgMembershipId: string; username: string; email?: string | null; firstName: string | null | undefined; lastName: string | null | undefined; - groups?: { - value: string; - display: string; - }[]; active: boolean; + createdAt: Date; + updatedAt: Date; }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -78,10 +77,10 @@ export const buildScimUser = ({ ] : [], active, - groups, meta: { resourceType: "User", - location: null + created: createdAt, + lastModified: updatedAt } }; @@ -109,14 +108,18 @@ export const buildScimGroupList = ({ export const buildScimGroup = ({ groupId, name, - members + members, + updatedAt, + createdAt }: { groupId: string; name: string; members: { value: string; - display: string; + display?: string; }[]; + createdAt: Date; + updatedAt: Date; }): TScimGroup => { const scimGroup = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], @@ -125,7 +128,8 @@ export const buildScimGroup = ({ members, meta: { resourceType: "Group", - location: null + created: createdAt, + lastModified: updatedAt } }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 0cb9e3e82..9352d09cc 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; +import { scimPatch } from "scim-patch"; import { OrgMembershipRole, OrgMembershipStatus, TableName, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -9,7 +10,6 @@ import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-grou import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; -import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; import { AuthTokenType } from "@app/services/auth/auth-type"; @@ -32,14 +32,7 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { - buildScimGroup, - buildScimGroupList, - buildScimUser, - buildScimUserList, - extractScimValueFromPath, - parseScimFilter -} from "./scim-fns"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns"; import { TCreateScimGroupDTO, TCreateScimTokenDTO, @@ -64,12 +57,18 @@ type TScimServiceFactoryDep = { scimDAL: Pick; userDAL: Pick< TUserDALFactory, - "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" | "updateById" >; - userAliasDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" + | "createMembership" + | "findById" + | "findMembership" + | "findMembershipWithScimFilter" + | "deleteMembershipById" + | "transaction" + | "updateMembershipById" >; orgMembershipDAL: Pick; projectDAL: Pick; @@ -193,7 +192,12 @@ export const scimServiceFactory = ({ }; // SCIM server endpoints - const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { + const listScimUsers = async ({ + startIndex = 0, + limit = 100, + filter, + orgId + }: TListScimUsersDTO): Promise => { const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -207,23 +211,20 @@ export const scimServiceFactory = ({ ...(limit && { limit }) }; - const users = await orgDAL.findMembership( - { - [`${TableName.OrgMembership}.orgId` as "id"]: orgId, - ...parseScimFilter(filter) - }, - findOpts - ); + const users = await orgDAL.findMembershipWithScimFilter(orgId, filter, findOpts); - const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email, isActive }) => - buildScimUser({ - orgMembershipId: id ?? "", - username: externalId ?? username, - firstName: firstName ?? "", - lastName: lastName ?? "", - email, - active: isActive - }) + const scimUsers = users.map( + ({ id, externalId, username, firstName, lastName, email, isActive, createdAt, updatedAt }) => + buildScimUser({ + orgMembershipId: id ?? "", + username: externalId ?? username, + firstName: firstName ?? "", + lastName: lastName ?? "", + email, + active: isActive, + createdAt, + updatedAt + }) ); return buildScimUserList({ @@ -258,11 +259,6 @@ export const scimServiceFactory = ({ status: 403 }); - const groupMembershipsInOrg = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg( - membership.userId, - orgId - ); - return buildScimUser({ orgMembershipId: membership.id, username: membership.externalId ?? membership.username, @@ -270,10 +266,8 @@ export const scimServiceFactory = ({ firstName: membership.firstName, lastName: membership.lastName, active: membership.isActive, - groups: groupMembershipsInOrg.map((group) => ({ - value: group.groupId, - display: group.groupName - })) + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; @@ -322,7 +316,7 @@ export const scimServiceFactory = ({ userId: userAlias.userId, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role: OrgMembershipRole.NoAccess, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, @@ -349,7 +343,11 @@ export const scimServiceFactory = ({ } if (!user) { - const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + const uniqueUsername = await normalizeUsername( + // external id is username + `${firstName}-${lastName}`, + userDAL + ); user = await userDAL.create( { username: serverCfg.trustSamlEmails ? email : uniqueUsername, @@ -430,10 +428,13 @@ export const scimServiceFactory = ({ firstName: createdUser.firstName, lastName: createdUser.lastName, email: createdUser.email ?? "", - active: createdOrgMembership.isActive + active: createdOrgMembership.isActive, + createdAt: createdOrgMembership.createdAt, + updatedAt: createdOrgMembership.updatedAt }); }; + // partial const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { const [membership] = await orgDAL .findMembership({ @@ -459,37 +460,52 @@ export const scimServiceFactory = ({ status: 403 }); - let active = true; - - operations.forEach((operation) => { - if (operation.op.toLowerCase() === "replace") { - if (operation.path === "active" && operation.value === "False") { - // azure scim op format - active = false; - } else if (typeof operation.value === "object" && operation.value.active === false) { - // okta scim op format - active = false; - } - } - }); - - if (!active) { - await orgMembershipDAL.updateById(membership.id, { - isActive: false - }); - } - - return buildScimUser({ + const scimUser = buildScimUser({ orgMembershipId: membership.id, - username: membership.externalId ?? membership.username, email: membership.email, - firstName: membership.firstName, lastName: membership.lastName, - active + firstName: membership.firstName, + active: membership.isActive, + username: membership.externalId ?? membership.username, + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); + scimPatch(scimUser, operations); + + const serverCfg = await getServerCfg(); + await userDAL.transaction(async (tx) => { + await orgMembershipDAL.updateById( + membership.id, + { + isActive: scimUser.active + }, + tx + ); + const hasEmailChanged = scimUser.emails[0].value !== membership.email; + await userDAL.updateById( + membership.userId, + { + firstName: scimUser.name.givenName, + email: scimUser.emails[0].value, + lastName: scimUser.name.familyName, + isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true + }, + tx + ); + }); + + return scimUser; }; - const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { + const replaceScimUser = async ({ + orgMembershipId, + active, + orgId, + lastName, + firstName, + email, + externalId + }: TReplaceScimUserDTO) => { const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -514,26 +530,47 @@ export const scimServiceFactory = ({ status: 403 }); - await orgMembershipDAL.updateById(membership.id, { - isActive: active + const serverCfg = await getServerCfg(); + await userDAL.transaction(async (tx) => { + await userAliasDAL.update( + { + orgId, + aliasType: UserAliasType.SAML, + userId: membership.userId + }, + { + externalId + }, + tx + ); + await orgMembershipDAL.updateById( + membership.id, + { + isActive: active + }, + tx + ); + await userDAL.updateById( + membership.userId, + { + firstName, + email, + lastName, + isEmailVerified: serverCfg.trustSamlEmails + }, + tx + ); }); - const groupMembershipsInOrg = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg( - membership.userId, - orgId - ); - return buildScimUser({ orgMembershipId: membership.id, - username: membership.externalId ?? membership.username, + username: externalId, email: membership.email, firstName: membership.firstName, lastName: membership.lastName, active, - groups: groupMembershipsInOrg.map((group) => ({ - value: group.groupId, - display: group.groupName - })) + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; @@ -570,7 +607,7 @@ export const scimServiceFactory = ({ return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, startIndex, limit, filter }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit, filter, isMembersExcluded }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -603,6 +640,21 @@ export const scimServiceFactory = ({ ); const scimGroups: TScimGroup[] = []; + if (isMembersExcluded) { + return buildScimGroupList({ + scimGroups: groups.map((group) => + buildScimGroup({ + groupId: group.id, + name: group.name, + members: [], + createdAt: group.createdAt, + updatedAt: group.updatedAt + }) + ), + startIndex, + limit + }); + } for await (const group of groups) { const members = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); @@ -612,7 +664,9 @@ export const scimServiceFactory = ({ members: members.map((member) => ({ value: member.orgMembershipId, display: `${member.firstName ?? ""} ${member.lastName ?? ""}` - })) + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt }); scimGroups.push(scimGroup); } @@ -696,7 +750,9 @@ export const scimServiceFactory = ({ members: orgMemberships.map(({ id, firstName, lastName }) => ({ value: id, display: `${firstName} ${lastName}` - })) + })), + createdAt: newGroup.group.createdAt, + updatedAt: newGroup.group.updatedAt }); }; @@ -739,31 +795,17 @@ export const scimServiceFactory = ({ members: orgMemberships.map(({ id, firstName, lastName }) => ({ value: id, display: `${firstName} ${lastName}` - })) + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt }); }; - const updateScimGroupNamePut = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => { - const plan = await licenseService.getPlan(orgId); - if (!plan.groups) - throw new BadRequestError({ - message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." - }); - - const org = await orgDAL.findById(orgId); - if (!org) { - throw new ScimRequestError({ - detail: "Organization Not Found", - status: 404 - }); - } - - if (!org.scimEnabled) - throw new ScimRequestError({ - detail: "SCIM is disabled for the organization", - status: 403 - }); - + const $replaceGroupDAL = async ( + groupId: string, + orgId: string, + { displayName, members = [] }: { displayName: string; members: { value: string }[] } + ) => { const updatedGroup = await groupDAL.transaction(async (tx) => { const [group] = await groupDAL.update( { @@ -782,74 +824,96 @@ export const scimServiceFactory = ({ }); } - if (members) { - const orgMemberships = await orgMembershipDAL.find({ - $in: { - id: members.map((member) => member.value) - } + const orgMemberships = members.length + ? await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }) + : []; + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); + const userGroupMembers = await userGroupMembershipDAL.find({ + groupId: group.id + }); + const directMemberUserIds = userGroupMembers.filter((el) => !el.isPending).map((membership) => membership.userId); + + const pendingGroupAdditionsUserIds = userGroupMembers + .filter((el) => el.isPending) + .map((pendingGroupAddition) => pendingGroupAddition.userId); + + const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); + const allMembersUserIdsSet = new Set(allMembersUserIds); + + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); + const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); + + if (toAddUserIds.length) { + await addUsersToGroupByUserIds({ + group, + userIds: toAddUserIds.map((member) => member.userId as string), + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx }); + } - const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); - - const directMemberUserIds = ( - await userGroupMembershipDAL.find({ - groupId: group.id, - isPending: false - }) - ).map((membership) => membership.userId); - - const pendingGroupAdditionsUserIds = ( - await userGroupMembershipDAL.find({ - groupId: group.id, - isPending: true - }) - ).map((pendingGroupAddition) => pendingGroupAddition.userId); - - const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); - const allMembersUserIdsSet = new Set(allMembersUserIds); - - const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); - const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); - - if (toAddUserIds.length) { - await addUsersToGroupByUserIds({ - group, - userIds: toAddUserIds.map((member) => member.userId as string), - userDAL, - userGroupMembershipDAL, - orgDAL, - groupProjectDAL, - projectKeyDAL, - projectDAL, - projectBotDAL, - tx - }); - } - - if (toRemoveUserIds.length) { - await removeUsersFromGroupByUserIds({ - group, - userIds: toRemoveUserIds, - userDAL, - userGroupMembershipDAL, - groupProjectDAL, - projectKeyDAL, - tx - }); - } + if (toRemoveUserIds.length) { + await removeUsersFromGroupByUserIds({ + group, + userIds: toRemoveUserIds, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx + }); } return group; }); + return updatedGroup; + }; + + const replaceScimGroup = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." + }); + + const org = await orgDAL.findById(orgId); + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const updatedGroup = await $replaceGroupDAL(groupId, orgId, { displayName, members }); + return buildScimGroup({ groupId: updatedGroup.id, name: updatedGroup.name, - members + members, + updatedAt: updatedGroup.updatedAt, + createdAt: updatedGroup.createdAt }); }; - const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { + const updateScimGroup = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -871,7 +935,7 @@ export const scimServiceFactory = ({ status: 403 }); - let group = await groupDAL.findOne({ + const group = await groupDAL.findOne({ id: groupId, orgId }); @@ -883,64 +947,28 @@ export const scimServiceFactory = ({ }); } - for await (const operation of operations) { - if (operation.op === "replace" || operation.op === "Replace") { - group = await groupDAL.updateById(group.id, { - name: operation.value.displayName - }); - } else if (operation.op === "add" || operation.op === "Add") { - try { - const orgMemberships = await orgMembershipDAL.find({ - $in: { - id: operation.value.map((member) => member.value) - } - }); - - await addUsersToGroupByUserIds({ - group, - userIds: orgMemberships.map((membership) => membership.userId as string), - userDAL, - userGroupMembershipDAL, - orgDAL, - groupProjectDAL, - projectKeyDAL, - projectDAL, - projectBotDAL - }); - } catch { - logger.info("Repeat SCIM user-group add operation"); - } - } else if (operation.op === "remove" || operation.op === "Remove") { - const orgMembershipId = extractScimValueFromPath(operation.path); - if (!orgMembershipId) throw new ScimRequestError({ detail: "Invalid path value", status: 400 }); - const orgMembership = await orgMembershipDAL.findById(orgMembershipId); - if (!orgMembership) throw new ScimRequestError({ detail: "Org Membership Not Found", status: 400 }); - await removeUsersFromGroupByUserIds({ - group, - userIds: [orgMembership.userId as string], - userDAL, - userGroupMembershipDAL, - groupProjectDAL, - projectKeyDAL - }); - } else { - throw new ScimRequestError({ - detail: "Invalid Operation", - status: 400 - }); - } - } - const members = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); - - return buildScimGroup({ + const scimGroup = buildScimGroup({ groupId: group.id, name: group.name, members: members.map((member) => ({ + value: member.orgMembershipId + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt + }); + scimPatch(scimGroup, operations); + // remove members is a weird case not following scim convention + await $replaceGroupDAL(groupId, orgId, { displayName: scimGroup.displayName, members: scimGroup.members }); + + const updatedScimMembers = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); + return { + ...scimGroup, + members: updatedScimMembers.map((member) => ({ value: member.orgMembershipId, display: `${member.firstName ?? ""} ${member.lastName ?? ""}` })) - }); + }; }; const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { @@ -1016,8 +1044,8 @@ export const scimServiceFactory = ({ createScimGroup, getScimGroup, deleteScimGroup, - updateScimGroupNamePut, - updateScimGroupNamePatch, + replaceScimGroup, + updateScimGroup, fnValidateScimToken }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 410b6557c..5099e4ca0 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -1,3 +1,5 @@ +import { ScimPatchOperation } from "scim-patch"; + import { TOrgPermission } from "@app/lib/types"; export type TCreateScimTokenDTO = { @@ -34,29 +36,25 @@ export type TGetScimUserDTO = { export type TCreateScimUserDTO = { externalId: string; email?: string; - firstName: string; - lastName: string; + firstName?: string; + lastName?: string; orgId: string; }; export type TUpdateScimUserDTO = { orgMembershipId: string; orgId: string; - operations: { - op: string; - path?: string; - value?: - | string - | { - active: boolean; - }; - }[]; + operations: ScimPatchOperation[]; }; export type TReplaceScimUserDTO = { orgMembershipId: string; active: boolean; orgId: string; + email?: string; + firstName?: string; + lastName?: string; + externalId: string; }; export type TDeleteScimUserDTO = { @@ -69,6 +67,7 @@ export type TListScimGroupsDTO = { filter?: string; limit: number; orgId: string; + isMembersExcluded?: boolean; }; export type TListScimGroups = { @@ -107,31 +106,7 @@ export type TUpdateScimGroupNamePutDTO = { export type TUpdateScimGroupNamePatchDTO = { groupId: string; orgId: string; - operations: (TRemoveOp | TReplaceOp | TAddOp)[]; -}; - -// akhilmhdh: I know, this is done due to lack of time. Need to change later to support as normalized rather than like this -// Forgive akhil blame tony -type TReplaceOp = { - op: "replace" | "Replace"; - value: { - id: string; - displayName: string; - }; -}; - -type TRemoveOp = { - op: "remove" | "Remove"; - path: string; -}; - -type TAddOp = { - op: "add" | "Add"; - path: string; - value: { - value: string; - display?: string; - }[]; + operations: ScimPatchOperation[]; }; export type TDeleteScimGroupDTO = { @@ -160,13 +135,10 @@ export type TScimUser = { type: string; }[]; active: boolean; - groups: { - value: string; - display: string; - }[]; meta: { resourceType: string; - location: null; + created: Date; + lastModified: Date; }; }; @@ -176,10 +148,11 @@ export type TScimGroup = { displayName: string; members: { value: string; - display: string; + display?: string; }[]; meta: { resourceType: string; - location: null; + created: Date; + lastModified: Date; }; }; diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index e4d341c1a..8a9eeab8c 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -16,6 +16,7 @@ import { import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TSnapshotDALFactory = ReturnType; @@ -599,6 +600,7 @@ export const snapshotDALFactory = (db: TDbClient) => { const pruneExcessSnapshots = async () => { const PRUNE_FOLDER_BATCH_SIZE = 10000; + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret snapshots started`); try { let uuidOffset = "00000000-0000-0000-0000-000000000000"; // cleanup snapshots from current folders @@ -714,6 +716,7 @@ export const snapshotDALFactory = (db: TDbClient) => { } catch (error) { throw new DatabaseError({ error, name: "SnapshotPrune" }); } + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret snapshots completed`); }; // special query for migration for secret v2 diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2a823024a..d38620837 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -964,6 +964,10 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", + githubVisibility: + "Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.", + githubVisibilityRepoIds: + "The repository IDs to sync secrets to when using the Github Integration. Only applicable when using Organization scope, and visibility is set to 'selected'", kmsKeyId: "The ID of the encryption key from AWS KMS.", shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store.", shouldMaskSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Masked'.", diff --git a/backend/src/lib/axios/digest-auth.ts b/backend/src/lib/axios/digest-auth.ts new file mode 100644 index 000000000..ee9dbd79b --- /dev/null +++ b/backend/src/lib/axios/digest-auth.ts @@ -0,0 +1,57 @@ +import crypto from "node:crypto"; + +import { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; + +export const createDigestAuthRequestInterceptor = ( + axiosInstance: AxiosInstance, + username: string, + password: string +) => { + let nc = 0; + + return async (opts: AxiosRequestConfig) => { + try { + return await axiosInstance.request(opts); + } catch (err) { + const error = err as AxiosError; + const authHeader = (error?.response?.headers?.["www-authenticate"] as string) || ""; + + if (error?.response?.status !== 401 || !authHeader?.includes("nonce")) { + return Promise.reject(error.message); + } + + if (!error.config) { + return Promise.reject(error); + } + + const authDetails = authHeader.split(",").map((el) => el.split("=")); + nc += 1; + const nonceCount = nc.toString(16).padStart(8, "0"); + const cnonce = crypto.randomBytes(24).toString("hex"); + const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1].replace(/"/g, ""); + const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1].replace(/"/g, ""); + const ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex"); + const path = opts.url; + + const ha2 = crypto + .createHash("md5") + .update(`${opts.method ?? "GET"}:${path}`) + .digest("hex"); + + const response = crypto + .createHash("md5") + .update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`) + .digest("hex"); + const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`; + + if (opts.headers) { + // eslint-disable-next-line + opts.headers.authorization = authorization; + } else { + // eslint-disable-next-line + opts.headers = { authorization }; + } + return axiosInstance.request(opts); + } + }; +}; diff --git a/backend/src/lib/knex/scim.ts b/backend/src/lib/knex/scim.ts new file mode 100644 index 000000000..530a7d45a --- /dev/null +++ b/backend/src/lib/knex/scim.ts @@ -0,0 +1,121 @@ +import { Knex } from "knex"; +import { Compare, Filter, parse } from "scim2-parse-filter"; + +const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { + if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { + return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; + } + return filter; +}; + +export const generateKnexQueryFromScim = ( + rootQuery: Knex.QueryBuilder, + rootScimFilter: string, + getAttributeField: (attr: string) => string | null +) => { + const scimRootFilterAst = parse(rootScimFilter); + const stack = [ + { + scimFilterAst: scimRootFilterAst, + query: rootQuery + } + ]; + + while (stack.length) { + const { scimFilterAst, query } = stack.pop()!; + switch (scimFilterAst.op) { + case "eq": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, scimFilterAst.compValue); + break; + } + case "pr": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereNotNull(attrPath); + break; + } + case "gt": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, ">", scimFilterAst.compValue); + break; + } + case "ge": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, ">=", scimFilterAst.compValue); + break; + } + case "lt": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, "<", scimFilterAst.compValue); + break; + } + case "le": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, "<=", scimFilterAst.compValue); + break; + } + case "sw": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `${scimFilterAst.compValue}%`); + break; + } + case "ew": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); + break; + } + case "co": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); + break; + } + case "ne": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); + break; + } + case "and": { + void query.andWhere((subQueryBuilder) => { + scimFilterAst.filters.forEach((el) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: el + }); + }); + }); + break; + } + case "or": { + void query.orWhere((subQueryBuilder) => { + scimFilterAst.filters.forEach((el) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: el + }); + }); + }); + break; + } + case "not": { + void query.whereNot((subQueryBuilder) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: scimFilterAst.filter + }); + }); + break; + } + case "[]": { + void query.whereNot((subQueryBuilder) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: appendParentToGroupingOperator(scimFilterAst.attrPath, scimFilterAst.valFilter) + }); + }); + break; + } + default: + break; + } + } +}; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index ee8acec0f..1ab8bc3fd 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -49,6 +49,21 @@ export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => { server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + if (!strBody) { + done(null, undefined); + return; + } + const json: unknown = JSON.parse(strBody); + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + try { await server.register(cookie, { secret: appCfg.COOKIE_SECRET_SIGN_KEY diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index a23c1f596..7a909cae4 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -48,7 +48,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }); server.route({ - method: "GET", + method: "POST", url: "/public/:id", config: { rateLimit: publicEndpointLimit @@ -57,38 +57,37 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => params: z.object({ id: z.string().uuid() }), - querystring: z.object({ - hashedHex: z.string().min(1) + body: z.object({ + hashedHex: z.string().min(1), + password: z.string().optional() }), response: { - 200: SecretSharingSchema.pick({ - encryptedValue: true, - iv: true, - tag: true, - expiresAt: true, - expiresAfterViews: true, - accessType: true - }).extend({ - orgName: z.string().optional() + 200: z.object({ + isPasswordProtected: z.boolean(), + secret: SecretSharingSchema.pick({ + encryptedValue: true, + iv: true, + tag: true, + expiresAt: true, + expiresAfterViews: true, + accessType: true + }) + .extend({ + orgName: z.string().optional() + }) + .optional() }) } }, handler: async (req) => { - const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById({ + const sharedSecret = await req.server.services.secretSharing.getSharedSecretById({ sharedSecretId: req.params.id, - hashedHex: req.query.hashedHex, + hashedHex: req.body.hashedHex, + password: req.body.password, orgId: req.permission?.orgId }); - if (!sharedSecret) return undefined; - return { - encryptedValue: sharedSecret.encryptedValue, - iv: sharedSecret.iv, - tag: sharedSecret.tag, - expiresAt: sharedSecret.expiresAt, - expiresAfterViews: sharedSecret.expiresAfterViews, - accessType: sharedSecret.accessType, - orgName: sharedSecret.orgName - }; + + return sharedSecret; } }); @@ -101,6 +100,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => schema: { body: z.object({ encryptedValue: z.string(), + password: z.string().optional(), hashedHex: z.string(), iv: z.string(), tag: z.string(), @@ -131,6 +131,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => schema: { body: z.object({ name: z.string().max(50).optional(), + password: z.string().optional(), encryptedValue: z.string(), hashedHex: z.string(), iv: z.string(), diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 931c65550..01a78fa81 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { IdentityAuthMethod, TableName, TIdentityAccessTokens } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TIdentityAccessTokenDALFactory = ReturnType; @@ -95,6 +97,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { }; const removeExpiredTokens = async (tx?: Knex) => { + logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token started`); try { const docs = (tx || db)(TableName.IdentityAccessToken) .where({ @@ -131,7 +134,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { }); }) .delete(); - return await docs; + await docs; + logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token completed`); } catch (error) { throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" }); } diff --git a/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts b/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts index ec0fd6fbf..2d008727d 100644 --- a/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts +++ b/backend/src/services/identity-ua/identity-ua-client-secret-dal.ts @@ -5,6 +5,7 @@ import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TIdentityUaClientSecretDALFactory = ReturnType; @@ -30,7 +31,9 @@ export const identityUaClientSecretDALFactory = (db: TDbClient) => { let deletedClientSecret: { id: string }[] = []; let numberOfRetryOnFailure = 0; + let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: remove expired univesal auth client secret started`); do { try { const findExpiredClientSecretQuery = (tx || db)(TableName.IdentityUaClientSecret) @@ -71,7 +74,9 @@ export const identityUaClientSecretDALFactory = (db: TDbClient) => { setTimeout(resolve, 10); // time to breathe for db }); } - } while (deletedClientSecret.length > 0 || numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE); + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedClientSecret.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: remove expired univesal auth client secret completed`); }; return { ...uaClientSecretOrm, incrementUsage, removeExpiredClientSecrets }; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index 5da48fb6e..bd9619ad4 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -30,6 +30,7 @@ const getIntegrationSecretsV2 = async ( environment: string; folderId: string; depth: number; + secretPath: string; decryptor: (value: Buffer | null | undefined) => string; }, secretV2BridgeDAL: Pick, @@ -306,6 +307,7 @@ export const deleteIntegrationSecrets = async ({ ? await getIntegrationSecretsV2( { environment: integration.environment.id, + secretPath: integration.secretPath, projectId: integration.projectId, folderId: folder.id, depth: 1, diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 91d9a68b5..94d7a95d6 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1625,7 +1625,11 @@ const syncSecretsGitHub = async ({ await octokit.request("PUT /orgs/{org}/actions/secrets/{secret_name}", { org: integration.owner as string, secret_name: key, - visibility: "all", + visibility: metadata.githubVisibility ?? "all", + ...(metadata.githubVisibility === "selected" && { + // we need to map the githubVisibilityRepoIds to numbers + selected_repository_ids: metadata.githubVisibilityRepoIds?.map(Number) ?? [] + }), encrypted_value: encryptedSecret, key_id: repoPublicKey.key_id }); diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts index 1ea01e56a..99f1d996f 100644 --- a/backend/src/services/integration/integration-schema.ts +++ b/backend/src/services/integration/integration-schema.ts @@ -5,14 +5,18 @@ import { INTEGRATION } from "@app/lib/api-docs"; import { IntegrationMappingBehavior } from "../integration-auth/integration-list"; export const IntegrationMetadataSchema = z.object({ + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z .nativeEnum(IntegrationMappingBehavior) .optional() .describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z .object({ labelName: z.string(), @@ -20,6 +24,7 @@ export const IntegrationMetadataSchema = z.object({ }) .optional() .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z .array( z.object({ @@ -29,7 +34,15 @@ export const IntegrationMetadataSchema = z.object({ ) .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + + githubVisibility: z + .union([z.literal("selected"), z.literal("private"), z.literal("all")]) + .optional() + .describe(INTEGRATION.CREATE.metadata.githubVisibility), + githubVisibilityRepoIds: z.array(z.string()).optional().describe(INTEGRATION.CREATE.metadata.githubVisibilityRepoIds), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete), shouldEnableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldEnableDelete), shouldMaskSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldMaskSecrets), diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index cfb6d70a4..0df8edc4a 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -27,6 +27,10 @@ export type TCreateIntegrationDTO = { key: string; value: string; }[]; + + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; + kmsKeyId?: string; shouldDisableDelete?: boolean; shouldMaskSecrets?: boolean; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index d7c6ba31a..d84dd1afd 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -12,6 +12,7 @@ import { } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; export type TOrgDALFactory = ReturnType; @@ -280,6 +281,67 @@ export const orgDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) + ) + .where({ isGhost: false }); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + const res = await query; + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + const findMembershipWithScimFilter = async ( + orgId: string, + scimFilter: string | undefined, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + try { + const query = (tx || db.replicaNode())(TableName.OrgMembership) + // eslint-disable-next-line + .where(`${TableName.OrgMembership}.orgId`, orgId) + .where((qb) => { + if (scimFilter) { + void generateKnexQueryFromScim(qb, scimFilter, (attrPath) => { + switch (attrPath) { + case "active": + return `${TableName.OrgMembership}.isActive`; + case "userName": + return `${TableName.UserAliases}.externalId`; + case "name.givenName": + return `${TableName.Users}.firstName`; + case "name.familyName": + return `${TableName.Users}.lastName`; + case "email.value": + return `${TableName.Users}.email`; + default: + return null; + } + }); + } + }) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) + .select( + selectAllTableCols(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), @@ -314,6 +376,7 @@ export const orgDALFactory = (db: TDbClient) => { updateById, deleteById, findMembership, + findMembershipWithScimFilter, createMembership, updateMembershipById, deleteMembershipById, diff --git a/backend/src/services/secret-folder/secret-folder-version-dal.ts b/backend/src/services/secret-folder/secret-folder-version-dal.ts index ba0c21f20..78186333d 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName, TSecretFolderVersions } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TSecretFolderVersionDALFactory = ReturnType; @@ -65,6 +67,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { }; const pruneExcessVersions = async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret folder versions started`); try { await db(TableName.SecretFolderVersion) .with("folder_cte", (qb) => { @@ -89,6 +92,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { name: "Secret Folder Version Prune" }); } + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret folder versions completed`); }; return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId, pruneExcessVersions }; diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index f7c31ac61..981a1bf62 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -158,9 +158,12 @@ export const fnSecretsV2FromImports = async ({ depth?: number; cyclicDetector?: Set; decryptor: (value?: Buffer | null) => string; - expandSecretReferences?: ( - secrets: Record - ) => Promise>; + expandSecretReferences?: (inputSecret: { + value?: string; + skipMultilineEncoding?: boolean | null; + secretPath: string; + environment: string; + }) => Promise; }) => { // avoid going more than a depth if (depth >= LEVEL_BREAK) return []; @@ -244,26 +247,21 @@ export const fnSecretsV2FromImports = async ({ }); if (expandSecretReferences) { - await Promise.all( - processedImports.map(async (processedImport) => { - const secretsGroupByKey = processedImport.secrets.reduce( - (acc, item) => { - acc[item.secretKey] = { - value: item.secretValue, - comment: item.secretComment, - skipMultilineEncoding: item.skipMultilineEncoding - }; - return acc; - }, - {} as Record - ); - // eslint-disable-next-line - await expandSecretReferences(secretsGroupByKey); - processedImport.secrets.forEach((decryptedSecret) => { - // eslint-disable-next-line no-param-reassign - decryptedSecret.secretValue = secretsGroupByKey[decryptedSecret.secretKey].value; - }); - }) + await Promise.allSettled( + processedImports.map((processedImport) => + Promise.allSettled( + processedImport.secrets.map(async (decryptedSecret, index) => { + const expandedSecretValue = await expandSecretReferences({ + value: decryptedSecret.secretValue, + secretPath: processedImport.secretPath, + environment: processedImport.environment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + }); + // eslint-disable-next-line no-param-reassign + processedImport.secrets[index].secretValue = expandedSecretValue || ""; + }) + ) + ) ); } diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts index d1b4edd4b..5c690b266 100644 --- a/backend/src/services/secret-sharing/secret-sharing-dal.ts +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName, TSecretSharing } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TSecretSharingDALFactory = ReturnType; @@ -30,6 +32,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { }; const pruneExpiredSharedSecrets = async (tx?: Knex) => { + logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired shared secret started`); try { const today = new Date(); const docs = await (tx || db)(TableName.SecretSharing) @@ -40,6 +43,7 @@ export const secretSharingDALFactory = (db: TDbClient) => { tag: "", iv: "" }); + logger.info(`${QueueName.DailyResourceCleanUp}: pruning expired shared secret completed`); return docs; } catch (error) { throw new DatabaseError({ error, name: "pruneExpiredSharedSecrets" }); diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 1f38bf1f1..6133db559 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,3 +1,6 @@ +import bcrypt from "bcrypt"; + +import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; @@ -36,6 +39,7 @@ export const secretSharingServiceFactory = ({ iv, tag, name, + password, accessType, expiresAt, expiresAfterViews @@ -60,8 +64,10 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Shared secret value too long" }); } + const hashedPassword = password ? await bcrypt.hash(password, 10) : null; const newSharedSecret = await secretSharingDAL.create({ name, + password: hashedPassword, encryptedValue, hashedHex, iv, @@ -77,6 +83,7 @@ export const secretSharingServiceFactory = ({ }; const createPublicSharedSecret = async ({ + password, encryptedValue, hashedHex, iv, @@ -102,7 +109,9 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Shared secret value too long" }); } + const hashedPassword = password ? await bcrypt.hash(password, 10) : null; const newSharedSecret = await secretSharingDAL.create({ + password: hashedPassword, encryptedValue, hashedHex, iv, @@ -111,6 +120,7 @@ export const secretSharingServiceFactory = ({ expiresAfterViews, accessType }); + return { id: newSharedSecret.id }; }; @@ -152,7 +162,21 @@ export const secretSharingServiceFactory = ({ }; }; - const getActiveSharedSecretById = async ({ sharedSecretId, hashedHex, orgId }: TGetActiveSharedSecretByIdDTO) => { + const $decrementSecretViewCount = async (sharedSecret: TSecretSharing, sharedSecretId: string) => { + const { expiresAfterViews } = sharedSecret; + + if (expiresAfterViews) { + // decrement view count if view count expiry set + await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + } + + await secretSharingDAL.updateById(sharedSecretId, { + lastViewedAt: new Date() + }); + }; + + /** Get's passwordless secret. validates all secret's requested (must be fresh). */ + const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex @@ -169,6 +193,8 @@ export const secretSharingServiceFactory = ({ if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) throw new UnauthorizedError(); + // all secrets pass through here, meaning we check if its expired first and then check if it needs verification + // or can be safely sent to the client. if (expiresAt !== null && expiresAt < new Date()) { // check lifetime expiry await secretSharingDAL.softDeleteById(sharedSecretId); @@ -185,21 +211,29 @@ export const secretSharingServiceFactory = ({ }); } - if (expiresAfterViews) { - // decrement view count if view count expiry set - await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + const isPasswordProtected = Boolean(sharedSecret.password); + const hasProvidedPassword = Boolean(password); + if (isPasswordProtected) { + if (hasProvidedPassword) { + const isMatch = await bcrypt.compare(password as string, sharedSecret.password as string); + if (!isMatch) throw new UnauthorizedError({ message: "Invalid credentials" }); + } else { + return { isPasswordProtected }; + } } - await secretSharingDAL.updateById(sharedSecretId, { - lastViewedAt: new Date() - }); + // decrement when we are sure the user will view secret. + await $decrementSecretViewCount(sharedSecret, sharedSecretId); return { - ...sharedSecret, - orgName: - sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId - ? orgName - : undefined + isPasswordProtected, + secret: { + ...sharedSecret, + orgName: + sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId + ? orgName + : undefined + } }; }; @@ -216,6 +250,6 @@ export const secretSharingServiceFactory = ({ createPublicSharedSecret, getSharedSecrets, deleteSharedSecretById, - getActiveSharedSecretById + getSharedSecretById }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 76d723c62..794d99a33 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -15,6 +15,7 @@ export type TSharedSecretPermission = { orgId: string; accessType?: SecretSharingAccessType; name?: string; + password?: string; }; export type TCreatePublicSharedSecretDTO = { @@ -24,6 +25,7 @@ export type TCreatePublicSharedSecretDTO = { tag: string; expiresAt: string; expiresAfterViews?: number; + password?: string; accessType: SecretSharingAccessType; }; @@ -31,6 +33,11 @@ export type TGetActiveSharedSecretByIdDTO = { sharedSecretId: string; hashedHex: string; orgId?: string; + password?: string; +}; + +export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { + password: string; }; export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index e58960a4b..7079c17ef 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -377,150 +377,116 @@ type TInterpolateSecretArg = { folderDAL: Pick; }; +const MAX_SECRET_REFERENCE_DEPTH = 10; export const expandSecretReferencesFactory = ({ projectId, decryptSecretValue: decryptSecret, secretDAL, folderDAL }: TInterpolateSecretArg) => { - const fetchSecretFactory = () => { - const secretCache: Record> = {}; + const secretCache: Record> = {}; + const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`; - return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { - const referredSecretPathURL = path.join("/", ...secRefPath); - const uniqueKey = `${secRefEnv}-${referredSecretPathURL}`; + const fetchSecret = async (environment: string, secretPath: string, secretKey: string) => { + const cacheKey = getCacheUniqueKey(environment, secretPath); - if (secretCache?.[uniqueKey]) { - return secretCache[uniqueKey][secRefKey]; - } + if (secretCache?.[cacheKey]) { + return secretCache[cacheKey][secretKey] || ""; + } - const folder = await folderDAL.findBySecretPath(projectId, secRefEnv, referredSecretPathURL); - if (!folder) return ""; - const secrets = await secretDAL.findByFolderId(folder.id); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return ""; + const secrets = await secretDAL.findByFolderId(folder.id); - const decryptedSecret = secrets.reduce>((prev, secret) => { - // eslint-disable-next-line - prev[secret.key] = decryptSecret(secret.encryptedValue) || ""; - return prev; - }, {}); + const decryptedSecret = secrets.reduce>((prev, secret) => { + // eslint-disable-next-line no-param-reassign + prev[secret.key] = decryptSecret(secret.encryptedValue) || ""; + return prev; + }, {}); - secretCache[uniqueKey] = decryptedSecret; + secretCache[cacheKey] = decryptedSecret; - return secretCache[uniqueKey][secRefKey]; - }; + return secretCache[cacheKey][secretKey] || ""; }; - const recursivelyExpandSecret = async ( - expandedSec: Record, - interpolatedSec: Record, - fetchSecret: (env: string, secPath: string[], secKey: string) => Promise, - recursionChainBreaker: Record, - key: string - ): Promise => { - if (expandedSec?.[key] !== undefined) { - return expandedSec[key]; - } - if (recursionChainBreaker?.[key]) { - return ""; - } - // eslint-disable-next-line - recursionChainBreaker[key] = true; + const recursivelyExpandSecret = async (dto: { value?: string; secretPath: string; environment: string }) => { + if (!dto.value) return ""; - let interpolatedValue = interpolatedSec[key]; - if (!interpolatedValue) { - // eslint-disable-next-line no-console - console.error(`Couldn't find referenced value - ${key}`); - return ""; - } + const stack = [{ ...dto, depth: 0 }]; + let expandedValue = dto.value; - const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); - if (refs) { - for (const interpolationSyntax of refs) { - const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); - const entities = interpolationKey.trim().split("."); + while (stack.length) { + const { value, secretPath, environment, depth } = stack.pop()!; + // eslint-disable-next-line no-continue + if (depth > MAX_SECRET_REFERENCE_DEPTH) continue; + const refs = value?.match(INTERPOLATION_SYNTAX_REG); - if (entities.length === 1) { - // eslint-disable-next-line - const val = await recursivelyExpandSecret( - expandedSec, - interpolatedSec, - fetchSecret, - recursionChainBreaker, - interpolationKey - ); - if (val) { - interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); - } - // eslint-disable-next-line - continue; - } + if (refs) { + for (const interpolationSyntax of refs) { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); - if (entities.length > 1) { - const secRefEnv = entities[0]; - const secRefPath = entities.slice(1, entities.length - 1); - const secRefKey = entities[entities.length - 1]; + // eslint-disable-next-line no-continue + if (!entities.length) continue; - // eslint-disable-next-line - const val = await fetchSecret(secRefEnv, secRefPath, secRefKey); - if (val) { - interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + if (entities.length === 1) { + const [secretKey] = entities; + + // eslint-disable-next-line no-continue,no-await-in-loop + const referedValue = await fetchSecret(environment, secretPath, secretKey); + const cacheKey = getCacheUniqueKey(environment, secretPath); + secretCache[cacheKey][secretKey] = referedValue; + if (INTERPOLATION_SYNTAX_REG.test(referedValue)) { + stack.push({ + value: referedValue, + secretPath, + environment, + depth: depth + 1 + }); + } + expandedValue = expandedValue.replaceAll(interpolationSyntax, referedValue); + } else { + const secretReferenceEnvironment = entities[0]; + const secretReferencePath = path.join("/", ...entities.slice(1, entities.length - 1)); + const secretReferenceKey = entities[entities.length - 1]; + + // eslint-disable-next-line no-await-in-loop + const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey); + const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); + secretCache[cacheKey][secretReferenceKey] = referedValue; + if (INTERPOLATION_SYNTAX_REG.test(referedValue)) { + stack.push({ + value: referedValue, + secretPath: secretReferencePath, + environment: secretReferenceEnvironment, + depth: depth + 1 + }); + } + + expandedValue = expandedValue.replaceAll(interpolationSyntax, referedValue); } } } } - // eslint-disable-next-line - expandedSec[key] = interpolatedValue; - return interpolatedValue; + return expandedValue; }; - const fetchSecret = fetchSecretFactory(); - const expandSecrets = async ( - inputSecrets: Record - ) => { - const expandedSecrets: Record = {}; - const toBeExpandedSecrets: Record = {}; + const expandSecret = async (inputSecret: { + value?: string; + skipMultilineEncoding?: boolean | null; + secretPath: string; + environment: string; + }) => { + if (!inputSecret.value) return inputSecret.value; - Object.keys(inputSecrets).forEach((key) => { - if (inputSecrets[key].value?.match(INTERPOLATION_SYNTAX_REG)) { - toBeExpandedSecrets[key] = inputSecrets[key].value; - } else { - expandedSecrets[key] = inputSecrets[key].value; - } - }); + const shouldExpand = Boolean(inputSecret.value?.match(INTERPOLATION_SYNTAX_REG)); + if (!shouldExpand) return inputSecret.value; - for (const key of Object.keys(inputSecrets)) { - if (expandedSecrets?.[key]) { - // should not do multi line encoding if user has set it to skip - // eslint-disable-next-line - inputSecrets[key].value = inputSecrets[key].skipMultilineEncoding - ? formatMultiValueEnv(expandedSecrets[key]) - : expandedSecrets[key]; - // eslint-disable-next-line - continue; - } - - // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic - // so for any recursion building if there is an entity two times same key meaning it will be looped - const recursionChainBreaker: Record = {}; - // eslint-disable-next-line - const expandedVal = await recursivelyExpandSecret( - expandedSecrets, - toBeExpandedSecrets, - fetchSecret, - recursionChainBreaker, - key - ); - - // eslint-disable-next-line - inputSecrets[key].value = inputSecrets[key].skipMultilineEncoding - ? formatMultiValueEnv(expandedVal) - : expandedVal; - } - - return inputSecrets; + const expandedSecretValue = await recursivelyExpandSecret(inputSecret); + return inputSecret.skipMultilineEncoding ? formatMultiValueEnv(expandedSecretValue) : expandedSecretValue; }; - return expandSecrets; + return expandSecret; }; export const reshapeBridgeSecret = ( diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index b594f98ca..ed9cd3a7d 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -521,27 +521,22 @@ export const secretV2BridgeServiceFactory = ({ if (shouldExpandSecretReferences) { const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); - for (const secretPathKey in secretsGroupByPath) { - if (Object.hasOwn(secretsGroupByPath, secretPathKey)) { - const secretsGroupByKey = secretsGroupByPath[secretPathKey].reduce( - (acc, item) => { - acc[item.secretKey] = { - value: item.secretValue, - comment: item.secretComment, - skipMultilineEncoding: item.skipMultilineEncoding - }; - return acc; - }, - {} as Record - ); - // eslint-disable-next-line - await expandSecretReferences(secretsGroupByKey); - secretsGroupByPath[secretPathKey].forEach((decryptedSecret) => { - // eslint-disable-next-line no-param-reassign - decryptedSecret.secretValue = secretsGroupByKey[decryptedSecret.secretKey].value || ""; - }); - } - } + await Promise.allSettled( + Object.keys(secretsGroupByPath).map((groupedPath) => + Promise.allSettled( + secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => { + const expandedSecretValue = await expandSecretReferences({ + value: decryptedSecret.secretValue, + secretPath: groupedPath, + environment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + }); + // eslint-disable-next-line no-param-reassign + secretsGroupByPath[groupedPath][index].secretValue = expandedSecretValue || ""; + }) + ) + ) + ); } if (!includeImports) { @@ -693,12 +688,14 @@ export const secretV2BridgeServiceFactory = ({ ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() : ""; if (shouldExpandSecretReferences && secretValue) { - const secretReferenceExpandedRecord = { - [secret.key]: { value: secretValue } - }; // eslint-disable-next-line - await expandSecretReferences(secretReferenceExpandedRecord); - secretValue = secretReferenceExpandedRecord[secret.key].value; + const expandedSecretValue = await expandSecretReferences({ + environment, + secretPath: path, + value: secretValue, + skipMultilineEncoding: secret.skipMultilineEncoding + }); + secretValue = expandedSecretValue || ""; } return reshapeBridgeSecret(projectId, environment, path, { diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index e4922a57d..a0bce5371 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TSecretVersionV2DALFactory = ReturnType; @@ -87,6 +89,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { }; const pruneExcessVersions = async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 started`); try { await db(TableName.SecretVersionV2) .with("version_cte", (qb) => { @@ -112,6 +115,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { name: "Secret Version Prune" }); } + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`); }; return { diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index d41770846..7837b716b 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -196,6 +196,13 @@ export const recursivelyGetSecretPaths = ({ return getPaths; }; +// used to convert multi line ones to quotes ones with \n +const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; +}; + type TInterpolateSecretArg = { projectId: string; secretEncKey: string; @@ -203,162 +210,128 @@ type TInterpolateSecretArg = { folderDAL: Pick; }; +const MAX_SECRET_REFERENCE_DEPTH = 5; const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderDAL }: TInterpolateSecretArg) => { - const fetchSecretsCrossEnv = () => { - const fetchCache: Record> = {}; + const secretCache: Record> = {}; + const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`; - return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { - const secRefPathUrl = path.join("/", ...secRefPath); - const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + const fetchSecret = async (environment: string, secretPath: string, secretKey: string) => { + const cacheKey = getCacheUniqueKey(environment, secretPath); + const uniqKey = `${environment}-${cacheKey}`; - if (fetchCache?.[uniqKey]) { - return fetchCache[uniqKey][secRefKey]; - } + if (secretCache?.[uniqKey]) { + return secretCache[uniqKey][secretKey] || ""; + } - const folder = await folderDAL.findBySecretPath(projectId, secRefEnv, secRefPathUrl); - if (!folder) return ""; - const secrets = await secretDAL.findByFolderId(folder.id); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) return ""; + const secrets = await secretDAL.findByFolderId(folder.id); - const decryptedSec = secrets.reduce>((prev, secret) => { - const secretKey = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - key: secretEncKey - }); - const secretValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key: secretEncKey - }); + const decryptedSec = secrets.reduce>((prev, secret) => { + const decryptedSecretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: secretEncKey + }); + const decryptedSecretValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: secretEncKey + }); - // eslint-disable-next-line - prev[secretKey] = secretValue; - return prev; - }, {}); + // eslint-disable-next-line + prev[decryptedSecretKey] = decryptedSecretValue; + return prev; + }, {}); - fetchCache[uniqKey] = decryptedSec; + secretCache[uniqKey] = decryptedSec; - return fetchCache[uniqKey][secRefKey]; - }; + return secretCache[uniqKey][secretKey] || ""; }; - const recursivelyExpandSecret = async ( - expandedSec: Record, - interpolatedSec: Record, - fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, - recursionChainBreaker: Record, - key: string - ) => { - if (expandedSec?.[key] !== undefined) { - return expandedSec[key]; - } - if (recursionChainBreaker?.[key]) { - return ""; - } - // eslint-disable-next-line - recursionChainBreaker[key] = true; + const recursivelyExpandSecret = async ({ + value, + secretPath, + environment, + depth = 0 + }: { + value?: string; + secretPath: string; + environment: string; + depth?: number; + }) => { + if (!value) return ""; + if (depth > MAX_SECRET_REFERENCE_DEPTH) return ""; - let interpolatedValue = interpolatedSec[key]; - if (!interpolatedValue) { - // eslint-disable-next-line no-console - console.error(`Couldn't find referenced value - ${key}`); - return ""; - } - - const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + const refs = value.match(INTERPOLATION_SYNTAX_REG); + let expandedValue = value; if (refs) { for (const interpolationSyntax of refs) { const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); const entities = interpolationKey.trim().split("."); if (entities.length === 1) { - const val = await recursivelyExpandSecret( - expandedSec, - interpolatedSec, - fetchCrossEnv, - recursionChainBreaker, - interpolationKey - ); - if (val) { - interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); - } + const [secretKey] = entities; // eslint-disable-next-line - continue; + let referenceValue = await fetchSecret(environment, secretPath, secretKey); + if (INTERPOLATION_SYNTAX_REG.test(referenceValue)) { + // eslint-disable-next-line + referenceValue = await recursivelyExpandSecret({ + environment, + secretPath, + value: referenceValue, + depth: depth + 1 + }); + } + const cacheKey = getCacheUniqueKey(environment, secretPath); + secretCache[cacheKey][secretKey] = referenceValue; + expandedValue = expandedValue.replaceAll(interpolationSyntax, referenceValue); } if (entities.length > 1) { - const secRefEnv = entities[0]; - const secRefPath = entities.slice(1, entities.length - 1); - const secRefKey = entities[entities.length - 1]; + const secretReferenceEnvironment = entities[0]; + const secretReferencePath = path.join("/", ...entities.slice(1, entities.length - 1)); + const secretReferenceKey = entities[entities.length - 1]; - const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); - if (val) { - interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + // eslint-disable-next-line + let referenceValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey); + if (INTERPOLATION_SYNTAX_REG.test(referenceValue)) { + // eslint-disable-next-line + referenceValue = await recursivelyExpandSecret({ + environment: secretReferenceEnvironment, + secretPath: secretReferencePath, + value: referenceValue, + depth: depth + 1 + }); } + const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); + secretCache[cacheKey][secretReferenceKey] = referenceValue; + expandedValue = expandedValue.replaceAll(interpolationSyntax, referenceValue); } } } - // eslint-disable-next-line - expandedSec[key] = interpolatedValue; - return interpolatedValue; + return expandedValue; }; - // used to convert multi line ones to quotes ones with \n - const formatMultiValueEnv = (val?: string) => { - if (!val) return ""; - if (!val.match("\n")) return val; - return `"${val.replace(/\n/g, "\\n")}"`; + const expandSecret = async (inputSecret: { + value?: string; + skipMultilineEncoding?: boolean | null; + secretPath: string; + environment: string; + }) => { + if (!inputSecret.value) return inputSecret.value; + + const shouldExpand = Boolean(inputSecret.value?.match(INTERPOLATION_SYNTAX_REG)); + if (!shouldExpand) return inputSecret.value; + + const expandedSecretValue = await recursivelyExpandSecret(inputSecret); + return inputSecret.skipMultilineEncoding ? formatMultiValueEnv(expandedSecretValue) : expandedSecretValue; }; - - const expandSecrets = async ( - secrets: Record - ) => { - const expandedSec: Record = {}; - const interpolatedSec: Record = {}; - - const crossSecEnvFetch = fetchSecretsCrossEnv(); - - Object.keys(secrets).forEach((key) => { - if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { - interpolatedSec[key] = secrets[key].value; - } else { - expandedSec[key] = secrets[key].value; - } - }); - - for (const key of Object.keys(secrets)) { - if (expandedSec?.[key]) { - // should not do multi line encoding if user has set it to skip - // eslint-disable-next-line - secrets[key].value = secrets[key].skipMultilineEncoding - ? formatMultiValueEnv(expandedSec[key]) - : expandedSec[key]; - // eslint-disable-next-line - continue; - } - - // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic - // so for any recursion building if there is an entity two times same key meaning it will be looped - const recursionChainBreaker: Record = {}; - const expandedVal = await recursivelyExpandSecret( - expandedSec, - interpolatedSec, - crossSecEnvFetch, - recursionChainBreaker, - key - ); - - // eslint-disable-next-line - secrets[key].value = secrets[key].skipMultilineEncoding ? formatMultiValueEnv(expandedVal) : expandedVal; - } - - return secrets; - }; - return expandSecrets; + return expandSecret; }; export const decryptSecretRaw = ( diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 36edcf195..0c63eb147 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -258,6 +258,7 @@ export const secretQueueFactory = ({ const getIntegrationSecretsV2 = async (dto: { projectId: string; environment: string; + secretPath: string; folderId: string; depth: number; decryptor: (value: Buffer | null | undefined) => string; @@ -269,30 +270,36 @@ export const secretQueueFactory = ({ ); return content; } - - // process secrets in current folder - const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); - secrets.forEach((secret) => { - const secretKey = secret.key; - const secretValue = dto.decryptor(secret.encryptedValue); - content[secretKey] = { value: secretValue }; - - if (secret.encryptedComment) { - const commentValue = dto.decryptor(secret.encryptedComment); - content[secretKey].comment = commentValue; - } - - content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); - }); - const expandSecretReferences = expandSecretReferencesFactory({ decryptSecretValue: dto.decryptor, secretDAL: secretV2BridgeDAL, folderDAL, projectId: dto.projectId }); + // process secrets in current folder + const secrets = await secretV2BridgeDAL.findByFolderId(dto.folderId); + + await Promise.allSettled( + secrets.map(async (secret) => { + const secretKey = secret.key; + const secretValue = dto.decryptor(secret.encryptedValue); + const expandedSecretValue = await expandSecretReferences({ + environment: dto.environment, + secretPath: dto.secretPath, + skipMultilineEncoding: secret.skipMultilineEncoding, + value: secretValue + }); + content[secretKey] = { value: expandedSecretValue || "" }; + + if (secret.encryptedComment) { + const commentValue = dto.decryptor(secret.encryptedComment); + content[secretKey].comment = commentValue; + } + + content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); + }) + ); - await expandSecretReferences(content); // check if current folder has any imports from other folders const secretImports = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); @@ -329,6 +336,7 @@ export const secretQueueFactory = ({ const getIntegrationSecrets = async (dto: { projectId: string; environment: string; + secretPath: string; folderId: string; key: string; depth: number; @@ -341,46 +349,52 @@ export const secretQueueFactory = ({ return content; } - // process secrets in current folder - const secrets = await secretDAL.findByFolderId(dto.folderId); - secrets.forEach((secret) => { - const secretKey = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - key: dto.key - }); - - const secretValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key: dto.key - }); - - content[secretKey] = { value: secretValue }; - - if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) { - const commentValue = decryptSymmetric128BitHexKeyUTF8({ - ciphertext: secret.secretCommentCiphertext, - iv: secret.secretCommentIV, - tag: secret.secretCommentTag, - key: dto.key - }); - content[secretKey].comment = commentValue; - } - - content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); - }); - - const expandSecrets = interpolateSecrets({ + const expandSecretReferences = interpolateSecrets({ projectId: dto.projectId, secretEncKey: dto.key, folderDAL, secretDAL }); - await expandSecrets(content); + // process secrets in current folder + const secrets = await secretDAL.findByFolderId(dto.folderId); + await Promise.allSettled( + secrets.map(async (secret) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: dto.key + }); + + const secretValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: dto.key + }); + const expandedSecretValue = await expandSecretReferences({ + environment: dto.environment, + secretPath: dto.secretPath, + skipMultilineEncoding: secret.skipMultilineEncoding, + value: secretValue + }); + + content[secretKey] = { value: expandedSecretValue || "" }; + + if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) { + const commentValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretCommentCiphertext, + iv: secret.secretCommentIV, + tag: secret.secretCommentTag, + key: dto.key + }); + content[secretKey].comment = commentValue; + } + + content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); + }) + ); // check if current folder has any imports from other folders const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); @@ -404,7 +418,8 @@ export const secretQueueFactory = ({ projectId: dto.projectId, folderId: folder.id, key: dto.key, - depth: dto.depth + 1 + depth: dto.depth + 1, + secretPath: dto.secretPath }); // add the imported secrets to the current folder secrets @@ -686,6 +701,7 @@ export const secretQueueFactory = ({ projectId, folderId: folder.id, depth: 1, + secretPath, decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "") }) : await getIntegrationSecrets({ @@ -693,7 +709,8 @@ export const secretQueueFactory = ({ projectId, folderId: folder.id, key: botKey as string, - depth: 1 + depth: 1, + secretPath }); for (const integration of toBeSyncedIntegrations) { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index c62a73f69..e502f577a 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -482,7 +482,7 @@ export const secretServiceFactory = ({ projectId, environmentSlug: folder.environment.slug }); - // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + // TODO(akhilmhdh-pg): license check, posthog service and snapshot return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; }; @@ -1047,74 +1047,47 @@ export const secretServiceFactory = ({ }; }); + const expandSecret = interpolateSecrets({ + folderDAL, + projectId, + secretDAL, + secretEncKey: botKey + }); + if (expandSecretReferences) { - const expandSecrets = interpolateSecrets({ - folderDAL, - projectId, - secretDAL, - secretEncKey: botKey - }); - - const batchSecretsExpand = async ( - secretBatch: { - secretKey: string; - secretValue: string; - secretComment?: string; - secretPath: string; - skipMultilineEncoding: boolean | null | undefined; - }[] - ) => { - // Group secrets by secretPath - const secretsByPath: Record< - string, - { - secretKey: string; - secretValue: string; - secretComment?: string; - skipMultilineEncoding: boolean | null | undefined; - }[] - > = {}; - - secretBatch.forEach((secret) => { - if (!secretsByPath[secret.secretPath]) { - secretsByPath[secret.secretPath] = []; - } - secretsByPath[secret.secretPath].push(secret); - }); - - // Expand secrets for each group - for (const secPath in secretsByPath) { - if (!Object.hasOwn(secretsByPath, path)) { - // eslint-disable-next-line no-continue - continue; - } - - const secretRecord: Record< - string, - { value: string; comment?: string; skipMultilineEncoding: boolean | null | undefined } - > = {}; - secretsByPath[secPath].forEach((decryptedSecret) => { - secretRecord[decryptedSecret.secretKey] = { - value: decryptedSecret.secretValue, - comment: decryptedSecret.secretComment, - skipMultilineEncoding: decryptedSecret.skipMultilineEncoding - }; - }); - - await expandSecrets(secretRecord); - - secretsByPath[secPath].forEach((decryptedSecret) => { - // eslint-disable-next-line no-param-reassign - decryptedSecret.secretValue = secretRecord[decryptedSecret.secretKey].value; - }); - } - }; - - // expand secrets - await batchSecretsExpand(filteredSecrets); - - // expand imports by batch - await Promise.all(processedImports.map((processedImport) => batchSecretsExpand(processedImport.secrets))); + const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath); + await Promise.allSettled( + Object.keys(secretsGroupByPath).map((groupedPath) => + Promise.allSettled( + secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => { + const expandedSecretValue = await expandSecret({ + value: decryptedSecret.secretValue, + secretPath: groupedPath, + environment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + }); + // eslint-disable-next-line no-param-reassign + secretsGroupByPath[groupedPath][index].secretValue = expandedSecretValue || ""; + }) + ) + ) + ); + await Promise.allSettled( + processedImports.map((processedImport) => + Promise.allSettled( + processedImport.secrets.map(async (decryptedSecret, index) => { + const expandedSecretValue = await expandSecret({ + value: decryptedSecret.secretValue, + secretPath: path, + environment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + }); + // eslint-disable-next-line no-param-reassign + processedImport.secrets[index].secretValue = expandedSecretValue || ""; + }) + ) + ) + ); } return { @@ -1177,40 +1150,19 @@ export const secretServiceFactory = ({ const decryptedSecret = decryptSecretRaw(encryptedSecret, botKey); if (expandSecretReferences) { - const expandSecrets = interpolateSecrets({ + const expandSecret = interpolateSecrets({ folderDAL, projectId, secretDAL, secretEncKey: botKey }); - - const expandSingleSecret = async (secret: { - secretKey: string; - secretValue: string; - secretComment?: string; - secretPath: string; - skipMultilineEncoding: boolean | null | undefined; - }) => { - const secretRecord: Record< - string, - { value: string; comment?: string; skipMultilineEncoding: boolean | null | undefined } - > = { - [secret.secretKey]: { - value: secret.secretValue, - comment: secret.secretComment, - skipMultilineEncoding: secret.skipMultilineEncoding - } - }; - - await expandSecrets(secretRecord); - - // Update the secret with the expanded value - // eslint-disable-next-line no-param-reassign - secret.secretValue = secretRecord[secret.secretKey].value; - }; - - // Expand the secret - await expandSingleSecret(decryptedSecret); + const expandedSecretValue = await expandSecret({ + environment, + secretPath: path, + value: decryptedSecret.secretValue, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding + }); + decryptedSecret.secretValue = expandedSecretValue || ""; } return decryptedSecret; diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 39a5089b2..cb04211d5 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { QueueName } from "@app/queue"; export type TSecretVersionDALFactory = ReturnType; @@ -112,6 +114,7 @@ export const secretVersionDALFactory = (db: TDbClient) => { }; const pruneExcessVersions = async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v1 started`); try { await db(TableName.SecretVersion) .with("version_cte", (qb) => { @@ -137,6 +140,7 @@ export const secretVersionDALFactory = (db: TDbClient) => { name: "Secret Version Prune" }); } + logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v1 completed`); }; return { diff --git a/backend/vitest.e2e.config.ts b/backend/vitest.e2e.config.ts index c660fed14..684a4dc42 100644 --- a/backend/vitest.e2e.config.ts +++ b/backend/vitest.e2e.config.ts @@ -1,4 +1,4 @@ -import tsconfigPaths from "vite-tsconfig-paths"; // only if you are using custom tsconfig paths +import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -15,7 +15,14 @@ export default defineConfig({ useAtomics: true, isolate: false } + }, + alias: { + "./license-fns": path.resolve(__dirname, "./src/ee/services/license/__mocks__/license-fns") } }, - plugins: [tsconfigPaths()] // only if you are using custom tsconfig paths, + resolve: { + alias: { + "@app": path.resolve(__dirname, "./src") + } + } }); diff --git a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx index d02f47858..c37de08ff 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -1,9 +1,9 @@ --- -title: "AWS Elasticahe" -description: "Learn how to dynamically generate Redis Database user credentials." +title: "AWS ElastiCache" +description: "Learn how to dynamically generate AWS ElastiCache user credentials." --- -The Infisical Redis dynamic secret allows you to generate Redis Database credentials on demand based on configured role. +The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCache credentials on demand based on configured role. ## Prerequisites @@ -38,7 +38,7 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent New leases may take up-to a couple of minutes before ElastiCache has the chance to complete their configuration. - It is recommended to use a retry strategy when establishing new Redis ElastiCache connections. + It is recommended to use a retry strategy when establishing new ElastiCache connections. This may prevent errors when trying to use a password that isn't yet live on the targeted ElastiCache cluster. While a leasing is being created, you will be unable to create new leases for the same dynamic secret. @@ -51,7 +51,7 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent -## Set up Dynamic Secrets with Redis +## Set up Dynamic Secrets with AWS ElastiCache @@ -60,8 +60,8 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png) - - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache) + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png) diff --git a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx new file mode 100644 index 000000000..c4e6633e3 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx @@ -0,0 +1,127 @@ +--- +title: "Elastic Search" +description: "Learn how to dynamically generate Elastic Search user credentials." +--- + +The Infisical Elastic Search dynamic secret allows you to generate Elastic Search credentials on demand based on configured role. + +## Prerequisites + + + +1. Create a role with at least `manage_security` and `monitor` permissions. +2. Assign the newly created role to your API key or user that you'll use later in the dynamic secret configuration. + + + For testing purposes, you can also use a highly privileged role like `superuser`, that will have full control over the cluster. This is not recommended in production environments following the principle of least privilege. + + +## Set up Dynamic Secrets with Elastic Search + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-elastic-search.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + Your Elastic Search host. This is the endpoint that your instance runs on. _(Example: https://your-cluster-ip)_ + + + + The port that your Elastic Search instance is running on. _(Example: 9200)_ + + + + The roles that the new user that is created when a lease is provisioned will be assigned to. This is a required field. This defaults to `superuser`, which is highly privileged. It is recommended to create a new role with the least privileges required for the lease. + + + + Select the authentication method you want to use to connect to your Elastic Search instance. + + + + The username of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. + + + + The password of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. + + + + The ID of the API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. + + + + The API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. + + + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certificate. + + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease-redis.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-elastic-search.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data-redis.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx new file mode 100644 index 000000000..f9352f2e5 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx @@ -0,0 +1,114 @@ +--- +title: "Mongo Atlas" +description: "Learn how to dynamically generate Mongo Atlas Database user credentials." +--- + +The Infisical Mongo Atlas dynamic secret allows you to generate Mongo Atlas Database credentials on demand based on configured role. + +## Prerequisite +Create a project scopped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). + + + The API Key must have permission to manage users in the project. + + +## Set up Dynamic Secrets with Mongo Atlas + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + The public key of your generated Atlas API Key. This acts as a username. + + + + The private key of your generated Atlas API Key. This acts as a password. + + + + Unique 24-hexadecimal digit string that identifies your project. This is same as project id + + + + List that provides the pairings of one role with one applicable database. + - **Database Name**: Database to which the user is granted access privileges. + - **Collection**: Collection on which this role applies. + - **Role Name**: Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. + - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) + + + + List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. + + ![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) + - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. + - **Type**: Category of resource that this database user can access. + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/integrations/github/integrations-github-scope-env.png b/docs/images/integrations/github/integrations-github-scope-env.png index e38874bd3..3c2592908 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-env.png and b/docs/images/integrations/github/integrations-github-scope-env.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-org.png b/docs/images/integrations/github/integrations-github-scope-org.png index d5ef76a2b..9f56e9e9f 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-org.png and b/docs/images/integrations/github/integrations-github-scope-org.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-repo.png b/docs/images/integrations/github/integrations-github-scope-repo.png index 353527c78..4b1b223f0 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-repo.png and b/docs/images/integrations/github/integrations-github-scope-repo.png differ diff --git a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png new file mode 100644 index 000000000..50c9f89bd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png new file mode 100644 index 000000000..f18ee3547 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png new file mode 100644 index 000000000..14d2d48b2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png new file mode 100644 index 000000000..e3989b00f Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-elastic-search.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-elastic-search.png new file mode 100644 index 000000000..bb21f628c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-elastic-search.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values-elastic-search.png b/docs/images/platform/dynamic-secrets/lease-values-elastic-search.png new file mode 100644 index 000000000..7d5685a81 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values-elastic-search.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-elastic-search-statement.png b/docs/images/platform/dynamic-secrets/modify-elastic-search-statement.png new file mode 100644 index 000000000..9315a4077 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-elastic-search-statement.png differ diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 936c8974a..93b1347f7 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -41,6 +41,13 @@ Prerequisites: ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) + + When using the organization scope, your secrets will be saved in the top-level of your Github Organization. + + You can choose the visibility, which defines which repositories can access the secrets. The options are: + - **All public repositories**: All public repositories in the organization can access the secrets. + - **All private repositories**: All private repositories in the organization can access the secrets. + - **Selected repositories**: Only the selected repositories can access the secrets. This gives a more fine-grained control over which repositories can access the secrets. You can select _both_ private and public repositories with this option. ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) diff --git a/docs/mint.json b/docs/mint.json index 34cb085a8..b56493e01 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -156,7 +156,9 @@ "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/redis", "documentation/platform/dynamic-secrets/aws-elasticache", - "documentation/platform/dynamic-secrets/aws-iam" + "documentation/platform/dynamic-secrets/elastic-search", + "documentation/platform/dynamic-secrets/aws-iam", + "documentation/platform/dynamic-secrets/mongo-atlas" ] }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74f00b74d..3148f5da2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -84,6 +85,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.0", "react-i18next": "^12.2.2", + "react-icons": "^5.3.0", "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", @@ -8609,26 +8611,6 @@ "integrity": "sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==", "dev": true }, - "node_modules/@types/eslint": { - "version": "8.56.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", - "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", @@ -9298,9 +9280,9 @@ "dev": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", @@ -9320,9 +9302,9 @@ "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { @@ -9343,15 +9325,15 @@ "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { @@ -9379,28 +9361,28 @@ "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -9408,24 +9390,24 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -9434,12 +9416,12 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -12933,9 +12915,9 @@ "integrity": "sha512-JGmudTwg7yxMYvR/gWbalqqQiyu7WTFv2Xu3vw4cJHXPFxNgAk0oy8UHaer8nLF4lZJa+rNoj6GsrKIVJTV6Tw==" }, "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "dev": true, "dependencies": { "bn.js": "^4.11.9", @@ -12998,9 +12980,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -15867,11 +15849,11 @@ } }, "node_modules/infisical-node/node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -18133,12 +18115,12 @@ ] }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -20968,6 +20950,14 @@ } } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -24479,9 +24469,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -24506,34 +24496,33 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -24666,9 +24655,9 @@ "dev": true }, "node_modules/webpack/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -24677,10 +24666,10 @@ "node": ">=0.4.0" } }, - "node_modules/webpack/node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/webpack/node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "peerDependencies": { "acorn": "^8" diff --git a/frontend/package.json b/frontend/package.json index 994485252..a538e7cf1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -92,6 +92,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.0", "react-i18next": "^12.2.2", + "react-icons": "^5.3.0", "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 9711e3e68..0455d3996 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -9,36 +9,42 @@ import { Tooltip } from "../Tooltip"; export type FormLabelProps = { id?: string; isRequired?: boolean; - isOptional?:boolean; + isOptional?: boolean; label?: ReactNode; icon?: ReactNode; className?: string; - tooltipText?: string; + tooltipClassName?: string; + tooltipText?: ReactNode; }; -export const FormLabel = ({ id, label, isRequired, icon, className,isOptional, tooltipText }: FormLabelProps) => ( +export const FormLabel = ({ + id, + label, + isRequired, + icon, + className, + isOptional, + tooltipClassName, + tooltipText +}: FormLabelProps) => ( {label} {isRequired && *} - {isOptional && - Optional} + {isOptional && - Optional} {icon && !tooltipText && ( {icon} )} {tooltipText && ( - - + + )} diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index ff881e506..b79c8f02e 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -20,7 +20,9 @@ export enum DynamicSecretProviders { Cassandra = "cassandra", AwsIam = "aws-iam", Redis = "redis", - AwsElastiCache = "aws-elasticache" + AwsElastiCache = "aws-elasticache", + MongoAtlas = "mongo-db-atlas", + ElasticSearch = "elastic-search" } export enum SqlProviders { @@ -97,6 +99,44 @@ export type TDynamicSecretProvider = revocationStatement: string; ca?: string | undefined; }; + } + | { + type: DynamicSecretProviders.MongoAtlas; + inputs: { + adminPublicKey: string; + adminPrivateKey: string; + groupId: string; + roles: { + databaseName: string; + roleName: string; + collectionName?: string; + }[]; + scopes?: { + name: string; + type: string; + }[]; + }; + } + | { + type: DynamicSecretProviders.ElasticSearch; + inputs: { + host: string; + port: number; + ca?: string | undefined; + roles: string[]; + + auth: + | { + type: "user"; + username: string; + password: string; + } + | { + type: "api-key"; + apiKey: string; + apiKeyId: string; + }; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index de4c1a914..ebb0e8b1e 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -71,6 +71,8 @@ export const useCreateIntegration = () => { key: string; value: string; }[]; + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; kmsKeyId?: string; shouldDisableDelete?: boolean; shouldMaskSecrets?: boolean; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 21e6bff26..f8c7ce244 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -34,6 +34,9 @@ export type TIntegration = { syncMessage?: string; __v: number; metadata?: { + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; + secretSuffix?: string; syncBehavior?: IntegrationSyncBehavior; mappingBehavior?: IntegrationMappingBehavior; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index 89a804f6e..c349d39f5 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -7,7 +7,11 @@ import { TSharedSecret, TViewSharedSecretResponse } from "./types"; export const secretSharingKeys = { allSharedSecrets: () => ["sharedSecrets"] as const, specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) => - [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const + [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const, + getSecretById: (arg: { id: string; hashedHex: string; password?: string }) => [ + "shared-secret", + arg + ] }; export const useGetSharedSecrets = ({ @@ -38,31 +42,28 @@ export const useGetSharedSecrets = ({ export const useGetActiveSharedSecretById = ({ sharedSecretId, - hashedHex + hashedHex, + password }: { sharedSecretId: string; hashedHex: string; + password?: string; }) => { - return useQuery({ - enabled: Boolean(sharedSecretId) && Boolean(hashedHex), - queryFn: async () => { - const params = new URLSearchParams({ - hashedHex - }); - - const { data } = await apiRequest.get( + return useQuery( + secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }), + async () => { + const { data } = await apiRequest.post( `/api/v1/secret-sharing/public/${sharedSecretId}`, { - params + hashedHex, + password } ); - return { - encryptedValue: data.encryptedValue, - iv: data.iv, - tag: data.tag, - accessType: data.accessType, - orgName: data.orgName - }; + + return data; + }, + { + enabled: Boolean(sharedSecretId) && Boolean(hashedHex) } - }); + ); }; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index d35fea8d6..0dd4a9555 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -15,6 +15,7 @@ export type TSharedSecret = { export type TCreateSharedSecretRequest = { name?: string; + password?: string; encryptedValue: string; hashedHex: string; iv: string; @@ -25,11 +26,14 @@ export type TCreateSharedSecretRequest = { }; export type TViewSharedSecretResponse = { - encryptedValue: string; - iv: string; - tag: string; - accessType: SecretSharingAccessType; - orgName?: string; + isPasswordProtected: boolean; + secret: { + encryptedValue: string; + iv: string; + tag: string; + accessType: SecretSharingAccessType; + orgName?: string; + }; }; export type TDeleteSharedSecretRequest = { @@ -40,3 +44,4 @@ export enum SecretSharingAccessType { Anyone = "anyone", Organization = "organization" } + diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 2015ac8f9..c002ea103 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import Head from "next/head"; import Image from "next/image"; @@ -53,6 +53,21 @@ enum TabSections { Options = "options" } +const secretsVisibility = [ + { + value: "selected", + label: "Select repositories" + }, + { + value: "all", + label: "All public repositories" + }, + { + value: "private", + label: "All private repositories" + } +] as const; + const targetEnv = ["github-repo", "github-org", "github-env"] as const; type TargetEnv = (typeof targetEnv)[number]; @@ -63,9 +78,12 @@ const schema = yup.object({ shouldEnableDelete: yup.boolean().optional(), scope: yup.mixed().oneOf(targetEnv.slice()).required(), - repoIds: yup.mixed().when("scope", { - is: "github-repo", - then: yup.array(yup.string().required()).min(1, "Select at least one repositories") + // Explanation: If scope is (github-repo) OR (github-org AND visibility is set to selected), then repoIds is required + repoIds: yup.mixed().when(["scope", "visibility"], { + is: (scope: string, visibility: string) => + scope === "github-repo" || (scope === "github-org" && visibility === "selected"), + then: yup.array(yup.string().required()).min(1, "Select at least one repository"), + otherwise: yup.mixed().notRequired() }), repoId: yup.mixed().when("scope", { @@ -91,6 +109,10 @@ const schema = yup.object({ orgId: yup.mixed().when("scope", { is: "github-org", then: yup.string().required("Organization is required") + }), + visibility: yup.mixed().when("scope", { + is: "github-org", + then: yup.string().required("Visibility is required") }) }); @@ -121,6 +143,7 @@ export default function GitHubCreateIntegrationPage() { secretPath: "/", scope: "github-repo", repoIds: [], + visibility: "all", shouldEnableDelete: false } }); @@ -130,6 +153,7 @@ export default function GitHubCreateIntegrationPage() { const repoIds = watch("repoIds"); const repoName = watch("repoName"); const repoOwner = watch("repoOwner"); + const selectedOrgId = watch("orgId"); const { data: integrationAuthGithubEnvs } = useGetIntegrationAuthGithubEnvs( integrationAuthId as string, @@ -196,6 +220,8 @@ export default function GitHubCreateIntegrationPage() { scope: data.scope, owner: integrationAuthOrgs?.find((e) => e.orgId === data.orgId)?.name, metadata: { + githubVisibility: data.visibility, + githubVisibilityRepoIds: data.repoIds, secretSuffix: data.secretSuffix, shouldEnableDelete: data.shouldEnableDelete } @@ -242,6 +268,15 @@ export default function GitHubCreateIntegrationPage() { } }; + const selectedOrganization = useMemo(() => { + if (!integrationAuthApps) return null; + + return integrationAuthApps.filter( + (authApp) => + integrationAuthOrgs?.find((e) => e.orgId === selectedOrgId)?.name === authApp.owner + ); + }, [selectedOrgId, integrationAuthApps]); + return integrationAuth && workspace && integrationAuthApps ? (
@@ -339,7 +374,10 @@ export default function GitHubCreateIntegrationPage() { + ( + - {integrationAuthOrgs && - integrationAuthOrgs.map(({ name, orgId }) => ( - - {name} + + + )} + /> + + ( + + - + + + )} + /> + + {watch("visibility") === "selected" && ( + ( + + + + {integrationAuthApps.length > 0 ? ( +
+ {repoIds.length === 1 + ? integrationAuthApps?.reduce( + (acc, { appId, name, owner }) => + repoIds[0] === appId ? `${owner}/${name}` : acc, + "" + ) + : `${repoIds.length} repositories selected`} + +
+ ) : ( +
+ No repositories found +
+ )} +
+ + {selectedOrganization ? ( + selectedOrganization.map((integrationAuthApp) => { + const isSelected = repoIds.includes( + String(integrationAuthApp.appId) + ); + + return ( + { + if (repoIds.includes(String(integrationAuthApp.appId))) { + onChange( + repoIds.filter( + (appId: string) => + appId !== String(integrationAuthApp.appId) + ) + ); + } else { + onChange([ + ...repoIds, + String(integrationAuthApp.appId) + ]); + } + }} + key={`repos-id-${integrationAuthApp.appId}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {integrationAuthApp.owner}/{integrationAuthApp.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> )} - /> + )} {scope === "github-env" && ( ; + onRemoveIntegration: VoidFunction; + onManualSyncIntegration: VoidFunction; +}; + +export const ConfiguredIntegrationItem = ({ + integration, + environments, + onRemoveIntegration, + onManualSyncIntegration +}: IProps) => { + return ( +
+
+
+ +
+ {environments.find((e) => e.id === integration.envId)?.name || "-"} +
+
+
+ +
+ {integration.secretPath} +
+
+
+ +
+
+ + {/* eslint-disable-next-line no-nested-ternary */} + {integration.metadata?.githubVisibility === "selected" + ? "Syncing to selected repositories in the organization. " + : integration.metadata?.githubVisibility === "private" + ? "Syncing to all private repositories in the organization" + : "Syncing to all public and private repositories in the organization"} +
+ ) : undefined + } + label="Integration" + /> +
+ {integrationSlugNameMapping[integration.integration]} +
+
+ {integration.integration === "qovery" && ( +
+
+ +
+ {integration?.owner || "-"} +
+
+
+ +
+ {integration?.targetService || "-"} +
+
+
+ +
+ {integration?.targetEnvironment || "-"} +
+
+
+ )} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
+
+ )} + {(integration.integration === "vercel" || + integration.integration === "netlify" || + integration.integration === "railway" || + integration.integration === "gitlab" || + integration.integration === "teamcity" || + integration.integration === "bitbucket" || + (integration.integration === "github" && integration.scope === "github-env")) && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "checkly" && integration.targetService && ( +
+ +
+ {integration.targetService} +
+
+ )} + {integration.integration === "terraform-cloud" && integration.targetService && ( +
+ +
+ {integration.targetService} +
+
+ )} + {(integration.integration === "checkly" || integration.integration === "github") && ( +
+ +
+ {integration?.metadata?.secretSuffix || "-"} +
+
+ )} +
+
+ {integration.isSynced != null && integration.lastUsed != null && ( + + +
+ +
Last sync
+
+
+ {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} +
+ {!integration.isSynced && ( + <> +
+ +
Fail reason
+
+
{integration.syncMessage}
+ + )} +
+ } + > +
+
{integration.isSynced ? "Synced" : "Not synced"}
+ {!integration.isSynced && } +
+ + + )} +
+ + + +
+ + {(isAllowed: boolean) => ( +
+ + onRemoveIntegration()} + ariaLabel="delete" + isDisabled={!isAllowed} + colorSchema="danger" + variant="star" + > + + + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 0be10f129..46037dfb4 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,27 +1,10 @@ -import { faCalendarCheck } from "@fortawesome/free-regular-svg-icons"; -import { faArrowRight, faRefresh, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; -import { integrationSlugNameMapping } from "public/data/frequentConstants"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - Checkbox, - DeleteActionModal, - EmptyState, - FormLabel, - IconButton, - Skeleton, - Tag, - Tooltip -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2"; import { usePopUp, useToggle } from "@app/hooks"; import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; -import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; +import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem"; + type Props = { environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; @@ -72,207 +55,22 @@ export const IntegrationsSection = ({ {!isLoading && (
{integrations?.map((integration) => ( -
-
-
- -
- {environments.find((e) => e.id === integration.envId)?.name || "-"} -
-
-
- -
- {integration.secretPath} -
-
-
- -
-
- -
- {integrationSlugNameMapping[integration.integration]} -
-
- {integration.integration === "qovery" && ( -
-
- -
- {integration?.owner || "-"} -
-
-
- -
- {integration?.targetService || "-"} -
-
-
- -
- {integration?.targetEnvironment || "-"} -
-
-
- )} - {!( - integration.integration === "aws-secret-manager" && - integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE - ) && ( -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (["aws-parameter-store", "rundeck"].includes(integration.integration) && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} -
-
- )} - {(integration.integration === "vercel" || - integration.integration === "netlify" || - integration.integration === "railway" || - integration.integration === "gitlab" || - integration.integration === "teamcity" || - integration.integration === "bitbucket" || - (integration.integration === "github" && integration.scope === "github-env")) && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "checkly" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {integration.integration === "terraform-cloud" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {(integration.integration === "checkly" || - integration.integration === "github") && ( -
- -
- {integration?.metadata?.secretSuffix || "-"} -
-
- )} -
-
- {integration.isSynced != null && integration.lastUsed != null && ( - - -
- -
Last sync
-
-
- {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} -
- {!integration.isSynced && ( - <> -
- -
Fail reason
-
-
- {integration.syncMessage} -
- - )} -
- } - > -
-
{integration.isSynced ? "Synced" : "Not synced"}
- {!integration.isSynced && } -
- - - )} -
- - - -
- - {(isAllowed: boolean) => ( -
- - { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - ariaLabel="delete" - isDisabled={!isAllowed} - colorSchema="danger" - variant="star" - > - - - -
- )} -
-
-
+ { + syncIntegration({ + workspaceId, + id: integration.id, + lastUsed: integration.lastUsed as string + }); + }} + onRemoveIntegration={() => { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + integration={integration} + environments={environments} + /> ))}
)} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index b28a07116..71b25e63c 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { DiRedis } from "react-icons/di"; +import { SiApachecassandra, SiElasticsearch, SiMongodb } from "react-icons/si"; import { faAws } from "@fortawesome/free-brands-svg-icons"; import { faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -10,6 +12,8 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm"; import { CassandraInputForm } from "./CassandraInputForm"; +import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; +import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; import { RedisInputForm } from "./RedisInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; @@ -28,29 +32,39 @@ enum WizardSteps { const DYNAMIC_SECRET_LIST = [ { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.SqlDatabase, title: "SQL\nDatabase" }, { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.Cassandra, title: "Cassandra" }, { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.Redis, title: "Redis" }, { - icon: faAws, + icon: , provider: DynamicSecretProviders.AwsElastiCache, title: "AWS ElastiCache" }, { - icon: faAws, + icon: , provider: DynamicSecretProviders.AwsIam, title: "AWS IAM" + }, + { + icon: , + provider: DynamicSecretProviders.MongoAtlas, + title: "Mongo Atlas" + }, + { + icon: , + provider: DynamicSecretProviders.ElasticSearch, + title: "Elastic Search" } ]; @@ -87,7 +101,7 @@ export const CreateDynamicSecretForm = ({ exit={{ opacity: 0, translateX: -30 }} >
Select a service to connect to:
-
+
{DYNAMIC_SECRET_LIST.map(({ icon, provider, title }) => (
- + {icon}
{title}
))} @@ -202,6 +216,42 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.MongoAtlas && ( + + + + )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.ElasticSearch && ( + + + + )} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/ElasticSearchInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/ElasticSearchInputForm.tsx new file mode 100644 index 000000000..ac232a725 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/ElasticSearchInputForm.tsx @@ -0,0 +1,425 @@ +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const authMethods = [ + { + label: "Username/Password", + value: "user" + }, + { + label: "API Key", + value: "api-key" + } +] as const; + +const formSchema = z.object({ + provider: z.object({ + host: z.string().trim().min(1), + port: z.coerce.number(), + + // two auth types "user, apikey" + auth: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("user"), + username: z.string().trim(), + password: z.string().trim() + }), + z.object({ + type: z.literal("api-key"), + apiKey: z.string().trim(), + apiKeyId: z.string().trim() + }) + ]), + + roles: z.array(z.string().trim().min(1)).min(1, "At least one role is required"), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const ElasticSearchInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + setValue, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + auth: { + type: "user" + }, + roles: ["superuser"], + port: 443 + } + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.ElasticSearch, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + const selectedAuthType = watch("provider.auth.type"); + const selectedRoles = watch("provider.roles"); + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ +
+ } + /> +
+ {selectedRoles.map((_, i) => ( + ( + +
+ + { + if (selectedRoles && selectedRoles?.length > 1) { + setValue( + "provider.roles", + selectedRoles.filter((__, idx) => idx !== i) + ); + } + }} + > + + +
+
+ )} + /> + ))} +
+
+
+ +
+
+ ( + + + + )} + /> +
+
+
+
+
+ + +
+ +
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx new file mode 100644 index 000000000..14f529fe0 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx @@ -0,0 +1,455 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + FormLabel, + IconButton, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + adminPublicKey: z.string().trim().min(1), + adminPrivateKey: z.string().trim().min(1), + groupId: z.string().trim().min(1), + roles: z + .object({ + collectionName: z.string().optional(), + databaseName: z.string().min(1), + roleName: z.string().min(1) + }) + .array() + .min(1), + scopes: z + .object({ + name: z.string().min(1), + type: z.string().min(1) + }) + .array() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +const ATLAS_SCOPE_TYPES = [ + { + label: "Cluster", + value: "CLUSTER" + }, + { + label: "Data Lake", + value: "DATA_LAKE" + }, + { + label: "Stream", + value: "STREAM" + } +]; + +export const MongoAtlasInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + getValues, + setValue, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + roles: [{ databaseName: "", roleName: "" }] + } + } + }); + + const roleFields = useFieldArray({ + control, + name: "provider.roles" + }); + + const scopeFields = useFieldArray({ + control, + name: "provider.scopes" + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.MongoAtlas, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + +
+ {roleFields.fields.map(({ id: roleFieldId }, i) => ( +
+
+ {i === 0 && Database Name} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ { + const roles = getValues("provider.roles"); + if (roles && roles?.length > 1) { + roleFields.remove(i); + } else { + setValue("provider.roles", [{ databaseName: "", roleName: "" }]); + } + }} + > + + +
+ ))} +
+ +
+
+ + + Advanced + + +
+ {scopeFields.fields.map(({ id: scopeFieldId }, i) => ( +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && Type} + ( + + + + )} + /> +
+ { + scopeFields.remove(i); + }} + > + + +
+ ))} +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 3cb709d59..9b3d069c2 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -57,7 +57,8 @@ const OutputDisplay = ({ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { if ( provider === DynamicSecretProviders.SqlDatabase || - provider === DynamicSecretProviders.Cassandra + provider === DynamicSecretProviders.Cassandra || + provider === DynamicSecretProviders.MongoAtlas ) { const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string }; return ( @@ -138,6 +139,24 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { ); } + if (provider === DynamicSecretProviders.ElasticSearch) { + const { DB_USERNAME, DB_PASSWORD } = data as { + DB_USERNAME: string; + DB_PASSWORD: string; + }; + + return ( +
+ + +
+ ); + } + return null; }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretElasticSearchForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretElasticSearchForm.tsx new file mode 100644 index 000000000..4e1d3b1f3 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretElasticSearchForm.tsx @@ -0,0 +1,429 @@ +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + FormLabel, + IconButton, + Input, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const authMethods = [ + { + label: "Username/Password", + value: "user" + }, + { + label: "API Key", + value: "api-key" + } +] as const; + +const formSchema = z.object({ + inputs: z.object({ + host: z.string().trim().min(1), + port: z.coerce.number(), + + // two auth types "user, apikey" + auth: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("user"), + username: z.string().trim(), + password: z.string().trim() + }), + z.object({ + type: z.literal("api-key"), + apiKey: z.string().trim(), + apiKeyId: z.string().trim() + }) + ]), + + roles: z.array(z.string().trim().min(1)).min(1, "At least one role is required"), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + newName: z + .string() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .optional() +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; + +export const EditDynamicSecretElasticSearchForm = ({ + onClose, + dynamicSecret, + secretPath, + environment, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + setValue, + watch + } = useForm({ + resolver: zodResolver(formSchema), + values: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL, + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { + // wait till previous request is finished + if (updateDynamicSecret.isLoading) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + maxTTL: maxTTL || undefined, + defaultTTL, + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + const selectedAuthType = watch("inputs.auth.type"); + const selectedRoles = watch("inputs.roles"); + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ +
+ } + /> + {selectedRoles.map((_, i) => ( + ( +
+
+ + + +
+ + { + if (selectedRoles && selectedRoles?.length > 1) { + setValue( + "inputs.roles", + selectedRoles.filter((__, idx) => idx !== i) + ); + } + }} + > + + +
+ )} + /> + ))} + +
+ +
+
+ +
+ ( + + + + )} + /> +
+
+
+
+
+ + +
+ +
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 3309126ee..f0a4fd957 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -7,6 +7,8 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm"; import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; +import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm"; +import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm"; import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm"; import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm"; @@ -128,6 +130,41 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.MongoAtlas && ( + + + + )} + + {dynamicSecretDetails?.type === DynamicSecretProviders.ElasticSearch && ( + + + + )} ); }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx new file mode 100644 index 000000000..d5daf34a9 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx @@ -0,0 +1,466 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + FormLabel, + IconButton, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + inputs: z + .object({ + adminPublicKey: z.string().trim().min(1), + adminPrivateKey: z.string().trim().min(1), + groupId: z.string().trim().min(1), + roles: z + .object({ + collectionName: z.string().optional(), + databaseName: z.string().min(1), + roleName: z.string().min(1) + }) + .array() + .min(1), + scopes: z + .object({ + name: z.string().min(1), + type: z.string().min(1) + }) + .array() + }) + .partial(), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + newName: z + .string() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .optional() +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; + +const ATLAS_SCOPE_TYPES = [ + { + label: "Cluster", + value: "CLUSTER" + }, + { + label: "Data Lake", + value: "DATA_LAKE" + }, + { + label: "Stream", + value: "STREAM" + } +]; + +export const EditDynamicSecretMongoAtlasForm = ({ + onClose, + dynamicSecret, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + getValues, + setValue, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL, + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const roleFields = useFieldArray({ + control, + name: "inputs.roles" + }); + + const scopeFields = useFieldArray({ + control, + name: "inputs.scopes" + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { + // wait till previous request is finished + if (updateDynamicSecret.isLoading) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + maxTTL: maxTTL || undefined, + defaultTTL, + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + +
+ {roleFields.fields.map(({ id: roleFieldId }, i) => ( +
+
+ {i === 0 && Database Name} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ { + const roles = getValues("inputs.roles"); + if (roles && roles?.length > 1) { + roleFields.remove(i); + } else { + setValue("inputs.roles", [{ databaseName: "", roleName: "" }]); + } + }} + > + + +
+ ))} +
+ +
+
+ + + Advanced + + +
+ {scopeFields.fields.map(({ id: scopeFieldId }, i) => ( +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && Type} + ( + + + + )} + /> +
+ { + scopeFields.remove(i); + }} + > + + +
+ ))} +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx index da9aa3c98..ea39e1265 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx @@ -32,7 +32,8 @@ const viewLimitOptions = [ const schema = z.object({ name: z.string().optional(), - secret: z.string(), + password: z.string().optional(), + secret: z.string().min(1), expiresIn: z.string(), viewLimit: z.string(), accessType: z.nativeEnum(SecretSharingAccessType).optional() @@ -67,7 +68,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { } }); - const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => { + const onFormSubmit = async ({ + name, + password, + secret, + expiresIn, + viewLimit, + accessType + }: FormData) => { try { const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); @@ -80,6 +88,7 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { const { id } = await createSharedSecret.mutateAsync({ name, + password, encryptedValue: ciphertext, hashedHex, iv, @@ -149,6 +158,20 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { )} /> + ( + + + + )} + /> { const router = useRouter(); + const [password, setPassword] = useState(); const { id, key: urlEncodedPublicKey } = router.query; const [hashedHex, key] = urlEncodedPublicKey ? urlEncodedPublicKey.toString().split("-") : ["", ""]; - const { data: secret, error } = useGetActiveSharedSecretById({ + const { + data: fetchSecret, + error, + isLoading, + isFetching + } = useGetActiveSharedSecretById({ sharedSecretId: id as string, - hashedHex + hashedHex, + password }); + const isInvalidCredential = + ((error as AxiosError)?.response?.data as { message: string })?.message === + "Invalid credentials"; + + const shouldShowPasswordPrompt = + isInvalidCredential || (fetchSecret?.isPasswordProtected && !fetchSecret.secret); + const isValidatingPassword = Boolean(password) && isFetching; + return (
@@ -52,8 +69,23 @@ export const ViewSecretPublicPage = () => {

- {secret && key && } - {error && } + {(shouldShowPasswordPrompt || isValidatingPassword) && ( + { + setPassword(el); + }} + isInvalidCredential={!isFetching && isInvalidCredential} + /> + )} + {!isLoading && ( + <> + {!error && fetchSecret?.secret && key && ( + + )} + {error && !isInvalidCredential && } + + )}
diff --git a/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx b/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx new file mode 100644 index 000000000..edf60447b --- /dev/null +++ b/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx @@ -0,0 +1,80 @@ +import { Controller, useForm } from "react-hook-form"; +import { faArrowRight, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; + +type Props = { + onPasswordSubmit: (val: any) => void; + isSubmitting?: boolean; + isInvalidCredential?: boolean; +}; + +const formSchema = z.object({ + password: z.string() +}); + +export type FormData = z.infer; + +export const PasswordContainer = ({ + onPasswordSubmit, + isSubmitting, + isInvalidCredential +}: Props) => { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(formSchema) + }); + + const onFormSubmit = async ({ password }: FormData) => { + onPasswordSubmit(password); + }; + + return ( +
+
+ ( + +
+ +
+ + + +
+
+
+ )} + /> + + +
+ ); +}; diff --git a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx index 58c6c57c2..a920c9d93 100644 --- a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx +++ b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx @@ -14,7 +14,7 @@ import { useTimedReset, useToggle } from "@app/hooks"; import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing"; type Props = { - secret: TViewSharedSecretResponse; + secret: TViewSharedSecretResponse["secret"]; secretKey: string; }; diff --git a/frontend/src/views/ViewSecretPublicPage/components/index.tsx b/frontend/src/views/ViewSecretPublicPage/components/index.tsx index b3424d790..8ba8b27c0 100644 --- a/frontend/src/views/ViewSecretPublicPage/components/index.tsx +++ b/frontend/src/views/ViewSecretPublicPage/components/index.tsx @@ -1,2 +1,3 @@ +export { PasswordContainer } from "./PasswordContainer"; export { SecretContainer } from "./SecretContainer"; export { SecretErrorContainer } from "./SecretErrorContainer";