diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 9ac2d9fe574f39..b9f57f19d0ca44 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -232,7 +232,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const result = await this.eligibilityService.mayStartWorkspace(user, new Date(), runningInstances); if (!result.enoughCredits) { - throw new ResponseError(ErrorCodes.NOT_ENOUGH_CREDIT, `Not enough monthly workspace hours. Please upgrade your account to get more hours for your workspaces.`); + throw new ResponseError( + ErrorCodes.NOT_ENOUGH_CREDIT, + `Not enough monthly workspace hours. Please upgrade your account to get more hours for your workspaces.`, + ); } if (!!result.hitParallelWorkspaceLimit) { throw new ResponseError( @@ -565,14 +568,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl { await this.guardAdminAccess("adminBlockUser", { req }, Permission.ADMIN_USERS); - const target = await this.userDB.findUserById(req.id); - if (!target) { + let targetUser; + try { + targetUser = await this.userService.blockUser(req.id, req.blocked); + } catch (error) { throw new ResponseError(ErrorCodes.NOT_FOUND, "not found"); } - target.blocked = !!req.blocked; - await this.userDB.storeUser(target); - const workspaceDb = this.workspaceDb.trace(ctx); const workspaces = await workspaceDb.findWorkspacesByUser(req.id); const isDefined = (x: T | undefined): x is T => x !== undefined; @@ -584,7 +586,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { // For some reason, returning the result of `this.userDB.storeUser(target)` does not work. The response never arrives the caller. // Returning `target` instead (which should be equivalent). - return this.censorUser(target); + return this.censorUser(targetUser); } async adminDeleteUser(ctx: TraceContext, userId: string): Promise { diff --git a/components/server/src/config.ts b/components/server/src/config.ts index d936304276f3b9..4c9c1d3543b710 100644 --- a/components/server/src/config.ts +++ b/components/server/src/config.ts @@ -18,11 +18,15 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env"; export const Config = Symbol("Config"); -export type Config = Omit & { +export type Config = Omit< + ConfigSerialized, + "blockedRepositories" | "hostUrl" | "chargebeeProviderOptionsFile" | "licenseFile" +> & { hostUrl: GitpodHostUrl; workspaceDefaults: WorkspaceDefaults; chargebeeProviderOptions?: ChargebeeProviderOptions; builtinAuthProvidersConfigured: boolean; + blockedRepositories: { urlRegExp: RegExp; blockUser: boolean }[]; }; export interface WorkspaceDefaults { @@ -152,6 +156,12 @@ export interface ConfigSerialized { * Key '*' specifies the default rate limit for a cloneURL, unless overriden by a specific cloneURL. */ prebuildLimiter: { [cloneURL: string]: number } & { "*": number }; + + /** + * List of repositories not allowed to be used for workspace starts. + * `blockUser` attribute to control handling of the user's account. + */ + blockedRepositories?: { urlRegExp: string; blockUser: boolean }[]; } export namespace ConfigFile { @@ -201,6 +211,15 @@ export namespace ConfigFile { if (licenseFile) { license = fs.readFileSync(filePathTelepresenceAware(licenseFile), "utf-8"); } + const blockedRepositories: { urlRegExp: RegExp; blockUser: boolean }[] = []; + if (config.blockedRepositories) { + for (const { blockUser, urlRegExp } of config.blockedRepositories) { + blockedRepositories.push({ + blockUser, + urlRegExp: new RegExp(urlRegExp), + }); + } + } return { ...config, hostUrl, @@ -214,6 +233,7 @@ export namespace ConfigFile { ? new Date(config.workspaceGarbageCollection.startDate).getTime() : Date.now(), }, + blockedRepositories, }; } } diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 23929112f82aed..e35a97cc5792bd 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -269,6 +269,16 @@ export class UserService { return false; } + async blockUser(targetUserId: string, block: boolean): Promise { + const target = await this.userDb.findUserById(targetUserId); + if (!target) { + throw new Error("Not found."); + } + + target.blocked = !!block; + return await this.userDb.storeUser(target); + } + async findUserForLogin(params: { candidate: Identity }) { let user = await this.userDb.findUserByIdentity(params.candidate); return user; diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index a953d9f55ab052..f396318c400418 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -219,6 +219,8 @@ export class WorkspaceStarter { options = options || {}; try { + await this.checkBlockedRepository(user, workspace.contextURL); + // Some workspaces do not have an image source. // Workspaces without image source are not only legacy, but also happened due to what looks like a bug. // Whenever a such a workspace is re-started we'll give it an image source now. This is in line with how this thing used to work. @@ -332,6 +334,22 @@ export class WorkspaceStarter { } } + protected async checkBlockedRepository(user: User, contextURL: string) { + const hit = this.config.blockedRepositories.find((r) => !!contextURL && r.urlRegExp.test(contextURL)); + if (!hit) { + return; + } + if (hit.blockUser) { + try { + await this.userService.blockUser(user.id, true); + log.info({ userId: user.id }, "Blocked user.", { contextURL }); + } catch (error) { + log.error({ userId: user.id }, "Failed to block user.", error, { contextURL }); + } + } + throw new Error(`${contextURL} is blocklisted on Gitpod.`); + } + // Note: this function does not expect to be awaited for by its caller. This means that it takes care of error handling itself. protected async actuallyStartWorkspace( ctx: TraceContext,