diff --git a/backend/src/db/migrations/20240307232900_integration-last-used.ts b/backend/src/db/migrations/20240307232900_integration-last-used.ts new file mode 100644 index 000000000..c64c31881 --- /dev/null +++ b/backend/src/db/migrations/20240307232900_integration-last-used.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Integration, (t) => { + t.datetime("lastUsed"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Integration, (t) => { + t.dropColumn("lastUsed"); + }); +} diff --git a/backend/src/db/schemas/integrations.ts b/backend/src/db/schemas/integrations.ts index cf8c88154..203498c85 100644 --- a/backend/src/db/schemas/integrations.ts +++ b/backend/src/db/schemas/integrations.ts @@ -27,7 +27,8 @@ export const IntegrationsSchema = z.object({ envId: z.string().uuid(), secretPath: z.string().default("/"), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + lastUsed: z.date().nullable().optional() }); export type TIntegrations = z.infer; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 9a91b745d..d2203fea3 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -12,9 +12,11 @@ import { groupBy, pick, unique } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; @@ -44,10 +46,12 @@ type TSecretApprovalRequestServiceFactoryDep = { secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory; secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory; folderDAL: Pick; - secretTagDAL: Pick; + secretDAL: TSecretDALFactory; + secretTagDAL: Pick; secretBlindIndexDAL: Pick; snapshotService: Pick; - secretVersionDAL: Pick; + secretVersionDAL: Pick; + secretVersionTagDAL: Pick; projectDAL: Pick; secretService: Pick< TSecretServiceFactory, @@ -64,8 +68,10 @@ export type TSecretApprovalRequestServiceFactory = ReturnType id), version: 1, type: SecretType.Shared - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) : []; const updatedSecrets = secretUpdationCommits.length @@ -367,7 +377,11 @@ export const secretApprovalRequestServiceFactory = ({ "secretBlindIndex" ]) } - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) : []; const deletedSecret = secretDeletionCommits.length @@ -455,7 +469,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: createdSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); commits.push( @@ -482,7 +497,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: updatedSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -492,7 +508,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secsGroupedByBlindIndex = groupBy(secretsToBeUpdated, (el) => el.secretBlindIndex as string); @@ -531,7 +548,8 @@ export const secretApprovalRequestServiceFactory = ({ inputSecrets: deletedSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secretsGroupedByBlindIndex = groupBy(secrets, (i) => { if (!i.secretBlindIndex) throw new BadRequestError({ message: "Missing secret blind index" }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7880f79e3..9394bad38 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -421,7 +421,12 @@ export const registerRoutes = async ( orgDAL, projectMembershipDAL, smtpService, - projectDAL + projectDAL, + projectBotDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL }); const secretBlindIndexService = secretBlindIndexServiceFactory({ permissionService, @@ -445,6 +450,7 @@ export const registerRoutes = async ( const sarService = secretApprovalRequestServiceFactory({ permissionService, folderDAL, + secretDAL, secretTagDAL, secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestReviewerDAL: sarReviewerDAL, @@ -454,6 +460,7 @@ export const registerRoutes = async ( secretApprovalRequestDAL, secretService, snapshotService, + secretVersionTagDAL, secretQueueService }); const secretRotationQueue = secretRotationQueueFactory({ diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 4d7aa1b1e..3f48a39d4 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -513,6 +513,37 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + url: "/:integrationAuthId/heroku/pipelines", + method: "GET", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + pipelines: z + .object({ + app: z.object({ appId: z.string() }), + stage: z.string(), + pipeline: z.object({ name: z.string(), pipelineId: z.string() }) + }) + .array() + }) + } + }, + handler: async (req) => { + const pipelines = await server.services.integrationAuth.getHerokuPipelines({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { pipelines }; + } + }); + server.route({ url: "/:integrationAuthId/railway/environments", method: "GET", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index ec712a543..ed1914ccc 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -32,6 +32,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { .object({ secretPrefix: z.string().optional(), secretSuffix: z.string().optional(), + initialSyncBehavior: z.string().optional(), secretGCPLabel: z .object({ labelName: z.string(), diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 17b1b63ad..62de06eb0 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -109,7 +109,7 @@ const getAppsGCPSecretManager = async ({ accessToken }: { accessToken: string }) */ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get<{ name: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { + await request.get<{ name: string; id: string }[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, { headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}` @@ -118,7 +118,8 @@ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { ).data; const apps = res.map((a) => ({ - name: a.name + name: a.name, + appId: a.id })); return apps; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index ee4528dce..4cd0bc159 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -20,9 +20,11 @@ import { TDeleteIntegrationAuthsDTO, TGetIntegrationAuthDTO, TGetIntegrationAuthTeamCityBuildConfigDTO, + THerokuPipelineCoupling, TIntegrationAuthAppsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthNorthflankSecretGroupDTO, TIntegrationAuthQoveryEnvironmentsDTO, TIntegrationAuthQoveryOrgsDTO, @@ -576,6 +578,38 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getHerokuPipelines = async ({ id, actor, actorId, actorOrgId }: TIntegrationAuthHerokuPipelinesDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + const { data } = await request.get( + `${IntegrationUrls.HEROKU_API_URL}/pipeline-couplings`, + { + headers: { + Accept: "application/vnd.heroku+json; version=3", + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data.map(({ app: { id: appId }, stage, pipeline: { id: pipelineId, name } }) => ({ + app: { appId }, + stage, + pipeline: { pipelineId, name } + })); + }; + const getRailwayEnvironments = async ({ id, actor, actorId, actorOrgId, appId }: TIntegrationAuthRailwayEnvDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); @@ -904,6 +938,7 @@ export const integrationAuthServiceFactory = ({ getQoveryApps, getQoveryEnvs, getQoveryJobs, + getHerokuPipelines, getQoveryOrgs, getQoveryProjects, getQoveryContainers, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 34c5d995a..e3dbc8341 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -62,6 +62,10 @@ export type TIntegrationAuthQoveryScopesDTO = { environmentId: string; } & Omit; +export type TIntegrationAuthHerokuPipelinesDTO = { + id: string; +} & Omit; + export type TIntegrationAuthRailwayEnvDTO = { id: string; appId: string; @@ -129,6 +133,12 @@ export type TNorthflankSecretGroup = { projectId: string; }; +export type THerokuPipelineCoupling = { + app: { id: string }; + stage: string; + pipeline: { id: string; name: string }; +}; + export type TTeamCityBuildConfig = { id: string; name: string; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index d3cabbcb5..e49cd3862 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -37,6 +37,12 @@ export enum IntegrationType { OAUTH2 = "oauth2" } +export enum IntegrationInitialSyncBehavior { + OVERWRITE_TARGET = "overwrite-target", + PREFER_TARGET = "prefer-target", + PREFER_SOURCE = "prefer-source" +} + export enum IntegrationUrls { // integration oauth endpoints GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 71c7ce686..4083e3af2 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -20,11 +20,13 @@ import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; import { z } from "zod"; -import { TIntegrationAuths, TIntegrations } from "@app/db/schemas"; +import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; +import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; -import { Integrations, IntegrationUrls } from "./integration-list"; +import { TIntegrationDALFactory } from "../integration/integration-dal"; +import { IntegrationInitialSyncBehavior, Integrations, IntegrationUrls } from "./integration-list"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -582,11 +584,25 @@ const syncSecretsAWSSecretManager = async ({ * Sync/push [secrets] to Heroku app named [integration.app] */ const syncSecretsHeroku = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, secrets, accessToken }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integrationDAL: Pick; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; secrets: Record; accessToken: string; }) => { @@ -600,12 +616,74 @@ const syncSecretsHeroku = async ({ }) ).data; + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + + const metadata = z.record(z.any()).parse(integration.metadata); + Object.keys(herokuSecrets).forEach((key) => { - if (!(key in secrets)) { - secrets[key] = null; - } + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.OVERWRITE_TARGET: { + if (!(key in secrets)) secrets[key] = null; + break; + } + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(key in secrets)) { + secretsToAdd[key] = herokuSecrets[key]; + } else if (secrets[key]?.value !== herokuSecrets[key]) { + secretsToUpdate[key] = herokuSecrets[key]; + } + secrets[key] = { + value: herokuSecrets[key] + }; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(key in secrets)) { + secrets[key] = herokuSecrets[key]; + secretsToAdd[key] = herokuSecrets[key]; + } + break; + } + default: { + if (!(key in secrets)) secrets[key] = null; + break; + } + } + } else if (!(key in secrets)) secrets[key] = null; }); + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + await request.patch( `${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`, getSecretKeyValuePair(secrets), @@ -617,6 +695,10 @@ const syncSecretsHeroku = async ({ } } ); + + await integrationDAL.updateById(integration.id, { + lastUsed: new Date() + }); }; /** @@ -2930,8 +3012,14 @@ const syncSecretsHasuraCloud = async ({ /** * Sync/push [secrets] to [app] in integration named [integration] + * + * Do this in terms of DAL + * */ export const syncIntegrationSecrets = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, integrationAuth, secrets, @@ -2939,7 +3027,18 @@ export const syncIntegrationSecrets = async ({ accessToken, appendices }: { - integration: TIntegrations; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integrationDAL: Pick; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + secretPath: string; + }; integrationAuth: TIntegrationAuths; secrets: Record; accessId: string | null; @@ -2979,6 +3078,9 @@ export const syncIntegrationSecrets = async ({ break; case Integrations.HEROKU: await syncSecretsHeroku({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, secrets, accessToken diff --git a/backend/src/services/project-bot/project-bot-fns.ts b/backend/src/services/project-bot/project-bot-fns.ts new file mode 100644 index 000000000..3f22b8704 --- /dev/null +++ b/backend/src/services/project-bot/project-bot-fns.ts @@ -0,0 +1,36 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { decryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; + +import { TGetPrivateKeyDTO } from "./project-bot-types"; + +export const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => + infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + +export const getBotKeyFnFactory = (projectBotDAL: TProjectBotDALFactory) => { + const getBotKeyFn = async (projectId: string) => { + const bot = await projectBotDAL.findOne({ projectId }); + + if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); + if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); + if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) + throw new BadRequestError({ message: "Encryption key missing" }); + + const botPrivateKey = getBotPrivateKey({ bot }); + + return decryptAsymmetric({ + ciphertext: bot.encryptedProjectKey, + privateKey: botPrivateKey, + nonce: bot.encryptedProjectKeyNonce, + publicKey: bot.sender.publicKey + }); + }; + + return getBotKeyFn; +}; diff --git a/backend/src/services/project-bot/project-bot-service.ts b/backend/src/services/project-bot/project-bot-service.ts index 456a1e7a0..6e281e69d 100644 --- a/backend/src/services/project-bot/project-bot-service.ts +++ b/backend/src/services/project-bot/project-bot-service.ts @@ -1,15 +1,16 @@ import { ForbiddenError } from "@casl/ability"; -import { ProjectVersion, SecretKeyEncoding } from "@app/db/schemas"; +import { ProjectVersion } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto"; -import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { generateAsymmetricKeyPair } from "@app/lib/crypto"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "./project-bot-dal"; -import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types"; +import { getBotKeyFnFactory, getBotPrivateKey } from "./project-bot-fns"; +import { TFindBotByProjectIdDTO, TSetActiveStateDTO } from "./project-bot-types"; type TProjectBotServiceFactoryDep = { permissionService: Pick; @@ -24,29 +25,10 @@ export const projectBotServiceFactory = ({ projectDAL, permissionService }: TProjectBotServiceFactoryDep) => { - const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) => - infisicalSymmetricDecrypt({ - keyEncoding: bot.keyEncoding as SecretKeyEncoding, - iv: bot.iv, - tag: bot.tag, - ciphertext: bot.encryptedPrivateKey - }); + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); const getBotKey = async (projectId: string) => { - const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) throw new BadRequestError({ message: "failed to find bot key" }); - if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" }); - if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey) - throw new BadRequestError({ message: "Encryption key missing" }); - - const botPrivateKey = getBotPrivateKey({ bot }); - - return decryptAsymmetric({ - ciphertext: bot.encryptedProjectKey, - privateKey: botPrivateKey, - nonce: bot.encryptedProjectKeyNonce, - publicKey: bot.sender.publicKey - }); + return getBotKeyFn(projectId); }; const findBotByProjectId = async ({ diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 0f6caa248..212bb01f0 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -1,12 +1,35 @@ /* eslint-disable no-await-in-loop */ import path from "path"; -import { SecretKeyEncoding, TSecretBlindIndexes, TSecrets } from "@app/db/schemas"; +import { + SecretEncryptionAlgo, + SecretKeyEncoding, + SecretType, + TableName, + TSecretBlindIndexes, + TSecrets +} from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { buildSecretBlindIndexFromName, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { + buildSecretBlindIndexFromName, + decryptSymmetric128BitHexKeyUTF8, + encryptSymmetric128BitHexKeyUTF8 +} from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { getBotKeyFnFactory } from "../project-bot/project-bot-fns"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretDALFactory } from "./secret-dal"; +import { + TCreateManySecretsRawFn, + TCreateManySecretsRawFnFactory, + TFnSecretBlindIndexCheck, + TFnSecretBulkInsert, + TFnSecretBulkUpdate, + TUpdateManySecretsRawFn, + TUpdateManySecretsRawFnFactory +} from "./secret-types"; export const generateSecretBlindIndexBySalt = async (secretName: string, secretBlindIndexDoc: TSecretBlindIndexes) => { const appCfg = getConfig(); @@ -228,3 +251,399 @@ export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environ user: secret.userId }; }; + +/** + * Checks and handles secrets using a blind index method. + * The function generates mappings between secret names and their blind indexes, validates user IDs for personal secrets, and retrieves secrets from the database based on their blind indexes. + * For new secrets (isNew = true), it ensures they don't already exist in the database. + * For existing secrets, it verifies their presence in the database. + * If discrepancies are found, errors are thrown. The function returns mappings and the fetched secrets. + */ +export const fnSecretBlindIndexCheck = async ({ + inputSecrets, + folderId, + isNew, + userId, + blindIndexCfg, + secretDAL +}: TFnSecretBlindIndexCheck) => { + const blindIndex2KeyName: Record = {}; // used at audit log point + const keyName2BlindIndex = await Promise.all( + inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg)) + ).then((blindIndexes) => + blindIndexes.reduce>((prev, curr, i) => { + // eslint-disable-next-line + prev[inputSecrets[i].secretName] = curr; + blindIndex2KeyName[curr] = inputSecrets[i].secretName; + return prev; + }, {}) + ); + + if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { + throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + + const secrets = await secretDAL.findByBlindIndexes( + folderId, + inputSecrets.map(({ secretName, type }) => ({ + blindIndex: keyName2BlindIndex[secretName], + type: type || SecretType.Shared + })), + userId + ); + + if (isNew) { + if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); + } else { + const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map( + (el) => blindIndex2KeyName[el.secretBlindIndex as string] + ); + const hasUnknownSecretsProvided = secretKeysInDB.length !== inputSecrets.length; + if (hasUnknownSecretsProvided) { + const keysMissingInDB = Object.keys(keyName2BlindIndex).filter((key) => !secretKeysInDB.includes(key)); + throw new BadRequestError({ + message: `Secret not found: blind index ${keysMissingInDB.join(",")}` + }); + } + } + + return { blindIndex2KeyName, keyName2BlindIndex, secrets }; +}; + +// these functions are special functions shared by a couple of resources +// used by secret approval, rotation or anywhere in which secret needs to modified +export const fnSecretBulkInsert = async ({ + // TODO: Pick types here + folderId, + inputSecrets, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx +}: TFnSecretBulkInsert) => { + const newSecrets = await secretDAL.insertMany( + inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })), + tx + ); + const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string); + const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id + })) + ); + const secretVersions = await secretVersionDAL.insertMany( + inputSecrets.map(({ tags, ...el }) => ({ + ...el, + folderId, + secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id + })), + tx + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ + [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const fnSecretBulkUpdate = async ({ + tx, + inputSecrets, + folderId, + projectId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL +}: TFnSecretBulkUpdate) => { + const newSecrets = await secretDAL.bulkUpdate( + inputSecrets.map(({ filter, data: { tags, ...data } }) => ({ + filter: { ...filter, folderId }, + data + })), + tx + ); + const secretVersions = await secretVersionDAL.insertMany( + newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({ + ...el, + secretId: id + })), + tx + ); + const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => + tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] + ); + if (secsUpdatedTag.length) { + await secretTagDAL.deleteTagsManySecret( + projectId, + secsUpdatedTag.map(({ secretId }) => secretId), + tx + ); + const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], secretId }) => + secretTags.map((tag) => ({ + [`${TableName.SecretTag}Id` as const]: tag, + [`${TableName.Secret}Id` as const]: secretId + })) + ); + if (newSecretTags.length) { + const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); + const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); + const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ + [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, + [`${TableName.SecretTag}Id` as const]: secret_tagsId + })); + await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); + } + } + + return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); +}; + +export const createManySecretsRawFnFactory = ({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL +}: TCreateManySecretsRawFnFactory) => { + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const createManySecretsRawFn = async ({ + projectId, + environment, + path: secretPath, + secrets, + userId + }: TCreateManySecretsRawFn) => { + const botKey = await getBotKeyFn(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + const folderId = folder.id; + + const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Create secret" }); + + // insert operation + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: secrets, + folderId, + isNew: true, + blindIndexCfg, + secretDAL + }); + + const inputSecrets = await Promise.all( + secrets.map(async (secret) => { + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + if (secret.type === SecretType.Personal) { + if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); + const sharedExist = await secretDAL.findOne({ + secretBlindIndex: keyName2BlindIndex[secret.secretName], + folderId, + type: SecretType.Shared + }); + + if (!sharedExist) + throw new BadRequestError({ + message: "Failed to create personal secret override for no corresponding shared secret" + }); + } + + const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; + if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags + }; + }) + ); + + const newSecrets = await secretDAL.transaction(async (tx) => + fnSecretBulkInsert({ + inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({ + ...el, + version: 0, + secretBlindIndex: keyName2BlindIndex[secretName], + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + })), + folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + return newSecrets; + }; + + return createManySecretsRawFn; +}; + +export const updateManySecretsRawFnFactory = ({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL +}: TUpdateManySecretsRawFnFactory) => { + const getBotKeyFn = getBotKeyFnFactory(projectBotDAL); + const updateManySecretsRawFn = async ({ + projectId, + environment, + path: secretPath, + secrets, // consider accepting instead ciphertext secrets + userId + }: TUpdateManySecretsRawFn): Promise> => { + const botKey = await getBotKeyFn(projectId); + if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update secret" }); + const folderId = folder.id; + + const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: secrets, + folderId, + isNew: false, + blindIndexCfg, + secretDAL, + userId + }); + + const inputSecrets = await Promise.all( + secrets.map(async (secret) => { + if (secret.newSecretName === "") { + throw new BadRequestError({ message: "New secret name cannot be empty" }); + } + + const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey); + const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey); + const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey); + + if (secret.type === SecretType.Personal) { + if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" }); + + const sharedExist = await secretDAL.findOne({ + secretBlindIndex: keyName2BlindIndex[secret.secretName], + folderId, + type: SecretType.Shared + }); + + if (!sharedExist) + throw new BadRequestError({ + message: "Failed to update personal secret override for no corresponding shared secret" + }); + + if (secret.newSecretName) + throw new BadRequestError({ message: "Personal secret cannot change the key name" }); + } + + const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : []; + if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + return { + type: secret.type, + userId: secret.type === SecretType.Personal ? userId : null, + secretName: secret.secretName, + newSecretName: secret.newSecretName, + secretKeyCiphertext: secretKeyEncrypted.ciphertext, + secretKeyIV: secretKeyEncrypted.iv, + secretKeyTag: secretKeyEncrypted.tag, + secretValueCiphertext: secretValueEncrypted.ciphertext, + secretValueIV: secretValueEncrypted.iv, + secretValueTag: secretValueEncrypted.tag, + secretCommentCiphertext: secretCommentEncrypted.ciphertext, + secretCommentIV: secretCommentEncrypted.iv, + secretCommentTag: secretCommentEncrypted.tag, + skipMultilineEncoding: secret.skipMultilineEncoding, + tags: secret.tags + }; + }) + ); + + const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags); + const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; + if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); + + // now find any secret that needs to update its name + // same process as above + const nameUpdatedSecrets = inputSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); + const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: nameUpdatedSecrets, + folderId, + isNew: true, + blindIndexCfg, + secretDAL + }); + + const updatedSecrets = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId, + projectId, + tx, + inputSecrets: inputSecrets.map(({ secretName, newSecretName, ...el }) => ({ + filter: { secretBlindIndex: keyName2BlindIndex[secretName], type: SecretType.Shared }, + data: { + ...el, + folderId, + secretBlindIndex: + newSecretName && newKeyName2BlindIndex[newSecretName] + ? newKeyName2BlindIndex[newSecretName] + : keyName2BlindIndex[secretName], + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8 + } + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL + }) + ); + + return updatedSecrets; + }; + + return updateManySecretsRawFn; +}; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index b797b7caf..eddf780e2 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -6,6 +6,12 @@ import { BadRequestError } from "@app/lib/errors"; import { isSamePath } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { createManySecretsRawFnFactory, updateManySecretsRawFnFactory } from "@app/services/secret/secret-fns"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; @@ -29,18 +35,23 @@ export type TSecretQueueFactory = ReturnType; type TSecretQueueFactoryDep = { queueService: TQueueServiceFactory; - integrationDAL: Pick; + integrationDAL: Pick; projectBotService: Pick; integrationAuthService: Pick; - folderDAL: Pick; - secretDAL: Pick; + folderDAL: TSecretFolderDALFactory; + secretDAL: TSecretDALFactory; secretImportDAL: Pick; webhookDAL: Pick; projectEnvDAL: Pick; - projectDAL: Pick; + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; projectMembershipDAL: Pick; smtpService: TSmtpService; orgDAL: Pick; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; }; export type TGetSecrets = { @@ -62,8 +73,35 @@ export const secretQueueFactory = ({ orgDAL, smtpService, projectDAL, - projectMembershipDAL + projectBotDAL, + projectMembershipDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL }: TSecretQueueFactoryDep) => { + const createManySecretsRawFn = createManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); + + const updateManySecretsRawFn = updateManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); + const syncIntegrations = async (dto: TGetSecrets) => { await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { attempts: 5, @@ -307,6 +345,9 @@ export const secretQueueFactory = ({ } await syncIntegrationSecrets({ + createManySecretsRawFn, + updateManySecretsRawFn, + integrationDAL, integration, integrationAuth, secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index ecd13dae1..51136e40f 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1,13 +1,13 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, SecretType, TableName } from "@app/db/schemas"; +import { SecretEncryptionAlgo, SecretKeyEncoding, SecretsSchema, SecretType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { getConfig } from "@app/lib/config/env"; import { buildSecretBlindIndexFromName, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; -import { groupBy, pick, unique } from "@app/lib/fn"; +import { groupBy, pick } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; @@ -19,7 +19,7 @@ import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { fnSecretsFromImports } from "../secret-import/secret-import-fns"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretDALFactory } from "./secret-dal"; -import { decryptSecretRaw, generateSecretBlindIndexBySalt } from "./secret-fns"; +import { decryptSecretRaw, fnSecretBlindIndexCheck, fnSecretBulkInsert, fnSecretBulkUpdate } from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { TCreateBulkSecretDTO, @@ -28,11 +28,8 @@ import { TDeleteBulkSecretDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, - TFnSecretBlindIndexCheck, TFnSecretBlindIndexCheckV2, TFnSecretBulkDelete, - TFnSecretBulkInsert, - TFnSecretBulkUpdate, TGetASecretDTO, TGetASecretRawDTO, TGetSecretsDTO, @@ -95,85 +92,6 @@ export const secretServiceFactory = ({ return secretBlindIndex; }; - // these functions are special functions shared by a couple of resources - // used by secret approval, rotation or anywhere in which secret needs to modified - const fnSecretBulkInsert = async ({ folderId, inputSecrets, tx }: TFnSecretBulkInsert) => { - const newSecrets = await secretDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })), - tx - ); - const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string); - const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) => - secretTags.map((tag) => ({ - [`${TableName.SecretTag}Id` as const]: tag, - [`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id - })) - ); - const secretVersions = await secretVersionDAL.insertMany( - inputSecrets.map(({ tags, ...el }) => ({ - ...el, - folderId, - secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id - })), - tx - ); - if (newSecretTags.length) { - const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); - const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); - const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ - [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, - [`${TableName.SecretTag}Id` as const]: secret_tagsId - })); - await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); - } - - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); - }; - - const fnSecretBulkUpdate = async ({ tx, inputSecrets, folderId, projectId }: TFnSecretBulkUpdate) => { - const newSecrets = await secretDAL.bulkUpdate( - inputSecrets.map(({ filter, data: { tags, ...data } }) => ({ - filter: { ...filter, folderId }, - data - })), - tx - ); - const secretVersions = await secretVersionDAL.insertMany( - newSecrets.map(({ id, createdAt, updatedAt, ...el }) => ({ - ...el, - secretId: id - })), - tx - ); - const secsUpdatedTag = inputSecrets.flatMap(({ data: { tags } }, i) => - tags !== undefined ? { tags, secretId: newSecrets[i].id } : [] - ); - if (secsUpdatedTag.length) { - await secretTagDAL.deleteTagsManySecret( - projectId, - secsUpdatedTag.map(({ secretId }) => secretId), - tx - ); - const newSecretTags = secsUpdatedTag.flatMap(({ tags: secretTags = [], secretId }) => - secretTags.map((tag) => ({ - [`${TableName.SecretTag}Id` as const]: tag, - [`${TableName.Secret}Id` as const]: secretId - })) - ); - if (newSecretTags.length) { - const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx); - const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId); - const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({ - [`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id, - [`${TableName.SecretTag}Id` as const]: secret_tagsId - })); - await secretVersionTagDAL.insertMany(newSecretVersionTags, tx); - } - } - - return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); - }; - const fnSecretBulkDelete = async ({ folderId, inputSecrets, tx, actorId }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretBlindIndex }) => ({ @@ -202,63 +120,6 @@ export const secretServiceFactory = ({ return deletedSecrets; }; - /** - * Checks and handles secrets using a blind index method. - * The function generates mappings between secret names and their blind indexes, validates user IDs for personal secrets, and retrieves secrets from the database based on their blind indexes. - * For new secrets (isNew = true), it ensures they don't already exist in the database. - * For existing secrets, it verifies their presence in the database. - * If discrepancies are found, errors are thrown. The function returns mappings and the fetched secrets. - */ - const fnSecretBlindIndexCheck = async ({ - inputSecrets, - folderId, - isNew, - userId, - blindIndexCfg - }: TFnSecretBlindIndexCheck) => { - const blindIndex2KeyName: Record = {}; // used at audit log point - const keyName2BlindIndex = await Promise.all( - inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg)) - ).then((blindIndexes) => - blindIndexes.reduce>((prev, curr, i) => { - // eslint-disable-next-line - prev[inputSecrets[i].secretName] = curr; - blindIndex2KeyName[curr] = inputSecrets[i].secretName; - return prev; - }, {}) - ); - - if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { - throw new BadRequestError({ message: "Missing user id for personal secret" }); - } - - const secrets = await secretDAL.findByBlindIndexes( - folderId, - inputSecrets.map(({ secretName, type }) => ({ - blindIndex: keyName2BlindIndex[secretName], - type: type || SecretType.Shared - })), - userId - ); - - if (isNew) { - if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); - } else { - const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map( - (el) => blindIndex2KeyName[el.secretBlindIndex as string] - ); - const hasUnknownSecretsProvided = secretKeysInDB.length !== inputSecrets.length; - if (hasUnknownSecretsProvided) { - const keysMissingInDB = Object.keys(keyName2BlindIndex).filter((key) => !secretKeysInDB.includes(key)); - throw new BadRequestError({ - message: `Secret not found: blind index ${keysMissingInDB.join(",")}` - }); - } - } - - return { blindIndex2KeyName, keyName2BlindIndex, secrets }; - }; - // this is used when secret blind index already exist // mainly for secret approval const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => { @@ -311,7 +172,8 @@ export const secretServiceFactory = ({ folderId, isNew: true, userId: actorId, - blindIndexCfg + blindIndexCfg, + secretDAL }); // if user creating personal check its shared also exist @@ -348,6 +210,10 @@ export const secretServiceFactory = ({ tags: inputSecret.tags } ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -395,7 +261,8 @@ export const secretServiceFactory = ({ folderId, isNew: false, blindIndexCfg, - userId: actorId + userId: actorId, + secretDAL }); if (inputSecret.newSecretName && inputSecret.type === SecretType.Personal) { throw new BadRequestError({ message: "Personal secret cannot change the key name" }); @@ -407,7 +274,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.newSecretName }], folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); newSecretNameBlindIndex = kN2NewBlindIndex[inputSecret.newSecretName]; } @@ -454,6 +322,10 @@ export const secretServiceFactory = ({ } } ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -496,7 +368,8 @@ export const secretServiceFactory = ({ inputSecrets: [{ secretName: inputSecret.secretName }], folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const deletedSecret = await secretDAL.transaction(async (tx) => @@ -679,13 +552,14 @@ export const secretServiceFactory = ({ const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); - if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" }); + if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Create secret" }); const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ inputSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags @@ -704,6 +578,10 @@ export const secretServiceFactory = ({ keyEncoding: SecretKeyEncoding.UTF8 })), folderId, + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, tx }) ); @@ -732,7 +610,7 @@ export const secretServiceFactory = ({ await projectDAL.checkProjectUpgradeStatus(projectId); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" }); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update secret" }); const folderId = folder.id; const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId }); @@ -742,7 +620,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); // now find any secret that needs to update its name @@ -752,7 +631,8 @@ export const secretServiceFactory = ({ inputSecrets: nameUpdatedSecrets, folderId, isNew: true, - blindIndexCfg + blindIndexCfg, + secretDAL }); // get all tags @@ -777,7 +657,11 @@ export const secretServiceFactory = ({ algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8 } - })) + })), + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL }) ); @@ -815,7 +699,8 @@ export const secretServiceFactory = ({ inputSecrets, folderId, isNew: false, - blindIndexCfg + blindIndexCfg, + secretDAL }); const secretsDeleted = await secretDAL.transaction(async (tx) => diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 50f678172..7ad4d65d7 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -2,6 +2,14 @@ import { Knex } from "knex"; import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; type TPartialSecret = Pick; @@ -181,12 +189,20 @@ export type TFnSecretBulkInsert = { folderId: string; tx?: Knex; inputSecrets: Array & { tags?: string[] }>; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; }; export type TFnSecretBulkUpdate = { folderId: string; projectId: string; inputSecrets: { filter: Partial; data: TSecretsUpdate & { tags?: string[] } }[]; + secretDAL: Pick; + secretVersionDAL: Pick; + secretTagDAL: Pick; + secretVersionTagDAL: Pick; tx?: Knex; }; @@ -204,6 +220,7 @@ export type TFnSecretBlindIndexCheck = { blindIndexCfg: TSecretBlindIndexes; inputSecrets: Array<{ secretName: string; type?: SecretType }>; isNew: boolean; + secretDAL: Pick; }; // when blind index is already present @@ -229,3 +246,66 @@ export type TRemoveSecretReminderDTO = { secretId: string; repeatDays: number; }; + +// --- + +export type TCreateManySecretsRawFnFactory = { + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TCreateManySecretsRawFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; // only relevant for personal secret(s) +}; + +export type TUpdateManySecretsRawFnFactory = { + projectDAL: TProjectDALFactory; + projectBotDAL: TProjectBotDALFactory; + secretDAL: TSecretDALFactory; + secretVersionDAL: TSecretVersionDALFactory; + secretBlindIndexDAL: TSecretBlindIndexDALFactory; + secretTagDAL: TSecretTagDALFactory; + secretVersionTagDAL: TSecretVersionTagDALFactory; + folderDAL: TSecretFolderDALFactory; +}; + +export type TUpdateManySecretsRawFn = { + projectId: string; + environment: string; + path: string; + secrets: { + secretName: string; + newSecretName?: string; + secretValue: string; + type: SecretType; + secretComment?: string; + skipMultilineEncoding?: boolean; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + tags?: string[]; + metadata?: { + source?: string; + }; + }[]; + userId?: string; +}; diff --git a/docs/images/integrations/heroku/integrations-heroku-create.png b/docs/images/integrations/heroku/integrations-heroku-create.png index a2a8d4e76..452dc5159 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-create.png and b/docs/images/integrations/heroku/integrations-heroku-create.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku.png b/docs/images/integrations/heroku/integrations-heroku.png index 31c8284cd..ead332447 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku.png and b/docs/images/integrations/heroku/integrations-heroku.png differ diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx index 2d8fdc445..903ab8270 100644 --- a/docs/integrations/cloud/heroku.mdx +++ b/docs/integrations/cloud/heroku.mdx @@ -30,6 +30,17 @@ description: "How to sync secrets from Infisical to Heroku" Select which Infisical environment secrets you want to sync to which Heroku app and press create integration to start syncing secrets to Heroku. ![integrations heroku](../../images/integrations/heroku/integrations-heroku-create.png) + + Here's some guidance on each field: + + - Project Environment: The environment in the current Infisical project from which you want to sync secrets from. + - Secrets Path: The path in the current Infisical project from which you want to sync secrets from such as `/` (for secrets that do not reside in a folder) or `/foo/bar` (for secrets nested in a folder, in this case a folder called `bar` in another folder called `foo`). + - Heroku App: The application in Heroku that you want to sync secrets to. + - Initial Sync Behavior (default is **Import - Prefer values from Infisical**): The behavior of the first sync operation triggered after creating the integration. + - **No Import - Overwrite all values in Heroku**: Sync secrets and overwrite any existing secrets in Heroku. + - **Import - Prefer values from Infisical**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, do nothing. Afterwards, sync secrets to Heroku. + - **Import - Prefer values from Heroku**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, replace its value with the one from Heroku. Afterwards, sync secrets to Heroku. + ![integrations heroku](../../images/integrations/heroku/integrations-heroku.png) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 710c7d8bf..5e30edfc3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", @@ -5223,6 +5224,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index a3c2e6b53..6a1666f05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popper": "^1.1.3", "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", diff --git a/frontend/src/components/v2/RadioGroup/RadioGroup.tsx b/frontend/src/components/v2/RadioGroup/RadioGroup.tsx new file mode 100644 index 000000000..1919e4f7a --- /dev/null +++ b/frontend/src/components/v2/RadioGroup/RadioGroup.tsx @@ -0,0 +1,40 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { twMerge } from "tailwind-merge"; + +export type RadioGroupProps = RadioGroupPrimitive.RadioGroupProps; + +// Note this component is not customizable (Heroku integration and potentially other pages depend on it) +export const RadioGroup = ({ className, children, ...props }: RadioGroupProps) => ( + +
+ + + + +
+
+ + + + +
+
+); \ No newline at end of file diff --git a/frontend/src/components/v2/RadioGroup/index.tsx b/frontend/src/components/v2/RadioGroup/index.tsx new file mode 100644 index 000000000..bf62b2e6d --- /dev/null +++ b/frontend/src/components/v2/RadioGroup/index.tsx @@ -0,0 +1,2 @@ +export type { RadioGroupProps } from "./RadioGroup"; +export { RadioGroup } from "./RadioGroup"; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 83dfb5d1a..d87bb66c8 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -8,6 +8,7 @@ import { BitBucketWorkspace, ChecklyGroup, Environment, + HerokuPipelineCoupling, IntegrationAuth, NorthflankSecretGroup, Org, @@ -63,6 +64,8 @@ const integrationAuthKeys = { environmentId: string; scope: "job" | "application" | "container"; }) => [{ integrationAuthId, environmentId, scope }, "integrationAuthQoveryScopes"] as const, + getIntegrationAuthHerokuPipelines: ({ integrationAuthId }: { integrationAuthId: string; }) => + [{ integrationAuthId}, "integrationAuthHerokuPipelines"] as const, getIntegrationAuthRailwayEnvironments: ({ integrationAuthId, appId @@ -289,6 +292,20 @@ const fetchIntegrationAuthQoveryScopes = async ({ return undefined; }; +const fetchIntegrationAuthHerokuPipelines = async ({ integrationAuthId }: { + integrationAuthId: string; +}) => { + const { + data: { pipelines } + } = await apiRequest.get<{ pipelines: HerokuPipelineCoupling[] }>( + `/api/v1/integration-auth/${integrationAuthId}/heroku/pipelines` + ); + + console.log(99999, pipelines) + + return pipelines; +}; + const fetchIntegrationAuthRailwayEnvironments = async ({ integrationAuthId, appId @@ -540,6 +557,23 @@ export const useGetIntegrationAuthQoveryScopes = ({ }); }; +export const useGetIntegrationAuthHerokuPipelines = ({ + integrationAuthId +}: { + integrationAuthId: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthHerokuPipelines({ + integrationAuthId + }), + queryFn: () => + fetchIntegrationAuthHerokuPipelines({ + integrationAuthId + }), + enabled: true + }); +}; + export const useGetIntegrationAuthRailwayEnvironments = ({ integrationAuthId, appId diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 2a5652673..cfd25df00 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -17,6 +17,17 @@ export type App = { secretGroups?: string[]; }; +export type Pipeline = { + pipelineId: string; + name: string; +}; + +export type HerokuPipelineCoupling = { + app: { appId: string }; + stage: string; + pipeline: { pipelineId: string; name: string }; +}; + export type Team = { name: string; teamId: string; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 70bee92c3..e50b914f1 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -61,6 +61,7 @@ export const useCreateIntegration = () => { metadata?: { secretPrefix?: string; secretSuffix?: string; + initialSyncBehavior?: string; } }) => { const { data: { integration } } = await apiRequest.post("/api/v1/integration", { diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index f63257f84..73db6c07d 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -33,9 +33,16 @@ export type TIntegration = { __v: number; metadata?: { secretSuffix?: string; + syncBehavior?: IntegrationSyncBehavior; scope: string; org: string; project: string; environment: string; }; }; + +export enum IntegrationSyncBehavior { + OVERWRITE_TARGET = "overwrite-target", + PREFER_TARGET = "prefer-target", + PREFER_SOURCE = "prefer-source" +} \ No newline at end of file diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index a63cc1d07..f0b66d52e 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -1,8 +1,25 @@ import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/router"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faBugs, + // faCircleInfo +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; import queryString from "query-string"; +// import { useGetIntegrationAuthHerokuPipelines } from "@app/hooks/api/integrationAuth/queries"; +// import { App, Pipeline } from "@app/hooks/api/integrationAuth/types"; +import * as yup from "yup"; +// import { RadioGroup } from "@app/components/v2/RadioGroup"; import { useCreateIntegration } from "@app/hooks/api"; +import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -17,54 +34,172 @@ import { useGetIntegrationAuthApps, useGetIntegrationAuthById } from "../../../hooks/api/integrationAuth"; -import { useGetWorkspaceById } from "../../../hooks/api/workspace"; +import { + // useCreateWsEnvironment, + useGetWorkspaceById +} from "../../../hooks/api/workspace"; + +const initialSyncBehaviors = [ + { label: "No Import - Overwrite all values in Heroku", value: IntegrationSyncBehavior.OVERWRITE_TARGET }, + { label: "Import - Prefer values from Heroku", value: IntegrationSyncBehavior.PREFER_TARGET }, + { label: "Import - Prefer values from Infisical", value: IntegrationSyncBehavior.PREFER_SOURCE } +]; + +const schema = yup.object({ + selectedSourceEnvironment: yup.string().required("Source environment is required"), + secretPath: yup.string().required("Secret path is required"), + targetApp: yup.string().required("Heroku app is required"), + initialSyncBehavior: yup + .string() + .oneOf(initialSyncBehaviors.map((b) => b.value), "Invalid initial sync behavior") + .required("Initial sync behavior is required") +}); + +type FormData = yup.InferType; export default function HerokuCreateIntegrationPage() { const router = useRouter(); + + const { control, handleSubmit, setValue, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + secretPath: "/", + initialSyncBehavior: IntegrationSyncBehavior.PREFER_SOURCE + } + }); + + const selectedSourceEnvironment = watch("selectedSourceEnvironment"); + + const { mutateAsync } = useCreateIntegration(); + // const { mutateAsync: mutateAsyncEnv } = useCreateWsEnvironment(); const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ""); - const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({ integrationAuthId: (integrationAuthId as string) ?? "" }); - const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); - const [targetApp, setTargetApp] = useState(""); - const [secretPath, setSecretPath] = useState("/"); - + // const { data: integrationAuthPipelineCouplings } = useGetIntegrationAuthHerokuPipelines({ + // integrationAuthId: (integrationAuthId as string) ?? "" + // }); + + // const [uniquePipelines, setUniquePipelines] = useState(); + // const [selectedPipeline, setSelectedPipeline] = useState(""); + // const [selectedPipelineApps, setSelectedPipelineApps] = useState(); + // const [integrationType, setIntegrationType] = useState("App"); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (workspace) { - setSelectedSourceEnvironment(workspace.environments[0].slug); + setValue("selectedSourceEnvironment", workspace.environments[0].slug); } }, [workspace]); + // useEffect(() => { + // if (integrationAuthPipelineCouplings) { + // const uniquePipelinesConst = Array.from( + // new Set( + // integrationAuthPipelineCouplings + // .map(({ pipeline: { pipelineId, name } }) => ({ + // name, + // pipelineId + // })) + // .map((obj) => JSON.stringify(obj)) + // )).map((str) => JSON.parse(str)) as { pipelineId: string; name: string }[] + + // [... (new Set())] + // setUniquePipelines(uniquePipelinesConst); + // if (uniquePipelinesConst) { + // if (uniquePipelinesConst!.length > 0) { + // setSelectedPipeline(uniquePipelinesConst![0].name); + // } else { + // setSelectedPipeline("none"); + // } + // } + // } + // }, [integrationAuthPipelineCouplings]); + + // useEffect(() => { + // if (integrationAuthPipelineCouplings) { + // setSelectedPipelineApps(integrationAuthApps?.filter(app => integrationAuthPipelineCouplings + // .filter((pipelineCoupling) => pipelineCoupling.pipeline.name === selectedPipeline) + // .map(coupling => coupling.app.appId).includes(String(app.appId)))) + // } + // }, [selectedPipeline]); + useEffect(() => { if (integrationAuthApps) { if (integrationAuthApps.length > 0) { - setTargetApp(integrationAuthApps[0].name); + setValue("targetApp", integrationAuthApps[0].name); } else { - setTargetApp("none"); + setValue("targetApp", "none"); } } }, [integrationAuthApps]); - const handleButtonClick = async () => { - try { - setIsLoading(true); + // const handleButtonClick = async () => { + // try { + // setIsLoading(true); + // if (!integrationAuth?.id) return; + + // if (integrationType === "App") { + // await mutateAsync({ + // integrationAuthId: integrationAuth?.id, + // isActive: true, + // app: targetApp, + // sourceEnvironment: selectedSourceEnvironment, + // secretPath + // }); + // } else if (integrationType === "Pipeline") { + // selectedPipelineApps?.map(async (app, index) => { + // setTimeout(async () => { + // await mutateAsyncEnv({ + // workspaceId: String(localStorage.getItem("projectData.id")), + // name: app.name, + // slug: app.name.toLowerCase().replaceAll(" ", "-") + // }); + // await mutateAsync({ + // integrationAuthId: integrationAuth?.id, + // isActive: true, + // app: app.name, + // sourceEnvironment: app.name.toLowerCase().replaceAll(" ", "-"), + // secretPath + // }) + // }, 1000*index) + // }) + // } + + // setIsLoading(false); + // router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + // } catch (err) { + // console.error(err); + // } + // }; + + const onFormSubmit = async ({ + secretPath, + targetApp, + initialSyncBehavior, + }: FormData) => { + try { if (!integrationAuth?.id) return; + setIsLoading(true); + await mutateAsync({ integrationAuthId: integrationAuth?.id, isActive: true, app: targetApp, sourceEnvironment: selectedSourceEnvironment, - secretPath + secretPath, + metadata: { + initialSyncBehavior + } }); setIsLoading(false); @@ -72,75 +207,203 @@ export default function HerokuCreateIntegrationPage() { } catch (err) { console.error(err); } - }; + } return integrationAuth && workspace && selectedSourceEnvironment && - integrationAuthApps && - targetApp ? ( -
- - Heroku Integration - - - - - setSecretPath(evt.target.value)} - placeholder="Provide a path, default is /" + + + )} /> - - - + + )} + /> + { + return ( + + + + ); + }} + /> + ( + + + + )} + /> + + Create Integration + + + {/* {integrationType === "App" && <> +
+
+
+ {" "} + Pro Tips +
+ + After creating an integration, your secrets will start syncing immediately. This might + cause an unexpected override of current secrets in Heroku with secrets from Infisical. + +
} */}
) : ( -
+
+ + Set Up Vercel Integration + + + {isIntegrationAuthAppsLoading ? ( + infisical loading indicator + ) : ( +
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + {" "} + if the issue persists. +

+
+ )} +
); } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index e2a06da16..26e7fcd60 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -62,7 +62,7 @@ export const IntegrationsSection = ({ project settings - to re-enable it . + to re-enable it.
@@ -80,7 +80,7 @@ export const IntegrationsSection = ({
{integrations?.map((integration) => (
diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index cf50e44f3..092495597 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -291,7 +291,7 @@ export const SecretDetailSidebar = ({ )} - Apply tags to this secrets + Add tags to this secret {tags.map((tag) => { const { id: tagId, name, color } = tag; diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index d165b4bd5..7996d932c 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -325,7 +325,7 @@ export const SecretItem = memo( )} - Apply tags to this secrets + Add tags to this secret {tags.map((tag) => { const { id: tagId, name, color } = tag; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 09208ea4e..1fba46266 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -2,12 +2,14 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { subject } from "@casl/ability"; import { faAngleDown, faArrowDown, faArrowUp, faFolderBlank, + faList, faFolderPlus, faMagnifyingGlass, faPlus @@ -21,6 +23,8 @@ import { Button, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, DropdownMenuTrigger, EmptyState, IconButton, @@ -104,6 +108,7 @@ export const SecretOverviewPage = () => { }, [isWorkspaceLoading, workspaceId, router.isReady]); const userAvailableEnvs = currentWorkspace?.environments || []; + const [visibleEnvs, setVisisbleEnvs] = useState(userAvailableEnvs); const { data: secrets, @@ -201,6 +206,14 @@ export const SecretOverviewPage = () => { } }; + const handleEnvSelect = (envId: string) => { + if (visibleEnvs.map(env => env.id).includes(envId)) { + setVisisbleEnvs(visibleEnvs.filter(env => env.id !== envId)) + } else { + setVisisbleEnvs(visibleEnvs.concat(userAvailableEnvs.filter(env => env.id === envId))) + } + }; + const handleSecretUpdate = async (env: string, key: string, value: string, secretId?: string) => { try { await updateSecretV3({ @@ -277,7 +290,7 @@ export const SecretOverviewPage = () => { } } const query: Record = { ...router.query, env: slug }; - const envIndex = userAvailableEnvs.findIndex((el) => slug === el.slug); + const envIndex = visibleEnvs.findIndex((el) => slug === el.slug); if (envIndex !== -1) { router.push({ pathname: "/project/[id]/secrets/[env]", @@ -377,6 +390,52 @@ export const SecretOverviewPage = () => {
+ + + + + + + + + + Choose visible environments + {userAvailableEnvs.map((avaiableEnv) => { + const { id: envId, name } = avaiableEnv; + + const isEnvSelected = visibleEnvs.map(env => env.id).includes(envId); + return ( + handleEnvSelect(envId)} + key={envId} + icon={isEnvSelected && } + iconPos="left" + > +
+ {name} +
+
+ ); + })} + {/* + + */} +
+
{
- {userAvailableEnvs?.map(({ name, slug }, index) => { + {visibleEnvs?.map(({ name, slug }, index) => { const envSecKeyCount = getEnvSecretKeyCount(slug); const missingKeyCount = secKeys.length - envSecKeyCount; return ( @@ -498,7 +557,7 @@ export const SecretOverviewPage = () => { {canViewOverviewPage && isTableLoading && ( { )} {isTableEmpty && !isTableLoading && ( - + @@ -532,13 +591,13 @@ export const SecretOverviewPage = () => { ))} {!isTableLoading && - (userAvailableEnvs?.length > 0 ? ( + (visibleEnvs?.length > 0 ? ( filteredSecretNames.map((key, index) => ( { onSecretDelete={handleSecretDelete} onSecretUpdate={handleSecretUpdate} key={`overview-${key}-${index + 1}`} - environments={userAvailableEnvs} + environments={visibleEnvs} secretKey={key} getSecretByKey={getSecretByKey} expandableColWidth={expandableTableWidth} @@ -564,7 +623,7 @@ export const SecretOverviewPage = () => { style={{ height: "45px" }} /> - {userAvailableEnvs.map(({ name, slug }) => ( + {visibleEnvs?.map(({ name, slug }) => (