diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2a823024a..d38620837 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -964,6 +964,10 @@ export const INTEGRATION = { shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", + githubVisibility: + "Define where the secrets from the Github Integration should be visible. Option 'selected' lets you directly define which repositories to sync secrets to.", + githubVisibilityRepoIds: + "The repository IDs to sync secrets to when using the Github Integration. Only applicable when using Organization scope, and visibility is set to 'selected'", kmsKeyId: "The ID of the encryption key from AWS KMS.", shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store.", shouldMaskSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Masked'.", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 91d9a68b5..94d7a95d6 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1625,7 +1625,11 @@ const syncSecretsGitHub = async ({ await octokit.request("PUT /orgs/{org}/actions/secrets/{secret_name}", { org: integration.owner as string, secret_name: key, - visibility: "all", + visibility: metadata.githubVisibility ?? "all", + ...(metadata.githubVisibility === "selected" && { + // we need to map the githubVisibilityRepoIds to numbers + selected_repository_ids: metadata.githubVisibilityRepoIds?.map(Number) ?? [] + }), encrypted_value: encryptedSecret, key_id: repoPublicKey.key_id }); diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts index 1ea01e56a..99f1d996f 100644 --- a/backend/src/services/integration/integration-schema.ts +++ b/backend/src/services/integration/integration-schema.ts @@ -5,14 +5,18 @@ import { INTEGRATION } from "@app/lib/api-docs"; import { IntegrationMappingBehavior } from "../integration-auth/integration-list"; export const IntegrationMetadataSchema = z.object({ + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z .nativeEnum(IntegrationMappingBehavior) .optional() .describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z .object({ labelName: z.string(), @@ -20,6 +24,7 @@ export const IntegrationMetadataSchema = z.object({ }) .optional() .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z .array( z.object({ @@ -29,7 +34,15 @@ export const IntegrationMetadataSchema = z.object({ ) .optional() .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + + githubVisibility: z + .union([z.literal("selected"), z.literal("private"), z.literal("all")]) + .optional() + .describe(INTEGRATION.CREATE.metadata.githubVisibility), + githubVisibilityRepoIds: z.array(z.string()).optional().describe(INTEGRATION.CREATE.metadata.githubVisibilityRepoIds), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete), shouldEnableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldEnableDelete), shouldMaskSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldMaskSecrets), diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index cfb6d70a4..0df8edc4a 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -27,6 +27,10 @@ export type TCreateIntegrationDTO = { key: string; value: string; }[]; + + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; + kmsKeyId?: string; shouldDisableDelete?: boolean; shouldMaskSecrets?: boolean; diff --git a/docs/images/integrations/github/integrations-github-scope-env.png b/docs/images/integrations/github/integrations-github-scope-env.png index e38874bd3..3c2592908 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-env.png and b/docs/images/integrations/github/integrations-github-scope-env.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-org.png b/docs/images/integrations/github/integrations-github-scope-org.png index d5ef76a2b..9f56e9e9f 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-org.png and b/docs/images/integrations/github/integrations-github-scope-org.png differ diff --git a/docs/images/integrations/github/integrations-github-scope-repo.png b/docs/images/integrations/github/integrations-github-scope-repo.png index 353527c78..4b1b223f0 100644 Binary files a/docs/images/integrations/github/integrations-github-scope-repo.png and b/docs/images/integrations/github/integrations-github-scope-repo.png differ diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 936c8974a..93b1347f7 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -41,6 +41,13 @@ Prerequisites: ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) + + When using the organization scope, your secrets will be saved in the top-level of your Github Organization. + + You can choose the visibility, which defines which repositories can access the secrets. The options are: + - **All public repositories**: All public repositories in the organization can access the secrets. + - **All private repositories**: All private repositories in the organization can access the secrets. + - **Selected repositories**: Only the selected repositories can access the secrets. This gives a more fine-grained control over which repositories can access the secrets. You can select _both_ private and public repositories with this option. ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 8594cfd73..0455d3996 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -13,8 +13,8 @@ export type FormLabelProps = { label?: ReactNode; icon?: ReactNode; className?: string; - tooltipText?: string; tooltipClassName?: string; + tooltipText?: ReactNode; }; export const FormLabel = ({ @@ -24,8 +24,8 @@ export const FormLabel = ({ icon, className, isOptional, - tooltipText, - tooltipClassName + tooltipClassName, + tooltipText }: FormLabelProps) => ( { key: string; value: string; }[]; + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; kmsKeyId?: string; shouldDisableDelete?: boolean; shouldMaskSecrets?: boolean; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 21e6bff26..f8c7ce244 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -34,6 +34,9 @@ export type TIntegration = { syncMessage?: string; __v: number; metadata?: { + githubVisibility?: string; + githubVisibilityRepoIds?: string[]; + secretSuffix?: string; syncBehavior?: IntegrationSyncBehavior; mappingBehavior?: IntegrationMappingBehavior; diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index 2015ac8f9..c002ea103 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import Head from "next/head"; import Image from "next/image"; @@ -53,6 +53,21 @@ enum TabSections { Options = "options" } +const secretsVisibility = [ + { + value: "selected", + label: "Select repositories" + }, + { + value: "all", + label: "All public repositories" + }, + { + value: "private", + label: "All private repositories" + } +] as const; + const targetEnv = ["github-repo", "github-org", "github-env"] as const; type TargetEnv = (typeof targetEnv)[number]; @@ -63,9 +78,12 @@ const schema = yup.object({ shouldEnableDelete: yup.boolean().optional(), scope: yup.mixed().oneOf(targetEnv.slice()).required(), - repoIds: yup.mixed().when("scope", { - is: "github-repo", - then: yup.array(yup.string().required()).min(1, "Select at least one repositories") + // Explanation: If scope is (github-repo) OR (github-org AND visibility is set to selected), then repoIds is required + repoIds: yup.mixed().when(["scope", "visibility"], { + is: (scope: string, visibility: string) => + scope === "github-repo" || (scope === "github-org" && visibility === "selected"), + then: yup.array(yup.string().required()).min(1, "Select at least one repository"), + otherwise: yup.mixed().notRequired() }), repoId: yup.mixed().when("scope", { @@ -91,6 +109,10 @@ const schema = yup.object({ orgId: yup.mixed().when("scope", { is: "github-org", then: yup.string().required("Organization is required") + }), + visibility: yup.mixed().when("scope", { + is: "github-org", + then: yup.string().required("Visibility is required") }) }); @@ -121,6 +143,7 @@ export default function GitHubCreateIntegrationPage() { secretPath: "/", scope: "github-repo", repoIds: [], + visibility: "all", shouldEnableDelete: false } }); @@ -130,6 +153,7 @@ export default function GitHubCreateIntegrationPage() { const repoIds = watch("repoIds"); const repoName = watch("repoName"); const repoOwner = watch("repoOwner"); + const selectedOrgId = watch("orgId"); const { data: integrationAuthGithubEnvs } = useGetIntegrationAuthGithubEnvs( integrationAuthId as string, @@ -196,6 +220,8 @@ export default function GitHubCreateIntegrationPage() { scope: data.scope, owner: integrationAuthOrgs?.find((e) => e.orgId === data.orgId)?.name, metadata: { + githubVisibility: data.visibility, + githubVisibilityRepoIds: data.repoIds, secretSuffix: data.secretSuffix, shouldEnableDelete: data.shouldEnableDelete } @@ -242,6 +268,15 @@ export default function GitHubCreateIntegrationPage() { } }; + const selectedOrganization = useMemo(() => { + if (!integrationAuthApps) return null; + + return integrationAuthApps.filter( + (authApp) => + integrationAuthOrgs?.find((e) => e.orgId === selectedOrgId)?.name === authApp.owner + ); + }, [selectedOrgId, integrationAuthApps]); + return integrationAuth && workspace && integrationAuthApps ? (
@@ -339,7 +374,10 @@ export default function GitHubCreateIntegrationPage() { + ( + - {integrationAuthOrgs && - integrationAuthOrgs.map(({ name, orgId }) => ( - - {name} + + + )} + /> + + ( + + - + + + )} + /> + + {watch("visibility") === "selected" && ( + ( + + + + {integrationAuthApps.length > 0 ? ( +
+ {repoIds.length === 1 + ? integrationAuthApps?.reduce( + (acc, { appId, name, owner }) => + repoIds[0] === appId ? `${owner}/${name}` : acc, + "" + ) + : `${repoIds.length} repositories selected`} + +
+ ) : ( +
+ No repositories found +
+ )} +
+ + {selectedOrganization ? ( + selectedOrganization.map((integrationAuthApp) => { + const isSelected = repoIds.includes( + String(integrationAuthApp.appId) + ); + + return ( + { + if (repoIds.includes(String(integrationAuthApp.appId))) { + onChange( + repoIds.filter( + (appId: string) => + appId !== String(integrationAuthApp.appId) + ) + ); + } else { + onChange([ + ...repoIds, + String(integrationAuthApp.appId) + ]); + } + }} + key={`repos-id-${integrationAuthApp.appId}`} + icon={ + isSelected ? ( + + ) : ( +
+ ) + } + iconPos="left" + className="w-[28.4rem] text-sm" + > + {integrationAuthApp.owner}/{integrationAuthApp.name} + + ); + }) + ) : ( +
+ )} + + + + )} + /> )} - /> + )} {scope === "github-env" && ( ; + onRemoveIntegration: VoidFunction; + onManualSyncIntegration: VoidFunction; +}; + +export const ConfiguredIntegrationItem = ({ + integration, + environments, + onRemoveIntegration, + onManualSyncIntegration +}: IProps) => { + return ( +
+
+
+ +
+ {environments.find((e) => e.id === integration.envId)?.name || "-"} +
+
+
+ +
+ {integration.secretPath} +
+
+
+ +
+
+ + {/* eslint-disable-next-line no-nested-ternary */} + {integration.metadata?.githubVisibility === "selected" + ? "Syncing to selected repositories in the organization. " + : integration.metadata?.githubVisibility === "private" + ? "Syncing to all private repositories in the organization" + : "Syncing to all public and private repositories in the organization"} +
+ ) : undefined + } + label="Integration" + /> +
+ {integrationSlugNameMapping[integration.integration]} +
+
+ {integration.integration === "qovery" && ( +
+
+ +
+ {integration?.owner || "-"} +
+
+
+ +
+ {integration?.targetService || "-"} +
+
+
+ +
+ {integration?.targetEnvironment || "-"} +
+
+
+ )} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
+
+ )} + {(integration.integration === "vercel" || + integration.integration === "netlify" || + integration.integration === "railway" || + integration.integration === "gitlab" || + integration.integration === "teamcity" || + integration.integration === "bitbucket" || + (integration.integration === "github" && integration.scope === "github-env")) && ( +
+ +
+ {integration.targetEnvironment || integration.targetEnvironmentId} +
+
+ )} + {integration.integration === "checkly" && integration.targetService && ( +
+ +
+ {integration.targetService} +
+
+ )} + {integration.integration === "terraform-cloud" && integration.targetService && ( +
+ +
+ {integration.targetService} +
+
+ )} + {(integration.integration === "checkly" || integration.integration === "github") && ( +
+ +
+ {integration?.metadata?.secretSuffix || "-"} +
+
+ )} +
+
+ {integration.isSynced != null && integration.lastUsed != null && ( + + +
+ +
Last sync
+
+
+ {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} +
+ {!integration.isSynced && ( + <> +
+ +
Fail reason
+
+
{integration.syncMessage}
+ + )} +
+ } + > +
+
{integration.isSynced ? "Synced" : "Not synced"}
+ {!integration.isSynced && } +
+ + + )} +
+ + + +
+ + {(isAllowed: boolean) => ( +
+ + onRemoveIntegration()} + ariaLabel="delete" + isDisabled={!isAllowed} + colorSchema="danger" + variant="star" + > + + + +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 0be10f129..46037dfb4 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -1,27 +1,10 @@ -import { faCalendarCheck } from "@fortawesome/free-regular-svg-icons"; -import { faArrowRight, faRefresh, faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; -import { integrationSlugNameMapping } from "public/data/frequentConstants"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - Button, - Checkbox, - DeleteActionModal, - EmptyState, - FormLabel, - IconButton, - Skeleton, - Tag, - Tooltip -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2"; import { usePopUp, useToggle } from "@app/hooks"; import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; -import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; +import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem"; + type Props = { environments: Array<{ name: string; slug: string; id: string }>; integrations?: TIntegration[]; @@ -72,207 +55,22 @@ export const IntegrationsSection = ({ {!isLoading && (
{integrations?.map((integration) => ( -
-
-
- -
- {environments.find((e) => e.id === integration.envId)?.name || "-"} -
-
-
- -
- {integration.secretPath} -
-
-
- -
-
- -
- {integrationSlugNameMapping[integration.integration]} -
-
- {integration.integration === "qovery" && ( -
-
- -
- {integration?.owner || "-"} -
-
-
- -
- {integration?.targetService || "-"} -
-
-
- -
- {integration?.targetEnvironment || "-"} -
-
-
- )} - {!( - integration.integration === "aws-secret-manager" && - integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE - ) && ( -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (["aws-parameter-store", "rundeck"].includes(integration.integration) && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} -
-
- )} - {(integration.integration === "vercel" || - integration.integration === "netlify" || - integration.integration === "railway" || - integration.integration === "gitlab" || - integration.integration === "teamcity" || - integration.integration === "bitbucket" || - (integration.integration === "github" && integration.scope === "github-env")) && ( -
- -
- {integration.targetEnvironment || integration.targetEnvironmentId} -
-
- )} - {integration.integration === "checkly" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {integration.integration === "terraform-cloud" && integration.targetService && ( -
- -
- {integration.targetService} -
-
- )} - {(integration.integration === "checkly" || - integration.integration === "github") && ( -
- -
- {integration?.metadata?.secretSuffix || "-"} -
-
- )} -
-
- {integration.isSynced != null && integration.lastUsed != null && ( - - -
- -
Last sync
-
-
- {format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")} -
- {!integration.isSynced && ( - <> -
- -
Fail reason
-
-
- {integration.syncMessage} -
- - )} -
- } - > -
-
{integration.isSynced ? "Synced" : "Not synced"}
- {!integration.isSynced && } -
- - - )} -
- - - -
- - {(isAllowed: boolean) => ( -
- - { - setShouldDeleteSecrets.off(); - handlePopUpOpen("deleteConfirmation", integration); - }} - ariaLabel="delete" - isDisabled={!isAllowed} - colorSchema="danger" - variant="star" - > - - - -
- )} -
-
-
+ { + syncIntegration({ + workspaceId, + id: integration.id, + lastUsed: integration.lastUsed as string + }); + }} + onRemoveIntegration={() => { + setShouldDeleteSecrets.off(); + handlePopUpOpen("deleteConfirmation", integration); + }} + integration={integration} + environments={environments} + /> ))}
)}