diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 1e6a88129..1d2959f5b 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -453,6 +453,40 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "POST", + url: "/:integrationAuthId/duplicate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + body: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + integrationAuth: integrationAuthPubSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.duplicateIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + id: req.params.integrationAuthId, + projectId: req.body.projectId + }); + + return { integrationAuth }; + } + }); + server.route({ method: "GET", url: "/:integrationAuthId/github/envs", diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index d19317c4b..3f9e3c300 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -16,6 +16,8 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { integrationAuthPubSchema } from "../sanitizedSchemas"; + export const registerOrgRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -67,6 +69,35 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:organizationId/integration-authorizations", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + authorizations: integrationAuthPubSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const authorizations = await server.services.integrationAuth.listOrgIntegrationAuth({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId + }); + + return { authorizations }; + } + }); + server.route({ method: "GET", url: "/audit-logs", diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index d429c0da4..5882983e0 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -1135,7 +1135,7 @@ export const getApps = async ({ case Integrations.GITHUB: return getAppsGithub({ accessToken, - authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata) + authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}) }); case Integrations.GITLAB: diff --git a/backend/src/services/integration-auth/integration-auth-dal.ts b/backend/src/services/integration-auth/integration-auth-dal.ts index d32cd1579..7a56afcbb 100644 --- a/backend/src/services/integration-auth/integration-auth-dal.ts +++ b/backend/src/services/integration-auth/integration-auth-dal.ts @@ -3,7 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TIntegrationAuthDALFactory = ReturnType; @@ -28,8 +28,23 @@ export const integrationAuthDALFactory = (db: TDbClient) => { } }; + const getByOrg = async (orgId: string, tx?: Knex) => { + try { + const integrationAuths = await (tx || db)(TableName.IntegrationAuth) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.IntegrationAuth}.projectId`) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + .where(`${TableName.Organization}.id`, "=", orgId) + .select(selectAllTableCols(TableName.IntegrationAuth)); + + return integrationAuths; + } catch (error) { + throw new DatabaseError({ error, name: "get by org" }); + } + }; + return { ...integrationAuthOrm, - bulkUpdate + bulkUpdate, + getByOrg }; }; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 1b1cc9c0c..8c0c309ca 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -10,7 +10,7 @@ import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { TProjectPermission } from "@app/lib/types"; +import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; @@ -24,6 +24,7 @@ import { TChecklyGroups, TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthsDTO, + TDuplicateGithubIntegrationAuthDTO, TGetIntegrationAuthDTO, TGetIntegrationAuthTeamCityBuildConfigDTO, THerokuPipelineCoupling, @@ -89,6 +90,24 @@ export const integrationAuthServiceFactory = ({ return authorizations; }; + const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { + const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); + + return Promise.all( + authorizations.filter(async (auth) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + auth.projectId, + actorAuthMethod, + actorOrgId + ); + + return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + }) + ); + }; + const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { const integrationAuth = await integrationAuthDAL.findById(id); if (!integrationAuth) throw new NotFoundError({ message: "Failed to find integration" }); @@ -350,7 +369,7 @@ export const integrationAuthServiceFactory = ({ } if ( integrationAuth.integration === Integrations.GITHUB && - IntegrationAuthMetadataSchema.parse(integrationAuth.metadata).installationId + IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}).installationId ) { return { accessToken: "", accessId: "" }; } @@ -612,7 +631,7 @@ export const integrationAuthServiceFactory = ({ const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); let octokit: Octokit; - const { installationId } = integrationAuth.metadata as { installationId: string }; + const { installationId } = (integrationAuth.metadata as { installationId: string }) || {}; if (installationId) { octokit = new Octokit({ authStrategy: createAppAuth, @@ -637,12 +656,12 @@ export const integrationAuthServiceFactory = ({ orgId: String(repo.owner.id) })) .filter((org) => { - const isOrgProcessed = !orgSet.has(org.orgId); + const isOrgProcessed = orgSet.has(org.orgId); if (!isOrgProcessed) { orgSet.add(org.orgId); } - return isOrgProcessed; + return !isOrgProcessed; }); } @@ -689,7 +708,7 @@ export const integrationAuthServiceFactory = ({ let octokit: Octokit; const appCfg = getConfig(); - const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata); + const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}); if (authMetadata.installationId) { octokit = new Octokit({ authStrategy: createAppAuth, @@ -1390,8 +1409,58 @@ export const integrationAuthServiceFactory = ({ return delIntegrationAuth; }; + // At the moment, we only use this for Github App integration as it's a special case + const duplicateIntegrationAuth = async ({ + id, + actorId, + actor, + actorAuthMethod, + actorOrgId, + projectId + }: TDuplicateGithubIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) { + throw new NotFoundError({ message: "Failed to find integration" }); + } + + const { permission: sourcePermission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(sourcePermission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const { permission: targetPermission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(targetPermission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Integrations + ); + + const newIntegrationAuth: Omit & { id?: string } = { + ...integrationAuth, + id: undefined, + projectId + }; + + return integrationAuthDAL.create(newIntegrationAuth); + }; + return { listIntegrationAuthByProjectId, + listOrgIntegrationAuth, getIntegrationOptions, getIntegrationAuth, oauthExchange, @@ -1418,6 +1487,7 @@ export const integrationAuthServiceFactory = ({ getNorthFlankSecretGroups, getTeamcityBuildConfigs, getBitbucketWorkspaces, - getIntegrationAccessToken + getIntegrationAccessToken, + duplicateIntegrationAuth }; }; diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index b8afe5ecb..eb8b8044d 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -108,6 +108,10 @@ export type TDeleteIntegrationAuthByIdDTO = { id: string; } & Omit; +export type TDuplicateGithubIntegrationAuthDTO = { + id: string; +} & TProjectPermission; + export type TGetIntegrationAuthTeamCityBuildConfigDTO = { id: string; appId: string; diff --git a/backend/src/services/integration-auth/integration-delete-secret.ts b/backend/src/services/integration-auth/integration-delete-secret.ts index a404f5911..d8ec89b15 100644 --- a/backend/src/services/integration-auth/integration-delete-secret.ts +++ b/backend/src/services/integration-auth/integration-delete-secret.ts @@ -367,7 +367,7 @@ export const deleteIntegrationSecrets = async ({ case Integrations.GITHUB: { await deleteGithubSecrets({ integration, - authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata), + authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}), accessToken, secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets }); diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index c08cff98d..a46b62f5e 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1557,7 +1557,7 @@ const syncSecretsGitHub = async ({ selected_repositories_url?: string | undefined; } - const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata); + const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}); let octokit: Octokit; const appCfg = getConfig(); diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 5f0503eee..0ae3511de 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -1,3 +1,4 @@ +export { useDuplicateIntegrationAuth } from "./mutations"; export { useAuthorizeIntegration, useDeleteIntegrationAuth, diff --git a/frontend/src/hooks/api/integrationAuth/mutations.tsx b/frontend/src/hooks/api/integrationAuth/mutations.tsx new file mode 100644 index 000000000..b7a3f18bd --- /dev/null +++ b/frontend/src/hooks/api/integrationAuth/mutations.tsx @@ -0,0 +1,19 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { IntegrationAuth, TDuplicateIntegrationAuthDTO } from "./types"; + +// For now, this should only be used in the Github app integration flow. +export const useDuplicateIntegrationAuth = () => { + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post<{ integrationAuth: IntegrationAuth }>( + `/api/v1/integration-auth/${body.integrationAuthId}/duplicate`, + body + ); + + return data.integrationAuth; + } + }); +}; diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index a32dab8ce..9a8ca0e31 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -11,6 +11,7 @@ export type IntegrationAuth = { teamId?: string; metadata: { installationName?: string; + installationId?: string; }; }; @@ -87,3 +88,8 @@ export type TeamCityBuildConfig = { name: string; buildConfigId: string; }; + +export type TDuplicateIntegrationAuthDTO = { + integrationAuthId: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index f7a898ef0..fece19e5f 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -8,8 +8,9 @@ export { useDeleteOrgTaxId, useGetIdentityMembershipOrgs, useGetOrganizationGroups, - useGetOrganizations, + useGetOrganizations, useGetOrgBillingDetails, + useGetOrgIntegrationAuths, useGetOrgInvoices, useGetOrgLicenses, useGetOrgPlanBillingInfo, @@ -20,4 +21,4 @@ export { useGetOrgTrialUrl, useUpdateOrg, useUpdateOrgBillingDetails -} from "./queries"; \ No newline at end of file +} from "./queries"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 670f39146..5e3dfbfa7 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { TGroupOrgMembership } from "../groups/types"; +import { IntegrationAuth } from "../types"; import { BillingDetails, Invoice, @@ -39,7 +40,8 @@ export const organizationKeys = { ...params }: TListOrgIdentitiesDTO) => [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, - getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const + getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, + getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const }; export const fetchOrganizations = async () => { @@ -463,3 +465,21 @@ export const useGetOrganizationGroups = (organizationId: string) => { } }); }; + +export const useGetOrgIntegrationAuths = ( + organizationId: string, + select?: (data: IntegrationAuth[]) => TData +) => { + return useQuery({ + queryKey: organizationKeys.getOrgIntegrationAuths(organizationId), + queryFn: async () => { + const { data } = await apiRequest.get<{ authorizations: IntegrationAuth[] }>( + `/api/v1/organization/${organizationId}/integration-authorizations` + ); + + return data.authorizations; + }, + enabled: Boolean(organizationId), + select + }); +}; diff --git a/frontend/src/pages/integrations/github/auth-mode-selection.tsx b/frontend/src/pages/integrations/github/auth-mode-selection.tsx index 5426cd562..e5f96636f 100644 --- a/frontend/src/pages/integrations/github/auth-mode-selection.tsx +++ b/frontend/src/pages/integrations/github/auth-mode-selection.tsx @@ -24,7 +24,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
@@ -61,7 +61,7 @@ export default function GithubIntegrationAuthModeSelectionPage() { router.push("/integrations/select-integration-auth?integrationSlug=github"); }} > - Connect with Github App + Connect with App
diff --git a/frontend/src/pages/integrations/select-integration-auth.tsx b/frontend/src/pages/integrations/select-integration-auth.tsx index bb55fc01c..6bfa0ae6e 100644 --- a/frontend/src/pages/integrations/select-integration-auth.tsx +++ b/frontend/src/pages/integrations/select-integration-auth.tsx @@ -6,46 +6,93 @@ import Image from "next/image"; import { useRouter } from "next/router"; import { Button, Card, CardTitle } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useGetCloudIntegrations, useGetWorkspaceAuthorizations } from "@app/hooks/api"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useDuplicateIntegrationAuth, + useGetCloudIntegrations, + useGetOrgIntegrationAuths +} from "@app/hooks/api"; import { IntegrationAuth } from "@app/hooks/api/types"; export default function SelectIntegrationAuthPage() { const router = useRouter(); const { data: cloudIntegrations } = useGetCloudIntegrations(); + const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const orgId = currentOrg?.id || ""; const integrationSlug = router.query.integrationSlug as string; const currentIntegration = cloudIntegrations?.find( (integration) => integration.slug === integrationSlug ); + const { mutateAsync: duplicateIntegrationAuth, isLoading: isIntegrationAuthSelectLoading } = + useDuplicateIntegrationAuth(); + // for Github, we want to reuse the same connection across the Infisical organization + // when we do need to reuse this page for other integrations, add handling to fetch workspace integration auths instead const { data: integrationAuths, isLoading: isLoadingIntegrationAuths } = - useGetWorkspaceAuthorizations( - workspaceId, - useCallback((data: IntegrationAuth[]) => { - const filteredIntegrationAuths = data.filter( - (integrationAuth) => integrationAuth.integration === integrationSlug - ); - - if (integrationSlug === "github") { - // for now, we only display the integration auths for Github apps - return filteredIntegrationAuths.filter((integrationAuth) => - Boolean(integrationAuth.metadata?.installationName) + useGetOrgIntegrationAuths( + orgId, + useCallback( + (data: IntegrationAuth[]) => { + const filteredIntegrationAuths = data.filter( + (integrationAuth) => integrationAuth.integration === integrationSlug ); - } - return []; - }, []) + if (integrationSlug === "github") { + const sameProjectIntegrationAuths = filteredIntegrationAuths.filter( + (auth) => auth.projectId === currentWorkspace?.id + ); + const differentProjectIntegrationAuths = filteredIntegrationAuths.filter( + (auth) => auth.projectId !== currentWorkspace?.id + ); + + const installationIds = new Set(); + + // for now, we only display the integration auths for Github apps + return ( + // we concatenate it this way so that integration auths from the same project are prioritized for display + sameProjectIntegrationAuths + .concat(differentProjectIntegrationAuths) + .filter((integrationAuth) => Boolean(integrationAuth.metadata?.installationId)) + // we filter it so that we only show unique installations because the same installation/connection + // can be used in multiple integration auths + .filter((integrationAuth) => { + const isProcessedInstallationId = installationIds.has( + integrationAuth.metadata.installationId as string + ); + + if (!isProcessedInstallationId) { + installationIds.add(integrationAuth.metadata.installationId as string); + } + + return !isProcessedInstallationId; + }) + ); + } + + return []; + }, + [integrationSlug] + ) ); const logo = integrationSlug === "github" ? "/images/integrations/GitHub.png" : ""; - const handleConnectionSelect = (integrationAuthId: string) => { + const handleConnectionSelect = async (integrationAuth: IntegrationAuth) => { if (integrationSlug === "github") { - router.push(`/integrations/github/create?integrationAuthId=${integrationAuthId}`); + if (integrationAuth.projectId === currentWorkspace?.id) { + router.push(`/integrations/github/create?integrationAuthId=${integrationAuth.id}`); + } else { + // we create a copy of the existing integration auth from another project to the current project + const newIntegrationAuth = await duplicateIntegrationAuth({ + projectId: currentWorkspace?.id || "", + integrationAuthId: integrationAuth.id + }); + + router.push(`/integrations/github/create?integrationAuthId=${newIntegrationAuth.id}`); + } } }; @@ -93,10 +140,11 @@ export default function SelectIntegrationAuthPage() { colorSchema="gray" variant="outline" className="mt-3 w-3/4" + isDisabled={isIntegrationAuthSelectLoading} key={integrationAuth.id} size="sm" type="submit" - onClick={() => handleConnectionSelect(integrationAuth.id)} + onClick={() => handleConnectionSelect(integrationAuth)} > {connectionName}