feat(secret-sync): Add gitlab secret sync

This commit is contained in:
carlosmonastyrski
2025-06-20 21:13:14 -03:00
parent e5352e7aa8
commit e201d77a8f
99 changed files with 2912 additions and 56 deletions
+15
View File
@@ -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"
}
}
};
@@ -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) => {
@@ -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;
}
});
};
@@ -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<AppConnection, (server:
[AppConnection.OnePass]: registerOnePassConnectionRouter,
[AppConnection.Heroku]: registerHerokuConnectionRouter,
[AppConnection.Render]: registerRenderConnectionRouter,
[AppConnection.Flyio]: registerFlyioConnectionRouter
[AppConnection.Flyio]: registerFlyioConnectionRouter,
[AppConnection.GitLab]: registerGitLabConnectionRouter
};
@@ -0,0 +1,13 @@
import { CreateGitLabSyncSchema, GitLabSyncSchema, UpdateGitLabSyncSchema } from "@app/services/secret-sync/gitlab";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerGitLabSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.GitLab,
server,
responseSchema: GitLabSyncSchema,
createSchema: CreateGitLabSyncSchema,
updateSchema: UpdateGitLabSyncSchema
});
@@ -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<SecretSync, (server: Fastif
[SecretSync.OnePass]: registerOnePassSyncRouter,
[SecretSync.Heroku]: registerHerokuSyncRouter,
[SecretSync.Render]: registerRenderSyncRouter,
[SecretSync.Flyio]: registerFlyioSyncRouter
[SecretSync.Flyio]: registerFlyioSyncRouter,
[SecretSync.GitLab]: registerGitLabSyncRouter
};
@@ -26,6 +26,7 @@ import { DatabricksSyncListItemSchema, DatabricksSyncSchema } from "@app/service
import { FlyioSyncListItemSchema, FlyioSyncSchema } from "@app/services/secret-sync/flyio";
import { GcpSyncListItemSchema, GcpSyncSchema } from "@app/services/secret-sync/gcp";
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret-sync/gitlab";
import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault";
import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku";
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
@@ -55,7 +56,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
OnePassSyncSchema,
HerokuSyncSchema,
RenderSyncSchema,
FlyioSyncSchema
FlyioSyncSchema,
GitLabSyncSchema
]);
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
@@ -78,7 +80,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
OnePassSyncListItemSchema,
HerokuSyncListItemSchema,
RenderSyncListItemSchema,
FlyioSyncListItemSchema
FlyioSyncListItemSchema,
GitLabSyncListItemSchema
]);
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
@@ -25,7 +25,8 @@ export enum AppConnection {
OnePass = "1password",
Heroku = "heroku",
Render = "render",
Flyio = "flyio"
Flyio = "flyio",
GitLab = "gitlab"
}
export enum AWSRegion {
@@ -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 (
@@ -27,7 +27,8 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[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<AppConnection, AppConnectionPlanType> = {
@@ -57,5 +58,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.MySql]: AppConnectionPlanType.Regular,
[AppConnection.Heroku]: AppConnectionPlanType.Regular,
[AppConnection.Render]: AppConnectionPlanType.Regular,
[AppConnection.Flyio]: AppConnectionPlanType.Regular
[AppConnection.Flyio]: AppConnectionPlanType.Regular,
[AppConnection.GitLab]: AppConnectionPlanType.Regular
};
@@ -56,6 +56,8 @@ import { gcpConnectionService } from "./gcp/gcp-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar";
import { ValidateGitLabConnectionCredentialsSchema } from "./gitlab";
import { gitlabConnectionService } from "./gitlab/gitlab-connection-service";
import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault";
import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service";
import { ValidateHerokuConnectionCredentialsSchema } from "./heroku";
@@ -113,7 +115,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.OnePass]: ValidateOnePassConnectionCredentialsSchema,
[AppConnection.Heroku]: ValidateHerokuConnectionCredentialsSchema,
[AppConnection.Render]: ValidateRenderConnectionCredentialsSchema,
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema,
[AppConnection.GitLab]: ValidateGitLabConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -521,6 +524,7 @@ export const appConnectionServiceFactory = ({
onepass: onePassConnectionService(connectAppConnectionById),
heroku: herokuConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
render: renderConnectionService(connectAppConnectionById),
flyio: flyioConnectionService(connectAppConnectionById)
flyio: flyioConnectionService(connectAppConnectionById),
gitlab: gitlabConnectionService(connectAppConnectionById, appConnectionDAL, kmsService)
};
};
@@ -92,6 +92,12 @@ import {
TGitHubRadarConnectionInput,
TValidateGitHubRadarConnectionCredentialsSchema
} from "./github-radar";
import {
TGitLabConnection,
TGitLabConnectionConfig,
TGitLabConnectionInput,
TValidateGitLabConnectionCredentialsSchema
} from "./gitlab";
import {
THCVaultConnection,
THCVaultConnectionConfig,
@@ -182,6 +188,7 @@ export type TAppConnection = { id: string } & (
| THerokuConnection
| TRenderConnection
| TFlyioConnection
| TGitLabConnection
);
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
@@ -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;
@@ -0,0 +1,4 @@
export enum GitLabConnectionMethod {
OAuth = "oauth",
AccessToken = "access-token"
}
@@ -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<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">,
instanceUrl?: string
): Promise<string> => {
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<GitLabOAuthTokenResponse>(
`${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<GitLabOAuthTokenResponse> => {
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<GitLabOAuthTokenResponse>(
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<TGitLabProject[]> | null = null;
try {
response = await request.get<TGitLabProject[]>(
`${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<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
teamId?: string;
}): Promise<TGitLabProject[]> => {
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<TGitLabProject[]>(`${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<TGitLabProject[]>(`${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<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
includeSubgroups?: boolean;
owned?: boolean;
}): Promise<TGitLabGroup[]> => {
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<TGitLabGroup[]>(`${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"
});
}
};
@@ -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()
});
@@ -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<TGitLabConnection>;
export const gitlabConnectionService = (
getAppConnection: TGetAppConnectionFunc,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
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
};
};
@@ -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<typeof GitLabConnectionSchema>;
export type TGitLabConnectionInput = z.infer<typeof CreateGitLabConnectionSchema> & {
app: AppConnection.GitLab;
};
export type TValidateGitLabConnectionCredentialsSchema = typeof ValidateGitLabConnectionCredentialsSchema;
export type TGitLabConnectionConfig = DiscriminativePick<TGitLabConnectionInput, "method" | "app" | "credentials"> & {
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;
}
@@ -0,0 +1,4 @@
export * from "./gitlab-connection-enums";
export * from "./gitlab-connection-fns";
export * from "./gitlab-connection-schemas";
export * from "./gitlab-connection-types";
@@ -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
};
@@ -0,0 +1,4 @@
export enum GitLabSyncScope {
Individual = "individual",
Group = "group"
}
@@ -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<TGitLabVariablePayload, "key"> {}
type TGitLabSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
const getValidAccessToken = async (
connection: TGitLabConnection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<string> => {
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<TGitLabVariable[]> => {
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<TGitLabVariable[]>(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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<TSecretMap> => {
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
}
};
@@ -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)
});
@@ -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<typeof GitLabSyncSchema>;
export type TGitLabSyncInput = z.infer<typeof CreateGitLabSyncSchema>;
export type TGitLabSyncListItem = z.infer<typeof GitLabSyncListItemSchema>;
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;
};
@@ -0,0 +1,4 @@
export * from "./gitlab-sync-constants";
export * from "./gitlab-sync-fns";
export * from "./gitlab-sync-schemas";
export * from "./gitlab-sync-types";
@@ -18,7 +18,8 @@ export enum SecretSync {
OnePass = "1password",
Heroku = "heroku",
Render = "render",
Flyio = "flyio"
Flyio = "flyio",
GitLab = "gitlab"
}
export enum SecretSyncInitialSyncBehavior {
@@ -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, TSecretSyncListItem> = {
[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}`
@@ -21,7 +21,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[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<SecretSync, AppConnection> = {
@@ -44,7 +45,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[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<SecretSync, SecretSyncPlanType> = {
@@ -67,5 +69,6 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.OnePass]: SecretSyncPlanType.Regular,
[SecretSync.Heroku]: SecretSyncPlanType.Regular,
[SecretSync.Render]: SecretSyncPlanType.Regular,
[SecretSync.Flyio]: SecretSyncPlanType.Regular
[SecretSync.Flyio]: SecretSyncPlanType.Regular,
[SecretSync.GitLab]: SecretSyncPlanType.Regular
};
@@ -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;