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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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:

View File

@@ -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<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 {
...integrationAuthOrm,
bulkUpdate
bulkUpdate,
getByOrg
};
};

View File

@@ -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<typeof integrationAuth, "id"> & { 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
};
};

View File

@@ -108,6 +108,10 @@ export type TDeleteIntegrationAuthByIdDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TDuplicateGithubIntegrationAuthDTO = {
id: string;
} & TProjectPermission;
export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
id: string;
appId: string;

View File

@@ -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
});

View File

@@ -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();

View File

@@ -1,3 +1,4 @@
export { useDuplicateIntegrationAuth } from "./mutations";
export {
useAuthorizeIntegration,
useDeleteIntegrationAuth,

View File

@@ -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;
}
});
};

View File

@@ -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;
};

View File

@@ -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";
} from "./queries";

View File

@@ -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 = <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
});
};

View File

@@ -24,7 +24,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
<Card className="mb-12 max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
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 items-center pb-0.5">
@@ -61,7 +61,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
router.push("/integrations/select-integration-auth?integrationSlug=github");
}}
>
Connect with Github App
Connect with App
</Button>
<Button
colorSchema="primary"
@@ -78,7 +78,7 @@ export default function GithubIntegrationAuthModeSelectionPage() {
);
}}
>
Connect with Github OAuth
Connect with OAuth
</Button>
</div>
</Card>

View File

@@ -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<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 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}
</Button>