diff --git a/.github/values.yaml b/.github/values.yaml index d5f0202b5..1b3ffd87a 100644 --- a/.github/values.yaml +++ b/.github/values.yaml @@ -27,7 +27,7 @@ infisical: deploymentAnnotations: secrets.infisical.com/auto-reload: "true" - kubeSecretRef: "infisical-gamma-secrets" + kubeSecretRef: "managed-secret" ingress: ## @param ingress.enabled Enable ingress diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index 9840e2d3e..d01d56198 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -23,6 +23,8 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 - run: git fetch --force --tags - run: echo "Ref name ${{github.ref_name}}" - uses: actions/setup-go@v3 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 054359527..8f608c40c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -190,21 +190,34 @@ dockers: - dockerfile: docker/alpine goos: linux goarch: amd64 + use: buildx ids: - all-other-builds image_templates: - - "infisical/cli:{{ .Version }}" - - "infisical/cli:{{ .Major }}.{{ .Minor }}" - - "infisical/cli:{{ .Major }}" - - "infisical/cli:latest" - + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:latest-amd64" + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" - dockerfile: docker/alpine goos: linux - goarch: arm64 + goarch: amd64 + use: buildx ids: - all-other-builds image_templates: - - "infisical/cli:{{ .Version }}" - - "infisical/cli:{{ .Major }}.{{ .Minor }}" - - "infisical/cli:{{ .Major }}" - - "infisical/cli:latest" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - "infisical/cli:latest-arm64" + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" + +docker_manifests: + - name_template: "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" + image_templates: + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-amd64" + - "infisical/cli:{{ .Major }}.{{ .Minor }}.{{ .Patch }}-arm64" + - name_template: "infisical/cli:latest" + image_templates: + - "infisical/cli:latest-amd64" + - "infisical/cli:latest-arm64" 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 def6e69f8..e7a0118d7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -436,7 +436,12 @@ export const registerRoutes = async ( orgDAL, projectMembershipDAL, smtpService, - projectDAL + projectDAL, + projectBotDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL }); const secretBlindIndexService = secretBlindIndexServiceFactory({ permissionService, @@ -460,6 +465,7 @@ export const registerRoutes = async ( const sarService = secretApprovalRequestServiceFactory({ permissionService, folderDAL, + secretDAL, secretTagDAL, secretApprovalRequestSecretDAL: sarSecretDAL, secretApprovalRequestReviewerDAL: sarReviewerDAL, @@ -469,6 +475,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/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 8a80fb728..af1bf7212 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -120,7 +120,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }); server.route({ - url: "/:folderId", + url: "/:folderIdOrName", method: "DELETE", schema: { description: "Delete a folder", @@ -131,7 +131,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => } ], params: z.object({ - folderId: z.string() + folderIdOrName: z.string() }), body: z.object({ workspaceId: z.string().trim(), @@ -155,7 +155,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, ...req.body, projectId: req.body.workspaceId, - id: req.params.folderId, + idOrName: req.params.folderIdOrName, path }); await server.services.auditLog.createAuditLog({ 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 0b3f9b4c3..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" }); @@ -649,33 +683,21 @@ export const integrationAuthServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); const botKey = await projectBotService.getBotKey(integrationAuth.projectId); const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); - if (appId) { + + if (appId && appId !== "") { const query = ` - query project($id: String!) { - project(id: $id) { - createdAt - deletedAt - id - description - expiredAt - isPublic - isTempProject - isUpdatable - name - prDeploys - teamId - updatedAt - upstreamUrl - services { - edges { - node { - id - name - } - } - } + query project($id: String!) { + project(id: $id) { + services { + edges { + node { + id + name + } + } + } + } } - } `; const variables = { @@ -711,6 +733,7 @@ export const integrationAuthServiceFactory = ({ ); return edges.map(({ node: { name, id: serviceId } }) => ({ name, serviceId })); } + return []; }; @@ -915,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 62a4dcfa7..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() + }); }; /** @@ -1204,21 +1286,21 @@ const syncSecretsRailway = async ({ } `; - const input = { - projectId: integration.appId, - environmentId: integration.targetEnvironmentId, - ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), - replace: true, - variables: getSecretKeyValuePair(secrets) + const variables = { + input: { + projectId: integration.appId, + environmentId: integration.targetEnvironmentId, + ...(integration.targetServiceId ? { serviceId: integration.targetServiceId } : {}), + replace: true, + variables: getSecretKeyValuePair(secrets) + } }; await request.post( IntegrationUrls.RAILWAY_API_URL, { query, - variables: { - input - } + variables }, { headers: { @@ -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-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 53d2a9fdf..26c1c1f4f 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; import path from "path"; -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { TSecretFoldersInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -164,7 +164,7 @@ export const secretFolderServiceFactory = ({ actorOrgId, environment, path: secretPath, - id + idOrName }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); ForbiddenError.from(permission).throwUnlessCan( @@ -179,7 +179,10 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx); if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); - const [doc] = await folderDAL.delete({ envId: env.id, id, parentId: parentFolder.id }, tx); + const [doc] = await folderDAL.delete( + { envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, + tx + ); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); return doc; }); diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 7a68434f5..88b7b1017 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -16,7 +16,7 @@ export type TUpdateFolderDTO = { export type TDeleteFolderDTO = { environment: string; path: string; - id: string; + idOrName: string; } & TProjectPermission; export type TGetFolderDTO = { 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 a686dd41f..2e5ea7f93 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/cli/packages/api/model.go b/cli/packages/api/model.go index 586a23511..310976078 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -299,10 +299,10 @@ type GetFoldersV1Response struct { } type CreateFolderV1Request struct { - FolderName string `json:"folderName"` + FolderName string `json:"name"` WorkspaceId string `json:"workspaceId"` Environment string `json:"environment"` - Directory string `json:"directory"` + Path string `json:"path"` } type CreateFolderV1Response struct { diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 750727df1..db7c81225 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -228,7 +228,9 @@ func secretTemplateFunction(accessToken string, existingEtag string, currentEtag *currentEtag = res.Etag } - return res.Secrets, nil + expandedSecrets := util.ExpandSecrets(res.Secrets, models.ExpandSecretsAuthentication{UniversalAuthAccessToken: accessToken}, "") + + return expandedSecrets, nil } } @@ -622,7 +624,7 @@ var agentCmd = &cobra.Command{ } if !FileExists(configPath) && agentConfigInBase64 == "" { - log.Error().Msgf("No agent config file provided. Please provide a agent config file", configPath) + log.Error().Msgf("No agent config file provided at %v. Please provide a agent config file", configPath) return } diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 26cc45f65..9c3590e89 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -87,7 +87,9 @@ var exportCmd = &cobra.Command{ var output string if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, "") } secrets = util.FilterSecretsByTag(secrets, tagSlugs) output, err = formatEnvs(secrets, format) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 2bb043c26..48eaffea9 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -110,7 +110,9 @@ var runCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, projectConfigDir) + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, projectConfigDir) } secretsByKey := getSecretsByKeys(secrets) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 778ac574e..667f39b95 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "fmt" + "os" "regexp" "sort" "strings" @@ -39,6 +40,11 @@ var secretsCmd = &cobra.Command{ } infisicalToken, err := cmd.Flags().GetString("token") + + if infisicalToken == "" { + infisicalToken = os.Getenv(util.INFISICAL_TOKEN_NAME) + } + if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -80,7 +86,9 @@ var secretsCmd = &cobra.Command{ } if shouldExpandSecrets { - secrets = util.ExpandSecrets(secrets, infisicalToken, "") + secrets = util.ExpandSecrets(secrets, models.ExpandSecretsAuthentication{ + InfisicalToken: infisicalToken, + }, "") } visualize.PrintAllSecretDetails(secrets) @@ -406,6 +414,11 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse path flag") } + showOnlyValue, err := cmd.Flags().GetBool("raw-value") + if err != nil { + util.HandleError(err, "Unable to parse path flag") + } + secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken, TagSlugs: tagSlugs, SecretsPath: secretsPath}, "") if err != nil { util.HandleError(err, "To fetch all secrets") @@ -427,7 +440,15 @@ func getSecretsByNames(cmd *cobra.Command, args []string) { } } - visualize.PrintAllSecretDetails(requestedSecrets) + if showOnlyValue && len(requestedSecrets) > 1 { + util.PrintErrorMessageAndExit("--raw-value only works with one secret.") + } + + if showOnlyValue { + fmt.Printf(requestedSecrets[0].Value) + } else { + visualize.PrintAllSecretDetails(requestedSecrets) + } Telemetry.CaptureEvent("cli-command:secrets get", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) } @@ -661,6 +682,7 @@ func init() { secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") secretsCmd.AddCommand(secretsGetCmd) secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") + secretsGetCmd.Flags().Bool("raw-value", false, "Returns only the value of secret, only works with one secret") secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index c3999f68b..576e74909 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -21,11 +21,12 @@ type LoggedInUser struct { } type SingleEnvironmentVariable struct { - Key string `json:"key"` - Value string `json:"value"` - Type string `json:"type"` - ID string `json:"_id"` - Tags []struct { + Key string `json:"key"` + WorkspaceId string `json:"workspace"` + Value string `json:"value"` + Type string `json:"type"` + ID string `json:"_id"` + Tags []struct { ID string `json:"_id"` Name string `json:"name"` Slug string `json:"slug"` @@ -68,6 +69,7 @@ type GetAllSecretsParameters struct { Environment string EnvironmentPassedViaFlag bool InfisicalToken string + UniversalAuthAccessToken string TagSlugs string WorkspaceId string SecretsPath string @@ -96,3 +98,8 @@ type DeleteFolderParameters struct { FolderPath string InfisicalToken string } + +type ExpandSecretsAuthentication struct { + InfisicalToken string + UniversalAuthAccessToken string +} diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 7275653d9..0f837ee71 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -154,7 +154,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er WorkspaceId: params.WorkspaceId, Environment: params.Environment, FolderName: params.FolderName, - Directory: params.FolderPath, + Path: params.FolderPath, } apiResponse, err := api.CallCreateFolderV1(httpClient, createFolderRequest) diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index cc75681e8..a1764d721 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -179,7 +179,7 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin } for _, secret := range rawSecrets.Secrets { - plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue}) + plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue, WorkspaceId: secret.Workspace}) } // if includeImports { @@ -248,11 +248,8 @@ func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tag } func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) { - var infisicalToken string if params.InfisicalToken == "" { - infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) - } else { - infisicalToken = params.InfisicalToken + params.InfisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) } isConnected := CheckIsConnectedToInternet() @@ -260,7 +257,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error - if infisicalToken == "" { + if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { if isConnected { log.Debug().Msg("GetAllEnvironmentVariables: Connected to internet, checking logged in creds") @@ -306,12 +303,6 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo infisicalDotJson.WorkspaceId = params.WorkspaceId } - // // Verify environment - // err = ValidateEnvironmentName(params.Environment, workspaceFile.WorkspaceId, loggedInUserDetails.UserCredentials) - // if err != nil { - // return nil, fmt.Errorf("unable to validate environment name because [err=%s]", err) - // } - secretsToReturn, errorToReturn = GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, infisicalDotJson.WorkspaceId, params.Environment, params.TagSlugs, params.SecretsPath, params.IncludeImport) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", errorToReturn) @@ -332,91 +323,19 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } } else { - log.Debug().Msg("Trying to fetch secrets using service token") - secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(infisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) - } + if params.InfisicalToken != "" { + log.Debug().Msg("Trying to fetch secrets using service token") + secretsToReturn, _, errorToReturn = GetPlainTextSecretsViaServiceToken(params.InfisicalToken, params.Environment, params.SecretsPath, params.IncludeImport) + } else if params.UniversalAuthAccessToken != "" { + log.Debug().Msg("Trying to fetch secrets using universal auth") + res, err := GetPlainTextSecretsViaMachineIdentity(params.UniversalAuthAccessToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport) - return secretsToReturn, errorToReturn -} - -// func ValidateEnvironmentName(environmentName string, workspaceId string, userLoggedInDetails models.UserCredentials) error { -// httpClient := resty.New() -// httpClient.SetAuthToken(userLoggedInDetails.JTWToken). -// SetHeader("Accept", "application/json") - -// response, err := api.CallGetAccessibleEnvironments(httpClient, api.GetAccessibleEnvironmentsRequest{WorkspaceId: workspaceId}) -// if err != nil { -// return err -// } - -// listOfEnvSlugs := []string{} -// mapOfEnvSlugs := make(map[string]interface{}) - -// for _, environment := range response.AccessibleEnvironments { -// listOfEnvSlugs = append(listOfEnvSlugs, environment.Slug) -// mapOfEnvSlugs[environment.Slug] = environment -// } - -// _, exists := mapOfEnvSlugs[environmentName] -// if !exists { -// HandleError(fmt.Errorf("the environment [%s] does not exist in project with [id=%s]. Only [%s] are available", environmentName, workspaceId, strings.Join(listOfEnvSlugs, ","))) -// } - -// return nil - -// } - -func getExpandedEnvVariable(secrets []models.SingleEnvironmentVariable, variableWeAreLookingFor string, hashMapOfCompleteVariables map[string]string, hashMapOfSelfRefs map[string]string) string { - if value, found := hashMapOfCompleteVariables[variableWeAreLookingFor]; found { - return value - } - - for _, secret := range secrets { - if secret.Key == variableWeAreLookingFor { - regex := regexp.MustCompile(`\${([^\}]*)}`) - variablesToPopulate := regex.FindAllString(secret.Value, -1) - - // case: variable is a constant so return its value - if len(variablesToPopulate) == 0 { - return secret.Value - } - - valueToEdit := secret.Value - for _, variableWithSign := range variablesToPopulate { - variableWithoutSign := strings.Trim(variableWithSign, "}") - variableWithoutSign = strings.Trim(variableWithoutSign, "${") - - // case: reference to self - if variableWithoutSign == secret.Key { - hashMapOfSelfRefs[variableWithoutSign] = variableWithoutSign - continue - } else { - var expandedVariableValue string - - if preComputedVariable, found := hashMapOfCompleteVariables[variableWithoutSign]; found { - expandedVariableValue = preComputedVariable - } else { - expandedVariableValue = getExpandedEnvVariable(secrets, variableWithoutSign, hashMapOfCompleteVariables, hashMapOfSelfRefs) - hashMapOfCompleteVariables[variableWithoutSign] = expandedVariableValue - } - - // If after expanding all the vars above, is the current var a self ref? if so no replacement needed for it - if _, found := hashMapOfSelfRefs[variableWithoutSign]; found { - continue - } else { - valueToEdit = strings.ReplaceAll(valueToEdit, variableWithSign, expandedVariableValue) - } - } - } - - return valueToEdit - - } else { - continue + errorToReturn = err + secretsToReturn = res.Secrets } } - return "${" + variableWeAreLookingFor + "}" + return secretsToReturn, errorToReturn } var secRefRegex = regexp.MustCompile(`\${([^\}]*)}`) @@ -428,7 +347,7 @@ func recursivelyExpandSecret(expandedSecs map[string]string, interpolatedSecs ma interpolatedVal, ok := interpolatedSecs[key] if !ok { - HandleError(fmt.Errorf("Could not find refered secret - %s", key), "Kindly check whether its provided") + HandleError(fmt.Errorf("could not find refered secret - %s", key), "Kindly check whether its provided") } refs := secRefRegex.FindAllStringSubmatch(interpolatedVal, -1) @@ -467,7 +386,7 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod return secretMapByName } -func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken string, projectConfigPathDir string) []models.SingleEnvironmentVariable { +func ExpandSecrets(secrets []models.SingleEnvironmentVariable, auth models.ExpandSecretsAuthentication, projectConfigPathDir string) []models.SingleEnvironmentVariable { expandedSecs := make(map[string]string) interpolatedSecs := make(map[string]string) // map[env.secret-path][keyname]Secret @@ -499,8 +418,18 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st uniqKey := fmt.Sprintf("%s.%s", env, secPathDot) if crossRefSec, ok := crossEnvRefSecs[uniqKey]; !ok { + + var refSecs []models.SingleEnvironmentVariable + var err error + // if not in cross reference cache, fetch it from server - refSecs, err := GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: infisicalToken, SecretsPath: secPath}, projectConfigPathDir) + if auth.InfisicalToken != "" { + refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir) + } else if auth.UniversalAuthAccessToken != "" { + refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir) + } else { + HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets") + } if err != nil { HandleError(err, fmt.Sprintf("Could not fetch secrets in environment: %s secret-path: %s", env, secPath), "If you are using a service token to fetch secrets, please ensure it is valid") } @@ -508,6 +437,7 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, infisicalToken st // save it to avoid calling api again for same environment and folder path crossEnvRefSecs[uniqKey] = refSecsByKey return refSecsByKey[secKey].Value + } else { return crossRefSec[secKey].Value } diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 8e2183ad9..cf76f1842 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -8,24 +8,28 @@ infisical secrets ``` ## Description + This command enables you to perform CRUD (create, read, update, delete) operations on secrets within your Infisical project. With it, you can view, create, update, and delete secrets in your environment. -### Sub-commands +### Sub-commands + Use this command to print out all of the secrets in your project - ```bash - $ infisical secrets - ``` +```bash +$ infisical secrets +``` + +### Environment variables - ### Environment variables Used to fetch secrets via a [service token](/documentation/platform/token) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_TOKEN=st.63e03c4a97cb4a747186c71e.ed5b46a34c078a8f94e8228f4ab0ff97.4f7f38034811995997d72badf44b42ec ``` + @@ -34,22 +38,26 @@ This command enables you to perform CRUD (create, read, update, delete) operatio To use, simply export this variable in the terminal before running this command. ```bash - # Example + # Example export INFISICAL_DISABLE_UPDATE_CHECK=true ``` + - ### Flags +### Flags + Parse shell parameter expansions in your secrets Default value: `true` + Used to select the environment name on which actions should be taken on Default value: `dev` + The `--path` flag indicates which project folder secrets will be injected from. @@ -58,6 +66,7 @@ This command enables you to perform CRUD (create, read, update, delete) operatio # Example infisical secrets --path="/" --env=dev ``` + @@ -65,38 +74,55 @@ This command enables you to perform CRUD (create, read, update, delete) operatio This command allows you selectively print the requested secrets by name - ```bash - $ infisical secrets get ... +```bash +$ infisical secrets get ... - # Example - $ infisical secrets get DOMAIN +# Example +$ infisical secrets get DOMAIN - ``` +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + + + + + Used to print the plain value of a single requested secret without any table style. + + Default value: `false` + + Example: `infisical secrets get DOMAIN --raw-value` + + + When running in CI/CD environments or in a script, set `INFISICAL_DISABLE_UPDATE_CHECK` env to `true`. This will help hide any CLI update messages and only show the secret value. + + -This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. +This command allows you to set or update secrets in your environment. If the secret key provided already exists, its value will be updated with the new value. If the secret key does not exist, a new secret will be created using both the key and value provided. ```bash $ infisical secrets set ... -## Example +## Example $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + Used to select the project folder in which the secrets will be set. This is useful when creating new secrets under a particular path. @@ -105,43 +131,48 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb # Example infisical secrets set DOMAIN=example.com --path="common/backend" ``` + This command allows you to delete secrets by their name(s). - ```bash - $ infisical secrets delete ... +```bash +$ infisical secrets delete ... - ## Example - $ infisical secrets delete STRIPE_API_KEY DOMAIN HASH - ``` +## Example +$ infisical secrets delete STRIPE_API_KEY DOMAIN HASH +``` + +### Flags - ### Flags Used to select the environment name on which actions should be taken on Default value: `dev` + - The `--path` flag indicates which project folder secrets will be injected from. + The `--path` flag indicates which project folder secrets will be injected from. ```bash # Example infisical secrets delete ... --path="/" ``` + This command allows you to fetch, create and delete folders from within a path from a given project. - ```bash - $ infisical secrets folders - ``` +```bash +$ infisical secrets folders +``` + +### sub commands - ### sub commands Used to fetch all folders within a path in a given project ``` @@ -179,6 +210,7 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb Default value: `` + @@ -194,10 +226,11 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb - Name of the folder to be deleted within selected `--path` + Name of the folder to be deleted within selected `--path` Default value: `` + @@ -210,14 +243,16 @@ To place default values in your example .env file, you can simply include the sy ```bash $ infisical secrets generate-example-env -## Example +## Example $ infisical secrets generate-example-env > .example-env ``` - ### Flags +### Flags + Used to select the environment name on which actions should be taken on Default value: `dev` + diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 329bc4b9f..66961db99 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -10,7 +10,7 @@ This means that updating the value of a base secret propagates directly to other Currently, the secret referencing feature is only supported by the - [Infisical CLI](/cli/overview) and [native integrations](/integrations/overview). + [Infisical CLI](/cli/overview), [native integrations](/integrations/overview) and [Infisical Agent](/infisical-agent/overview). We intend to add support for it to the [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java) this quarter. diff --git a/docs/images/docker-swarm-secrets-complete.png b/docs/images/docker-swarm-secrets-complete.png new file mode 100644 index 000000000..28b439445 Binary files /dev/null and b/docs/images/docker-swarm-secrets-complete.png differ 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/infisical-agent/guides/docker-swarm-with-agent.mdx b/docs/infisical-agent/guides/docker-swarm-with-agent.mdx new file mode 100644 index 000000000..8ab4ca962 --- /dev/null +++ b/docs/infisical-agent/guides/docker-swarm-with-agent.mdx @@ -0,0 +1,164 @@ +--- +title: 'Docker Swarm' +description: "How to manage secrets in Docker Swarm services" +--- + +In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm. +Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens. +These secrets are then stored in a shared volume accessible by other services in your Docker Swarm. + +## Prerequisites +- Infisical account +- Docker version 20.10.24 or newer +- Basic knowledge of Docker Swarm +- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system +- Familiarity with the [Infisical Agent](/infisical-agent/overview) + +## Objective +Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments. + + + + Start by cloning the [Infisical guide assets repository](https://github.com/Infisical/infisical-guides.git) from Github. This repository includes necessary assets for this and other Infisical guides. Focus on the `docker-swarm-with-agent` sub-directory, which we'll use as our working directory. + + + + To allow the agent to fetch your Infisical secrets, choose an authentication method for the agent. For this guide, we will use [Universal Auth](/documentation/platform/identities/universal-auth) for authentication. Follow the instructions [here](/documentation/platform/identities/universal-auth) to generate a client ID and client secret. + + + + Copy the client ID and client secret obtained in the previous step into the `client-id` and `client-secret` text files, respectively. + + + + The Infisical Agent will authenticate using Universal Auth and retrieve secrets for rendering as specified in the template(s). + Adjust the `polling-interval` to control the frequency of secret updates. + + In the example template, the secrets are rendered as an HTML page, which will be set as Nginx's home page to demonstrate successful secret retrieval and utilization. + + + Remember to add your project id, environment slug and path of corresponding Infisical project to the secret template. + + + + ```yaml infisical-agent-config + infisical: + address: "https://app.infisical.com" + auth: + type: "universal-auth" + config: + client-id: "/run/secrets/infisical-universal-auth-client-id" + client-secret: "/run/secrets/infisical-universal-auth-client-secret" + remove_client_secret_on_read: false + sinks: + - type: "file" + config: + path: "/infisical-secrets/access-token" + templates: + - source-path: /run/secrets/nginx-home-page-template + destination-path: /infisical-secrets/index.html + config: + polling-interval: 60s + ``` + + Some paths contain `/run/secrets/` because the contents of those files reside in a [Docker secret](https://docs.docker.com/engine/swarm/secrets/#how-docker-manages-secrets). + + + + ```html nginx-home-page-template + + + +

This file is rendered by Infisical agent template engine

+

Here are the secrets that have been fetched from Infisical and stored in your volume mount

+
    + {{- with secret "7df67a5f-d26a-4988-a375-7153c08149da" "dev" "/" }} + {{- range . }} +
  1. {{ .Key }}={{ .Value }}
  2. + {{- end }} + {{- end }} +
+ + + ``` +
+
+
+ + + Define the `infisical-agent` and `nginx` services in your Docker Compose file. `infisical-agent` will handle secret retrieval and storage. These secrets are stored in a volume, accessible by other services like Nginx. + + ```yaml docker-compose.yaml + version: "3.1" + + services: + infisical-agent: + container_name: infisical-agnet + image: infisical/cli:0.18.0 + command: agent --config=/run/secrets/infisical-agent-config + volumes: + - infisical-agent:/infisical-secrets + secrets: + - infisical-universal-auth-client-id + - infisical-universal-auth-client-secret + - infisical-agent-config + - nginx-home-page-template + networks: + - infisical_network + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - infisical-agent:/usr/share/nginx/html + networks: + - infisical_network + + volumes: + infisical-agent: + + secrets: + infisical-universal-auth-client-id: + file: ./client-id + infisical-universal-auth-client-secret: + file: ./client-secret + infisical-agent-config: + file: ./infisical-agent-config + nginx-home-page-template: + file: ./nginx-home-page-template + + + networks: + infisical_network: + ``` + + + + ``` + docker swarm init + ``` + + + + ``` + docker stack deploy -c docker-compose.yaml agent-demo + ``` + + + + To confirm that secrets are properly rendered and accessible, navigate to `http://localhost`. You should see the Infisical secrets displayed on the Nginx landing page. + + ![Nginx displaying Infisical secrets](/images/docker-swarm-secrets-complete.png) + + + + ``` + docker stack rm agent-demo + ``` + +
+ +## Considerations +- Secret Updates: Applications that access secrets directly from the volume mount will receive updates in real-time, in accordance with the `polling-interval` set in agent config. +- In-Memory Secrets: If your application loads secrets into memory, the new secrets will be available to the application on the next deployment. 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/docs/mint.json b/docs/mint.json index d316ff81a..5da117548 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -236,12 +236,27 @@ { "group": "Agent", "pages": [ - "infisical-agent/overview" + "infisical-agent/overview", + { + "group": "Use cases", + "pages": [ + "infisical-agent/guides/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + } ] }, { "group": "Infrastructure Integrations", "pages": [ + { + "group": "Container orchestrators", + "pages": [ + "integrations/platforms/kubernetes", + "infisical-agent/guides/docker-swarm-with-agent", + "integrations/platforms/ecs-with-agent" + ] + }, { "group": "Docker", "pages": [ @@ -251,10 +266,8 @@ "integrations/platforms/docker-compose" ] }, - "integrations/platforms/kubernetes", "integrations/frameworks/terraform", - "integrations/platforms/ansible", - "integrations/platforms/ecs-with-agent" + "integrations/platforms/ansible" ] }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ec5b5578..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", @@ -68,7 +69,7 @@ "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.105.4", + "posthog-js": "^1.105.6", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", @@ -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", @@ -19065,9 +19098,9 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/posthog-js": { - "version": "1.105.4", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.4.tgz", - "integrity": "sha512-hazxQYi4nxSqktu0Hh1xCV+sJCpN8mp5E5Ei/cfEa2nsb13xQbzn81Lf3VIDA0xMU1mXxNRStntlY267eQVC/w==", + "version": "1.105.6", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.105.6.tgz", + "integrity": "sha512-5ITXsh29XIuNohHLy21nawGnfFZDpyt+yfnWge9sJl5yv0nNuoUmLiDgw1tJafoqGrfd5CUasKyzSI21HxsSeQ==", "dependencies": { "fflate": "^0.4.8", "preact": "^10.19.3" diff --git a/frontend/package.json b/frontend/package.json index 5871c9599..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", @@ -76,7 +77,7 @@ "next": "^12.3.4", "nprogress": "^0.2.0", "picomatch": "^2.3.1", - "posthog-js": "^1.105.4", + "posthog-js": "^1.105.6", "query-string": "^7.1.3", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.1", 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/pages/integrations/railway/create.tsx b/frontend/src/pages/integrations/railway/create.tsx index 2f715be4b..26845cb72 100644 --- a/frontend/src/pages/integrations/railway/create.tsx +++ b/frontend/src/pages/integrations/railway/create.tsx @@ -74,16 +74,6 @@ export default function RailwayCreateIntegrationPage() { } }, [targetEnvironments]); - useEffect(() => { - if (targetServices) { - if (targetServices.length > 0) { - setTargetServiceId(targetServices[0].serviceId); - } else { - setTargetServiceId("none"); - } - } - }, [targetServices]); - const handleButtonClick = async () => { try { setIsLoading(true); @@ -124,11 +114,14 @@ export default function RailwayCreateIntegrationPage() { } }; + const filteredTargetServices = targetServices ? [ { name: "", serviceId: "none" }, ...targetServices ] : [ { name: "", serviceId: "none" } ]; + return workspace && selectedSourceEnvironment && integrationAuthApps && targetEnvironments && - targetServices ? ( + targetServices && + filteredTargetServices ? (
Railway Integration @@ -208,20 +201,14 @@ export default function RailwayCreateIntegrationPage() { className="w-full border border-mineshaft-500" isDisabled={targetServices.length === 0} > - {targetServices.length > 0 ? ( - targetServices.map((targetService) => ( + {filteredTargetServices.map((targetService) => ( {targetService.name} - )) - ) : ( - - No services found - - )} + ))}
@@ -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 }) => (