Skip to content

Commit 26a7555

Browse files
gitlab permission syncing wip
1 parent 384aa9e commit 26a7555

File tree

7 files changed

+141
-13
lines changed

7 files changed

+141
-13
lines changed

packages/backend/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const SINGLE_TENANT_ORG_ID = 1;
55

66
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
77
'github',
8+
'gitlab',
89
];
910

1011
export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Redis } from 'ioredis';
77
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
88
import { env } from "../env.js";
99
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
10+
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
1011
import { Settings } from "../types.js";
1112
import { getAuthCredentialsForRepo } from "../utils.js";
1213

@@ -194,6 +195,33 @@ export class RepoPermissionSyncer {
194195
},
195196
});
196197

198+
return accounts.map(account => account.userId);
199+
} else if (repo.external_codeHostType === 'gitlab') {
200+
const api = await createGitLabFromPersonalAccessToken({
201+
token: credentials.token,
202+
url: credentials.hostUrl,
203+
});
204+
205+
const projectId = repo.external_id;
206+
if (!projectId) {
207+
throw new Error(`Repo ${id} does not have an external_id`);
208+
}
209+
210+
const members = await getProjectMembers(projectId, api);
211+
const gitlabUserIds = members.map(member => member.id.toString());
212+
213+
const accounts = await this.db.account.findMany({
214+
where: {
215+
provider: 'gitlab',
216+
providerAccountId: {
217+
in: gitlabUserIds,
218+
}
219+
},
220+
select: {
221+
userId: true,
222+
},
223+
});
224+
197225
return accounts.map(account => account.userId);
198226
}
199227

packages/backend/src/ee/userPermissionSyncer.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import { Redis } from "ioredis";
66
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
77
import { env } from "../env.js";
88
import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js";
9+
import { createGitLabFromOAuthToken, createGitLabFromPersonalAccessToken, getProjectsForAuthenticatedUser } from "../gitlab.js";
910
import { hasEntitlement } from "@sourcebot/shared";
1011
import { Settings } from "../types.js";
1112

12-
const logger = createLogger('user-permission-syncer');
13+
const LOG_TAG = 'user-permission-syncer';
14+
const logger = createLogger(LOG_TAG);
15+
const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}`);
1316

1417
const QUEUE_NAME = 'userPermissionSyncQueue';
1518

@@ -132,6 +135,8 @@ export class UserPermissionSyncer {
132135

133136
private async runJob(job: Job<UserPermissionSyncJob>) {
134137
const id = job.data.jobId;
138+
const logger = createJobLogger(id);
139+
135140
const { user } = await this.db.userPermissionSyncJob.update({
136141
where: {
137142
id,
@@ -183,6 +188,37 @@ export class UserPermissionSyncer {
183188
}
184189
});
185190

191+
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
192+
} else if (account.provider === 'gitlab') {
193+
if (!account.access_token) {
194+
throw new Error(`User '${user.email}' does not have a GitLab OAuth access token associated with their GitLab account.`);
195+
}
196+
197+
const api = await createGitLabFromOAuthToken({
198+
oauthToken: account.access_token,
199+
url: env.AUTH_EE_GITLAB_BASE_URL,
200+
});
201+
202+
// @note: we only care about the private and internal repos since we don't need to build a mapping
203+
// for public repos.
204+
// @see: packages/web/src/prisma.ts
205+
const privateGitLabProjects = await getProjectsForAuthenticatedUser('private', api);
206+
const internalGitLabProjects = await getProjectsForAuthenticatedUser('internal', api);
207+
208+
const gitLabProjectIds = [
209+
...privateGitLabProjects,
210+
...internalGitLabProjects,
211+
].map(project => project.id.toString());
212+
213+
const repos = await this.db.repo.findMany({
214+
where: {
215+
external_codeHostType: 'gitlab',
216+
external_id: {
217+
in: gitLabProjectIds,
218+
}
219+
}
220+
});
221+
186222
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
187223
}
188224
}
@@ -212,6 +248,8 @@ export class UserPermissionSyncer {
212248
}
213249

214250
private async onJobCompleted(job: Job<UserPermissionSyncJob>) {
251+
const logger = createJobLogger(job.data.jobId);
252+
215253
const { user } = await this.db.userPermissionSyncJob.update({
216254
where: {
217255
id: job.data.jobId,
@@ -234,6 +272,8 @@ export class UserPermissionSyncer {
234272
}
235273

236274
private async onJobFailed(job: Job<UserPermissionSyncJob> | undefined, err: Error) {
275+
const logger = createJobLogger(job?.data.jobId ?? 'unknown');
276+
237277
Sentry.captureException(err, {
238278
tags: {
239279
jobId: job?.data.jobId,
@@ -260,7 +300,7 @@ export class UserPermissionSyncer {
260300

261301
logger.error(errorMessage(user.email ?? user.id));
262302
} else {
263-
logger.error(errorMessage('unknown user (id not found)'));
303+
logger.error(errorMessage('unknown job (id not found)'));
264304
}
265305
}
266306
}

packages/backend/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const env = createEnv({
5656

5757
EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
5858
AUTH_EE_GITHUB_BASE_URL: z.string().optional(),
59+
AUTH_EE_GITLAB_BASE_URL: z.string().default("https://gitlab.com"),
5960
},
6061
runtimeEnv: process.env,
6162
emptyStringAsUndefined: true,

packages/backend/src/gitlab.ts

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,28 @@ import { getTokenFromConfig } from "@sourcebot/crypto";
1212
const logger = createLogger('gitlab');
1313
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
1414

15+
export const createGitLabFromPersonalAccessToken = async ({ token, url }: { token?: string, url?: string }) => {
16+
const isGitLabCloud = url ? new URL(url).hostname === GITLAB_CLOUD_HOSTNAME : false;
17+
return new Gitlab({
18+
token,
19+
...(isGitLabCloud ? {} : {
20+
host: url,
21+
}),
22+
queryTimeout: env.GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS * 1000,
23+
});
24+
}
25+
26+
export const createGitLabFromOAuthToken = async ({ oauthToken, url }: { oauthToken?: string, url?: string }) => {
27+
const isGitLabCloud = url ? new URL(url).hostname === GITLAB_CLOUD_HOSTNAME : false;
28+
return new Gitlab({
29+
oauthToken,
30+
...(isGitLabCloud ? {} : {
31+
host: url,
32+
}),
33+
queryTimeout: env.GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS * 1000,
34+
});
35+
}
36+
1537
export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => {
1638
const hostname = config.url ?
1739
new URL(config.url).hostname :
@@ -22,15 +44,10 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
2244
hostname === GITLAB_CLOUD_HOSTNAME ?
2345
env.FALLBACK_GITLAB_CLOUD_TOKEN :
2446
undefined;
25-
26-
const api = new Gitlab({
27-
...(token ? {
28-
token,
29-
} : {}),
30-
...(config.url ? {
31-
host: config.url,
32-
} : {}),
33-
queryTimeout: env.GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS * 1000,
47+
48+
const api = await createGitLabFromPersonalAccessToken({
49+
token,
50+
url: config.url,
3451
});
3552

3653
let allRepos: ProjectSchema[] = [];
@@ -261,4 +278,37 @@ export const shouldExcludeProject = ({
261278
}
262279

263280
return false;
281+
}
282+
283+
export const getProjectMembers = async (projectId: string, api: InstanceType<typeof Gitlab>) => {
284+
try {
285+
const fetchFn = () => api.ProjectMembers.all(projectId, {
286+
perPage: 100,
287+
includeInherited: true,
288+
});
289+
290+
const members = await fetchWithRetry(fetchFn, `project ${projectId}`, logger);
291+
return members as Array<{ id: number }>;
292+
} catch (error) {
293+
Sentry.captureException(error);
294+
logger.error(`Failed to fetch members for project ${projectId}.`, error);
295+
throw error;
296+
}
297+
}
298+
299+
export const getProjectsForAuthenticatedUser = async (visibility: 'private' | 'internal' | 'public' | 'all' = 'all', api: InstanceType<typeof Gitlab>) => {
300+
try {
301+
const fetchFn = () => api.Projects.all({
302+
membership: true,
303+
...(visibility !== 'all' ? {
304+
visibility,
305+
} : {}),
306+
perPage: 100,
307+
});
308+
return fetchWithRetry(fetchFn, `authenticated user`, logger) as Promise<ProjectSchema[]>;
309+
} catch (error) {
310+
Sentry.captureException(error);
311+
logger.error(`Failed to fetch projects for authenticated user.`, error);
312+
throw error;
313+
}
264314
}

packages/backend/src/repoCompileUtils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ export const compileGitlabConfig = async (
121121
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
122122
const cloneUrl = new URL(project.http_url_to_repo);
123123
const isFork = project.forked_from_project !== undefined;
124-
// @todo: we will need to double check whether 'internal' should also be considered public or not.
125124
const isPublic = project.visibility === 'public';
126125
const repoDisplayName = project.path_with_namespace;
127126
const repoName = path.join(repoNameRoot, repoDisplayName);

packages/web/src/ee/features/sso/sso.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,16 @@ export const getSSOProviders = (): Provider[] => {
5151
authorization: {
5252
url: `${env.AUTH_EE_GITLAB_BASE_URL}/oauth/authorize`,
5353
params: {
54-
scope: "read_user",
54+
scope: [
55+
"read_user",
56+
// Permission syncing requires the `read_api` scope in order to fetch projects
57+
// for the authenticated user and project members.
58+
// @see: https://docs.gitlab.com/ee/api/projects.html#list-all-projects
59+
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
60+
['read_api'] :
61+
[]
62+
),
63+
].join(' '),
5564
},
5665
},
5766
token: {

0 commit comments

Comments
 (0)