feat: added handling of using same connection with different projects

This commit is contained in:
Sheen Capadngan
2024-10-15 03:37:11 +08:00
parent faf2c6df90
commit 73382c5363
15 changed files with 287 additions and 38 deletions
@@ -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({ server.route({
method: "GET", method: "GET",
url: "/:integrationAuthId/github/envs", url: "/:integrationAuthId/github/envs",
@@ -16,6 +16,8 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ActorType, AuthMode } from "@app/services/auth/auth-type";
import { integrationAuthPubSchema } from "../sanitizedSchemas";
export const registerOrgRouter = async (server: FastifyZodProvider) => { export const registerOrgRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", 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({ server.route({
method: "GET", method: "GET",
url: "/audit-logs", url: "/audit-logs",
@@ -1135,7 +1135,7 @@ export const getApps = async ({
case Integrations.GITHUB: case Integrations.GITHUB:
return getAppsGithub({ return getAppsGithub({
accessToken, accessToken,
authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata) authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {})
}); });
case Integrations.GITLAB: case Integrations.GITLAB:
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas"; import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>; export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>;
@@ -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 { return {
...integrationAuthOrm, ...integrationAuthOrm,
bulkUpdate bulkUpdate,
getByOrg
}; };
}; };
@@ -10,7 +10,7 @@ import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; 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 { TIntegrationDALFactory } from "../integration/integration-dal";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
@@ -24,6 +24,7 @@ import {
TChecklyGroups, TChecklyGroups,
TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthByIdDTO,
TDeleteIntegrationAuthsDTO, TDeleteIntegrationAuthsDTO,
TDuplicateGithubIntegrationAuthDTO,
TGetIntegrationAuthDTO, TGetIntegrationAuthDTO,
TGetIntegrationAuthTeamCityBuildConfigDTO, TGetIntegrationAuthTeamCityBuildConfigDTO,
THerokuPipelineCoupling, THerokuPipelineCoupling,
@@ -89,6 +90,24 @@ export const integrationAuthServiceFactory = ({
return authorizations; 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 getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id); const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: "Failed to find integration" }); if (!integrationAuth) throw new NotFoundError({ message: "Failed to find integration" });
@@ -350,7 +369,7 @@ export const integrationAuthServiceFactory = ({
} }
if ( if (
integrationAuth.integration === Integrations.GITHUB && integrationAuth.integration === Integrations.GITHUB &&
IntegrationAuthMetadataSchema.parse(integrationAuth.metadata).installationId IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}).installationId
) { ) {
return { accessToken: "", accessId: "" }; return { accessToken: "", accessId: "" };
} }
@@ -612,7 +631,7 @@ export const integrationAuthServiceFactory = ({
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
let octokit: Octokit; let octokit: Octokit;
const { installationId } = integrationAuth.metadata as { installationId: string }; const { installationId } = (integrationAuth.metadata as { installationId: string }) || {};
if (installationId) { if (installationId) {
octokit = new Octokit({ octokit = new Octokit({
authStrategy: createAppAuth, authStrategy: createAppAuth,
@@ -637,12 +656,12 @@ export const integrationAuthServiceFactory = ({
orgId: String(repo.owner.id) orgId: String(repo.owner.id)
})) }))
.filter((org) => { .filter((org) => {
const isOrgProcessed = !orgSet.has(org.orgId); const isOrgProcessed = orgSet.has(org.orgId);
if (!isOrgProcessed) { if (!isOrgProcessed) {
orgSet.add(org.orgId); orgSet.add(org.orgId);
} }
return isOrgProcessed; return !isOrgProcessed;
}); });
} }
@@ -689,7 +708,7 @@ export const integrationAuthServiceFactory = ({
let octokit: Octokit; let octokit: Octokit;
const appCfg = getConfig(); const appCfg = getConfig();
const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata); const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {});
if (authMetadata.installationId) { if (authMetadata.installationId) {
octokit = new Octokit({ octokit = new Octokit({
authStrategy: createAppAuth, authStrategy: createAppAuth,
@@ -1390,8 +1409,58 @@ export const integrationAuthServiceFactory = ({
return delIntegrationAuth; 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<typeof integrationAuth, "id"> & { id?: string } = {
...integrationAuth,
id: undefined,
projectId
};
return integrationAuthDAL.create(newIntegrationAuth);
};
return { return {
listIntegrationAuthByProjectId, listIntegrationAuthByProjectId,
listOrgIntegrationAuth,
getIntegrationOptions, getIntegrationOptions,
getIntegrationAuth, getIntegrationAuth,
oauthExchange, oauthExchange,
@@ -1418,6 +1487,7 @@ export const integrationAuthServiceFactory = ({
getNorthFlankSecretGroups, getNorthFlankSecretGroups,
getTeamcityBuildConfigs, getTeamcityBuildConfigs,
getBitbucketWorkspaces, getBitbucketWorkspaces,
getIntegrationAccessToken getIntegrationAccessToken,
duplicateIntegrationAuth
}; };
}; };
@@ -108,6 +108,10 @@ export type TDeleteIntegrationAuthByIdDTO = {
id: string; id: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TDuplicateGithubIntegrationAuthDTO = {
id: string;
} & TProjectPermission;
export type TGetIntegrationAuthTeamCityBuildConfigDTO = { export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
id: string; id: string;
appId: string; appId: string;
@@ -367,7 +367,7 @@ export const deleteIntegrationSecrets = async ({
case Integrations.GITHUB: { case Integrations.GITHUB: {
await deleteGithubSecrets({ await deleteGithubSecrets({
integration, integration,
authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata), authMetadata: IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {}),
accessToken, accessToken,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets
}); });
@@ -1557,7 +1557,7 @@ const syncSecretsGitHub = async ({
selected_repositories_url?: string | undefined; selected_repositories_url?: string | undefined;
} }
const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata); const authMetadata = IntegrationAuthMetadataSchema.parse(integrationAuth.metadata || {});
let octokit: Octokit; let octokit: Octokit;
const appCfg = getConfig(); const appCfg = getConfig();
@@ -1,3 +1,4 @@
export { useDuplicateIntegrationAuth } from "./mutations";
export { export {
useAuthorizeIntegration, useAuthorizeIntegration,
useDeleteIntegrationAuth, useDeleteIntegrationAuth,
@@ -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<IntegrationAuth, {}, TDuplicateIntegrationAuthDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<{ integrationAuth: IntegrationAuth }>(
`/api/v1/integration-auth/${body.integrationAuthId}/duplicate`,
body
);
return data.integrationAuth;
}
});
};
@@ -11,6 +11,7 @@ export type IntegrationAuth = {
teamId?: string; teamId?: string;
metadata: { metadata: {
installationName?: string; installationName?: string;
installationId?: string;
}; };
}; };
@@ -87,3 +88,8 @@ export type TeamCityBuildConfig = {
name: string; name: string;
buildConfigId: string; buildConfigId: string;
}; };
export type TDuplicateIntegrationAuthDTO = {
integrationAuthId: string;
projectId: string;
};
+3 -2
View File
@@ -8,8 +8,9 @@ export {
useDeleteOrgTaxId, useDeleteOrgTaxId,
useGetIdentityMembershipOrgs, useGetIdentityMembershipOrgs,
useGetOrganizationGroups, useGetOrganizationGroups,
useGetOrganizations, useGetOrganizations,
useGetOrgBillingDetails, useGetOrgBillingDetails,
useGetOrgIntegrationAuths,
useGetOrgInvoices, useGetOrgInvoices,
useGetOrgLicenses, useGetOrgLicenses,
useGetOrgPlanBillingInfo, useGetOrgPlanBillingInfo,
@@ -20,4 +21,4 @@ export {
useGetOrgTrialUrl, useGetOrgTrialUrl,
useUpdateOrg, useUpdateOrg,
useUpdateOrgBillingDetails useUpdateOrgBillingDetails
} from "./queries"; } from "./queries";
@@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { TGroupOrgMembership } from "../groups/types"; import { TGroupOrgMembership } from "../groups/types";
import { IntegrationAuth } from "../types";
import { import {
BillingDetails, BillingDetails,
Invoice, Invoice,
@@ -39,7 +40,8 @@ export const organizationKeys = {
...params ...params
}: TListOrgIdentitiesDTO) => }: TListOrgIdentitiesDTO) =>
[...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, [...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 () => { export const fetchOrganizations = async () => {
@@ -463,3 +465,21 @@ export const useGetOrganizationGroups = (organizationId: string) => {
} }
}); });
}; };
export const useGetOrgIntegrationAuths = <TData = IntegrationAuth[],>(
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
});
};
@@ -24,7 +24,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
<Card className="mb-12 max-w-lg rounded-md border border-mineshaft-600"> <Card className="mb-12 max-w-lg rounded-md border border-mineshaft-600">
<CardTitle <CardTitle
className="px-6 text-left text-xl" className="px-6 text-left text-xl"
subTitle="Select how you'd like to integrate with GitHub. For more precise control, we recommend using the GitHub App method." subTitle="Select how you'd like to integrate with GitHub. We recommend using the GitHub App method for fine-grained access."
> >
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<div className="flex items-center pb-0.5"> <div className="flex items-center pb-0.5">
@@ -61,7 +61,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
router.push("/integrations/select-integration-auth?integrationSlug=github"); router.push("/integrations/select-integration-auth?integrationSlug=github");
}} }}
> >
Connect with Github App Connect with App
</Button> </Button>
<Button <Button
colorSchema="primary" colorSchema="primary"
@@ -78,7 +78,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
); );
}} }}
> >
Connect with Github OAuth Connect with OAuth
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -6,46 +6,93 @@ import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Button, Card, CardTitle } from "@app/components/v2"; import { Button, Card, CardTitle } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { useGetCloudIntegrations, useGetWorkspaceAuthorizations } from "@app/hooks/api"; import {
useDuplicateIntegrationAuth,
useGetCloudIntegrations,
useGetOrgIntegrationAuths
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/types"; import { IntegrationAuth } from "@app/hooks/api/types";
export default function SelectIntegrationAuthPage() { export default function SelectIntegrationAuthPage() {
const router = useRouter(); const router = useRouter();
const { data: cloudIntegrations } = useGetCloudIntegrations(); const { data: cloudIntegrations } = useGetCloudIntegrations();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const orgId = currentOrg?.id || "";
const integrationSlug = router.query.integrationSlug as string; const integrationSlug = router.query.integrationSlug as string;
const currentIntegration = cloudIntegrations?.find( const currentIntegration = cloudIntegrations?.find(
(integration) => integration.slug === integrationSlug (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 } = const { data: integrationAuths, isLoading: isLoadingIntegrationAuths } =
useGetWorkspaceAuthorizations( useGetOrgIntegrationAuths(
workspaceId, orgId,
useCallback((data: IntegrationAuth[]) => { useCallback(
const filteredIntegrationAuths = data.filter( (data: IntegrationAuth[]) => {
(integrationAuth) => integrationAuth.integration === integrationSlug 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)
); );
}
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<string>();
// 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 logo = integrationSlug === "github" ? "/images/integrations/GitHub.png" : "";
const handleConnectionSelect = (integrationAuthId: string) => { const handleConnectionSelect = async (integrationAuth: IntegrationAuth) => {
if (integrationSlug === "github") { 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" colorSchema="gray"
variant="outline" variant="outline"
className="mt-3 w-3/4" className="mt-3 w-3/4"
isDisabled={isIntegrationAuthSelectLoading}
key={integrationAuth.id} key={integrationAuth.id}
size="sm" size="sm"
type="submit" type="submit"
onClick={() => handleConnectionSelect(integrationAuth.id)} onClick={() => handleConnectionSelect(integrationAuth)}
> >
{connectionName} {connectionName}
</Button> </Button>