feat(secret-sync): rework GitLab secret-sync to add group variables

This commit is contained in:
carlosmonastyrski
2025-06-24 18:01:32 -03:00
parent 43e0d400f9
commit d4bf8a33dc
20 changed files with 504 additions and 460 deletions
+67
View File
@@ -30,6 +30,7 @@
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/swagger": "^8.14.0", "@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.1.0", "@fastify/swagger-ui": "^2.1.0",
"@gitbeaker/rest": "^42.5.0",
"@google-cloud/kms": "^4.5.0", "@google-cloud/kms": "^4.5.0",
"@infisical/quic": "^1.0.8", "@infisical/quic": "^1.0.8",
"@node-saml/passport-saml": "^5.0.1", "@node-saml/passport-saml": "^5.0.1",
@@ -7807,6 +7808,48 @@
"p-limit": "^3.1.0" "p-limit": "^3.1.0"
} }
}, },
"node_modules/@gitbeaker/core": {
"version": "42.5.0",
"resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-42.5.0.tgz",
"integrity": "sha512-rMWpOPaZi1iLiifnOIoVO57p2EmQQdfIwP4txqNyMvG4WjYP5Ez0U7jRD9Nra41x6K5kTPBZkuQcAdxVWRJcEQ==",
"license": "MIT",
"dependencies": {
"@gitbeaker/requester-utils": "^42.5.0",
"qs": "^6.12.2",
"xcase": "^2.0.1"
},
"engines": {
"node": ">=18.20.0"
}
},
"node_modules/@gitbeaker/requester-utils": {
"version": "42.5.0",
"resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-42.5.0.tgz",
"integrity": "sha512-HLdLS9LPBMVQumvroQg/4qkphLDtwDB+ygEsrD2u4oYCMUtXV4V1xaVqU4yTXjbTJ5sItOtdB43vYRkBcgueBw==",
"license": "MIT",
"dependencies": {
"picomatch-browser": "^2.2.6",
"qs": "^6.12.2",
"rate-limiter-flexible": "^4.0.1",
"xcase": "^2.0.1"
},
"engines": {
"node": ">=18.20.0"
}
},
"node_modules/@gitbeaker/rest": {
"version": "42.5.0",
"resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-42.5.0.tgz",
"integrity": "sha512-oC5cM6jS7aFOp0luTw5mWSRuMgdxwHRLZQ/aWkI+ETMfsprR/HyxsXfljlMY/XJ/fRxTbRJiodR5Axf66WjO3w==",
"license": "MIT",
"dependencies": {
"@gitbeaker/core": "^42.5.0",
"@gitbeaker/requester-utils": "^42.5.0"
},
"engines": {
"node": ">=18.20.0"
}
},
"node_modules/@google-cloud/kms": { "node_modules/@google-cloud/kms": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz",
@@ -24628,6 +24671,18 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/picomatch-browser": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/picomatch-browser/-/picomatch-browser-2.2.6.tgz",
"integrity": "sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": { "node_modules/pify": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
@@ -25562,6 +25617,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/rate-limiter-flexible": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-4.0.1.tgz",
"integrity": "sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==",
"license": "ISC"
},
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "2.5.2", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
@@ -31039,6 +31100,12 @@
} }
} }
}, },
"node_modules/xcase": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz",
"integrity": "sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==",
"license": "MIT"
},
"node_modules/xml-crypto": { "node_modules/xml-crypto": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.1.tgz", "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.1.tgz",
+1
View File
@@ -149,6 +149,7 @@
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/swagger": "^8.14.0", "@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.1.0", "@fastify/swagger-ui": "^2.1.0",
"@gitbeaker/rest": "^42.5.0",
"@google-cloud/kms": "^4.5.0", "@google-cloud/kms": "^4.5.0",
"@infisical/quic": "^1.0.8", "@infisical/quic": "^1.0.8",
"@node-saml/passport-saml": "^5.0.1", "@node-saml/passport-saml": "^5.0.1",
+5 -4
View File
@@ -2409,10 +2409,11 @@ export const SecretSyncs = {
appId: "The ID of the Fly.io app to sync secrets to." appId: "The ID of the Fly.io app to sync secrets to."
}, },
GITLAB: { GITLAB: {
projectId: "The GitLab project to sync secrets to.", projectId: "The GitLab Project ID to sync secrets to.",
projectName: "The GitLab project name to sync secrets to.", projectName: "The GitLab Project Name to sync secrets to.",
groupId: "The GitLab group to sync secrets to.", groupId: "The GitLab Group ID to sync secrets to.",
scope: "The GitLab project scope that secrets should be synced to. (default: individual)", groupName: "The GitLab Group Name to sync secrets to.",
scope: "The GitLab scope that secrets should be synced to. (default: project)",
targetEnvironment: "The GitLab environment scope that secrets should be synced to. (default: *)", targetEnvironment: "The GitLab environment scope that secrets should be synced to. (default: *)",
shouldProtectSecrets: "Whether variables should be protected", shouldProtectSecrets: "Whether variables should be protected",
shouldMaskSecrets: "Whether variables should be masked in logs", shouldMaskSecrets: "Whether variables should be masked in logs",
+4
View File
@@ -247,6 +247,10 @@ const envSchema = z
INF_APP_CONNECTION_GITHUB_RADAR_APP_ID: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_RADAR_APP_ID: zpStr(z.string().optional()),
INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET: zpStr(z.string().optional()),
// gitlab oauth
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID: zpStr(z.string().optional()),
INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()),
// gcp app // gcp app
INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()), INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()),
@@ -34,9 +34,6 @@ export const registerGitLabConnectionRouter = async (server: FastifyZodProvider)
params: z.object({ params: z.object({
connectionId: z.string().uuid() connectionId: z.string().uuid()
}), }),
querystring: z.object({
group: z.string().optional()
}),
response: { response: {
200: z 200: z
.object({ .object({
@@ -52,8 +49,7 @@ export const registerGitLabConnectionRouter = async (server: FastifyZodProvider)
const projects: TGitLabProject[] = await server.services.appConnection.gitlab.listProjects( const projects: TGitLabProject[] = await server.services.appConnection.gitlab.listProjects(
connectionId, connectionId,
req.permission, req.permission
req.query.group
); );
return projects; return projects;
@@ -1,5 +1,6 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { AxiosError, AxiosResponse } from "axios"; import { GitbeakerRequestError, Gitlab } from "@gitbeaker/rest";
import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
@@ -13,7 +14,7 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal"; import { TAppConnectionDALFactory } from "../app-connection-dal";
import { GitLabConnectionMethod } from "./gitlab-connection-enums"; import { GitLabAccessTokenType, GitLabConnectionMethod } from "./gitlab-connection-enums";
import { TGitLabConnection, TGitLabConnectionConfig, TGitLabGroup, TGitLabProject } from "./gitlab-connection-types"; import { TGitLabConnection, TGitLabConnectionConfig, TGitLabGroup, TGitLabProject } from "./gitlab-connection-types";
interface GitLabOAuthTokenResponse { interface GitLabOAuthTokenResponse {
@@ -26,7 +27,7 @@ interface GitLabOAuthTokenResponse {
} }
export const getGitLabConnectionListItem = () => { export const getGitLabConnectionListItem = () => {
const { CLIENT_ID_GITLAB_LOGIN } = getConfig(); const { INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID } = getConfig();
return { return {
name: "GitLab" as const, name: "GitLab" as const,
@@ -35,7 +36,7 @@ export const getGitLabConnectionListItem = () => {
GitLabConnectionMethod.AccessToken, GitLabConnectionMethod.AccessToken,
GitLabConnectionMethod.OAuth GitLabConnectionMethod.OAuth
], ],
oauthClientId: CLIENT_ID_GITLAB_LOGIN oauthClientId: INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID
}; };
}; };
@@ -47,6 +48,18 @@ export const getGitLabInstanceUrl = async (instanceUrl?: string) => {
return gitLabInstanceUrl; return gitLabInstanceUrl;
}; };
export const getGitLabClient = async (accessToken: string, instanceUrl?: string, isOAuth = false) => {
const host = await getGitLabInstanceUrl(instanceUrl);
const client = new Gitlab<true>({
host,
...(isOAuth ? { oauthToken: accessToken } : { token: accessToken }),
camelize: true
});
return client;
};
export const refreshGitLabToken = async ( export const refreshGitLabToken = async (
refreshToken: string, refreshToken: string,
appId: string, appId: string,
@@ -55,8 +68,9 @@ export const refreshGitLabToken = async (
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">, kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">,
instanceUrl?: string instanceUrl?: string
): Promise<string> => { ): Promise<string> => {
const { CLIENT_ID_GITLAB_LOGIN, CLIENT_SECRET_GITLAB_LOGIN, SITE_URL } = getConfig(); const { INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET, SITE_URL } =
if (!CLIENT_SECRET_GITLAB_LOGIN || !CLIENT_ID_GITLAB_LOGIN || !SITE_URL) { getConfig();
if (!INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET || !INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID || !SITE_URL) {
throw new InternalServerError({ throw new InternalServerError({
message: `GitLab environment variables have not been configured` message: `GitLab environment variables have not been configured`
}); });
@@ -65,9 +79,9 @@ export const refreshGitLabToken = async (
const payload = new URLSearchParams({ const payload = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: refreshToken, refresh_token: refreshToken,
client_id: CLIENT_ID_GITLAB_LOGIN, client_id: INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID,
client_secret: CLIENT_SECRET_GITLAB_LOGIN, client_secret: INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/integrations/gitlab/oauth2/callback` redirect_uri: `${SITE_URL}/organization/app-connections/gitlab/oauth/callback`
}); });
try { try {
@@ -95,7 +109,6 @@ export const refreshGitLabToken = async (
}); });
await appConnectionDAL.updateById(appId, { encryptedCredentials }); await appConnectionDAL.updateById(appId, { encryptedCredentials });
return data.access_token; return data.access_token;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -113,8 +126,9 @@ export const exchangeGitLabOAuthCode = async (
code: string, code: string,
instanceUrl?: string instanceUrl?: string
): Promise<GitLabOAuthTokenResponse> => { ): Promise<GitLabOAuthTokenResponse> => {
const { CLIENT_ID_GITLAB_LOGIN, CLIENT_SECRET_GITLAB_LOGIN, SITE_URL } = getConfig(); const { INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET, SITE_URL } =
if (!CLIENT_SECRET_GITLAB_LOGIN || !CLIENT_ID_GITLAB_LOGIN || !SITE_URL) { getConfig();
if (!INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET || !INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID || !SITE_URL) {
throw new InternalServerError({ throw new InternalServerError({
message: `GitLab environment variables have not been configured` message: `GitLab environment variables have not been configured`
}); });
@@ -124,9 +138,9 @@ export const exchangeGitLabOAuthCode = async (
const payload = new URLSearchParams({ const payload = new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code, code,
client_id: CLIENT_ID_GITLAB_LOGIN, client_id: INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID,
client_secret: CLIENT_SECRET_GITLAB_LOGIN, client_secret: INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/integrations/gitlab/oauth2/callback` redirect_uri: `${SITE_URL}/organization/app-connections/gitlab/oauth/callback`
}); });
const url = await getGitLabInstanceUrl(instanceUrl); const url = await getGitLabInstanceUrl(instanceUrl);
@@ -173,30 +187,24 @@ export const validateGitLabConnectionCredentials = async (config: TGitLabConnect
}); });
} }
let response: AxiosResponse<TGitLabProject[]> | null = null;
try { try {
const url = await getGitLabInstanceUrl(inputCredentials.instanceUrl); const client = await getGitLabClient(
response = await request.get<TGitLabProject[]>(`${url}/api/v4/user`, { accessToken,
headers: { inputCredentials.instanceUrl,
Authorization: `Bearer ${accessToken}`, method === GitLabConnectionMethod.OAuth
Accept: "application/json" );
} await client.Users.showCurrentUser();
});
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof AxiosError) { logger.error(error, "Error validating GitLab connection credentials");
if (error instanceof GitbeakerRequestError) {
throw new BadRequestError({ throw new BadRequestError({
message: `Failed to validate credentials: ${error.message}` message: `Failed to validate credentials: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
}); });
} }
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
if (!response?.data) { throw new BadRequestError({
throw new InternalServerError({ message: `Failed to validate credentials: ${(error as Error)?.message || "verify credentials"}`
message: "Failed to validate credentials: Response was empty"
}); });
} }
@@ -217,20 +225,18 @@ export const validateGitLabConnectionCredentials = async (config: TGitLabConnect
export const listGitLabProjects = async ({ export const listGitLabProjects = async ({
appConnection, appConnection,
appConnectionDAL, appConnectionDAL,
kmsService, kmsService
teamId
}: { }: {
appConnection: TGitLabConnection; appConnection: TGitLabConnection;
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
teamId?: string;
}): Promise<TGitLabProject[]> => { }): Promise<TGitLabProject[]> => {
let { accessToken } = appConnection.credentials; let { accessToken } = appConnection.credentials;
if ( if (
appConnection.method === GitLabConnectionMethod.OAuth && appConnection.method === GitLabConnectionMethod.OAuth &&
appConnection.credentials.refreshToken && appConnection.credentials.refreshToken &&
appConnection.credentials.expiresAt < new Date() new Date(appConnection.credentials.expiresAt) < new Date()
) { ) {
accessToken = await refreshGitLabToken( accessToken = await refreshGitLabToken(
appConnection.credentials.refreshToken, appConnection.credentials.refreshToken,
@@ -242,160 +248,29 @@ export const listGitLabProjects = async ({
); );
} }
const url = await getGitLabInstanceUrl(appConnection.credentials.instanceUrl);
const gitLabApiUrl = `${url}/api/v4`;
const projects: TGitLabProject[] = [];
let page = 1;
const perPage = 100;
let hasMorePages = true;
try { try {
if (teamId) { const client = await getGitLabClient(
while (hasMorePages) { accessToken,
const { data } = await request.get<TGitLabProject[]>(`${gitLabApiUrl}/groups/${teamId}/projects`, { appConnection.credentials.instanceUrl,
headers: { appConnection.method === GitLabConnectionMethod.OAuth
Authorization: `Bearer ${accessToken}`, );
Accept: "application/json" const projects = await client.Projects.all({
}, archived: false,
params: { includePendingDelete: false,
page: page.toString(), membership: true,
per_page: perPage.toString(), includeHidden: false,
order_by: "updated_at", imported: false
sort: "desc", });
include_subgroups: "true"
}
});
if (!data) { return projects.map((project) => ({
throw new InternalServerError({ name: project.pathWithNamespace,
message: "Failed to get group projects: Response was empty" id: project.id.toString()
}); }));
}
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) { } catch (error: unknown) {
if (error instanceof AxiosError) { if (error instanceof GitbeakerRequestError) {
const status = error.response?.status; throw new BadRequestError({
const { message } = error; message: `Failed to fetch GitLab projects: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
});
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) { if (error instanceof InternalServerError) {
@@ -411,22 +286,25 @@ export const listGitLabProjects = async ({
export const listGitLabGroups = async ({ export const listGitLabGroups = async ({
appConnection, appConnection,
appConnectionDAL, appConnectionDAL,
kmsService, kmsService
includeSubgroups = true,
owned = false
}: { }: {
appConnection: TGitLabConnection; appConnection: TGitLabConnection;
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
includeSubgroups?: boolean;
owned?: boolean;
}): Promise<TGitLabGroup[]> => { }): Promise<TGitLabGroup[]> => {
let { accessToken } = appConnection.credentials; let { accessToken } = appConnection.credentials;
if (
appConnection.method === GitLabConnectionMethod.AccessToken &&
appConnection.credentials.accessTokenType === GitLabAccessTokenType.Project
) {
return [];
}
if ( if (
appConnection.method === GitLabConnectionMethod.OAuth && appConnection.method === GitLabConnectionMethod.OAuth &&
appConnection.credentials.refreshToken && appConnection.credentials.refreshToken &&
appConnection.credentials.expiresAt < new Date() new Date(appConnection.credentials.expiresAt) < new Date()
) { ) {
accessToken = await refreshGitLabToken( accessToken = await refreshGitLabToken(
appConnection.credentials.refreshToken, appConnection.credentials.refreshToken,
@@ -438,69 +316,28 @@ export const listGitLabGroups = async ({
); );
} }
const url = await getGitLabInstanceUrl(appConnection.credentials.instanceUrl);
const gitLabApiUrl = `${url}/api/v4`;
const groups: TGitLabGroup[] = [];
let page = 1;
const perPage = 100;
let hasMorePages = true;
try { try {
while (hasMorePages) { const client = await getGitLabClient(
const { data } = await request.get<TGitLabGroup[]>(`${gitLabApiUrl}/groups`, { accessToken,
headers: { appConnection.credentials.instanceUrl,
Authorization: `Bearer ${accessToken}`, appConnection.method === GitLabConnectionMethod.OAuth
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) { const groups = await client.Groups.all({
throw new InternalServerError({ orderBy: "name",
message: "Failed to get groups: Response was empty" sort: "asc",
}); minAccessLevel: 50
} });
data.forEach((group) => { return groups.map((group) => ({
groups.push({ id: group.id.toString(),
id: group.id.toString(), name: group.name
name: group.name }));
});
});
hasMorePages = data.length === perPage;
page += 1;
}
return groups;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof AxiosError) { if (error instanceof GitbeakerRequestError) {
const status = error.response?.status; throw new BadRequestError({
const { message } = error; message: `Failed to fetch GitLab groups: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
});
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) { if (error instanceof InternalServerError) {
@@ -18,10 +18,10 @@ export const gitlabConnectionService = (
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">, appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => { ) => {
const listProjects = async (connectionId: string, actor: OrgServiceActor, teamId?: string) => { const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
try { try {
const appConnection = await getAppConnection(AppConnection.GitLab, connectionId, actor); const appConnection = await getAppConnection(AppConnection.GitLab, connectionId, actor);
const projects = await listGitLabProjects({ appConnection, appConnectionDAL, kmsService, teamId }); const projects = await listGitLabProjects({ appConnection, appConnectionDAL, kmsService });
return projects; return projects;
} catch (error) { } catch (error) {
logger.error(error, `Failed to establish connection with GitLab for app ${connectionId}`); logger.error(error, `Failed to establish connection with GitLab for app ${connectionId}`);
@@ -1,4 +1,4 @@
export enum GitLabSyncScope { export enum GitLabSyncScope {
Individual = "individual", Project = "project",
Group = "group" Group = "group"
} }
@@ -1,9 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { request } from "@app/lib/config/request"; import { GitbeakerRequestError } from "@gitbeaker/rest";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { GitLabConnectionMethod, refreshGitLabToken, TGitLabConnection } from "@app/services/app-connection/gitlab"; import {
import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; getGitLabClient,
GitLabConnectionMethod,
refreshGitLabToken,
TGitLabConnection
} from "@app/services/app-connection/gitlab";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TGitLabSyncWithCredentials, TGitLabVariable } from "@app/services/secret-sync/gitlab/gitlab-sync-types"; import { TGitLabSyncWithCredentials, TGitLabVariable } from "@app/services/secret-sync/gitlab/gitlab-sync-types";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
@@ -11,6 +15,7 @@ import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps";
import { GitLabSyncScope } from "./gitlab-sync-enums";
interface TGitLabVariablePayload { interface TGitLabVariablePayload {
key?: string; key?: string;
@@ -42,81 +47,64 @@ const getValidAccessToken = async (
if ( if (
connection.method === GitLabConnectionMethod.OAuth && connection.method === GitLabConnectionMethod.OAuth &&
connection.credentials.refreshToken && connection.credentials.refreshToken &&
connection.credentials.expiresAt < new Date() new Date(connection.credentials.expiresAt) < new Date()
) { ) {
const accessToken = await refreshGitLabToken( const accessToken = await refreshGitLabToken(
connection.credentials.refreshToken, connection.credentials.refreshToken,
connection.id, connection.id,
connection.orgId, connection.orgId,
appConnectionDAL, appConnectionDAL,
kmsService kmsService,
connection.credentials.instanceUrl
); );
return accessToken; return accessToken;
} }
return connection.credentials.accessToken; return connection.credentials.accessToken;
}; };
const getGitLabApiUrl = async (connection: TGitLabConnection): Promise<string> => {
const baseUrl = connection.credentials.instanceUrl || IntegrationUrls.GITLAB_API_URL;
await blockLocalAndPrivateIpAddresses(baseUrl);
return baseUrl.includes("/api") ? baseUrl : `${baseUrl}/api`;
};
const buildVariablesEndpoint = (apiUrl: string, projectId: string): string => {
return `${apiUrl}/v4/projects/${encodeURIComponent(projectId)}/variables`;
};
const getGitLabVariables = async ({ const getGitLabVariables = async ({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
targetEnvironment targetEnvironment
}: { }: {
accessToken: string; accessToken: string;
connection: TGitLabConnection; connection: TGitLabConnection;
projectId: string; scope: GitLabSyncScope;
resourceId: string;
targetEnvironment?: string; targetEnvironment?: string;
}): Promise<TGitLabVariable[]> => { }): Promise<TGitLabVariable[]> => {
try { try {
const apiUrl = await getGitLabApiUrl(connection); const client = await getGitLabClient(
const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); accessToken,
connection.credentials.instanceUrl,
connection.method === GitLabConnectionMethod.OAuth
);
const headers = { let variables: TGitLabVariable[] = [];
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json",
"Content-Type": "application/json"
};
let allVariables: TGitLabVariable[] = []; if (scope === GitLabSyncScope.Project) {
let url: string | null = `${baseEndpoint}?per_page=100`; variables = await client.ProjectVariables.all(resourceId);
} else {
if (targetEnvironment) { variables = await client.GroupVariables.all(resourceId);
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) { if (targetEnvironment) {
return allVariables.filter((variable) => variable.environment_scope === targetEnvironment); variables = variables.filter((v) => v.environmentScope === targetEnvironment);
} }
return allVariables; return variables;
} catch (error) { } catch (error) {
if (error instanceof GitbeakerRequestError) {
throw new SecretSyncError({
error: new Error(
`Failed to fetch variables: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
)
});
}
throw new SecretSyncError({ throw new SecretSyncError({
error, error
secretKey: "list_variables"
}); });
} }
}; };
@@ -124,38 +112,61 @@ const getGitLabVariables = async ({
const createGitLabVariable = async ({ const createGitLabVariable = async ({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
variable variable
}: { }: {
accessToken: string; accessToken: string;
connection: TGitLabConnection; connection: TGitLabConnection;
projectId: string; scope: GitLabSyncScope;
resourceId: string;
variable: TGitLabVariableCreate; variable: TGitLabVariableCreate;
}): Promise<void> => { }): Promise<void> => {
try { try {
const apiUrl = await getGitLabApiUrl(connection); const client = await getGitLabClient(
const endpoint = buildVariablesEndpoint(apiUrl, projectId); accessToken,
connection.credentials.instanceUrl,
connection.method === GitLabConnectionMethod.OAuth
);
const payload = { const payload = {
key: variable.key, key: variable.key,
value: variable.value, value: variable.value,
variable_type: variable.variable_type || "env_var", variableType: "env_var",
environment_scope: variable.environment_scope || "*", environmentScope: variable.environment_scope || "*",
protected: variable.protected || false, protected: variable.protected || false,
masked: variable.masked || false, masked: variable.masked || false,
masked_and_hidden: variable.masked_and_hidden || false, masked_and_hidden: variable.masked_and_hidden || false,
raw: false, raw: false
...(variable.description && { description: variable.description })
}; };
await request.post(endpoint, payload, { if (scope === GitLabSyncScope.Project) {
headers: { await client.ProjectVariables.create(resourceId, payload.key, payload.value, {
Authorization: `Bearer ${accessToken}`, variableType: "env_var",
"Accept-Encoding": "application/json", environmentScope: payload.environmentScope,
"Content-Type": "application/json" protected: payload.protected,
} masked: payload.masked,
}); masked_and_hidden: payload.masked_and_hidden,
raw: false
});
} else {
await client.GroupVariables.create(resourceId, payload.key, payload.value, {
variableType: "env_var",
environmentScope: payload.environmentScope,
protected: payload.protected,
masked: payload.masked,
raw: false
});
}
} catch (error) { } catch (error) {
if (error instanceof GitbeakerRequestError) {
throw new SecretSyncError({
error: new Error(
`Failed to create variable: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
),
secretKey: variable.key
});
}
throw new SecretSyncError({ throw new SecretSyncError({
error, error,
secretKey: variable.key secretKey: variable.key
@@ -166,45 +177,57 @@ const createGitLabVariable = async ({
const updateGitLabVariable = async ({ const updateGitLabVariable = async ({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
key, key,
variable, variable,
targetEnvironment targetEnvironment
}: { }: {
accessToken: string; accessToken: string;
connection: TGitLabConnection; connection: TGitLabConnection;
projectId: string; scope: GitLabSyncScope;
resourceId: string;
key: string; key: string;
variable: TGitLabVariableUpdate; variable: TGitLabVariableUpdate;
targetEnvironment?: string; targetEnvironment?: string;
}): Promise<void> => { }): Promise<void> => {
try { try {
const apiUrl = await getGitLabApiUrl(connection); const client = await getGitLabClient(
const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); accessToken,
let url = `${baseEndpoint}/${encodeURIComponent(key)}`; connection.credentials.instanceUrl,
connection.method === GitLabConnectionMethod.OAuth
);
if (targetEnvironment) { const options = {
url += `?filter[environment_scope]=${encodeURIComponent(targetEnvironment)}`; ...(variable.environment_scope && { environmentScope: variable.environment_scope }),
}
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.protected !== undefined && { protected: variable.protected }),
...(variable.masked !== undefined && { masked: variable.masked }), ...(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, { if (targetEnvironment) {
headers: { options.environmentScope = targetEnvironment;
Authorization: `Bearer ${accessToken}`, }
"Accept-Encoding": "application/json",
"Content-Type": "application/json" if (scope === GitLabSyncScope.Project) {
} await client.ProjectVariables.edit(resourceId, key, variable.value, {
}); ...options,
filter: { environment_scope: targetEnvironment || "*" }
});
} else {
await client.GroupVariables.edit(resourceId, key, variable.value, {
...options,
filter: { environment_scope: targetEnvironment || "*" }
});
}
} catch (error) { } catch (error) {
if (error instanceof GitbeakerRequestError) {
throw new SecretSyncError({
error: new Error(
`Failed to update variable: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
),
secretKey: key
});
}
throw new SecretSyncError({ throw new SecretSyncError({
error, error,
secretKey: key secretKey: key
@@ -215,35 +238,52 @@ const updateGitLabVariable = async ({
const deleteGitLabVariable = async ({ const deleteGitLabVariable = async ({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
key, key,
targetEnvironment targetEnvironment,
allVariables
}: { }: {
accessToken: string; accessToken: string;
connection: TGitLabConnection; connection: TGitLabConnection;
projectId: string; scope: GitLabSyncScope;
resourceId: string;
key: string; key: string;
targetEnvironment?: string; targetEnvironment?: string;
allVariables?: TGitLabVariable[];
}): Promise<void> => { }): Promise<void> => {
if (allVariables && !allVariables.find((v) => v.key === key)) {
return;
}
try { try {
const apiUrl = await getGitLabApiUrl(connection); const client = await getGitLabClient(
const baseEndpoint = buildVariablesEndpoint(apiUrl, projectId); accessToken,
let url = `${baseEndpoint}/${encodeURIComponent(key)}`; connection.credentials.instanceUrl,
connection.method === GitLabConnectionMethod.OAuth
);
const options: { filter?: { environment_scope: string } } = {};
if (targetEnvironment) { if (targetEnvironment) {
url += `?filter[environment_scope]=${encodeURIComponent(targetEnvironment)}`; options.filter = { environment_scope: targetEnvironment || "*" };
} }
await request.delete(url, { if (scope === GitLabSyncScope.Project) {
headers: { await client.ProjectVariables.remove(resourceId, key, options);
Authorization: `Bearer ${accessToken}`, } else {
"Accept-Encoding": "application/json", await client.GroupVariables.remove(resourceId, key);
"Content-Type": "application/json" }
}
});
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof GitbeakerRequestError) {
throw new SecretSyncError({
error: new Error(
`Failed to delete variable: ${error.message ?? "Unknown error"}${error.cause?.description && error.message !== "Unauthorized" ? `. Cause: ${error.cause.description}` : ""}`
),
secretKey: key
});
}
throw new SecretSyncError({ throw new SecretSyncError({
error error,
secretKey: key
}); });
} }
}; };
@@ -255,8 +295,9 @@ export const GitLabSyncFns = {
{ appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps { appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps
): Promise<void> => { ): Promise<void> => {
const { connection, environment, destinationConfig } = secretSync; const { connection, environment, destinationConfig } = secretSync;
const { scope, targetEnvironment } = destinationConfig;
const { projectId, targetEnvironment } = destinationConfig; const resourceId = scope === GitLabSyncScope.Project ? destinationConfig.projectId : destinationConfig.groupId;
const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService); const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService);
@@ -264,13 +305,20 @@ export const GitLabSyncFns = {
const currentVariables = await getGitLabVariables({ const currentVariables = await getGitLabVariables({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
targetEnvironment targetEnvironment
}); });
const currentVariableMap = new Map(currentVariables.map((v) => [v.key, v])); const currentVariableMap = new Map(currentVariables.map((v) => [v.key, v]));
for (const [key, { value }] of Object.entries(secretMap)) { for (const [key, { value }] of Object.entries(secretMap)) {
if (value?.length < 8 && destinationConfig.shouldMaskSecrets) {
throw new SecretSyncError({
message: `Secret ${key} is too short to be masked. GitLab requires a minimum of 8 characters for masked secrets.`,
secretKey: key
});
}
try { try {
const existingVariable = currentVariableMap.get(key); const existingVariable = currentVariableMap.get(key);
@@ -279,17 +327,14 @@ export const GitLabSyncFns = {
await updateGitLabVariable({ await updateGitLabVariable({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
key, key,
variable: { variable: {
value, value,
variable_type: existingVariable.variable_type, environment_scope: targetEnvironment,
environment_scope: targetEnvironment || existingVariable.environment_scope,
protected: destinationConfig.shouldProtectSecrets ?? existingVariable.protected, protected: destinationConfig.shouldProtectSecrets ?? existingVariable.protected,
...(!existingVariable.masked && destinationConfig.shouldMaskSecrets && { masked: value?.length > 8 }), masked: existingVariable.masked || destinationConfig.shouldMaskSecrets
...(!existingVariable.hidden &&
destinationConfig.shouldHideSecrets && { masked_and_hidden: value?.length > 8 }),
description: existingVariable.description ?? undefined
}, },
targetEnvironment targetEnvironment
}); });
@@ -298,15 +343,16 @@ export const GitLabSyncFns = {
await createGitLabVariable({ await createGitLabVariable({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
variable: { variable: {
key, key,
value, value,
variable_type: "env_var", variable_type: "env_var",
environment_scope: targetEnvironment || "*", environment_scope: targetEnvironment || "*",
protected: destinationConfig.shouldProtectSecrets || false, protected: destinationConfig.shouldProtectSecrets || false,
masked: value?.length > 8 ? destinationConfig.shouldMaskSecrets || false : false, masked: destinationConfig.shouldMaskSecrets || false,
masked_and_hidden: value?.length > 8 ? destinationConfig.shouldHideSecrets || false : false masked_and_hidden: destinationConfig.shouldHideSecrets || false
} }
}); });
} }
@@ -323,13 +369,14 @@ export const GitLabSyncFns = {
try { try {
const shouldDelete = const shouldDelete =
matchesSchema(variable.key, environment?.slug || "", secretSync.syncOptions.keySchema) && matchesSchema(variable.key, environment?.slug || "", secretSync.syncOptions.keySchema) &&
!(variable.key in secretMap); variable.key in secretMap;
if (shouldDelete) { if (shouldDelete) {
await deleteGitLabVariable({ await deleteGitLabVariable({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
key: variable.key, key: variable.key,
targetEnvironment targetEnvironment
}); });
@@ -359,19 +406,30 @@ export const GitLabSyncFns = {
{ appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps { appConnectionDAL, kmsService }: TGitLabSyncFactoryDeps
): Promise<void> => { ): Promise<void> => {
const { connection, destinationConfig } = secretSync; const { connection, destinationConfig } = secretSync;
const { scope, targetEnvironment } = destinationConfig;
const { projectId, targetEnvironment } = destinationConfig; const resourceId = scope === GitLabSyncScope.Project ? destinationConfig.projectId : destinationConfig.groupId;
const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService); const accessToken = await getValidAccessToken(connection, appConnectionDAL, kmsService);
const allVariables = await getGitLabVariables({
accessToken,
connection,
scope,
resourceId,
targetEnvironment
});
for (const key of Object.keys(secretMap)) { for (const key of Object.keys(secretMap)) {
try { try {
await deleteGitLabVariable({ await deleteGitLabVariable({
accessToken, accessToken,
connection, connection,
projectId, scope,
resourceId,
key, key,
targetEnvironment targetEnvironment,
allVariables
}); });
} catch (error) { } catch (error) {
throw new SecretSyncError({ throw new SecretSyncError({
@@ -14,7 +14,7 @@ import { GitLabSyncScope } from "./gitlab-sync-enums";
const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
z.object({ z.object({
scope: z.literal(GitLabSyncScope.Individual).describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.scope), scope: z.literal(GitLabSyncScope.Project).describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.scope),
projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectId), projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectId),
projectName: z projectName: z
.string() .string()
@@ -44,11 +44,7 @@ const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
z.object({ z.object({
scope: z.literal(GitLabSyncScope.Group).describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.scope), 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), 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), groupName: z.string().min(1, "Group name is required").describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.groupName),
projectName: z
.string()
.min(1, "Project name is required")
.describe(SecretSyncs.DESTINATION_CONFIG.GITLAB.projectName),
targetEnvironment: z targetEnvironment: z
.string() .string()
.optional() .optional()
@@ -15,13 +15,9 @@ export type TGitLabSyncWithCredentials = TGitLabSync & {
export type TGitLabVariable = { export type TGitLabVariable = {
key: string; key: string;
value: string; value: string;
variable_type: "env_var" | "file";
protected: boolean; protected: boolean;
masked: boolean; masked: boolean;
hidden: boolean; environmentScope?: string;
raw: boolean;
environment_scope: string;
description: string | null;
}; };
export type TGitLabVariableCreate = { export type TGitLabVariableCreate = {
+2 -2
View File
@@ -489,10 +489,10 @@ When set, all visits to the Infisical login page will automatically redirect use
<Accordion title="Gitlab"> <Accordion title="Gitlab">
Follow detailed guide to configure [GitLab SSO](/documentation/platform/sso/gitlab) Follow detailed guide to configure [GitLab SSO](/documentation/platform/sso/gitlab)
<ParamField query="CLIENT_ID_GITLAB_LOGIN" type="string" default="none" optional> <ParamField query="INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_ID" type="string" default="none" optional>
OAuth2 client ID for GitLab login OAuth2 client ID for GitLab login
</ParamField> </ParamField>
<ParamField query="CLIENT_SECRET_GITLAB_LOGIN" type="string" default="none" optional> <ParamField query="INF_APP_CONNECTION_GITLAB_OAUTH_CLIENT_SECRET" type="string" default="none" optional>
OAuth2 client secret for GitLab login OAuth2 client secret for GitLab login
</ParamField> </ParamField>
<ParamField query="URL_GITLAB_LOGIN" type="string" default="https://gitlab.com" optional> <ParamField query="URL_GITLAB_LOGIN" type="string" default="https://gitlab.com" optional>
@@ -68,7 +68,6 @@ export const GitLabSyncFields = () => {
const connectionId = useWatch({ name: "connection.id", control }); const connectionId = useWatch({ name: "connection.id", control });
const scope = useWatch({ name: "destinationConfig.scope", control }); const scope = useWatch({ name: "destinationConfig.scope", control });
const selectedGroup = useWatch({ name: "destinationConfig.groupId", control });
const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control }); const shouldMaskSecrets = useWatch({ name: "destinationConfig.shouldMaskSecrets", control });
const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, { const { data: groups, isLoading: isGroupsLoading } = useGitlabConnectionListGroups(connectionId, {
@@ -77,7 +76,6 @@ export const GitLabSyncFields = () => {
const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects( const { data: projects, isLoading: isProjectsLoading } = useGitlabConnectionListProjects(
connectionId, connectionId,
selectedGroup,
{ {
enabled: Boolean(connectionId) enabled: Boolean(connectionId)
} }
@@ -90,14 +88,15 @@ export const GitLabSyncFields = () => {
setValue("destinationConfig.projectId", ""); setValue("destinationConfig.projectId", "");
setValue("destinationConfig.projectName", ""); setValue("destinationConfig.projectName", "");
setValue("destinationConfig.groupId", ""); setValue("destinationConfig.groupId", "");
setValue("destinationConfig.scope", GitlabSyncScope.Individual); setValue("destinationConfig.groupName", "");
setValue("destinationConfig.scope", GitlabSyncScope.Project);
}} }}
/> />
<Controller <Controller
name="destinationConfig.scope" name="destinationConfig.scope"
control={control} control={control}
defaultValue={GitlabSyncScope.Individual} defaultValue={GitlabSyncScope.Project}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Scope"> <FormControl errorText={error?.message} isError={Boolean(error?.message)} label="Scope">
<Select <Select
@@ -107,6 +106,7 @@ export const GitLabSyncFields = () => {
setValue("destinationConfig.projectId", ""); setValue("destinationConfig.projectId", "");
setValue("destinationConfig.projectName", ""); setValue("destinationConfig.projectName", "");
setValue("destinationConfig.groupId", ""); setValue("destinationConfig.groupId", "");
setValue("destinationConfig.groupName", "");
}} }}
className="w-full border border-mineshaft-500 capitalize" className="w-full border border-mineshaft-500 capitalize"
position="popper" position="popper"
@@ -151,6 +151,10 @@ export const GitLabSyncFields = () => {
value={groups?.find((group) => group.id === value) ?? null} value={groups?.find((group) => group.id === value) ?? null}
onChange={(option) => { onChange={(option) => {
onChange((option as SingleValue<TGitLabGroup>)?.id ?? ""); onChange((option as SingleValue<TGitLabGroup>)?.id ?? "");
setValue(
"destinationConfig.groupName",
(option as SingleValue<TGitLabGroup>)?.name ?? ""
);
}} }}
options={groups} options={groups}
placeholder="Select a group..." placeholder="Select a group..."
@@ -162,46 +166,48 @@ export const GitLabSyncFields = () => {
/> />
)} )}
<Controller {scope === GitlabSyncScope.Project && (
name="destinationConfig.projectId" <Controller
control={control} name="destinationConfig.projectId"
render={({ field: { value, onChange }, fieldState: { error } }) => ( control={control}
<FormControl render={({ field: { value, onChange }, fieldState: { error } }) => (
isError={Boolean(error)} <FormControl
errorText={error?.message} isError={Boolean(error)}
label="GitLab Project" errorText={error?.message}
helperText={ label="GitLab Project"
<Tooltip helperText={
className="max-w-md" <Tooltip
content="Ensure the project exists in the connection's GitLab instance URL and the connection has access to it." className="max-w-md"
> content="Ensure the project exists in the connection's GitLab instance URL and the connection has access to it."
<div> >
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "} <div>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
</div> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip> </div>
} </Tooltip>
> }
<FilterableSelect >
menuPlacement="top" <FilterableSelect
isLoading={isProjectsLoading && Boolean(connectionId)} menuPlacement="top"
isDisabled={!connectionId || (scope === GitlabSyncScope.Group && !selectedGroup)} isLoading={isProjectsLoading && Boolean(connectionId)}
value={projects?.find((project) => project.id === value) ?? null} isDisabled={!connectionId}
onChange={(option) => { value={projects?.find((project) => project.id === value) ?? null}
onChange((option as SingleValue<TGitLabProject>)?.id ?? ""); onChange={(option) => {
setValue( onChange((option as SingleValue<TGitLabProject>)?.id ?? "");
"destinationConfig.projectName", setValue(
(option as SingleValue<TGitLabProject>)?.name ?? "" "destinationConfig.projectName",
); (option as SingleValue<TGitLabProject>)?.name ?? ""
}} );
options={projects} }}
placeholder="Select a project..." options={projects}
getOptionLabel={(option) => option.name} placeholder="Select a project..."
getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name}
/> getOptionValue={(option) => option.id}
</FormControl> />
)} </FormControl>
/> )}
/>
)}
<Controller <Controller
control={control} control={control}
@@ -9,7 +9,7 @@ export const GitlabSyncDestinationSchema = BaseSecretSyncSchema().merge(
destination: z.literal(SecretSync.GitLab), destination: z.literal(SecretSync.GitLab),
destinationConfig: z.discriminatedUnion("scope", [ destinationConfig: z.discriminatedUnion("scope", [
z.object({ z.object({
scope: z.literal(GitlabSyncScope.Individual), scope: z.literal(GitlabSyncScope.Project),
projectId: z.string().trim().min(1, "Project ID required"), projectId: z.string().trim().min(1, "Project ID required"),
projectName: z.string().trim().min(1, "Project name required"), projectName: z.string().trim().min(1, "Project name required"),
targetEnvironment: z.string().optional(), targetEnvironment: z.string().optional(),
@@ -19,10 +19,9 @@ export const GitlabSyncDestinationSchema = BaseSecretSyncSchema().merge(
}), }),
z.object({ z.object({
scope: z.literal(GitlabSyncScope.Group), 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(), targetEnvironment: z.string().optional(),
groupId: z.string().trim().min(1, "Group ID required"), groupId: z.string().trim().min(1, "Group ID required"),
groupName: z.string().trim().min(1, "Group name required"),
shouldProtectSecrets: z.boolean().optional().default(false), shouldProtectSecrets: z.boolean().optional().default(false),
shouldMaskSecrets: z.boolean().optional().default(false), shouldMaskSecrets: z.boolean().optional().default(false),
shouldHideSecrets: z.boolean().optional().default(false) shouldHideSecrets: z.boolean().optional().default(false)
@@ -7,15 +7,14 @@ import { TGitLabGroup, TGitLabProject } from "./types";
const gitlabConnectionKeys = { const gitlabConnectionKeys = {
all: [...appConnectionKeys.all, "gitlab"] as const, all: [...appConnectionKeys.all, "gitlab"] as const,
listProjects: (connectionId: string, group?: string) => listProjects: (connectionId: string) =>
[...gitlabConnectionKeys.all, "projects", connectionId, group] as const, [...gitlabConnectionKeys.all, "projects", connectionId] as const,
listGroups: (connectionId: string) => listGroups: (connectionId: string) =>
[...gitlabConnectionKeys.all, "groups", connectionId] as const [...gitlabConnectionKeys.all, "groups", connectionId] as const
}; };
export const useGitlabConnectionListProjects = ( export const useGitlabConnectionListProjects = (
connectionId: string, connectionId: string,
group?: string,
options?: Omit< options?: Omit<
UseQueryOptions< UseQueryOptions<
TGitLabProject[], TGitLabProject[],
@@ -27,10 +26,10 @@ export const useGitlabConnectionListProjects = (
> >
) => { ) => {
return useQuery({ return useQuery({
queryKey: gitlabConnectionKeys.listProjects(connectionId, group), queryKey: gitlabConnectionKeys.listProjects(connectionId),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<TGitLabProject[]>( const { data } = await apiRequest.get<TGitLabProject[]>(
`/api/v1/app-connections/gitlab/${connectionId}/projects${group ? `?group=${group}` : ""}` `/api/v1/app-connections/gitlab/${connectionId}/projects`
); );
return data; return data;
@@ -3,7 +3,7 @@ import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export enum GitlabSyncScope { export enum GitlabSyncScope {
Individual = "individual", Project = "project",
Group = "group" Group = "group"
} }
@@ -11,7 +11,7 @@ export type TGitlabSync = TRootSecretSync & {
destination: SecretSync.GitLab; destination: SecretSync.GitLab;
destinationConfig: destinationConfig:
| { | {
scope: GitlabSyncScope.Individual; scope: GitlabSyncScope.Project;
projectId: string; projectId: string;
projectName: string; projectName: string;
targetEnvironment?: string; targetEnvironment?: string;
@@ -22,8 +22,7 @@ export type TGitlabSync = TRootSecretSync & {
| { | {
scope: GitlabSyncScope.Group; scope: GitlabSyncScope.Group;
groupId: string; groupId: string;
projectId: string; groupName: string;
projectName: string;
targetEnvironment?: string; targetEnvironment?: string;
shouldProtectSecrets?: boolean; shouldProtectSecrets?: boolean;
shouldMaskSecrets?: boolean; shouldMaskSecrets?: boolean;
@@ -145,7 +145,7 @@ export const GitLabConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr
oauthUrl.searchParams.set("client_id", oauthClientId); oauthUrl.searchParams.set("client_id", oauthClientId);
oauthUrl.searchParams.set( oauthUrl.searchParams.set(
"redirect_uri", "redirect_uri",
`${window.location.origin}/integrations/gitlab/oauth2/callback` `${window.location.origin}/organization/app-connections/gitlab/oauth/callback`
); );
oauthUrl.searchParams.set("response_type", "code"); oauthUrl.searchParams.set("response_type", "code");
oauthUrl.searchParams.set("state", state); oauthUrl.searchParams.set("state", state);
@@ -11,12 +11,14 @@ import {
AzureDevOpsConnectionMethod, AzureDevOpsConnectionMethod,
AzureKeyVaultConnectionMethod, AzureKeyVaultConnectionMethod,
GitHubConnectionMethod, GitHubConnectionMethod,
GitlabConnectionMethod,
TAzureAppConfigurationConnection, TAzureAppConfigurationConnection,
TAzureClientSecretsConnection, TAzureClientSecretsConnection,
TAzureDevOpsConnection, TAzureDevOpsConnection,
TAzureKeyVaultConnection, TAzureKeyVaultConnection,
TGitHubConnection, TGitHubConnection,
TGitHubRadarConnection, TGitHubRadarConnection,
TGitlabConnection,
useCreateAppConnection, useCreateAppConnection,
useUpdateAppConnection useUpdateAppConnection
} from "@app/hooks/api/appConnections"; } from "@app/hooks/api/appConnections";
@@ -25,6 +27,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
type BaseFormData = { type BaseFormData = {
returnUrl?: string; returnUrl?: string;
connectionId?: string; connectionId?: string;
isUpdate?: boolean;
}; };
type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" | "description">; type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" | "description">;
@@ -32,6 +35,8 @@ type GithubFormData = BaseFormData & Pick<TGitHubConnection, "name" | "method" |
type GithubRadarFormData = BaseFormData & type GithubRadarFormData = BaseFormData &
Pick<TGitHubRadarConnection, "name" | "method" | "description">; Pick<TGitHubRadarConnection, "name" | "method" | "description">;
type GitlabFormData = BaseFormData & Pick<TGitlabConnection, "name" | "method" | "description">;
type AzureKeyVaultFormData = BaseFormData & type AzureKeyVaultFormData = BaseFormData &
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> & Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
Pick<TAzureKeyVaultConnection["credentials"], "tenantId">; Pick<TAzureKeyVaultConnection["credentials"], "tenantId">;
@@ -60,6 +65,7 @@ type AzureDevOpsFormData = BaseFormData &
type FormDataMap = { type FormDataMap = {
[AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub }; [AppConnection.GitHub]: GithubFormData & { app: AppConnection.GitHub };
[AppConnection.GitHubRadar]: GithubRadarFormData & { app: AppConnection.GitHubRadar }; [AppConnection.GitHubRadar]: GithubRadarFormData & { app: AppConnection.GitHubRadar };
[AppConnection.Gitlab]: GitlabFormData & { app: AppConnection.Gitlab };
[AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault }; [AppConnection.AzureKeyVault]: AzureKeyVaultFormData & { app: AppConnection.AzureKeyVault };
[AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & { [AppConnection.AzureAppConfiguration]: AzureAppConfigurationFormData & {
app: AppConnection.AzureAppConfiguration; app: AppConnection.AzureAppConfiguration;
@@ -75,6 +81,7 @@ type FormDataMap = {
const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = { const formDataStorageFieldMap: Partial<Record<AppConnection, string>> = {
[AppConnection.GitHub]: "githubConnectionFormData", [AppConnection.GitHub]: "githubConnectionFormData",
[AppConnection.GitHubRadar]: "githubRadarConnectionFormData", [AppConnection.GitHubRadar]: "githubRadarConnectionFormData",
[AppConnection.Gitlab]: "gitlabConnectionFormData",
[AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData", [AppConnection.AzureKeyVault]: "azureKeyVaultConnectionFormData",
[AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData", [AppConnection.AzureAppConfiguration]: "azureAppConfigurationConnectionFormData",
[AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData", [AppConnection.AzureClientSecrets]: "azureClientSecretsConnectionFormData",
@@ -133,6 +140,57 @@ export const OAuthCallbackPage = () => {
} }
}; };
const handleGitlab = useCallback(async () => {
const formData = getFormData(AppConnection.Gitlab);
if (formData === null) return null;
clearState(AppConnection.Gitlab);
const { connectionId, name, description, returnUrl, isUpdate } = formData;
try {
if (isUpdate && connectionId) {
await updateAppConnection.mutateAsync({
app: AppConnection.Gitlab,
connectionId,
credentials: {
code: code as string
}
});
} else {
await createAppConnection.mutateAsync({
app: AppConnection.Gitlab,
name,
description,
method: GitlabConnectionMethod.OAuth,
credentials: {
code: code as string
}
});
}
navigate({
to: returnUrl ?? "/organization/app-connections"
});
return {
connectionId,
returnUrl,
appConnectionName: formData.app
};
} catch (err: any) {
createNotification({
title: `Failed to ${connectionId ? "update" : "add"} GitLab Connection`,
text: err?.message,
type: "error"
});
navigate({
to: returnUrl ?? "/organization/app-connections"
});
return null;
}
}, []);
const handleAzureKeyVault = useCallback(async () => { const handleAzureKeyVault = useCallback(async () => {
const formData = getFormData(AppConnection.AzureKeyVault); const formData = getFormData(AppConnection.AzureKeyVault);
if (formData === null) return null; if (formData === null) return null;
@@ -458,11 +516,14 @@ export const OAuthCallbackPage = () => {
(async () => { (async () => {
let data: { connectionId?: string; returnUrl?: string; appConnectionName?: string } | null = let data: { connectionId?: string; returnUrl?: string; appConnectionName?: string } | null =
null; null;
console.log("appConnection", appConnection);
if (appConnection === AppConnection.GitHub) { if (appConnection === AppConnection.GitHub) {
data = await handleGithub(); data = await handleGithub();
} else if (appConnection === AppConnection.GitHubRadar) { } else if (appConnection === AppConnection.GitHubRadar) {
data = await handleGithubRadar(); data = await handleGithubRadar();
} else if (appConnection === AppConnection.Gitlab) {
data = await handleGitlab();
} else if (appConnection === AppConnection.AzureKeyVault) { } else if (appConnection === AppConnection.AzureKeyVault) {
data = await handleAzureKeyVault(); data = await handleAzureKeyVault();
} else if (appConnection === AppConnection.AzureAppConfiguration) { } else if (appConnection === AppConnection.AzureAppConfiguration) {
@@ -5,6 +5,7 @@ import {
GitHubSyncScope, GitHubSyncScope,
GitHubSyncVisibility GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync"; } from "@app/hooks/api/secretSyncs/types/github-sync";
import { GitlabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync";
import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync";
// This functional ensures parity across what is displayed in the destination column // This functional ensures parity across what is displayed in the destination column
@@ -129,8 +130,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
secondaryText = "App ID"; secondaryText = "App ID";
break; break;
case SecretSync.GitLab: case SecretSync.GitLab:
primaryText = destinationConfig.projectName; if (destinationConfig.scope === GitlabSyncScope.Project) {
secondaryText = destinationConfig.projectId; primaryText = destinationConfig.projectName;
secondaryText = destinationConfig.projectId;
} else if (destinationConfig.scope === GitlabSyncScope.Group) {
primaryText = destinationConfig.groupName;
secondaryText = destinationConfig.groupId;
} else {
throw new Error(`Unhandled GitLab Scope Destination Col Values ${destination}`);
}
break; break;
default: default:
throw new Error(`Unhandled Destination Col Values ${destination}`); throw new Error(`Unhandled Destination Col Values ${destination}`);
@@ -1,5 +1,5 @@
import { GenericFieldLabel } from "@app/components/secret-syncs"; import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TGitlabSync } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; import { GitlabSyncScope, TGitlabSync } from "@app/hooks/api/secretSyncs/types/gitlab-sync";
type Props = { type Props = {
secretSync: TGitlabSync; secretSync: TGitlabSync;
@@ -8,8 +8,6 @@ type Props = {
export const GitLabSyncDestinationSection = ({ secretSync }: Props) => { export const GitLabSyncDestinationSection = ({ secretSync }: Props) => {
const { const {
destinationConfig: { destinationConfig: {
projectName,
projectId,
targetEnvironment, targetEnvironment,
shouldProtectSecrets, shouldProtectSecrets,
shouldMaskSecrets, shouldMaskSecrets,
@@ -19,8 +17,26 @@ export const GitLabSyncDestinationSection = ({ secretSync }: Props) => {
return ( return (
<> <>
<GenericFieldLabel label="Project Name">{projectName}</GenericFieldLabel> {secretSync.destinationConfig.scope === GitlabSyncScope.Project && (
<GenericFieldLabel label="Project ID">{projectId}</GenericFieldLabel> <>
<GenericFieldLabel label="Project Name">
{secretSync.destinationConfig.projectName}
</GenericFieldLabel>
<GenericFieldLabel label="Project ID">
{secretSync.destinationConfig.projectId}
</GenericFieldLabel>
</>
)}
{secretSync.destinationConfig.scope === GitlabSyncScope.Group && (
<>
<GenericFieldLabel label="Group Name">
{secretSync.destinationConfig.groupName}
</GenericFieldLabel>
<GenericFieldLabel label="Group ID">
{secretSync.destinationConfig.groupId}
</GenericFieldLabel>
</>
)}
{targetEnvironment && ( {targetEnvironment && (
<GenericFieldLabel label="Environment">{targetEnvironment}</GenericFieldLabel> <GenericFieldLabel label="Environment">{targetEnvironment}</GenericFieldLabel>
)} )}