diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 67b70079b..728ab1d64 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2228,6 +2228,11 @@ export const AppConnections = { }, FLYIO: { accessToken: "The Access Token used to access fly.io." + }, + GITLAB: { + instanceUrl: "The GitLab instance URL to connect with.", + accessToken: "The Access Token used to access GitLab.", + code: "The OAuth code to use to connect with GitLab." } } }; @@ -2401,6 +2406,16 @@ export const SecretSyncs = { }, FLYIO: { appId: "The ID of the Fly.io app to sync secrets to." + }, + GITLAB: { + projectId: "The GitLab project to sync secrets to.", + projectName: "The GitLab project name to sync secrets to.", + groupId: "The GitLab group to sync secrets to.", + scope: "The GitLab project scope that secrets should be synced to. (default: individual)", + targetEnvironment: "The GitLab environment scope that secrets should be synced to. (default: *)", + shouldProtectSecrets: "Whether variables should be protected", + shouldMaskSecrets: "Whether variables should be masked in logs", + shouldHideSecrets: "Whether variables should be hidden" } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index e474859c3..1f3757073 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -46,6 +46,7 @@ import { GitHubRadarConnectionListItemSchema, SanitizedGitHubRadarConnectionSchema } from "@app/services/app-connection/github-radar"; +import { GitLabConnectionListItemSchema, SanitizedGitLabConnectionSchema } from "@app/services/app-connection/gitlab"; import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema @@ -109,7 +110,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOnePassConnectionSchema.options, ...SanitizedHerokuConnectionSchema.options, ...SanitizedRenderConnectionSchema.options, - ...SanitizedFlyioConnectionSchema.options + ...SanitizedFlyioConnectionSchema.options, + ...SanitizedGitLabConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -139,7 +141,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OnePassConnectionListItemSchema, HerokuConnectionListItemSchema, RenderConnectionListItemSchema, - FlyioConnectionListItemSchema + FlyioConnectionListItemSchema, + GitLabConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/gitlab-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/gitlab-connection-router.ts new file mode 100644 index 000000000..59bb7d3a4 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/gitlab-connection-router.ts @@ -0,0 +1,94 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitLabConnectionSchema, + SanitizedGitLabConnectionSchema, + TGitLabGroup, + TGitLabProject, + UpdateGitLabConnectionSchema +} from "@app/services/app-connection/gitlab"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitLabConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.GitLab, + server, + sanitizedResponseSchema: SanitizedGitLabConnectionSchema, + createSchema: CreateGitLabConnectionSchema, + updateSchema: UpdateGitLabConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + group: z.string().optional() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects: TGitLabProject[] = await server.services.appConnection.gitlab.listProjects( + connectionId, + req.permission, + req.query.group + ); + + return projects; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const groups: TGitLabGroup[] = await server.services.appConnection.gitlab.listGroups( + connectionId, + req.permission + ); + + return groups; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index da1857ac5..b1a385fd7 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -15,6 +15,7 @@ import { registerFlyioConnectionRouter } from "./flyio-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; +import { registerGitLabConnectionRouter } from "./gitlab-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; @@ -58,5 +59,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.GitLab, + server, + responseSchema: GitLabSyncSchema, + createSchema: CreateGitLabSyncSchema, + updateSchema: UpdateGitLabSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 989d289ec..dff5937c2 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -12,6 +12,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerFlyioSyncRouter } from "./flyio-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; +import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; @@ -43,5 +44,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index e0b7a61f1..e55dfa3b1 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -25,7 +25,8 @@ export enum AppConnection { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + GitLab = "gitlab" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 66027e24b..2c46d5fc3 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -64,6 +64,7 @@ import { GitHubRadarConnectionMethod, validateGitHubRadarConnectionCredentials } from "./github-radar"; +import { getGitLabConnectionListItem, GitLabConnectionMethod, validateGitLabConnectionCredentials } from "./gitlab"; import { getHCVaultConnectionListItem, HCVaultConnectionMethod, @@ -128,7 +129,8 @@ export const listAppConnectionOptions = () => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), - getFlyioConnectionListItem() + getFlyioConnectionListItem(), + getGitLabConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -206,7 +208,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -223,6 +226,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case GitHubConnectionMethod.OAuth: case AzureDevOpsConnectionMethod.OAuth: case HerokuConnectionMethod.OAuth: + case GitLabConnectionMethod.OAuth: return "OAuth"; case HerokuConnectionMethod.AuthToken: return "Auth Token"; @@ -318,7 +322,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.OnePass]: platformManagedCredentialsNotSupported, [AppConnection.Heroku]: platformManagedCredentialsNotSupported, [AppConnection.Render]: platformManagedCredentialsNotSupported, - [AppConnection.Flyio]: platformManagedCredentialsNotSupported + [AppConnection.Flyio]: platformManagedCredentialsNotSupported, + [AppConnection.GitLab]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 57725d1e1..d605279a3 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -27,7 +27,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", - [AppConnection.Flyio]: "Fly.io" + [AppConnection.Flyio]: "Fly.io", + [AppConnection.GitLab]: "GitLab" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -57,5 +58,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -216,6 +223,7 @@ export type TAppConnectionInput = { id: string } & ( | THerokuConnectionInput | TRenderConnectionInput | TFlyioConnectionInput + | TGitLabConnectionInput ); export type TSqlConnectionInput = @@ -257,7 +265,8 @@ export type TAppConnectionConfig = | TOnePassConnectionConfig | THerokuConnectionConfig | TRenderConnectionConfig - | TFlyioConnectionConfig; + | TFlyioConnectionConfig + | TGitLabConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -286,7 +295,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateOnePassConnectionCredentialsSchema | TValidateHerokuConnectionCredentialsSchema | TValidateRenderConnectionCredentialsSchema - | TValidateFlyioConnectionCredentialsSchema; + | TValidateFlyioConnectionCredentialsSchema + | TValidateGitLabConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts new file mode 100644 index 000000000..2046ceae8 --- /dev/null +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts @@ -0,0 +1,4 @@ +export enum GitLabConnectionMethod { + OAuth = "oauth", + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts new file mode 100644 index 000000000..858e83ee5 --- /dev/null +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-fns.ts @@ -0,0 +1,510 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError, AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "../app-connection-dal"; +import { GitLabConnectionMethod } from "./gitlab-connection-enums"; +import { TGitLabConnection, TGitLabConnectionConfig, TGitLabGroup, TGitLabProject } from "./gitlab-connection-types"; + +interface GitLabOAuthTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + created_at: number; + scope?: string; +} + +export const getGitLabConnectionListItem = () => { + const { CLIENT_ID_GITLAB_LOGIN } = getConfig(); + + return { + name: "GitLab" as const, + app: AppConnection.GitLab as const, + methods: Object.values(GitLabConnectionMethod) as [ + GitLabConnectionMethod.AccessToken, + GitLabConnectionMethod.OAuth + ], + oauthClientId: CLIENT_ID_GITLAB_LOGIN + }; +}; + +export const refreshGitLabToken = async ( + refreshToken: string, + appId: string, + orgId: string, + appConnectionDAL: Pick, + kmsService: Pick, + instanceUrl?: string +): Promise => { + const { CLIENT_ID_GITLAB_LOGIN, CLIENT_SECRET_GITLAB_LOGIN, SITE_URL } = getConfig(); + if (!CLIENT_SECRET_GITLAB_LOGIN || !CLIENT_ID_GITLAB_LOGIN || !SITE_URL) { + throw new InternalServerError({ + message: `GitLab environment variables have not been configured` + }); + } + + const payload = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID_GITLAB_LOGIN, + client_secret: CLIENT_SECRET_GITLAB_LOGIN, + redirect_uri: `${SITE_URL}/integrations/gitlab/oauth2/callback` + }); + + try { + const { data } = await request.post( + `${instanceUrl ? `${instanceUrl}/oauth/token` : IntegrationUrls.GITLAB_TOKEN_URL}`, + payload.toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json" + } + } + ); + + const expiresAt = new Date(Date.now() + data.expires_in * 1000 - 60000); + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: { + refreshToken: data.refresh_token, + accessToken: data.access_token, + expiresAt + }, + orgId, + kmsService + }); + + await appConnectionDAL.updateById(appId, { encryptedCredentials }); + + return data.access_token; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to refresh GitLab token: ${error.message}` + }); + } + throw new BadRequestError({ + message: "Unable to refresh GitLab token" + }); + } +}; + +export const exchangeGitLabOAuthCode = async ( + code: string, + instanceUrl?: string +): Promise => { + const { CLIENT_ID_GITLAB_LOGIN, CLIENT_SECRET_GITLAB_LOGIN, SITE_URL } = getConfig(); + if (!CLIENT_SECRET_GITLAB_LOGIN || !CLIENT_ID_GITLAB_LOGIN || !SITE_URL) { + throw new InternalServerError({ + message: `GitLab environment variables have not been configured` + }); + } + + try { + const payload = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: CLIENT_ID_GITLAB_LOGIN, + client_secret: CLIENT_SECRET_GITLAB_LOGIN, + redirect_uri: `${SITE_URL}/integrations/gitlab/oauth2/callback` + }); + + const response = await request.post( + instanceUrl ? `${instanceUrl}/oauth/token` : IntegrationUrls.GITLAB_TOKEN_URL, + payload.toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json" + } + } + ); + + if (!response.data) { + throw new InternalServerError({ + message: "Failed to exchange OAuth code: Empty response" + }); + } + + return response.data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to exchange OAuth code: ${error.message}` + }); + } + throw new BadRequestError({ + message: "Unable to exchange OAuth code" + }); + } +}; + +export const validateGitLabConnectionCredentials = async (config: TGitLabConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + let accessToken: string; + let oauthData: GitLabOAuthTokenResponse | null = null; + + if (method === GitLabConnectionMethod.OAuth && "code" in inputCredentials) { + oauthData = await exchangeGitLabOAuthCode(inputCredentials.code, inputCredentials.instanceUrl); + accessToken = oauthData.access_token; + } else if (method === GitLabConnectionMethod.AccessToken && "accessToken" in inputCredentials) { + accessToken = inputCredentials.accessToken; + } else { + throw new BadRequestError({ + message: "Invalid credentials for the selected connection method" + }); + } + + let response: AxiosResponse | null = null; + + try { + response = await request.get( + `${inputCredentials.instanceUrl ? `${inputCredentials.instanceUrl}/api` : IntegrationUrls.GITLAB_API_URL}/v4/groups`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to validate credentials: Response was empty" + }); + } + + if (method === GitLabConnectionMethod.OAuth && oauthData) { + return { + accessToken, + refreshToken: oauthData.refresh_token, + expiresAt: new Date(Date.now() + oauthData.expires_in * 1000 - 60000), + tokenType: oauthData.token_type, + createdAt: new Date(oauthData.created_at * 1000) + }; + } + + return inputCredentials; +}; + +export const listGitLabProjects = async ({ + appConnection, + appConnectionDAL, + kmsService, + teamId +}: { + appConnection: TGitLabConnection; + appConnectionDAL: Pick; + kmsService: Pick; + teamId?: string; +}): Promise => { + let { accessToken } = appConnection.credentials; + + if ( + appConnection.method === GitLabConnectionMethod.OAuth && + appConnection.credentials.refreshToken && + appConnection.credentials.expiresAt < new Date() + ) { + accessToken = await refreshGitLabToken( + appConnection.credentials.refreshToken, + appConnection.id, + appConnection.orgId, + appConnectionDAL, + kmsService, + appConnection.credentials.instanceUrl + ); + } + + const gitLabApiUrl = appConnection.credentials.instanceUrl + ? `${appConnection.credentials.instanceUrl}/api/v4` + : `${IntegrationUrls.GITLAB_API_URL}/v4`; + + const projects: TGitLabProject[] = []; + let page = 1; + const perPage = 100; + let hasMorePages = true; + + try { + if (teamId) { + while (hasMorePages) { + const { data } = await request.get(`${gitLabApiUrl}/groups/${teamId}/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }, + params: { + page: page.toString(), + per_page: perPage.toString(), + order_by: "updated_at", + sort: "desc", + include_subgroups: "true" + } + }); + + if (!data) { + throw new InternalServerError({ + message: "Failed to get group projects: Response was empty" + }); + } + + data.forEach((project) => { + projects.push({ + name: project.name, + id: project.id.toString() + }); + }); + + hasMorePages = data.length === perPage; + page += 1; + } + } else { + const { data: userData } = await request.get<{ id: string }>(`${gitLabApiUrl}/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + }); + + if (!userData?.id) { + throw new InternalServerError({ + message: "Failed to get current user information" + }); + } + + while (hasMorePages) { + const { data } = await request.get(`${gitLabApiUrl}/users/${userData.id}/projects`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }, + params: { + page: page.toString(), + per_page: perPage.toString(), + order_by: "updated_at", + sort: "desc" + } + }); + + if (!data) { + throw new InternalServerError({ + message: "Failed to get user projects: Response was empty" + }); + } + + data.forEach((project) => { + projects.push({ + name: project.name, + id: project.id.toString() + }); + }); + + hasMorePages = data.length === perPage; + page += 1; + } + + if (projects.length === 0 && appConnection.method === GitLabConnectionMethod.AccessToken) { + try { + const { data: tokenAssociations } = await request.get<{ + projects?: TGitLabProject[]; + groups?: Array<{ projects?: TGitLabProject[] }>; + }>(`${gitLabApiUrl}/personal_access_tokens/self/associations`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }, + params: { + min_access_level: "50" + } + }); + + if (tokenAssociations?.projects) { + tokenAssociations.projects.forEach((project) => { + projects.push({ + name: project.name, + id: project.id.toString() + }); + }); + } + + if (tokenAssociations?.groups) { + tokenAssociations.groups.forEach((group) => { + if (group.projects) { + group.projects.forEach((project) => { + const existingProject = projects.find((p) => p.id === project.id.toString()); + if (!existingProject) { + projects.push({ + name: project.name, + id: project.id.toString() + }); + } + }); + } + }); + } + } catch (error) { + logger.warn(error, "Failed to fetch projects via personal access token associations:"); + } + } + } + + return projects; + } catch (error: unknown) { + if (error instanceof AxiosError) { + const status = error.response?.status; + const { message } = error; + + if (status === 401) { + throw new BadRequestError({ + message: `GitLab authentication failed: ${message}` + }); + } else if (status === 403) { + throw new BadRequestError({ + message: `GitLab access forbidden: ${message}` + }); + } else if (status === 404) { + throw new BadRequestError({ + message: teamId ? `GitLab group not found or access denied: ${message}` : `GitLab user not found: ${message}` + }); + } else { + throw new BadRequestError({ + message: `Failed to fetch GitLab projects: ${message}` + }); + } + } + + if (error instanceof InternalServerError) { + throw error; + } + + throw new InternalServerError({ + message: "Unable to fetch GitLab projects" + }); + } +}; + +export const listGitLabGroups = async ({ + appConnection, + appConnectionDAL, + kmsService, + includeSubgroups = true, + owned = false +}: { + appConnection: TGitLabConnection; + appConnectionDAL: Pick; + kmsService: Pick; + includeSubgroups?: boolean; + owned?: boolean; +}): Promise => { + let { accessToken } = appConnection.credentials; + + if ( + appConnection.method === GitLabConnectionMethod.OAuth && + appConnection.credentials.refreshToken && + appConnection.credentials.expiresAt < new Date() + ) { + accessToken = await refreshGitLabToken( + appConnection.credentials.refreshToken, + appConnection.id, + appConnection.orgId, + appConnectionDAL, + kmsService, + appConnection.credentials.instanceUrl + ); + } + + const gitLabApiUrl = appConnection.credentials.instanceUrl + ? `${appConnection.credentials.instanceUrl}/api/v4` + : `${IntegrationUrls.GITLAB_API_URL}/v4`; + + const groups: TGitLabGroup[] = []; + let page = 1; + const perPage = 100; + let hasMorePages = true; + + try { + while (hasMorePages) { + const { data } = await request.get(`${gitLabApiUrl}/groups`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }, + params: { + page: page.toString(), + per_page: perPage.toString(), + order_by: "name", + sort: "asc", + all_available: (!owned).toString(), + owned: owned.toString(), + min_access_level: "10", + ...(includeSubgroups && { with_custom_attributes: "true" }) + } + }); + + if (!data) { + throw new InternalServerError({ + message: "Failed to get groups: Response was empty" + }); + } + + data.forEach((group) => { + groups.push({ + id: group.id.toString(), + name: group.name + }); + }); + + hasMorePages = data.length === perPage; + page += 1; + } + + return groups; + } catch (error: unknown) { + if (error instanceof AxiosError) { + const status = error.response?.status; + const { message } = error; + + if (status === 401) { + throw new BadRequestError({ + message: `GitLab authentication failed: ${message}` + }); + } else if (status === 403) { + throw new BadRequestError({ + message: `GitLab access forbidden: ${message}` + }); + } else { + throw new BadRequestError({ + message: `Failed to fetch GitLab groups: ${message}` + }); + } + } + + if (error instanceof InternalServerError) { + throw error; + } + + throw new InternalServerError({ + message: "Unable to fetch GitLab groups" + }); + } +}; diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts new file mode 100644 index 000000000..2d4b9bc1b --- /dev/null +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts @@ -0,0 +1,139 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { GitLabConnectionMethod } from "./gitlab-connection-enums"; + +// Fixed: Use consistent accessToken naming throughout +export const GitLabConnectionAccessTokenCredentialsSchema = z.object({ + accessToken: z + .string() + .trim() + .min(1, "Access Token required") + .describe(AppConnections.CREDENTIALS.GITLAB.accessToken), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.GITLAB.instanceUrl) +}); + +export const GitLabConnectionOAuthCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.GITLAB.code), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.GITLAB.instanceUrl) +}); + +// Fixed: Updated schema to match GitLab's actual OAuth response structure +export const GitLabConnectionOAuthOutputCredentialsSchema = z.object({ + accessToken: z.string().trim(), + refreshToken: z.string().trim(), + expiresAt: z.date(), + tokenType: z.string().optional().default("bearer"), + createdAt: z.string().optional(), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.GITLAB.instanceUrl) +}); + +// Schema for refresh token input during initial setup +export const GitLabConnectionRefreshTokenCredentialsSchema = z.object({ + refreshToken: z.string().trim().min(1, "Refresh token required"), + instanceUrl: z + .string() + .trim() + .url("Invalid Instance URL") + .optional() + .describe(AppConnections.CREDENTIALS.GITLAB.instanceUrl) +}); + +const BaseGitLabConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.GitLab) +}); + +export const GitLabConnectionSchema = z.intersection( + BaseGitLabConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitLabConnectionMethod.AccessToken), + credentials: GitLabConnectionAccessTokenCredentialsSchema + }), + z.object({ + method: z.literal(GitLabConnectionMethod.OAuth), + credentials: GitLabConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedGitLabConnectionSchema = z.discriminatedUnion("method", [ + BaseGitLabConnectionSchema.extend({ + method: z.literal(GitLabConnectionMethod.AccessToken), + credentials: GitLabConnectionAccessTokenCredentialsSchema.pick({ + instanceUrl: true + }) // Don't expose sensitive data + }), + BaseGitLabConnectionSchema.extend({ + method: z.literal(GitLabConnectionMethod.OAuth), + credentials: GitLabConnectionOAuthOutputCredentialsSchema.pick({ + instanceUrl: true + }) + }) +]); + +export const ValidateGitLabConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitLabConnectionMethod.AccessToken).describe(AppConnections.CREATE(AppConnection.GitLab).method), + credentials: GitLabConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitLab).credentials + ) + }), + z.object({ + method: z.literal(GitLabConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitLab).method), + credentials: z + .union([ + GitLabConnectionOAuthCredentialsSchema, + GitLabConnectionRefreshTokenCredentialsSchema, + GitLabConnectionOAuthOutputCredentialsSchema + ]) + .describe(AppConnections.CREATE(AppConnection.GitLab).credentials) + }) +]); + +export const CreateGitLabConnectionSchema = ValidateGitLabConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitLab) +); + +export const UpdateGitLabConnectionSchema = z + .object({ + credentials: z + .union([ + GitLabConnectionAccessTokenCredentialsSchema, + GitLabConnectionOAuthOutputCredentialsSchema, + GitLabConnectionRefreshTokenCredentialsSchema, + GitLabConnectionOAuthCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.GitLab).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitLab)); + +export const GitLabConnectionListItemSchema = z.object({ + name: z.literal("GitLab"), + app: z.literal(AppConnection.GitLab), + methods: z.nativeEnum(GitLabConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-service.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-service.ts new file mode 100644 index 000000000..863ef1659 --- /dev/null +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-service.ts @@ -0,0 +1,47 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "../app-connection-dal"; +import { AppConnection } from "../app-connection-enums"; +import { listGitLabGroups, listGitLabProjects } from "./gitlab-connection-fns"; +import { TGitLabConnection } from "./gitlab-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const gitlabConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor, teamId?: string) => { + try { + const appConnection = await getAppConnection(AppConnection.GitLab, connectionId, actor); + const projects = await listGitLabProjects({ appConnection, appConnectionDAL, kmsService, teamId }); + return projects; + } catch (error) { + logger.error(error, `Failed to establish connection with GitLab for app ${connectionId}`); + return []; + } + }; + + const listGroups = async (connectionId: string, actor: OrgServiceActor) => { + try { + const appConnection = await getAppConnection(AppConnection.GitLab, connectionId, actor); + const groups = await listGitLabGroups({ appConnection, appConnectionDAL, kmsService }); + return groups; + } catch (error) { + logger.error(error, `Failed to establish connection with GitLab for app ${connectionId}`); + return []; + } + }; + + return { + listProjects, + listGroups + }; +}; diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-types.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-types.ts new file mode 100644 index 000000000..73c3d7a46 --- /dev/null +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-types.ts @@ -0,0 +1,56 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitLabConnectionSchema, + GitLabConnectionSchema, + ValidateGitLabConnectionCredentialsSchema +} from "./gitlab-connection-schemas"; + +export type TGitLabConnection = z.infer; + +export type TGitLabConnectionInput = z.infer & { + app: AppConnection.GitLab; +}; + +export type TValidateGitLabConnectionCredentialsSchema = typeof ValidateGitLabConnectionCredentialsSchema; + +export type TGitLabConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type TGitLabProject = { + name: string; + id: string; +}; + +export type TGitLabAccessTokenCredentials = { + accessToken: string; + instanceUrl: string; +}; + +export type TGitLabOAuthCredentials = { + accessToken: string; + refreshToken: string; + expiresAt: Date; + tokenType?: string; + createdAt?: Date; + instanceUrl: string; +}; + +export type TGitLabOAuthCodeCredentials = { + code: string; + instanceUrl: string; +}; + +export type TGitLabRefreshTokenCredentials = { + refreshToken: string; + instanceUrl: string; +}; + +export interface TGitLabGroup { + id: string; + name: string; +} diff --git a/backend/src/services/app-connection/gitlab/index.ts b/backend/src/services/app-connection/gitlab/index.ts new file mode 100644 index 000000000..9c6463d8c --- /dev/null +++ b/backend/src/services/app-connection/gitlab/index.ts @@ -0,0 +1,4 @@ +export * from "./gitlab-connection-enums"; +export * from "./gitlab-connection-fns"; +export * from "./gitlab-connection-schemas"; +export * from "./gitlab-connection-types"; diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-constants.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-constants.ts new file mode 100644 index 000000000..95ea1b424 --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const GITLAB_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "GitLab", + destination: SecretSync.GitLab, + connection: AppConnection.GitLab, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-enums.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-enums.ts new file mode 100644 index 000000000..c93e51f5f --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-enums.ts @@ -0,0 +1,4 @@ +export enum GitLabSyncScope { + Individual = "individual", + Group = "group" +} diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts new file mode 100644 index 000000000..fea0092be --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-fns.ts @@ -0,0 +1,369 @@ +/* eslint-disable no-await-in-loop */ +import { request } from "@app/lib/config/request"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { GitLabConnectionMethod, refreshGitLabToken, TGitLabConnection } from "@app/services/app-connection/gitlab"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TGitLabSyncWithCredentials, TGitLabVariable } from "@app/services/secret-sync/gitlab/gitlab-sync-types"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + +interface TGitLabVariablePayload { + key?: string; + value: string; + variable_type?: "env_var" | "file"; + environment_scope?: string; + protected?: boolean; + masked?: boolean; + masked_and_hidden?: boolean; + description?: string; +} + +interface TGitLabVariableCreate extends TGitLabVariablePayload { + key: string; +} + +interface TGitLabVariableUpdate extends Omit {} + +type TGitLabSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const getValidAccessToken = async ( + connection: TGitLabConnection, + appConnectionDAL: Pick, + kmsService: Pick +): Promise => { + if ( + connection.method === GitLabConnectionMethod.OAuth && + connection.credentials.refreshToken && + connection.credentials.expiresAt < new Date() + ) { + const accessToken = await refreshGitLabToken( + connection.credentials.refreshToken, + connection.id, + connection.orgId, + appConnectionDAL, + kmsService + ); + return accessToken; + } + return connection.credentials.accessToken; +}; + +const getGitLabApiUrl = (connection: TGitLabConnection): string => { + const baseUrl = connection.credentials.instanceUrl || IntegrationUrls.GITLAB_API_URL; + return baseUrl.includes("/api") ? baseUrl : `${baseUrl}/api`; +}; + +const buildVariablesEndpoint = (apiUrl: string, projectId: string): string => { + return `${apiUrl}/v4/projects/${encodeURIComponent(projectId)}/variables`; +}; + +const getGitLabVariables = async ({ + accessToken, + connection, + projectId, + targetEnvironment +}: { + accessToken: string; + connection: TGitLabConnection; + projectId: string; + targetEnvironment?: string; +}): Promise => { + try { + const apiUrl = getGitLabApiUrl(connection); + const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); + + const headers = { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + "Content-Type": "application/json" + }; + + let allVariables: TGitLabVariable[] = []; + let url: string | null = `${baseEndpoint}?per_page=100`; + + if (targetEnvironment) { + url += `&filter[environment_scope]=${encodeURIComponent(targetEnvironment)}`; + } + + while (url) { + const response = await request.get(url, { headers }); + allVariables = [...allVariables, ...(response.data || [])]; + + const linkHeader = response.headers.link as string; + const nextLink = linkHeader?.split(",").find((part: string) => part.includes('rel="next"')); + + if (nextLink) { + url = nextLink.trim().split(";")[0].slice(1, -1); + } else { + url = null; + } + } + + if (targetEnvironment) { + return allVariables.filter((variable) => variable.environment_scope === targetEnvironment); + } + + return allVariables; + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: "list_variables" + }); + } +}; + +const createGitLabVariable = async ({ + accessToken, + connection, + projectId, + variable +}: { + accessToken: string; + connection: TGitLabConnection; + projectId: string; + variable: TGitLabVariableCreate; +}): Promise => { + try { + const apiUrl = getGitLabApiUrl(connection); + const endpoint = buildVariablesEndpoint(apiUrl, projectId); + + const payload = { + key: variable.key, + value: variable.value, + variable_type: variable.variable_type || "env_var", + environment_scope: variable.environment_scope || "*", + protected: variable.protected || false, + masked: variable.masked || false, + masked_and_hidden: variable.masked_and_hidden || false, + raw: false, + ...(variable.description && { description: variable.description }) + }; + + await request.post(endpoint, payload, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +const updateGitLabVariable = async ({ + accessToken, + connection, + projectId, + key, + variable, + targetEnvironment +}: { + accessToken: string; + connection: TGitLabConnection; + projectId: string; + key: string; + variable: TGitLabVariableUpdate; + targetEnvironment?: string; +}): Promise => { + try { + const apiUrl = getGitLabApiUrl(connection); + const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); + let url = `${baseEndpoint}/${encodeURIComponent(key)}`; + + if (targetEnvironment) { + url += `?filter[environment_scope]=${encodeURIComponent(targetEnvironment)}`; + } + + const payload = { + value: variable.value, + ...(variable.variable_type && { variable_type: variable.variable_type }), + ...(variable.environment_scope && { environment_scope: variable.environment_scope }), + ...(variable.protected !== undefined && { protected: variable.protected }), + ...(variable.masked !== undefined && { masked: variable.masked }), + ...(variable.masked_and_hidden !== undefined && { masked_and_hidden: variable.masked_and_hidden }), + ...(variable.description !== undefined && { description: variable.description || "" }) + }; + + await request.put(url, payload, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const deleteGitLabVariable = async ({ + accessToken, + connection, + projectId, + key, + targetEnvironment +}: { + accessToken: string; + connection: TGitLabConnection; + projectId: string; + key: string; + targetEnvironment?: string; +}): Promise => { + try { + const apiUrl = getGitLabApiUrl(connection); + const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); + let url = `${baseEndpoint}/${encodeURIComponent(key)}`; + + if (targetEnvironment) { + url += `?filter[environment_scope]=${encodeURIComponent(targetEnvironment)}`; + } + + await request.delete(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + "Content-Type": "application/json" + } + }); + } catch (error: unknown) { + throw new SecretSyncError({ + error + }); + } +}; + +export const GitLabSyncFns = { + syncSecrets: async ( + secretSync: TGitLabSyncWithCredentials, + secretMap: TSecretMap, + { appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps + ): Promise => { + const { connection, environment, destinationConfig } = secretSync; + + const { projectId, targetEnvironment } = destinationConfig; + + const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService); + + try { + const currentVariables = await getGitLabVariables({ + accessToken, + connection, + projectId, + targetEnvironment + }); + + const currentVariableMap = new Map(currentVariables.map((v) => [v.key, v])); + + for (const [key, { value }] of Object.entries(secretMap)) { + const existingVariable = currentVariableMap.get(key); + + if (existingVariable) { + if (existingVariable.value !== value) { + await updateGitLabVariable({ + accessToken, + connection, + projectId, + key, + variable: { + value, + variable_type: existingVariable.variable_type, + environment_scope: targetEnvironment || existingVariable.environment_scope, + protected: destinationConfig.shouldProtectSecrets ?? existingVariable.protected, + ...(!existingVariable.masked && destinationConfig.shouldMaskSecrets && { masked: value?.length > 8 }), + ...(!existingVariable.hidden && + destinationConfig.shouldHideSecrets && { masked_and_hidden: value?.length > 8 }), + description: existingVariable.description ?? undefined + }, + targetEnvironment + }); + } + } else { + await createGitLabVariable({ + accessToken, + connection, + projectId, + variable: { + key, + value, + variable_type: "env_var", + environment_scope: targetEnvironment || "*", + protected: destinationConfig.shouldProtectSecrets || false, + masked: value?.length > 8 ? destinationConfig.shouldMaskSecrets || false : false, + masked_and_hidden: value?.length > 8 ? destinationConfig.shouldHideSecrets || false : false + } + }); + } + } + + if (!secretSync.syncOptions.disableSecretDeletion) { + for (const variable of currentVariables) { + const shouldDelete = + matchesSchema(variable.key, environment?.slug || "", secretSync.syncOptions.keySchema) && + !(variable.key in secretMap); + + if (shouldDelete) { + await deleteGitLabVariable({ + accessToken, + connection, + projectId, + key: variable.key, + targetEnvironment + }); + } + } + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: "batch_sync" + }); + } + }, + + removeSecrets: async ( + secretSync: TGitLabSyncWithCredentials, + secretMap: TSecretMap, + { appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps + ): Promise => { + const { connection, destinationConfig } = secretSync; + + const { projectId, targetEnvironment } = destinationConfig; + + const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService); + + try { + for (const key of Object.keys(secretMap)) { + await deleteGitLabVariable({ + accessToken, + connection, + projectId, + key, + targetEnvironment + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: "batch_remove" + }); + } + }, + + getSecrets: async (secretSync: TGitLabSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + } +}; diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts new file mode 100644 index 000000000..1715c9799 --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { GitLabSyncScope } from "./gitlab-sync-enums"; + +const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitLabSyncScope.Individual).describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.scope), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectId), + projectName: z + .string() + .min(1, "Project name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectName), + targetEnvironment: z + .string() + .optional() + .default("*") + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.targetEnvironment), + shouldProtectSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldProtectSecrets), + shouldMaskSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldMaskSecrets), + shouldHideSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldHideSecrets) + }), + z.object({ + scope: z.literal(GitLabSyncScope.Group).describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.scope), + groupId: z.string().min(1, "Group ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.groupId), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectId), + projectName: z + .string() + .min(1, "Project name is required") + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectName), + targetEnvironment: z + .string() + .optional() + .default("*") + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.targetEnvironment), + shouldProtectSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldProtectSecrets), + shouldMaskSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldMaskSecrets), + shouldHideSecrets: z + .boolean() + .optional() + .default(false) + .describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.shouldHideSecrets) + }) +]); + +const GitLabSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const GitLabSyncSchema = BaseSecretSyncSchema(SecretSync.GitLab, GitLabSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.GitLab), + destinationConfig: GitLabSyncDestinationConfigSchema +}); + +export const CreateGitLabSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.GitLab, + GitLabSyncOptionsConfig +).extend({ + destinationConfig: GitLabSyncDestinationConfigSchema +}); + +export const UpdateGitLabSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.GitLab, + GitLabSyncOptionsConfig +).extend({ + destinationConfig: GitLabSyncDestinationConfigSchema.optional() +}); + +export const GitLabSyncListItemSchema = z.object({ + name: z.literal("GitLab"), + connection: z.literal(AppConnection.GitLab), + destination: z.literal(SecretSync.GitLab), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-types.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-types.ts new file mode 100644 index 000000000..c9b82739e --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-types.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import { TGitLabConnection } from "@app/services/app-connection/gitlab"; + +import { CreateGitLabSyncSchema, GitLabSyncListItemSchema, GitLabSyncSchema } from "./gitlab-sync-schemas"; + +export type TGitLabSync = z.infer; +export type TGitLabSyncInput = z.infer; +export type TGitLabSyncListItem = z.infer; + +export type TGitLabSyncWithCredentials = TGitLabSync & { + connection: TGitLabConnection; +}; + +// GitLab CI/CD Variable structure based on API documentation +export type TGitLabVariable = { + key: string; + value: string; + variable_type: "env_var" | "file"; + protected: boolean; + masked: boolean; + hidden: boolean; + raw: boolean; + environment_scope: string; + description: string | null; +}; + +// Type for creating a new variable +export type TGitLabVariableCreate = { + key: string; + value: string; + variable_type?: "env_var" | "file"; + protected?: boolean; + masked?: boolean; + raw?: boolean; + environment_scope?: string; + description?: string; +}; + +// Type for updating an existing variable +export type TGitLabVariableUpdate = { + value: string; + variable_type?: "env_var" | "file"; + protected?: boolean; + masked?: boolean; + raw?: boolean; + environment_scope?: string; + description?: string | null; +}; + +export type TGitLabListVariables = { + accessToken: string; + projectId: string; + environmentScope?: string; +}; + +export type TGitLabCreateVariable = TGitLabListVariables & { + variable: TGitLabVariableCreate; +}; + +export type TGitLabUpdateVariable = TGitLabListVariables & { + key: string; + variable: TGitLabVariableUpdate; +}; diff --git a/backend/src/services/secret-sync/gitlab/index.ts b/backend/src/services/secret-sync/gitlab/index.ts new file mode 100644 index 000000000..5072b77f1 --- /dev/null +++ b/backend/src/services/secret-sync/gitlab/index.ts @@ -0,0 +1,4 @@ +export * from "./gitlab-sync-constants"; +export * from "./gitlab-sync-fns"; +export * from "./gitlab-sync-schemas"; +export * from "./gitlab-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 04aaed0ce..8fededef2 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -18,7 +18,8 @@ export enum SecretSync { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + GitLab = "gitlab" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 63822a59e..7d7fab0b0 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -32,6 +32,7 @@ import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; +import { GITLAB_SYNC_LIST_OPTION, GitLabSyncFns } from "./gitlab"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; @@ -63,7 +64,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION, [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION, [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, - [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION + [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION, + [SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -227,6 +229,8 @@ export const SecretSyncFns = { return RenderSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Flyio: return FlyioSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.GitLab: + return GitLabSyncFns.syncSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -313,6 +317,9 @@ export const SecretSyncFns = { case SecretSync.Flyio: secretMap = await FlyioSyncFns.getSecrets(secretSync); break; + case SecretSync.GitLab: + secretMap = await GitLabSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -386,6 +393,8 @@ export const SecretSyncFns = { return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Flyio: return FlyioSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.GitLab: + return GitLabSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index f8f5d813c..6a29b71da 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -21,7 +21,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.OnePass]: "1Password", [SecretSync.Heroku]: "Heroku", [SecretSync.Render]: "Render", - [SecretSync.Flyio]: "Fly.io" + [SecretSync.Flyio]: "Fly.io", + [SecretSync.GitLab]: "GitLab" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -44,7 +45,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.OnePass]: AppConnection.OnePass, [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, - [SecretSync.Flyio]: AppConnection.Flyio + [SecretSync.Flyio]: AppConnection.Flyio, + [SecretSync.GitLab]: AppConnection.GitLab }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -67,5 +69,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.OnePass]: SecretSyncPlanType.Regular, [SecretSync.Heroku]: SecretSyncPlanType.Regular, [SecretSync.Render]: SecretSyncPlanType.Regular, - [SecretSync.Flyio]: SecretSyncPlanType.Regular + [SecretSync.Flyio]: SecretSyncPlanType.Regular, + [SecretSync.GitLab]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index d3dddea82..ef0233d6c 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -74,6 +74,7 @@ import { } from "./azure-key-vault"; import { TFlyioSync, TFlyioSyncInput, TFlyioSyncListItem, TFlyioSyncWithCredentials } from "./flyio/flyio-sync-types"; import { TGcpSync, TGcpSyncInput, TGcpSyncListItem, TGcpSyncWithCredentials } from "./gcp"; +import { TGitLabSync, TGitLabSyncInput, TGitLabSyncListItem, TGitLabSyncWithCredentials } from "./gitlab"; import { THCVaultSync, THCVaultSyncInput, @@ -127,7 +128,8 @@ export type TSecretSync = | TOnePassSync | THerokuSync | TRenderSync - | TFlyioSync; + | TFlyioSync + | TGitLabSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -149,7 +151,8 @@ export type TSecretSyncWithCredentials = | TOnePassSyncWithCredentials | THerokuSyncWithCredentials | TRenderSyncWithCredentials - | TFlyioSyncWithCredentials; + | TFlyioSyncWithCredentials + | TGitLabSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -171,7 +174,8 @@ export type TSecretSyncInput = | TOnePassSyncInput | THerokuSyncInput | TRenderSyncInput - | TFlyioSyncInput; + | TFlyioSyncInput + | TGitLabSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -193,7 +197,8 @@ export type TSecretSyncListItem = | TOnePassSyncListItem | THerokuSyncListItem | TRenderSyncListItem - | TFlyioSyncListItem; + | TFlyioSyncListItem + | TGitLabSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/docs/api-reference/endpoints/app-connections/gitlab/available.mdx b/docs/api-reference/endpoints/app-connections/gitlab/available.mdx new file mode 100644 index 000000000..155d8e98e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/gitlab/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/gitlab/create.mdx b/docs/api-reference/endpoints/app-connections/gitlab/create.mdx new file mode 100644 index 000000000..3acab1ea9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/gitlab" +--- + + + Gitlab OAuth Connections must be created through the Infisical UI. + Check out the configuration docs for [Gitlab OAuth Connections](/integrations/app-connections/gitlab) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/gitlab/delete.mdx b/docs/api-reference/endpoints/app-connections/gitlab/delete.mdx new file mode 100644 index 000000000..d5b32edba --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/gitlab/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/gitlab/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/gitlab/get-by-id.mdx new file mode 100644 index 000000000..b581efa22 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/gitlab/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/gitlab/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/gitlab/get-by-name.mdx new file mode 100644 index 000000000..32da4f6ad --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/gitlab/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/gitlab/list.mdx b/docs/api-reference/endpoints/app-connections/gitlab/list.mdx new file mode 100644 index 000000000..d1fc14563 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/gitlab" +--- diff --git a/docs/api-reference/endpoints/app-connections/gitlab/update.mdx b/docs/api-reference/endpoints/app-connections/gitlab/update.mdx new file mode 100644 index 000000000..16162ad8f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/gitlab/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/gitlab/{connectionId}" +--- + + + Gitlab OAuth Connections must be updated through the Infisical UI. + Check out the configuration docs for [Gitlab OAuth Connections](/integrations/app-connections/gitlab) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/create.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/create.mdx new file mode 100644 index 000000000..179655883 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/delete.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/delete.mdx new file mode 100644 index 000000000..57b036a7d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/gitlab/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-id.mdx new file mode 100644 index 000000000..2737afca4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/gitlab/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-name.mdx new file mode 100644 index 000000000..a17a27a9b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/gitlab/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/list.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/list.mdx new file mode 100644 index 000000000..d9f6ac63b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/gitlab" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/remove-secrets.mdx new file mode 100644 index 000000000..bf67639f3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/gitlab/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/sync-secrets.mdx new file mode 100644 index 000000000..7fc46593f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/gitlab/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/gitlab/update.mdx b/docs/api-reference/endpoints/secret-syncs/gitlab/update.mdx new file mode 100644 index 000000000..1cb5658a1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gitlab/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/gitlab/{syncId}" +--- diff --git a/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png index 379d6e0e1..382df365b 100644 Binary files a/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png and b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png differ diff --git a/docs/images/app-connections/gitlab/create-gitlab-oauth-connection.png b/docs/images/app-connections/gitlab/create-gitlab-oauth-connection.png new file mode 100644 index 000000000..f0df047fa Binary files /dev/null and b/docs/images/app-connections/gitlab/create-gitlab-oauth-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png deleted file mode 100644 index 85da8a929..000000000 Binary files a/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png and /dev/null differ diff --git a/docs/images/app-connections/gitlab/gitlab-access-token-connection.png b/docs/images/app-connections/gitlab/gitlab-access-token-connection.png new file mode 100644 index 000000000..70a1dfb50 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-access-token-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-add-access-token.png b/docs/images/app-connections/gitlab/gitlab-add-access-token.png index b1307f774..73073b61c 100644 Binary files a/docs/images/app-connections/gitlab/gitlab-add-access-token.png and b/docs/images/app-connections/gitlab/gitlab-add-access-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-applications.png b/docs/images/app-connections/gitlab/gitlab-applications.png new file mode 100644 index 000000000..0e69b42b2 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-applications.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-authorization-page.png b/docs/images/app-connections/gitlab/gitlab-authorization-page.png new file mode 100644 index 000000000..298d5a7ec Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-authorization-page.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-config-credentials.png b/docs/images/app-connections/gitlab/gitlab-config-credentials.png new file mode 100644 index 000000000..028b20bbc Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-config-credentials.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-copy-token.png b/docs/images/app-connections/gitlab/gitlab-copy-token.png index e425ac3ef..9d5b56977 100644 Binary files a/docs/images/app-connections/gitlab/gitlab-copy-token.png and b/docs/images/app-connections/gitlab/gitlab-copy-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png b/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png new file mode 100644 index 000000000..1be784b08 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-bottom.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-create-application-top.png b/docs/images/app-connections/gitlab/gitlab-create-application-top.png new file mode 100644 index 000000000..676c2f203 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-create-application-top.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-dashboard.png b/docs/images/app-connections/gitlab/gitlab-dashboard.png new file mode 100644 index 000000000..ee61cccc6 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-dashboard.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-oauth-connection.png b/docs/images/app-connections/gitlab/gitlab-oauth-connection.png new file mode 100644 index 000000000..972c69d20 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-oauth-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png new file mode 100644 index 000000000..87b140b15 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-personal-access-token-form.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-created.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-created.png new file mode 100644 index 000000000..09de4c027 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-project-access-token-created.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png new file mode 100644 index 000000000..c6f9593cb Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-project-access-token-form.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-project-access-token-list.png b/docs/images/app-connections/gitlab/gitlab-project-access-token-list.png new file mode 100644 index 000000000..72499d0a1 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-project-access-token-list.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png deleted file mode 100644 index 69575afe7..000000000 Binary files a/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png and /dev/null differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-created.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-created.png new file mode 100644 index 000000000..6588f443d Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-created.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-destination.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-destination.png new file mode 100644 index 000000000..58034deeb Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-destination.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-details.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-details.png new file mode 100644 index 000000000..93717b659 Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-details.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-option.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-option.png new file mode 100644 index 000000000..658e5a5d0 Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-option.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-options.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-options.png new file mode 100644 index 000000000..cd897816d Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-options.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-review.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-review.png new file mode 100644 index 000000000..b389aa686 Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-review.png differ diff --git a/docs/images/secret-syncs/gitlab/gitlab-secret-sync-source.png b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-source.png new file mode 100644 index 000000000..cc0a5e72f Binary files /dev/null and b/docs/images/secret-syncs/gitlab/gitlab-secret-sync-source.png differ diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx new file mode 100644 index 000000000..c923f9c63 --- /dev/null +++ b/docs/integrations/app-connections/gitlab.mdx @@ -0,0 +1,176 @@ +--- +title: "GitLab App Connection" +description: "Learn how to configure a GitLab App Connection for Infisical using OAuth or Access Token methods." +--- + +Infisical supports two methods for connecting to GitLab: **OAuth** and **Access Token**. Choose the method that best fits your setup and security requirements. + + + + The OAuth method provides secure authentication through GitLab's OAuth flow. + + + Using the GitLab App Connection with OAuth on a self-hosted instance of Infisical requires configuring an OAuth application in GitLab and registering your instance with it. + + **Prerequisites:** + - A GitLab account with existing projects + - Self-hosted Infisical instance + + + + Navigate to your user Settings > Applications to create a new GitLab application. + + ![GitLab Dashboard](/images/app-connections/gitlab/gitlab-dashboard.png) + ![GitLab Applications Settings](/images/app-connections/gitlab/gitlab-applications.png) + + + Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/integrations/gitlab/oauth2/callback`. + + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-top.png) + ![GitLab New Application Form](/images/app-connections/gitlab/gitlab-create-application-bottom.png) + + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + + If you have a GitLab group, you can create an OAuth application under it in your group Settings > Applications. + + + + Obtain the **Application ID** and **Secret** for your GitLab OAuth application. + + ![GitLab Application Credentials](/images/app-connections/gitlab/gitlab-config-credentials.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your GitLab OAuth application: + + - `CLIENT_ID_GITLAB`: The **Application ID** of your GitLab OAuth application. + - `CLIENT_SECRET_GITLAB`: The **Secret** of your GitLab OAuth application. + + Once added, restart your Infisical instance and use the GitLab App Connection. + + + + + ## Setup GitLab OAuth Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitLab App Connection** option from the connection options modal. + ![Select GitLab Connection](/images/app-connections/gitlab/select-gitlab-connection.png) + + + Select the **OAuth** method and click **Connect to GitLab**. + + ![Connect via GitLab OAuth](/images/app-connections/gitlab/create-gitlab-oauth-connection.png) + + + You will be redirected to GitLab to grant Infisical access to your GitLab account. Once granted, you will be redirected back to Infisical's App Connections page. + ![GitLab Authorization](/images/app-connections/gitlab/gitlab-authorization-page.png) + + + Your **GitLab App Connection** is now available for use. + ![GitLab OAuth Connection](/images/app-connections/gitlab/gitlab-oauth-connection.png) + + + + + + + The Access Token method uses a GitLab access token for authentication, providing a straightforward setup process. + + ## Generate GitLab Access Token + + + + Personal access tokens provide access to your GitLab account and all projects you have access to. + + + + Log in to your GitLab account and navigate to User Settings > Access tokens. Click **Add new token** to create a new personal access token. + + ![GitLab Personal Access Tokens](/images/app-connections/gitlab/gitlab-add-access-token.png) + + + Fill in the token details: + - **Token name**: A descriptive name for the token (e.g., "connection-token") + - **Expiration date**: Set an appropriate expiration date + - **Select scopes**: Choose the **api** scope for full API access + + ![GitLab Personal Token Form](/images/app-connections/gitlab/gitlab-personal-access-token-form.png) + + + + Copy the generated token immediately as it won't be shown again. + + ![GitLab Personal Token Created](/images/app-connections/gitlab/gitlab-copy-token.png) + + + Keep your access token secure and do not share it. Anyone with access to this token can access your GitLab account and projects. + + + + + + + Project access tokens provide access to a specific GitLab project, offering more granular control. + + + + Go to your GitLab project and navigate to Settings > Access Tokens. Click **Add new token** to create a new project access token. + + ![GitLab Project Access Tokens](/images/app-connections/gitlab/gitlab-project-access-token-list.png) + + + Fill in the token details: + - **Token name**: A descriptive name for the token + - **Expiration date**: Set an appropriate expiration date + - **Select role**: Choose **Owner** or higher role + - **Select scopes**: Choose the **api** scope for API access + + ![GitLab Create Project Token](/images/app-connections/gitlab/gitlab-project-access-token-form.png) + + + + Copy the generated token immediately as it won't be shown again. + + ![GitLab Project Token Form](/images/app-connections/gitlab/gitlab-project-access-token-created.png) + + + + + + ## Setup GitLab Access Token Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitLab App Connection** option from the connection options modal. + ![Select GitLab Connection](/images/app-connections/gitlab/select-gitlab-connection.png) + + + Select the **Access Token** method and paste your GitLab access token in the provided field. + + ![Configure Access Token](/images/app-connections/gitlab/create-gitlab-access-token-connection.png) + + Click **Connect** to establish the connection. + + + Your **GitLab App Connection** is now available for use. + ![GitLab Access Token Connection](/images/app-connections/gitlab/gitlab-access-token-connection.png) + + + + + Access Token connections require manual token rotation when your GitLab access token expires or is regenerated. Monitor your connection status and update the token as needed. + + + + diff --git a/docs/integrations/secret-syncs/gitlab.mdx b/docs/integrations/secret-syncs/gitlab.mdx new file mode 100644 index 000000000..a304571a5 --- /dev/null +++ b/docs/integrations/secret-syncs/gitlab.mdx @@ -0,0 +1,175 @@ +--- +title: "GitLab Sync" +description: "Learn how to configure a GitLab Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [GitLab Connection](/integrations/app-connections/gitlab) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **GitLab** option. + ![Select GitLab](/images/secret-syncs/gitlab/gitlab-secret-sync-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/gitlab/gitlab-secret-sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/gitlab/gitlab-secret-sync-destination.png) + + - **GitLab Connection**: The GitLab Connection to authenticate with. + - **Scope**: The GitLab secret scope to sync secrets to. + - **Individual**: Sync secrets to a specific project. + - **Group**: Sync secrets to a project within a group. +

+ The remaining fields are determined by the selected **Scope**: + + + - **GitLab Project**: The project to deploy secrets to. + - **GitLab Environment Scope**: The environment scope to deploy secrets to (optional, defaults to "*" for all environments). + - **Mark Infisical secrets in GitLab as 'Protected' secrets**: If enabled, synced secrets will be marked as protected in GitLab. + - **Mark Infisical secrets in GitLab as 'Masked' secrets**: If enabled, synced secrets will be masked in GitLab CI/CD logs. + - **Mark Infisical secrets in GitLab as 'Hidden' secrets**: If enabled, synced secrets will be hidden from the GitLab UI. + + + - **GitLab Group**: The group containing the project. + - **GitLab Project**: The project to deploy secrets to. + - **GitLab Environment Scope**: The environment scope to deploy secrets to (optional, defaults to "*" for all environments). + - **Mark Infisical secrets in GitLab as 'Protected' secrets**: If enabled, synced secrets will be marked as protected in GitLab. + - **Mark Infisical secrets in GitLab as 'Masked' secrets**: If enabled, synced secrets will be masked in GitLab CI/CD logs. + - **Mark Infisical secrets in GitLab as 'Hidden' secrets**: If enabled, synced secrets will be hidden from the GitLab UI. + + + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/gitlab/gitlab-secret-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + + GitLab does not support importing secrets. + + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your GitLab Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/gitlab/gitlab-secret-sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your GitLab Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/gitlab/gitlab-secret-sync-review.png) + + 8. If enabled, your GitLab Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/gitlab/gitlab-secret-sync-created.png) + + + + To create a **GitLab Sync**, make an API request to the [Create GitLab Sync](/api-reference/endpoints/secret-syncs/gitlab/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/gitlab \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-gitlab-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "scope": "individual", + "projectId": "70998370", + "projectName": "test", + "targetEnvironment": "*", + "shouldProtectSecrets": true, + "shouldMaskSecrets": true, + "shouldHideSecrets": false + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-gitlab-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "gitlab", + "name": "my-gitlab-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "gitlab", + "destinationConfig": { + "scope": "individual", + "projectId": "70998370", + "projectName": "test", + "targetEnvironment": "*", + "shouldProtectSecrets": true, + "shouldMaskSecrets": true, + "shouldHideSecrets": false + } + } + } + ``` + + + diff --git a/docs/mint.json b/docs/mint.json index f90554102..2dcb233d3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -508,6 +508,7 @@ "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/github-radar", + "integrations/app-connections/gitlab", "integrations/app-connections/hashicorp-vault", "integrations/app-connections/heroku", "integrations/app-connections/humanitec", @@ -544,6 +545,7 @@ "integrations/secret-syncs/flyio", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", + "integrations/secret-syncs/gitlab", "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", @@ -1305,6 +1307,18 @@ "api-reference/endpoints/app-connections/github/delete" ] }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/app-connections/gitlab/list", + "api-reference/endpoints/app-connections/gitlab/available", + "api-reference/endpoints/app-connections/gitlab/get-by-id", + "api-reference/endpoints/app-connections/gitlab/get-by-name", + "api-reference/endpoints/app-connections/gitlab/create", + "api-reference/endpoints/app-connections/gitlab/update", + "api-reference/endpoints/app-connections/gitlab/delete" + ] + }, { "group": "GitHub Radar", "pages": [ @@ -1642,6 +1656,19 @@ "api-reference/endpoints/secret-syncs/github/remove-secrets" ] }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/secret-syncs/gitlab/list", + "api-reference/endpoints/secret-syncs/gitlab/get-by-id", + "api-reference/endpoints/secret-syncs/gitlab/get-by-name", + "api-reference/endpoints/secret-syncs/gitlab/create", + "api-reference/endpoints/secret-syncs/gitlab/update", + "api-reference/endpoints/secret-syncs/gitlab/delete", + "api-reference/endpoints/secret-syncs/gitlab/sync-secrets", + "api-reference/endpoints/secret-syncs/gitlab/remove-secrets" + ] + }, { "group": "Hashicorp Vault", "pages": [ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitlabSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitlabSyncFields.tsx new file mode 100644 index 000000000..146d75b22 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GitlabSyncFields.tsx @@ -0,0 +1,278 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { + FilterableSelect, + FormControl, + Input, + Select, + SelectItem, + Switch, + Tooltip +} from "@app/components/v2"; +import { + TGitLabGroup, + TGitLabProject, + useGitlabConnectionListGroups, + useGitlabConnectionListProjects +} from "@app/hooks/api/appConnections/gitlab"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GitlabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; + +import { TSecretSyncForm } from "../schemas"; + +const SecretProtectionOption = ({ + title, + isEnabled, + onChange, + id, + isDisabled = false, + tooltip +}: { + title: string; + isEnabled: boolean; + onChange: (checked: boolean) => void; + id: string; + isDisabled?: boolean; + tooltip?: string; +}) => { + return ( +

+
+
+
+

{title}

+ {tooltip && ( + + + + )} +
+
+
+
+ +
+
+ ); +}; + +export const GitLabSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Gitlab } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const scope = useWatch({ name: "destinationConfig.scope", control }); + const selectedGroup = useWatch({ name: "destinationConfig.groupId", control }); + const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control }); + + const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, { + enabled: Boolean(connectionId) && scope === GitlabSyncScope.Group + }); + + const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects( + connectionId, + selectedGroup, + { + enabled: Boolean(connectionId) + } + ); + + return ( +
+ { + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.groupId", ""); + setValue("destinationConfig.scope", GitlabSyncScope.Individual); + }} + /> + + ( + + + + )} + /> + + {scope === GitlabSyncScope.Group && ( + ( + +
+ Don't see the group you're looking for?{" "} + +
+ + } + > + group.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? ""); + }} + options={groups} + placeholder="Select a group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + )} + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + project.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? ""); + setValue( + "destinationConfig.projectName", + (option as SingleValue)?.name ?? "" + ); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ( + + + + )} + /> + + {/* Secret Protection Settings Section */} +
+
+ ( + + )} + /> + + ( + { + onChange(checked); + if (!checked) { + setValue("destinationConfig.shouldHideSecrets", false); + } + }} + /> + )} + /> + + ( +
+ +
+ )} + /> +
+
+
+ ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 64345aae4..ef91053ee 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -14,6 +14,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { FlyioSyncFields } from "./FlyioSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; +import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; @@ -70,6 +71,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Flyio: return ; + case SecretSync.Gitlab: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 55aeca3c6..87115868e 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -55,6 +55,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Heroku: case SecretSync.Render: case SecretSync.Flyio: + case SecretSync.Gitlab: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitlabSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitlabSyncReviewFields.tsx new file mode 100644 index 000000000..17ece3586 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GitlabSyncReviewFields.tsx @@ -0,0 +1,32 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const GitLabSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectId = watch("destinationConfig.projectId"); + const targetEnvironment = watch("destinationConfig.targetEnvironment"); + const groupId = watch("destinationConfig.groupId"); + const scope = watch("destinationConfig.scope"); + const shouldProtectSecrets = watch("destinationConfig.shouldProtectSecrets"); + const shouldMaskSecrets = watch("destinationConfig.shouldMaskSecrets"); + const shouldHideSecrets = watch("destinationConfig.shouldHideSecrets"); + + return ( + <> + {scope} + {projectId} + {groupId && {groupId}} + {targetEnvironment && ( + {targetEnvironment} + )} + + {shouldProtectSecrets ? "Yes" : "No"} + + {shouldMaskSecrets ? "Yes" : "No"} + {shouldHideSecrets ? "Yes" : "No"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 2131518a0..3a55b6fc9 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -23,6 +23,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; +import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; @@ -116,6 +117,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Flyio: DestinationFieldsComponent = ; break; + case SecretSync.Gitlab: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/gitlab-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/gitlab-sync-destination-schema.ts new file mode 100644 index 000000000..8019b0f26 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/gitlab-sync-destination-schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GitlabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; + +export const GitlabSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Gitlab), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitlabSyncScope.Individual), + projectId: z.string().trim().min(1, "Project ID required"), + projectName: z.string().trim().min(1, "Project name required"), + targetEnvironment: z.string().optional(), + shouldProtectSecrets: z.boolean().optional().default(false), + shouldMaskSecrets: z.boolean().optional().default(false), + shouldHideSecrets: z.boolean().optional().default(false) + }), + z.object({ + scope: z.literal(GitlabSyncScope.Group), + projectId: z.string().trim().min(1, "Project ID required"), + projectName: z.string().trim().min(1, "Project name required"), + targetEnvironment: z.string().optional(), + groupId: z.string().trim().min(1, "Group ID required"), + shouldProtectSecrets: z.boolean().optional().default(false), + shouldMaskSecrets: z.boolean().optional().default(false), + shouldHideSecrets: z.boolean().optional().default(false) + }) + ]) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index b296cb6d6..15e7b0f03 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -11,6 +11,7 @@ import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-s import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; +import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; @@ -41,7 +42,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ OnePassSyncDestinationSchema, HerokuSyncDestinationSchema, RenderSyncDestinationSchema, - FlyioSyncDestinationSchema + FlyioSyncDestinationSchema, + GitlabSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d0e5be036..db679c8a5 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -23,6 +23,7 @@ import { GcpConnectionMethod, GitHubConnectionMethod, GitHubRadarConnectionMethod, + GitlabConnectionMethod, HCVaultConnectionMethod, HumanitecConnectionMethod, LdapConnectionMethod, @@ -84,7 +85,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, - [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" } + [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" }, + [AppConnection.Gitlab]: { name: "Gitlab", image: "GitLab.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -98,6 +100,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureDevOpsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: case HerokuConnectionMethod.OAuth: + case GitlabConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: case OCIConnectionMethod.AccessKey: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index afcd32958..8c12556d7 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -73,6 +73,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.OnePass]: AppConnection.OnePass, [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, - [SecretSync.Flyio]: AppConnection.Flyio + [SecretSync.Flyio]: AppConnection.Flyio, + [SecretSync.Gitlab]: AppConnection.Gitlab }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index d4ffdaba3..c316da5d7 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -25,5 +25,6 @@ export enum AppConnection { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + Gitlab = "gitlab" } diff --git a/frontend/src/hooks/api/appConnections/gitlab/index.ts b/frontend/src/hooks/api/appConnections/gitlab/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/gitlab/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/gitlab/queries.tsx b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx new file mode 100644 index 000000000..86bc30444 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/gitlab/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TGitLabGroup, TGitLabProject } from "./types"; + +const gitlabConnectionKeys = { + all: [...appConnectionKeys.all, "gitlab"] as const, + listProjects: (connectionId: string, group?: string) => + [...gitlabConnectionKeys.all, "projects", connectionId, group] as const, + listGroups: (connectionId: string) => + [...gitlabConnectionKeys.all, "groups", connectionId] as const +}; + +export const useGitlabConnectionListProjects = ( + connectionId: string, + group?: string, + options?: Omit< + UseQueryOptions< + TGitLabProject[], + unknown, + TGitLabProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: gitlabConnectionKeys.listProjects(connectionId, group), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/gitlab/${connectionId}/projects${group ? `?group=${group}` : ""}` + ); + + return data; + }, + ...options + }); +}; + +export const useGitlabConnectionListGroups = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TGitLabGroup[], + unknown, + TGitLabGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: gitlabConnectionKeys.listGroups(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/gitlab/${connectionId}/groups` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/gitlab/types.ts b/frontend/src/hooks/api/appConnections/gitlab/types.ts new file mode 100644 index 000000000..8e1eef31b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/gitlab/types.ts @@ -0,0 +1,9 @@ +export type TGitLabProject = { + id: string; + name: string; +}; + +export type TGitLabGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 1332354ec..d0664b36c 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -123,6 +123,11 @@ export type TFlyioConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Flyio; }; +export type TGitlabConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Gitlab; + oauthClientId?: string; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -148,7 +153,8 @@ export type TAppConnectionOption = | TOnePassConnectionOption | THerokuConnectionOption | TRenderConnectionOption - | TFlyioConnectionOption; + | TFlyioConnectionOption + | TGitlabConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -178,4 +184,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; + [AppConnection.Gitlab]: TGitlabConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts new file mode 100644 index 000000000..cee39d425 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/gitlab-connection.ts @@ -0,0 +1,24 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum GitlabConnectionMethod { + AccessToken = "access-token", + OAuth = "oauth" +} + +export type TGitlabConnection = TRootAppConnection & { app: AppConnection.Gitlab } & ( + | { + method: GitlabConnectionMethod.AccessToken; + credentials: { + instanceUrl?: string; + accessToken: string; + }; + } + | { + method: GitlabConnectionMethod.OAuth; + credentials: { + code: string; + instanceUrl?: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 413ad0782..61ddacbf4 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -13,6 +13,7 @@ import { TFlyioConnection } from "./flyio-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; import { TGitHubRadarConnection } from "./github-radar-connection"; +import { TGitlabConnection } from "./gitlab-connection"; import { THCVaultConnection } from "./hc-vault-connection"; import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; @@ -41,6 +42,7 @@ export * from "./flyio-connection"; export * from "./gcp-connection"; export * from "./github-connection"; export * from "./github-radar-connection"; +export * from "./gitlab-connection"; export * from "./hc-vault-connection"; export * from "./heroku-connection"; export * from "./humanitec-connection"; @@ -83,7 +85,8 @@ export type TAppConnection = | TOnePassConnection | THerokuConnection | TRenderConnection - | TFlyioConnection; + | TFlyioConnection + | TGitlabConnection; export type TAvailableAppConnection = Pick; @@ -138,4 +141,5 @@ export type TAppConnectionMap = { [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; + [AppConnection.Gitlab]: TGitlabConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index a59ba20c9..26c9d6391 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -18,7 +18,8 @@ export enum SecretSync { OnePass = "1password", Heroku = "heroku", Render = "render", - Flyio = "flyio" + Flyio = "flyio", + Gitlab = "gitlab" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts new file mode 100644 index 000000000..8738fa606 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/gitlab-sync.ts @@ -0,0 +1,37 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export enum GitlabSyncScope { + Individual = "individual", + Group = "group" +} + +export type TGitlabSync = TRootSecretSync & { + destination: SecretSync.Gitlab; + destinationConfig: + | { + scope: GitlabSyncScope.Individual; + projectId: string; + projectName: string; + targetEnvironment?: string; + shouldProtectSecrets?: boolean; + shouldMaskSecrets?: boolean; + shouldHideSecrets?: boolean; + } + | { + scope: GitlabSyncScope.Group; + groupId: string; + projectId: string; + projectName: string; + targetEnvironment?: string; + shouldProtectSecrets?: boolean; + shouldMaskSecrets?: boolean; + shouldHideSecrets?: boolean; + }; + connection: { + app: AppConnection.Gitlab; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index aa46b5988..48921b408 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -13,6 +13,7 @@ import { TDatabricksSync } from "./databricks-sync"; import { TFlyioSync } from "./flyio-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; +import { TGitlabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; @@ -49,7 +50,8 @@ export type TSecretSync = | TOnePassSync | THerokuSync | TRenderSync - | TFlyioSync; + | TFlyioSync + | TGitlabSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 45d550dae..27648e31c 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -22,6 +22,7 @@ import { FlyioConnectionForm } from "./FlyioConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { GitHubRadarConnectionForm } from "./GitHubRadarConnectionForm"; +import { GitLabConnectionForm } from "./GitLabConnectionForm"; import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; import { HerokuConnectionForm } from "./HerokuAppConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; @@ -128,6 +129,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Flyio: return ; + case AppConnection.Gitlab: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -218,6 +221,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Flyio: return ; + case AppConnection.Gitlab: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitlabConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitlabConnectionForm.tsx new file mode 100644 index 000000000..4e4f5ece6 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitlabConnectionForm.tsx @@ -0,0 +1,297 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable no-nested-ternary */ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { useGetAppConnectionOption } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + GitlabConnectionMethod, + TGitlabConnection +} from "@app/hooks/api/appConnections/types/gitlab-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TGitlabConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const formSchema = z.discriminatedUnion("method", [ + genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Gitlab), + method: z.literal(GitlabConnectionMethod.AccessToken), + credentials: z.object({ + accessToken: z.string().min(1, "Access token is required"), + instanceUrl: z + .string() + .trim() + .transform((value) => value || undefined) + .refine((value) => (!value ? true : z.string().url().safeParse(value).success), { + message: "Invalid instance URL" + }) + .optional() + }) + }), + genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Gitlab), + method: z.literal(GitlabConnectionMethod.OAuth), + credentials: z.object({ + code: z.string().min(1, "Code is required"), + instanceUrl: z + .string() + .trim() + .transform((value) => value || undefined) + .refine((value) => (!value ? true : z.string().url().safeParse(value).success), { + message: "Invalid instance URL" + }) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const GitLabConnectionForm = ({ appConnection, onSubmit: formSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.Gitlab); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: + appConnection?.method === GitlabConnectionMethod.OAuth + ? { ...appConnection, credentials: { code: "custom" } } + : (appConnection ?? + ({ + app: AppConnection.Gitlab, + method: GitlabConnectionMethod.AccessToken, + credentials: { + accessToken: "", + instanceUrl: "" + } + } as FormData)) + }); + + const { + handleSubmit, + control, + watch, + setValue, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + const gitLabURL = watch("credentials.instanceUrl"); + + const onSubmit = async (formData: FormData) => { + try { + switch (formData.method) { + case GitlabConnectionMethod.AccessToken: + await formSubmit(formData); + break; + + case GitlabConnectionMethod.OAuth: + if (!oauthClientId) { + return; + } + setIsRedirecting(true); + + // Generate CSRF token + const state = crypto.randomBytes(16).toString("hex"); + + // Store state and form data for callback + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "gitlabConnectionFormData", + JSON.stringify({ + ...formData, + connectionId: appConnection?.id, + isUpdate + }) + ); + + // Redirect to Gitlab OAuth + const baseURL = + gitLabURL && (gitLabURL as string)?.trim() !== "" + ? (gitLabURL as string)?.trim() + : "https://gitlab.com"; + const oauthUrl = new URL(`${baseURL}/oauth/authorize`); + oauthUrl.searchParams.set("client_id", oauthClientId); + oauthUrl.searchParams.set( + "redirect_uri", + `${window.location.origin}/integrations/gitlab/oauth2/callback` + ); + oauthUrl.searchParams.set("response_type", "code"); + oauthUrl.searchParams.set("state", state); + + window.location.assign(oauthUrl.toString()); + break; + + default: + throw new Error("Unhandled Gitlab Connection method"); + } + } catch (error) { + console.error("Error handling form submission:", error); + setIsRedirecting(false); + } + }; + + let isMissingConfig: boolean; + + switch (selectedMethod) { + case GitlabConnectionMethod.OAuth: + isMissingConfig = !oauthClientId; + break; + case GitlabConnectionMethod.AccessToken: + isMissingConfig = false; + break; + default: + throw new Error(`Unhandled Gitlab Connection method: ${selectedMethod}`); + } + + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +
+ {!isUpdate && } + + ( + + onChange(e.target.value)} + placeholder="https://gitlab.com" + /> + + )} + /> + + ( + + + + )} + /> + + {selectedMethod === GitlabConnectionMethod.AccessToken && ( + ( + + onChange(e.target.value)} + /> + + )} + /> + )} + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitlabSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitlabSyncDestinationCol.tsx new file mode 100644 index 000000000..05da8a2d5 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/GitlabSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TGitlabSync } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TGitlabSync; +}; + +export const GitLabSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 449ee4b58..4f16534eb 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -11,6 +11,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; +import { GitLabSyncDestinationCol } from "./GitLabSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; @@ -67,6 +68,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Flyio: return ; + case SecretSync.Gitlab: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 3e36cd3bc..ff29ad282 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -128,6 +128,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.appId; secondaryText = "App ID"; break; + case SecretSync.Gitlab: + primaryText = destinationConfig.projectName; + secondaryText = destinationConfig.projectId; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitlabSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitlabSyncDestinationSection.tsx new file mode 100644 index 000000000..615c469e0 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/GitlabSyncDestinationSection.tsx @@ -0,0 +1,34 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TGitlabSync } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; + +type Props = { + secretSync: TGitlabSync; +}; + +export const GitLabSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { + projectName, + projectId, + targetEnvironment, + shouldProtectSecrets, + shouldMaskSecrets, + shouldHideSecrets + } + } = secretSync; + + return ( + <> + {projectName} + {projectId} + {targetEnvironment && ( + {targetEnvironment} + )} + + {shouldProtectSecrets ? "Yes" : "No"} + + {shouldMaskSecrets ? "Yes" : "No"} + {shouldHideSecrets ? "Yes" : "No"} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 95a2a9f8e..1bf3aa418 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -22,6 +22,7 @@ import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSec import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; +import { GitLabSyncDestinationSection } from "./GitLabSyncDestinationSection"; import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; @@ -106,6 +107,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Flyio: DestinationComponents = ; break; + case SecretSync.Gitlab: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index a76a20e3e..b37085b31 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -58,6 +58,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Heroku: case SecretSync.Render: case SecretSync.Flyio: + case SecretSync.Gitlab: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/integrations/GitlabOauthCallbackPage/GitlabOauthCallbackPage.tsx b/frontend/src/pages/secret-manager/integrations/GitlabOauthCallbackPage/GitlabOauthCallbackPage.tsx index bf7230146..d6bf83f03 100644 --- a/frontend/src/pages/secret-manager/integrations/GitlabOauthCallbackPage/GitlabOauthCallbackPage.tsx +++ b/frontend/src/pages/secret-manager/integrations/GitlabOauthCallbackPage/GitlabOauthCallbackPage.tsx @@ -3,11 +3,13 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { ROUTE_PATHS } from "@app/const/routes"; import { useWorkspace } from "@app/context"; -import { useAuthorizeIntegration } from "@app/hooks/api"; +import { useCreateAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections"; +import { GitlabConnectionMethod } from "@app/hooks/api/appConnections/types/gitlab-connection"; export const GitLabOAuthCallbackPage = () => { const navigate = useNavigate(); - const { mutateAsync } = useAuthorizeIntegration(); + const { mutateAsync: createAppConnection } = useCreateAppConnection(); + const { mutateAsync: updateAppConnection } = useUpdateAppConnection(); const { code, state } = useSearch({ from: ROUTE_PATHS.SecretManager.Integratons.GitlabOauthCallbackPage.id @@ -17,37 +19,83 @@ export const GitLabOAuthCallbackPage = () => { useEffect(() => { (async () => { try { - // validate state - const [csrfToken, url] = (state as string).split("|", 2); + // Validate CSRF state token + const [csrfToken] = (state as string).split("|", 2); + const storedState = localStorage.getItem("latestCSRFToken"); + if (csrfToken !== storedState) { + console.error("CSRF token mismatch"); + navigate({ + to: "/organization/app-connections", + search: { error: "invalid_state" } + }); + return; + } - if (csrfToken !== localStorage.getItem("latestCSRFToken")) return; localStorage.removeItem("latestCSRFToken"); - const integrationAuth = await mutateAsync({ - workspaceId: currentWorkspace.id, - code: code as string, - integration: "gitlab", - ...(url === "" - ? {} - : { - url - }) - }); + // Retrieve stored form dataAdd commentMore actions + const storedFormData = localStorage.getItem("gitlabConnectionFormData"); + if (!storedFormData) { + console.error("No stored form data found"); + navigate({ + to: "/organization/app-connections", + search: { error: "missing_form_data" } + }); + return; + } + + const formData = JSON.parse(storedFormData); + localStorage.removeItem("gitlabConnectionFormData"); + + // Prepare app connection data with OAuth credentials + const connectionData = { + ...formData, + method: GitlabConnectionMethod.OAuth, + credentials: { + code: code as string + } + }; + + let appConnection; + + // Create or update app connection + if (formData.isUpdate && formData.connectionId) { + appConnection = await updateAppConnection({ + connectionId: formData.connectionId, + ...connectionData + }); + } else { + appConnection = await createAppConnection({ + workspaceId: currentWorkspace.id, + ...connectionData + }); + } + + // Navigate to success page or app connections list navigate({ - to: "/secret-manager/$projectId/integrations/gitlab/create", - params: { - projectId: currentWorkspace.id - }, + to: "/organization/app-connections", search: { - integrationAuthId: integrationAuth.id + success: formData.isUpdate ? "connection_updated" : "connection_created", + connectionId: appConnection.id } }); } catch (err) { - console.error(err); + console.error("Error handling GitLab OAuth callback:", err); + navigate({ + to: "/organization/app-connections", + search: { error: "connection_failed" } + }); } })(); - }, []); + }, [code, state, navigate, createAppConnection, updateAppConnection, currentWorkspace.id]); - return
; + return ( +
+
+
+

Connecting to GitLab...

+
+
+ ); };