Compare commits

...

15 Commits

Author SHA1 Message Date
b99b98b6a4 misc: remove encrypted data key from org response 2024-12-17 21:24:56 +08:00
379e526200 Merge pull request #2888 from Infisical/fix/false-org-error
fix: resolves a false org not logged in error
2024-12-17 16:53:09 +05:30
6b2eb9c6c9 fix: resolves a false org not logged in error 2024-12-17 14:37:41 +05:30
b669b0a9f8 Merge pull request #2883 from Infisical/feat/sync-circle-ci-context
feat: circle ci context integration
2024-12-17 02:12:32 +08:00
9e768640cd misc: made scope project the default 2024-12-17 00:12:25 +08:00
e3d29b637d misc: added type assertion 2024-12-16 22:27:29 +08:00
9cd0dc8970 Merge pull request #2884 from akhilmhdh/fix/group-access-failing 2024-12-16 09:25:01 -05:00
f8f5000bad misc: addressed review comments 2024-12-16 22:20:59 +08:00
40919ccf59 misc: finalized docs and other details 2024-12-16 20:15:14 +08:00
=
44303aca6a fix: group only access to project failing 2024-12-16 16:09:05 +05:30
4bd50c3548 misc: unified to a single integration 2024-12-16 16:08:51 +08:00
1cbf030e6c Merge remote-tracking branch 'origin/main' into feat/sync-circle-ci-context 2024-12-13 22:34:06 +08:00
7c8f2e5548 docs + minor fixes 2024-12-10 21:14:13 +01:00
a730b16318 fix circleCI name spacing 2024-12-10 20:12:55 +01:00
cc3d132f5d feat(integrations): New CircleCI Context Sync 2024-12-10 20:07:23 +01:00
24 changed files with 703 additions and 344 deletions

View File

@ -1185,4 +1185,50 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider)
return { spaces }; return { spaces };
} }
}); });
server.route({
method: "GET",
url: "/:integrationAuthId/circleci/organizations",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
params: z.object({
integrationAuthId: z.string().trim()
}),
response: {
200: z.object({
organizations: z
.object({
name: z.string(),
slug: z.string(),
projects: z
.object({
name: z.string(),
id: z.string()
})
.array(),
contexts: z
.object({
name: z.string(),
id: z.string()
})
.array()
})
.array()
})
}
},
handler: async (req) => {
const organizations = await server.services.integrationAuth.getCircleCIOrganizations({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.integrationAuthId
});
return { organizations };
}
});
}; };

View File

@ -16,6 +16,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas"; import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
import { integrationAuthPubSchema } from "../sanitizedSchemas"; import { integrationAuthPubSchema } from "../sanitizedSchemas";
@ -29,9 +30,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
response: { response: {
200: z.object({ 200: z.object({
organizations: OrganizationsSchema.extend({ organizations: sanitizedOrganizationSchema
.extend({
orgAuthMethod: z.string() orgAuthMethod: z.string()
}).array() })
.array()
}) })
} }
}, },

View File

@ -1,10 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
import { ApiKeysSchema } from "@app/db/schemas/api-keys"; import { ApiKeysSchema } from "@app/db/schemas/api-keys";
import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { authRateLimit, 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 { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type";
import { sanitizedOrganizationSchema } from "@app/services/org/org-schema";
export const registerUserRouter = async (server: FastifyZodProvider) => { export const registerUserRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@ -134,7 +135,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
description: "Return organizations that current user is part of", description: "Return organizations that current user is part of",
response: { response: {
200: z.object({ 200: z.object({
organizations: OrganizationsSchema.array() organizations: sanitizedOrganizationSchema.array()
}) })
} }
}, },

View File

@ -0,0 +1,5 @@
export type TCircleCIContext = {
id: string;
name: string;
created_at: string;
};

View File

@ -17,6 +17,8 @@ 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, InternalServerError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { TGenericPermission, TProjectPermission } from "@app/lib/types"; import { TGenericPermission, TProjectPermission } from "@app/lib/types";
import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal";
@ -24,6 +26,7 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { getApps } from "./integration-app-list"; import { getApps } from "./integration-app-list";
import { TCircleCIContext } from "./integration-app-types";
import { TIntegrationAuthDALFactory } from "./integration-auth-dal"; import { TIntegrationAuthDALFactory } from "./integration-auth-dal";
import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema";
import { import {
@ -31,6 +34,7 @@ import {
TBitbucketEnvironment, TBitbucketEnvironment,
TBitbucketWorkspace, TBitbucketWorkspace,
TChecklyGroups, TChecklyGroups,
TCircleCIOrganization,
TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthByIdDTO,
TDeleteIntegrationAuthsDTO, TDeleteIntegrationAuthsDTO,
TDuplicateGithubIntegrationAuthDTO, TDuplicateGithubIntegrationAuthDTO,
@ -42,6 +46,7 @@ import {
TIntegrationAuthBitbucketEnvironmentsDTO, TIntegrationAuthBitbucketEnvironmentsDTO,
TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthBitbucketWorkspaceDTO,
TIntegrationAuthChecklyGroupsDTO, TIntegrationAuthChecklyGroupsDTO,
TIntegrationAuthCircleCIOrganizationDTO,
TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubEnvsDTO,
TIntegrationAuthGithubOrgsDTO, TIntegrationAuthGithubOrgsDTO,
TIntegrationAuthHerokuPipelinesDTO, TIntegrationAuthHerokuPipelinesDTO,
@ -1578,6 +1583,120 @@ export const integrationAuthServiceFactory = ({
return []; return [];
}; };
const getCircleCIOrganizations = async ({
actorId,
actor,
actorOrgId,
actorAuthMethod,
id
}: TIntegrationAuthCircleCIOrganizationDTO) => {
const integrationAuth = await integrationAuthDAL.findById(id);
if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
integrationAuth.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey);
const { data: organizations }: { data: TCircleCIOrganization[] } = await request.get(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/me/collaborations`,
{
headers: {
"Circle-Token": `${accessToken}`,
"Accept-Encoding": "application/json"
}
}
);
let projects: {
orgName: string;
projectName: string;
projectId?: string;
}[] = [];
try {
const projectRes = (
await request.get<{ reponame: string; username: string; vcs_url: string }[]>(
`${IntegrationUrls.CIRCLECI_API_URL}/v1.1/projects`,
{
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
}
}
)
).data;
projects = projectRes.map((a) => ({
orgName: a.username, // username maps to unique organization name in CircleCI
projectName: a.reponame, // reponame maps to project name within an organization in CircleCI
projectId: a.vcs_url.split("/").pop() // vcs_url maps to the project id in CircleCI
}));
} catch (error) {
logger.error(error);
}
const projectsByOrg = groupBy(
projects.map((p) => ({
orgName: p.orgName,
name: p.projectName,
id: p.projectId as string
})),
(p) => p.orgName
);
const getOrgContexts = async (orgSlug: string) => {
type NextPageToken = string | null | undefined;
try {
const contexts: TCircleCIContext[] = [];
let nextPageToken: NextPageToken;
while (nextPageToken !== null) {
// eslint-disable-next-line no-await-in-loop
const { data } = await request.get<{
items: TCircleCIContext[];
next_page_token: NextPageToken;
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context`, {
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
},
params: new URLSearchParams({
"owner-slug": orgSlug,
...(nextPageToken ? { "page-token": nextPageToken } : {})
})
});
contexts.push(...data.items);
nextPageToken = data.next_page_token;
}
return contexts?.map((context) => ({
name: context.name,
id: context.id
}));
} catch (error) {
logger.error(error);
}
};
return Promise.all(
organizations.map(async (org) => ({
name: org.name,
slug: org.slug,
projects: projectsByOrg[org.name] ?? [],
contexts: (await getOrgContexts(org.slug)) ?? []
}))
);
};
const deleteIntegrationAuths = async ({ const deleteIntegrationAuths = async ({
projectId, projectId,
integration, integration,
@ -1790,6 +1909,7 @@ export const integrationAuthServiceFactory = ({
getTeamcityBuildConfigs, getTeamcityBuildConfigs,
getBitbucketWorkspaces, getBitbucketWorkspaces,
getBitbucketEnvironments, getBitbucketEnvironments,
getCircleCIOrganizations,
getIntegrationAccessToken, getIntegrationAccessToken,
duplicateIntegrationAuth, duplicateIntegrationAuth,
getOctopusDeploySpaces, getOctopusDeploySpaces,

View File

@ -128,6 +128,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = {
appId: string; appId: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TIntegrationAuthCircleCIOrganizationDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TVercelBranches = { export type TVercelBranches = {
ref: string; ref: string;
lastCommit: string; lastCommit: string;
@ -189,6 +193,14 @@ export type TTeamCityBuildConfig = {
webUrl: string; webUrl: string;
}; };
export type TCircleCIOrganization = {
id: string;
vcsType: string;
name: string;
avatarUrl: string;
slug: string;
};
export type TIntegrationsWithEnvironment = TIntegrations & { export type TIntegrationsWithEnvironment = TIntegrations & {
environment?: environment?:
| { | {
@ -215,6 +227,11 @@ export enum OctopusDeployScope {
// add tenant, variable set, etc. // add tenant, variable set, etc.
} }
export enum CircleCiScope {
Project = "project",
Context = "context"
}
export type TOctopusDeployVariableSet = { export type TOctopusDeployVariableSet = {
Id: string; Id: string;
OwnerId: string; OwnerId: string;

View File

@ -76,7 +76,6 @@ export enum IntegrationUrls {
RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2",
FLYIO_API_URL = "https://api.fly.io/graphql", FLYIO_API_URL = "https://api.fly.io/graphql",
CIRCLECI_API_URL = "https://circleci.com/api", CIRCLECI_API_URL = "https://circleci.com/api",
DATABRICKS_API_URL = "https:/xxxx.com/api",
TRAVISCI_API_URL = "https://api.travis-ci.com", TRAVISCI_API_URL = "https://api.travis-ci.com",
SUPABASE_API_URL = "https://api.supabase.com", SUPABASE_API_URL = "https://api.supabase.com",
LARAVELFORGE_API_URL = "https://forge.laravel.com", LARAVELFORGE_API_URL = "https://forge.laravel.com",

View File

@ -39,7 +39,12 @@ import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/
import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationDALFactory } from "../integration/integration-dal";
import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationMetadataSchema } from "../integration/integration-schema";
import { IntegrationAuthMetadataSchema } from "./integration-auth-schema"; import { IntegrationAuthMetadataSchema } from "./integration-auth-schema";
import { OctopusDeployScope, TIntegrationsWithEnvironment, TOctopusDeployVariableSet } from "./integration-auth-types"; import {
CircleCiScope,
OctopusDeployScope,
TIntegrationsWithEnvironment,
TOctopusDeployVariableSet
} from "./integration-auth-types";
import { import {
IntegrationInitialSyncBehavior, IntegrationInitialSyncBehavior,
IntegrationMappingBehavior, IntegrationMappingBehavior,
@ -2245,6 +2250,77 @@ const syncSecretsCircleCI = async ({
secrets: Record<string, { value: string; comment?: string }>; secrets: Record<string, { value: string; comment?: string }>;
accessToken: string; accessToken: string;
}) => { }) => {
if (integration.scope === CircleCiScope.Context) {
// sync secrets to CircleCI
await Promise.all(
Object.keys(secrets).map(async (key) =>
request.put(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`,
{
value: secrets[key].value
},
{
headers: {
"Circle-Token": accessToken,
"Content-Type": "application/json"
}
}
)
)
);
// get secrets from CircleCI
const getSecretsRes = async () => {
type EnvVars = {
variable: string;
created_at: string;
updated_at: string;
context_id: string;
};
let nextPageToken: string | null | undefined;
const envVars: EnvVars[] = [];
while (nextPageToken !== null) {
const res = await request.get<{
items: EnvVars[];
next_page_token: string | null;
}>(`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, {
headers: {
"Circle-Token": accessToken,
"Accept-Encoding": "application/json"
},
params: nextPageToken
? new URLSearchParams({
"page-token": nextPageToken
})
: undefined
});
envVars.push(...res.data.items);
nextPageToken = res.data.next_page_token;
}
return envVars;
};
// delete secrets from CircleCI
await Promise.all(
(await getSecretsRes()).map(async (sec) => {
if (!(sec.variable in secrets)) {
return request.delete(
`${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`,
{
headers: {
"Circle-Token": accessToken,
"Content-Type": "application/json"
}
}
);
}
})
);
} else {
const getProjectSlug = async () => { const getProjectSlug = async () => {
const requestConfig = { const requestConfig = {
headers: { headers: {
@ -2341,6 +2417,7 @@ const syncSecretsCircleCI = async ({
} }
}) })
); );
}
}; };
/** /**

View File

@ -0,0 +1,16 @@
import { OrganizationsSchema } from "@app/db/schemas";
export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
id: true,
name: true,
customerId: true,
slug: true,
createdAt: true,
updatedAt: true,
authEnforced: true,
scimEnabled: true,
kmsDefaultKeyId: true,
defaultMembershipRole: true,
enforceMfa: true,
selectedMfaMethod: true
});

View File

@ -51,7 +51,7 @@ export const projectDALFactory = (db: TDbClient) => {
.join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.orgId`, orgId) .where(`${TableName.Project}.orgId`, orgId)
.andWhere((qb) => { .andWhere((qb) => {
if (projectType) { if (projectType !== "all") {
void qb.where(`${TableName.Project}.type`, projectType); void qb.where(`${TableName.Project}.type`, projectType);
} }
}) })

View File

@ -441,7 +441,12 @@ export const projectServiceFactory = ({
const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type); const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type);
if (includeRoles) { if (includeRoles) {
const { permission } = await permissionService.getUserOrgPermission(actorId, actorOrgId, actorAuthMethod); const { permission } = await permissionService.getUserOrgPermission(
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
// `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls. // `includeRoles` is specifically used by organization admins when inviting new users to the organizations to avoid looping redundant api calls.
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 555 KiB

View File

@ -11,21 +11,30 @@ Prerequisites:
<Step title="Authorize Infisical for CircleCI"> <Step title="Authorize Infisical for CircleCI">
Obtain an API token in User Settings > Personal API Tokens Obtain an API token in User Settings > Personal API Tokens
![integrations circleci token](../../images/integrations/circleci/integrations-circleci-token.png) ![integrations circleci token](/images/integrations/circleci/integrations-circleci-token.png)
Navigate to your project's integrations tab in Infisical. Navigate to your project's integrations tab in Infisical.
![integrations](../../images/integrations.png) ![integrations](/images/integrations.png)
Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account.
![integrations circleci authorization](../../images/integrations/circleci/integrations-circleci-auth.png) ![integrations circleci authorization](/images/integrations/circleci/integrations-circleci-auth.png)
</Step> </Step>
<Step title="Start integration"> <Step title="Start integration">
Select which Infisical environment secrets you want to sync to which CircleCI project and press create integration to start syncing secrets to CircleCI. Select which Infisical environment secrets you want to sync to which CircleCI project or context.
<Tabs>
<Tab title="Project">
![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-project.png)
</Tab>
<Tab title="Context">
![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-context.png)
</Tab>
</Tabs>
Finally, press create integration to start syncing secrets to CircleCI.
![integrations circleci](/images/integrations/circleci/integrations-circleci.png)
![create integration circleci](../../images/integrations/circleci/integrations-circleci-create.png)
![integrations circleci](../../images/integrations/circleci/integrations-circleci.png)
</Step> </Step>
</Steps> </Steps>

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -7,6 +7,7 @@ export {
useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthBitBucketWorkspaces,
useGetIntegrationAuthById, useGetIntegrationAuthById,
useGetIntegrationAuthChecklyGroups, useGetIntegrationAuthChecklyGroups,
useGetIntegrationAuthCircleCIOrganizations,
useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubEnvs,
useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthGithubOrgs,
useGetIntegrationAuthNorthflankSecretGroups, useGetIntegrationAuthNorthflankSecretGroups,

View File

@ -8,6 +8,7 @@ import {
BitBucketEnvironment, BitBucketEnvironment,
BitBucketWorkspace, BitBucketWorkspace,
ChecklyGroup, ChecklyGroup,
CircleCIOrganization,
Environment, Environment,
HerokuPipelineCoupling, HerokuPipelineCoupling,
IntegrationAuth, IntegrationAuth,
@ -128,7 +129,9 @@ const integrationAuthKeys = {
integrationAuthId, integrationAuthId,
...params ...params
}: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) =>
[{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const,
getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) =>
[{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const
}; };
const fetchIntegrationAuthById = async (integrationAuthId: string) => { const fetchIntegrationAuthById = async (integrationAuthId: string) => {
@ -510,6 +513,15 @@ const fetchIntegrationAuthOctopusDeployScopeValues = async ({
return data; return data;
}; };
const fetchIntegrationAuthCircleCIOrganizations = async (integrationAuthId: string) => {
const {
data: { organizations }
} = await apiRequest.get<{
organizations: CircleCIOrganization[];
}>(`/api/v1/integration-auth/${integrationAuthId}/circleci/organizations`);
return organizations;
};
export const useGetIntegrationAuthById = (integrationAuthId: string) => { export const useGetIntegrationAuthById = (integrationAuthId: string) => {
return useQuery({ return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId),
@ -884,6 +896,13 @@ export const useGetIntegrationAuthTeamCityBuildConfigs = ({
}); });
}; };
export const useGetIntegrationAuthCircleCIOrganizations = (integrationAuthId: string) => {
return useQuery({
queryKey: integrationAuthKeys.getIntegrationAuthCircleCIOrganizations(integrationAuthId),
queryFn: () => fetchIntegrationAuthCircleCIOrganizations(integrationAuthId)
});
};
export const useAuthorizeIntegration = () => { export const useAuthorizeIntegration = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@ -105,6 +105,19 @@ export enum OctopusDeployScope {
// tenant, variable set // tenant, variable set
} }
export type CircleCIOrganization = {
name: string;
slug: string;
projects: {
name: string;
id: string;
}[];
contexts: {
name: string;
id: string;
}[];
};
export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = {
integrationAuthId: string; integrationAuthId: string;
spaceId: string; spaceId: string;
@ -125,3 +138,8 @@ export type TOctopusDeployVariableSetScopeValues = {
Name: string; Name: string;
}[]; }[];
}; };
export enum CircleCiScope {
Context = "context",
Project = "project"
}

View File

@ -1,155 +1,150 @@
import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form";
import Head from "next/head";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
faArrowUpRightFromSquare,
faBookOpen,
faBugs,
faCircleInfo
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import queryString from "query-string"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { useCreateIntegration } from "@app/hooks/api";
import { import {
Button, Button,
Card, Card,
CardTitle, CardTitle,
FilterableSelect,
FormControl, FormControl,
Input,
Select, Select,
SelectItem SelectItem,
} from "../../../components/v2"; Spinner
import { } from "@app/components/v2";
useGetIntegrationAuthApps, import { SecretPathInput } from "@app/components/v2/SecretPathInput";
useGetIntegrationAuthById import { useWorkspace } from "@app/context";
} from "../../../hooks/api/integrationAuth"; import { useCreateIntegration } from "@app/hooks/api";
import { useGetWorkspaceById } from "../../../hooks/api/workspace"; import { useGetIntegrationAuthCircleCIOrganizations } from "@app/hooks/api/integrationAuth";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
const formSchema = z.discriminatedUnion("scope", [
z.object({
scope: z.literal(CircleCiScope.Context),
secretPath: z.string().default("/"),
sourceEnvironment: z.object({ name: z.string(), slug: z.string() }),
targetOrg: z.object({ name: z.string().min(1), slug: z.string().min(1) }),
targetContext: z.object({ name: z.string().min(1), id: z.string().min(1) })
}),
z.object({
scope: z.literal(CircleCiScope.Project),
secretPath: z.string().default("/"),
sourceEnvironment: z.object({ name: z.string(), slug: z.string() }),
targetOrg: z.object({ name: z.string().min(1), slug: z.string().min(1) }),
targetProject: z.object({ name: z.string().min(1), id: z.string().min(1) })
})
]);
type TFormData = z.infer<typeof formSchema>;
export default function CircleCICreateIntegrationPage() { export default function CircleCICreateIntegrationPage() {
const router = useRouter(); const router = useRouter();
const { mutateAsync } = useCreateIntegration(); const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration();
const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace();
const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); const integrationAuthId = router.query.integrationAuthId as string;
const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); const { control, watch, handleSubmit, setValue } = useForm<TFormData>({
const { data: integrationAuth, isLoading: isintegrationAuthLoading } = useGetIntegrationAuthById( resolver: zodResolver(formSchema),
(integrationAuthId as string) ?? "" defaultValues: {
); secretPath: "/",
const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = sourceEnvironment: currentWorkspace?.environments[0],
useGetIntegrationAuthApps({ scope: CircleCiScope.Project
integrationAuthId: (integrationAuthId as string) ?? "" }
}); });
const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const selectedScope = watch("scope");
const [targetOrganization, setTargetOrganization] = useState(""); const selectedOrg = watch("targetOrg");
const [secretPath, setSecretPath] = useState("/");
const [targetProjectId, setTargetProjectId] = useState(""); const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } =
useGetIntegrationAuthCircleCIOrganizations(integrationAuthId);
const [isLoading, setIsLoading] = useState(false); const selectedOrganizationEntry = selectedOrg
? circleCIOrganizations?.find((org) => org.slug === selectedOrg.slug)
: undefined;
useEffect(() => { const onSubmit = async (data: TFormData) => {
if (workspace) {
setSelectedSourceEnvironment(workspace.environments[0].slug);
}
}, [workspace]);
const handleButtonClick = async () => {
try { try {
if (!integrationAuth?.id) return; if (data.scope === CircleCiScope.Context) {
if (!targetProjectId || targetOrganization === "none") {
createNotification({
type: "error",
text: "Please select a project"
});
setIsLoading(false);
return;
}
setIsLoading(true);
const selectedApp = integrationAuthApps?.find(
(integrationAuthApp) => integrationAuthApp.appId === targetProjectId
);
if (!selectedApp) {
createNotification({
type: "error",
text: "Invalid project selected"
});
setIsLoading(false);
return;
}
await mutateAsync({ await mutateAsync({
integrationAuthId: integrationAuth?.id, scope: data.scope,
integrationAuthId,
isActive: true, isActive: true,
app: selectedApp.name, // project name sourceEnvironment: data.sourceEnvironment.slug,
owner: selectedApp.owner, // organization name app: data.targetContext.name,
appId: selectedApp.appId, // project id (used for syncing) appId: data.targetContext.id,
sourceEnvironment: selectedSourceEnvironment, owner: data.targetOrg.name,
secretPath secretPath: data.secretPath
}); });
} else {
await mutateAsync({
scope: data.scope,
integrationAuthId,
isActive: true,
app: data.targetProject.name, // project name
owner: data.targetOrg.name, // organization name
appId: data.targetProject.id, // project id (used for syncing)
sourceEnvironment: data.sourceEnvironment.slug,
secretPath: data.secretPath
});
}
setIsLoading(false); createNotification({
type: "success",
router.push(`/integrations/${localStorage.getItem("projectData.id")}`); text: "Successfully created integration"
});
router.push(`/integrations/${currentWorkspace?.id}`);
} catch (err) { } catch (err) {
createNotification({
type: "error",
text: "Failed to create integration"
});
console.error(err); console.error(err);
} }
}; };
const filteredProjects = useMemo(() => { if (isProjectLoading || isCircleCIOrganizationsLoading)
if (!integrationAuthApps) return []; return (
<div className="flex h-full w-full items-center justify-center p-24">
<Spinner />
</div>
);
return integrationAuthApps.filter((integrationAuthApp) => { return (
return integrationAuthApp.owner === targetOrganization; <form
}); onSubmit={handleSubmit(onSubmit)}
}, [integrationAuthApps, targetOrganization]); className="flex h-full w-full items-center justify-center"
const filteredOrganizations = useMemo(() => {
const organizations = new Set<string>();
if (integrationAuthApps) {
integrationAuthApps.forEach((integrationAuthApp) => {
if (!integrationAuthApp.owner) return;
organizations.add(integrationAuthApp.owner);
});
}
return Array.from(organizations);
}, [integrationAuthApps]);
return integrationAuth && workspace && selectedSourceEnvironment && integrationAuthApps ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<Head>
<title>Set Up CircleCI Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
<Card className="max-w-lg rounded-md border border-mineshaft-600">
<CardTitle
className="px-6 text-left text-xl"
subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI environment variables."
> >
<div className="flex flex-row items-center"> <Card className="max-w-lg rounded-md p-8 pt-4">
<div className="flex items-center pb-0.5"> <CardTitle
className="w-full px-0 text-left text-xl"
subTitle="Choose which environment or folder in Infisical you want to sync to CircleCI."
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-1.5">
<Image <Image
src="/images/integrations/CircleCI.png" src="/images/integrations/CircleCI.png"
height={30} height={30}
width={30} width={30}
alt="CircleCI logo" alt="CircleCI logo"
/> />
<span className="">CircleCI Context Integration </span>
</div> </div>
<span className="ml-1.5">CircleCI Integration </span>
<Link href="https://infisical.com/docs/integrations/cicd/circleci" passHref> <Link
<a target="_blank" rel="noopener noreferrer"> href="https://infisical.com/docs/integrations/cicd/circleci"
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100"> target="_blank"
rel="noopener noreferrer"
passHref
>
<div className="ml-2 mb-1 flex cursor-pointer flex-row items-center gap-0.5 rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" /> <FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs Docs
<FontAwesomeIcon <FontAwesomeIcon
@ -157,137 +152,158 @@ export default function CircleCICreateIntegrationPage() {
className="ml-1.5 mb-[0.07rem] text-xxs" className="ml-1.5 mb-[0.07rem] text-xxs"
/> />
</div> </div>
</a>
</Link> </Link>
</div> </div>
</CardTitle> </CardTitle>
<Controller
<FormControl label="Project Environment" className="px-6"> control={control}
<Select name="sourceEnvironment"
value={selectedSourceEnvironment} render={({ field: { value, onChange }, fieldState: { error } }) => (
onValueChange={(val) => setSelectedSourceEnvironment(val)} <FormControl
className="w-full border border-mineshaft-500" errorText={error?.message}
isError={Boolean(error)}
label="Project Environment"
> >
{workspace?.environments.map((sourceEnvironment) => ( <FilterableSelect
<SelectItem getOptionValue={(option) => option.slug}
value={sourceEnvironment.slug} value={value}
key={`source-environment-${sourceEnvironment.slug}`} getOptionLabel={(option) => option.name}
> onChange={onChange}
{sourceEnvironment.name} options={currentWorkspace?.environments}
</SelectItem> placeholder="Select a project environment"
))} isDisabled={!currentWorkspace?.environments.length}
</Select>
</FormControl>
<FormControl label="Secrets Path" className="px-6">
<Input
value={secretPath}
onChange={(evt) => setSecretPath(evt.target.value)}
placeholder="Provide a path, default is /"
/> />
</FormControl> </FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl label="Secrets Path" errorText={error?.message} isError={Boolean(error)}>
<SecretPathInput {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="targetOrg"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Organization"
>
<FilterableSelect
getOptionValue={(option) => option.slug}
value={value}
getOptionLabel={(option) => option.name}
onChange={(e) => {
setValue("targetProject", {
name: "",
id: ""
});
setValue("targetContext", {
name: "",
id: ""
});
<FormControl label="CircleCI Organization" className="px-6"> onChange(e);
}}
options={circleCIOrganizations}
placeholder={
circleCIOrganizations?.length
? "Select an organization..."
: "No organizations found..."
}
isDisabled={!circleCIOrganizations?.length}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="scope"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Scope" errorText={error?.message} isError={Boolean(error)}>
<Select <Select
value={targetOrganization} defaultValue={field.value}
onValueChange={(val) => { onValueChange={(e) => {
setTargetOrganization(val); onChange(e);
setTargetProjectId("none");
}} }}
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
isDisabled={filteredOrganizations.length === 0}
> >
{filteredOrganizations.length > 0 ? ( <SelectItem value={CircleCiScope.Project}>Project</SelectItem>
filteredOrganizations.map((org) => ( <SelectItem value={CircleCiScope.Context}>Context</SelectItem>
<SelectItem value={org} key={`target-org-${org}`}>
{org}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No organizations found
</SelectItem>
)}
</Select> </Select>
</FormControl> </FormControl>
{targetOrganization && (
<FormControl label="CircleCI Project ID" className="px-6">
<Select
value={targetProjectId}
onValueChange={(val) => {
setTargetProjectId(val);
}}
className="w-full border border-mineshaft-500"
isDisabled={filteredProjects.length === 0}
>
{filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<SelectItem value={project.appId!} key={`target-project-${project.owner}`}>
{project.name}
</SelectItem>
))
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)} )}
</Select> />
{selectedScope === CircleCiScope.Context && selectedOrganizationEntry && (
<Controller
control={control}
name="targetContext"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Context"
>
<FilterableSelect
value={value}
getOptionValue={(option) => option.id!}
getOptionLabel={(option) => option.name}
onChange={onChange}
options={selectedOrganizationEntry?.contexts}
placeholder={
selectedOrganizationEntry.contexts?.length
? "Select a context..."
: "No contexts found..."
}
isDisabled={!selectedOrganizationEntry.contexts?.length}
/>
</FormControl> </FormControl>
)} )}
/>
)}
{selectedScope === CircleCiScope.Project && selectedOrganizationEntry && (
<Controller
control={control}
name="targetProject"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error)}
label="CircleCI Project"
>
<FilterableSelect
value={value}
getOptionValue={(option) => option.id!}
getOptionLabel={(option) => option.name}
onChange={onChange}
options={selectedOrganizationEntry?.projects}
placeholder={
selectedOrganizationEntry.projects?.length
? "Select a project..."
: "No projects found..."
}
isDisabled={!selectedOrganizationEntry.projects?.length}
/>
</FormControl>
)}
/>
)}
<Button <Button
onClick={handleButtonClick} type="submit"
colorSchema="primary" colorSchema="primary"
variant="outline_bg" className="mt-4"
className="mb-6 mt-2 ml-auto mr-6 w-min" isLoading={isCreatingIntegration}
isLoading={isLoading} isDisabled={isCreatingIntegration}
isDisabled={integrationAuthApps.length === 0}
> >
Create Integration Create Integration
</Button> </Button>
</Card> </Card>
<div className="mt-6 w-full max-w-md border-t border-mineshaft-800" /> </form>
<div className="mt-6 flex w-full max-w-lg flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4">
<div className="flex flex-row items-center">
<FontAwesomeIcon icon={faCircleInfo} className="text-xl text-mineshaft-200" />{" "}
<span className="text-md ml-3 text-mineshaft-100">Pro Tip</span>
</div>
<span className="mt-4 text-sm text-mineshaft-300">
After creating an integration, your secrets will start syncing immediately. This might
cause an unexpected override of current secrets in CircleCI with secrets from Infisical.
</span>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Head>
<title>Set Up CircleCI Integration</title>
<link rel="icon" href="/infisical.ico" />
</Head>
{isIntegrationAuthAppsLoading || isintegrationAuthLoading ? (
<img
src="/images/loading/loading.gif"
height={70}
width={120}
alt="infisical loading indicator"
/>
) : (
<div className="flex h-max max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6 text-center text-mineshaft-200">
<FontAwesomeIcon icon={faBugs} className="inlineli my-2 text-6xl" />
<p>
Something went wrong. Please contact{" "}
<a
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
target="_blank"
rel="noopener noreferrer"
href="mailto:support@infisical.com"
>
support@infisical.com
</a>{" "}
if the issue persists.
</p>
</div>
)}
</div>
); );
} }

View File

@ -1,6 +1,7 @@
import { integrationSlugNameMapping } from "public/data/frequentConstants"; import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { FormLabel } from "@app/components/v2"; import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types"; import { IntegrationMappingBehavior, TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
type Props = { type Props = {
@ -46,6 +47,11 @@ export const IntegrationConnectionSection = ({ integration }: Props) => {
case "qovery": case "qovery":
return integration.scope; return integration.scope;
case "circleci": case "circleci":
if (integration.scope === CircleCiScope.Context) {
return "Context";
}
return "Project";
case "terraform-cloud": case "terraform-cloud":
return "Project"; return "Project";
case "aws-secret-manager": case "aws-secret-manager":
@ -77,7 +83,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => {
return `${integration.owner}`; return `${integration.owner}`;
} }
return `${integration.owner}/${integration.app}`; return `${integration.owner}/${integration.app}`;
case "aws-parameter-store": case "aws-parameter-store":
case "rundeck": case "rundeck":
return `${integration.path}`; return `${integration.path}`;

View File

@ -1,4 +1,5 @@
import { FormLabel } from "@app/components/v2"; import { FormLabel } from "@app/components/v2";
import { CircleCiScope } from "@app/hooks/api/integrationAuth/types";
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types"; import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
type Props = { type Props = {
@ -52,7 +53,8 @@ export const IntegrationDetails = ({ integration }: Props) => {
<FormLabel <FormLabel
label={ label={
(integration.integration === "qovery" && integration?.scope) || (integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") || (integration.integration === "circleci" &&
(integration.scope === CircleCiScope.Context ? "Context" : "Project")) ||
(integration.integration === "bitbucket" && "Repository") || (integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") || (integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") || (integration.integration === "aws-secret-manager" && "Secret") ||