From b169754e88d174a44abc082d3a008ff04325bbcd Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Tue, 21 Nov 2023 17:19:37 +0000 Subject: [PATCH 1/6] [server] add ScmService to be used by ScmServiceAPI (and WS API) --- components/gitpod-protocol/src/protocol.ts | 7 - .../src/public-api-converter.ts | 24 ++ components/server/src/api/scm-service-api.ts | 95 +++++++ components/server/src/api/server.ts | 5 + components/server/src/api/teams.spec.db.ts | 2 + .../server/src/auth/auth-provider-service.ts | 48 ++-- components/server/src/container-module.ts | 2 +- .../server/src/projects/projects-service.ts | 2 +- components/server/src/projects/scm-service.ts | 29 --- components/server/src/scm/scm-service.ts | 234 ++++++++++++++++++ components/server/src/user/token-provider.ts | 2 +- components/server/src/user/token-service.ts | 26 +- .../src/workspace/git-token-scope-guesser.ts | 11 +- .../src/workspace/gitpod-server-impl.ts | 191 ++------------ 14 files changed, 428 insertions(+), 250 deletions(-) create mode 100644 components/server/src/api/scm-service-api.ts delete mode 100644 components/server/src/projects/scm-service.ts create mode 100644 components/server/src/scm/scm-service.ts diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 5244c2f4faf4cb..791ea58afd635b 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -891,13 +891,6 @@ export interface GuessGitTokenScopesParams { host: string; repoUrl: string; gitCommand: string; - currentToken: GitToken; -} - -export interface GitToken { - token: string; - user: string; - scopes: string[]; } export interface GuessedGitTokenScopes { diff --git a/components/gitpod-protocol/src/public-api-converter.ts b/components/gitpod-protocol/src/public-api-converter.ts index ea5fe19ed9338d..b36c0bc0e6319c 100644 --- a/components/gitpod-protocol/src/public-api-converter.ts +++ b/components/gitpod-protocol/src/public-api-converter.ts @@ -43,6 +43,7 @@ import { EnvironmentVariableAdmission, UserEnvironmentVariable, } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb"; +import { SCMToken, SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb"; import { ContextURL } from "./context-url"; import { Prebuild, @@ -65,6 +66,8 @@ import { UserEnvVarValue, ProjectEnvVar, PrebuiltWorkspaceState, + Token, + SuggestedRepository as SuggestedRepositoryProtocol, } from "./protocol"; import { OrgMemberInfo, @@ -675,4 +678,25 @@ export class PublicAPIConverter { } return PrebuildPhase_Phase.UNSPECIFIED; } + + toSCMToken(t: Token): SCMToken { + return new SCMToken({ + username: t.username, + value: t.value, + refreshToken: t.refreshToken, + expiryDate: t.expiryDate ? Timestamp.fromDate(new Date(t.expiryDate)) : undefined, + updateDate: t.updateDate ? Timestamp.fromDate(new Date(t.updateDate)) : undefined, + scopes: t.scopes, + idToken: t.idToken, + }); + } + + toSuggestedRepository(r: SuggestedRepositoryProtocol): SuggestedRepository { + return new SuggestedRepository({ + url: r.url, + repoName: r.repositoryName, + configurationId: r.projectId, + configurationName: r.projectName, + }); + } } diff --git a/components/server/src/api/scm-service-api.ts b/components/server/src/api/scm-service-api.ts new file mode 100644 index 00000000000000..edad687058e370 --- /dev/null +++ b/components/server/src/api/scm-service-api.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { HandlerContext, ServiceImpl } from "@connectrpc/connect"; +import { SCMService as ScmServiceInterface } from "@gitpod/public-api/lib/gitpod/v1/scm_connect"; +import { inject, injectable } from "inversify"; +import { PublicAPIConverter } from "@gitpod/gitpod-protocol/lib/public-api-converter"; +import { ScmService } from "../scm/scm-service"; +import { + GuessTokenScopesRequest, + GuessTokenScopesResponse, + SearchRepositoriesRequest, + SearchRepositoriesResponse, + ListSuggestedRepositoriesRequest, + ListSuggestedRepositoriesResponse, + SearchSCMTokensRequest, + SearchSCMTokensResponse, +} from "@gitpod/public-api/lib/gitpod/v1/scm_pb"; +import { ctxUserId } from "../util/request-context"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { validate as uuidValidate } from "uuid"; +import { ProjectsService } from "../projects/projects-service"; +import { WorkspaceService } from "../workspace/workspace-service"; +import { PaginationResponse } from "@gitpod/public-api/lib/gitpod/v1/pagination_pb"; + +@injectable() +export class ScmServiceAPI implements ServiceImpl { + constructor( + @inject(PublicAPIConverter) private readonly apiConverter: PublicAPIConverter, + @inject(ScmService) private readonly scmService: ScmService, + @inject(ProjectsService) private readonly projectService: ProjectsService, + @inject(WorkspaceService) private readonly workspaceService: WorkspaceService, + ) {} + + async searchSCMTokens(request: SearchSCMTokensRequest, _: HandlerContext): Promise { + const userId = ctxUserId(); + const response = new SearchSCMTokensResponse(); + try { + const token = await this.scmService.getToken(userId, request); + response.tokens.push(this.apiConverter.toSCMToken(token)); + } catch (error) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, "Token not found."); + } + return response; + } + + async guessTokenScopes(request: GuessTokenScopesRequest, _: HandlerContext): Promise { + const userId = ctxUserId(); + const { scopes, message } = await this.scmService.guessTokenScopes(userId, request); + return new GuessTokenScopesResponse({ + scopes, + message, + }); + } + + async searchRepositories( + request: SearchRepositoriesRequest, + _: HandlerContext, + ): Promise { + const userId = ctxUserId(); + const repos = await this.scmService.searchRepositories(userId, { + searchString: request.searchString, + limit: request.limit, + }); + return new SearchRepositoriesResponse({ + repositories: repos.map((r) => this.apiConverter.toSuggestedRepository(r)), + }); + } + + async listSuggestedRepositories( + request: ListSuggestedRepositoriesRequest, + _: HandlerContext, + ): Promise { + const userId = ctxUserId(); + const { organizationId } = request; + + if (!uuidValidate(organizationId)) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID"); + } + + const projectsPromise = this.projectService.getProjects(userId, organizationId); + const workspacesPromise = this.workspaceService.getWorkspaces(userId, { organizationId }); + const repos = await this.scmService.listSuggestedRepositories(userId, { projectsPromise, workspacesPromise }); + return new ListSuggestedRepositoriesResponse({ + repositories: repos.map((r) => this.apiConverter.toSuggestedRepository(r)), + pagination: new PaginationResponse({ + nextToken: "", + total: repos.length, + }), + }); + } +} diff --git a/components/server/src/api/server.ts b/components/server/src/api/server.ts index 62f1c7c4849924..a01db5034cc64c 100644 --- a/components/server/src/api/server.ts +++ b/components/server/src/api/server.ts @@ -51,6 +51,8 @@ import { EnvironmentVariableServiceAPI } from "./envvar-service-api"; import { Unauthenticated } from "./unauthenticated"; import { SubjectId } from "../auth/subject-id"; import { BearerAuth } from "../auth/bearer-authenticator"; +import { ScmServiceAPI } from "./scm-service-api"; +import { SCMService } from "@gitpod/public-api/lib/gitpod/v1/scm_connect"; decorate(injectable(), PublicAPIConverter); @@ -67,6 +69,7 @@ export class API { @inject(ConfigurationServiceAPI) private readonly configurationServiceApi: ConfigurationServiceAPI; @inject(AuthProviderServiceAPI) private readonly authProviderServiceApi: AuthProviderServiceAPI; @inject(EnvironmentVariableServiceAPI) private readonly envvarServiceApi: EnvironmentVariableServiceAPI; + @inject(ScmServiceAPI) private readonly scmServiceAPI: ScmServiceAPI; @inject(StatsServiceAPI) private readonly tatsServiceApi: StatsServiceAPI; @inject(HelloServiceAPI) private readonly helloServiceApi: HelloServiceAPI; @inject(SessionHandler) private readonly sessionHandler: SessionHandler; @@ -121,6 +124,7 @@ export class API { service(ConfigurationService, this.configurationServiceApi), service(AuthProviderService, this.authProviderServiceApi), service(EnvironmentVariableService, this.envvarServiceApi), + service(SCMService, this.scmServiceAPI), ]) { router.service(type, new Proxy(impl, this.interceptService(type))); } @@ -372,6 +376,7 @@ export class API { bind(ConfigurationServiceAPI).toSelf().inSingletonScope(); bind(AuthProviderServiceAPI).toSelf().inSingletonScope(); bind(EnvironmentVariableServiceAPI).toSelf().inSingletonScope(); + bind(ScmServiceAPI).toSelf().inSingletonScope(); bind(StatsServiceAPI).toSelf().inSingletonScope(); bind(API).toSelf().inSingletonScope(); } diff --git a/components/server/src/api/teams.spec.db.ts b/components/server/src/api/teams.spec.db.ts index 98469f1f2278ab..e9aaf13f6b25ef 100644 --- a/components/server/src/api/teams.spec.db.ts +++ b/components/server/src/api/teams.spec.db.ts @@ -29,6 +29,7 @@ import { ProjectsService } from "../projects/projects-service"; import { AuthProviderService } from "../auth/auth-provider-service"; import { BearerAuth } from "../auth/bearer-authenticator"; import { EnvVarService } from "../user/env-var-service"; +import { ScmService } from "../scm/scm-service"; const expect = chai.expect; @@ -55,6 +56,7 @@ export class APITeamsServiceSpec { this.container.bind(ProjectsService).toConstantValue({} as ProjectsService); this.container.bind(AuthProviderService).toConstantValue({} as AuthProviderService); this.container.bind(EnvVarService).toConstantValue({} as EnvVarService); + this.container.bind(ScmService).toConstantValue({} as ScmService); // Clean-up database const typeorm = testContainer.get(TypeORM); diff --git a/components/server/src/auth/auth-provider-service.ts b/components/server/src/auth/auth-provider-service.ts index b3d39583cb581b..56fcdf2936fabe 100644 --- a/components/server/src/auth/auth-provider-service.ts +++ b/components/server/src/auth/auth-provider-service.ts @@ -86,44 +86,52 @@ export class AuthProviderService { return result.map(toPublic); } + async findAuthProviderDescription(user: User, host: string): Promise { + const provider = + this.config.authProviderConfigs.find((p) => p.host.toLowerCase() === host?.toLowerCase()) || + (await this.getAllAuthProviderParams()).find((p) => p.host.toLowerCase() === host?.toLowerCase()); + return provider ? this.toInfo(provider) : undefined; + } + + // explicitly copy to avoid bleeding sensitive details + private toInfo(ap: AuthProviderParams): AuthProviderInfo { + return { + authProviderId: ap.id, + authProviderType: ap.type, + ownerId: ap.ownerId, + organizationId: ap.organizationId, + verified: ap.verified, + host: ap.host, + icon: ap.icon, + hiddenOnDashboard: ap.hiddenOnDashboard, + disallowLogin: ap.disallowLogin, + description: ap.description, + scopes: getScopesOfProvider(ap), + requirements: getRequiredScopes(ap), + }; + } + async getAuthProviderDescriptions(user: User): Promise { const { builtinAuthProvidersConfigured } = this.config; const authProviders = [...(await this.getAllAuthProviderParams()), ...this.config.authProviderConfigs]; - // explicitly copy to avoid bleeding sensitive details - const toInfo = (ap: AuthProviderParams) => - { - authProviderId: ap.id, - authProviderType: ap.type, - ownerId: ap.ownerId, - organizationId: ap.organizationId, - verified: ap.verified, - host: ap.host, - icon: ap.icon, - hiddenOnDashboard: ap.hiddenOnDashboard, - disallowLogin: ap.disallowLogin, - description: ap.description, - scopes: getScopesOfProvider(ap), - requirements: getRequiredScopes(ap), - }; - const result: AuthProviderInfo[] = []; for (const p of authProviders) { const identity = user.identities.find((i) => i.authProviderId === p.id); if (identity) { - result.push(toInfo(p)); + result.push(this.toInfo(p)); continue; } if (p.ownerId === user.id) { - result.push(toInfo(p)); + result.push(this.toInfo(p)); continue; } if (builtinAuthProvidersConfigured && !this.isBuiltIn(p)) { continue; } if (this.isNotHidden(p) && this.isVerified(p)) { - result.push(toInfo(p)); + result.push(this.toInfo(p)); } } return result; diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 6ee639d1104c98..31a57bac4e4e5a 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -86,7 +86,6 @@ import { IncrementalWorkspaceService } from "./prebuilds/incremental-workspace-s import { PrebuildManager } from "./prebuilds/prebuild-manager"; import { PrebuildStatusMaintainer } from "./prebuilds/prebuilt-status-maintainer"; import { ProjectsService } from "./projects/projects-service"; -import { ScmService } from "./projects/scm-service"; import { RedisMutex } from "./redis/mutex"; import { Server } from "./server"; import { SessionHandler } from "./session-handler"; @@ -128,6 +127,7 @@ import { WorkspaceStartController } from "./workspace/workspace-start-controller import { WorkspaceStarter } from "./workspace/workspace-starter"; import { DefaultWorkspaceImageValidator } from "./orgs/default-workspace-image-validator"; import { ContextAwareAnalyticsWriter } from "./analytics"; +import { ScmService } from "./scm/scm-service"; export const productionContainerModule = new ContainerModule( (bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => { diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts index d6a21be9c50fe6..a8925288895432 100644 --- a/components/server/src/projects/projects-service.ts +++ b/components/server/src/projects/projects-service.ts @@ -28,9 +28,9 @@ import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messag import { URL } from "url"; import { Authorizer, SYSTEM_USER } from "../authorization/authorizer"; import { TransactionalContext } from "@gitpod/gitpod-db/lib/typeorm/transactional-db-impl"; -import { ScmService } from "./scm-service"; import { daysBefore, isDateSmaller } from "@gitpod/gitpod-protocol/lib/util/timeutil"; import deepmerge from "deepmerge"; +import { ScmService } from "../scm/scm-service"; const MAX_PROJECT_NAME_LENGTH = 100; diff --git a/components/server/src/projects/scm-service.ts b/components/server/src/projects/scm-service.ts deleted file mode 100644 index 90fe7ad0259625..00000000000000 --- a/components/server/src/projects/scm-service.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) 2023 Gitpod GmbH. All rights reserved. - * Licensed under the GNU Affero General Public License (AGPL). - * See License.AGPL.txt in the project root for license information. - */ - -import { Project, User } from "@gitpod/gitpod-protocol"; -import { RepoURL } from "../repohost"; -import { inject, injectable } from "inversify"; -import { HostContextProvider } from "../auth/host-context-provider"; -import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; - -@injectable() -export class ScmService { - constructor(@inject(HostContextProvider) private readonly hostContextProvider: HostContextProvider) {} - - async installWebhookForPrebuilds(project: Project, installer: User) { - // Install the prebuilds webhook if possible - const { teamId, cloneUrl } = project; - const parsedUrl = RepoURL.parseRepoUrl(project.cloneUrl); - const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined; - - const repositoryService = hostContext?.services?.repositoryService; - if (repositoryService) { - log.info({ organizationId: teamId, userId: installer.id }, "Update prebuild installation for project."); - await repositoryService.installAutomatedPrebuilds(installer, cloneUrl); - } - } -} diff --git a/components/server/src/scm/scm-service.ts b/components/server/src/scm/scm-service.ts new file mode 100644 index 00000000000000..b0845d00bd2e6f --- /dev/null +++ b/components/server/src/scm/scm-service.ts @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { inject, injectable } from "inversify"; +import { Authorizer } from "../authorization/authorizer"; +import { Config } from "../config"; +import { TokenProvider } from "../user/token-provider"; +import { CommitContext, Project, SuggestedRepository, Token, User, WorkspaceInfo } from "@gitpod/gitpod-protocol"; +import { HostContextProvider } from "../auth/host-context-provider"; +import { RepoURL } from "../repohost"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { AuthProviderService } from "../auth/auth-provider-service"; +import { UserService } from "../user/user-service"; +import { GitTokenScopeGuesser } from "../workspace/git-token-scope-guesser"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { + SuggestedRepositoryWithSorting, + sortSuggestedRepositories, + suggestionFromProject, + suggestionFromRecentWorkspace, + suggestionFromUserRepo, +} from "../workspace/suggested-repos-sorter"; + +@injectable() +export class ScmService { + constructor( + @inject(Config) protected readonly config: Config, + @inject(Authorizer) private readonly auth: Authorizer, + @inject(TokenProvider) private readonly tokenProvider: TokenProvider, + @inject(HostContextProvider) private readonly hostContextProvider: HostContextProvider, + @inject(AuthProviderService) private readonly authProviderService: AuthProviderService, + @inject(UserService) private readonly userService: UserService, + @inject(GitTokenScopeGuesser) private readonly gitTokenScopeGuesser: GitTokenScopeGuesser, + ) {} + + public async getToken(userId: string, query: { host: string }): Promise { + // FIXME(at) this doesn't sound right. "token" is pretty overloaded, thus `read_scm_tokens` would be correct + await this.auth.checkPermissionOnUser(userId, "read_tokens", userId); + const { host } = query; + const token = await this.tokenProvider.getTokenForHost(userId, host); + return token; + } + + public async installWebhookForPrebuilds(project: Project, installer: User) { + // Install the prebuilds webhook if possible + const { teamId, cloneUrl } = project; + const parsedUrl = RepoURL.parseRepoUrl(project.cloneUrl); + const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined; + + const repositoryService = hostContext?.services?.repositoryService; + if (repositoryService) { + log.info({ organizationId: teamId, userId: installer.id }, "Update prebuild installation for project."); + await repositoryService.installAutomatedPrebuilds(installer, cloneUrl); + } + } + + /** + * `guessTokenScopes` requires the same permissions as `getToken`. + */ + public async guessTokenScopes( + userId: string, + params: { host: string; repoUrl: string; gitCommand: string }, + ): Promise<{ scopes?: string[]; message?: string }> { + const { host, repoUrl, gitCommand } = params; + + const user = await this.userService.findUserById(userId, userId); + + const provider = await this.authProviderService.findAuthProviderDescription(user, host); + if (!provider) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, `Auth provider not found.`); + } + + const { value: currentToken } = await this.getToken(userId, { host }); + return await this.gitTokenScopeGuesser.guessGitTokenScopes(provider, { + host, + repoUrl, + gitCommand, + currentToken, + }); + } + + public async searchRepositories(userId: string, params: { searchString: string; limit?: number }) { + const user = await this.userService.findUserById(userId, userId); + const hosts = (await this.authProviderService.getAuthProviderDescriptions(user)).map((p) => p.host); + + const limit: number = params.limit || 30; + + const providerRepos = await Promise.all( + hosts.map(async (host): Promise => { + try { + const hostContext = this.hostContextProvider.get(host); + const services = hostContext?.services; + if (!services) { + return []; + } + const repos = await services.repositoryProvider.searchRepos(user, params.searchString, limit); + + return repos.map((r) => + suggestionFromUserRepo({ + url: r.url.replace(/\.git$/, ""), + repositoryName: r.name, + }), + ); + } catch (error) { + log.warn("Could not search repositories from host " + host, error); + } + + return []; + }), + ); + + const sortedRepos = sortSuggestedRepositories(providerRepos.flat()); + + //return only the first 'limit' results + return sortedRepos.slice(0, limit).map( + (repo): SuggestedRepository => ({ + url: repo.url, + repositoryName: repo.repositoryName, + }), + ); + } + + public async listSuggestedRepositories( + userId: string, + params: { + projectsPromise: Promise; + workspacesPromise: Promise; + }, + ) { + const user = await this.userService.findUserById(userId, userId); + const logCtx = { userId: user.id }; + + const fetchProjects = async (): Promise => { + const projects = await params.projectsPromise; + + const projectRepos = projects.map((project) => { + return suggestionFromProject({ + url: project.cloneUrl.replace(/\.git$/, ""), + projectId: project.id, + projectName: project.name, + }); + }); + + return projectRepos; + }; + + // Load user repositories (from Git hosts directly) + const fetchUserRepos = async (): Promise => { + const authProviders = await this.authProviderService.getAuthProviderDescriptions(user); + + const providerRepos = await Promise.all( + authProviders.map(async (p): Promise => { + try { + const hostContext = this.hostContextProvider.get(p.host); + const services = hostContext?.services; + if (!services) { + log.error(logCtx, "Unsupported repository host: " + p.host); + return []; + } + const userRepos = await services.repositoryProvider.getUserRepos(user); + + return userRepos.map((r) => + suggestionFromUserRepo({ + url: r.url.replace(/\.git$/, ""), + repositoryName: r.name, + }), + ); + } catch (error) { + log.debug(logCtx, "Could not get user repositories from host " + p.host, error); + } + + return []; + }), + ); + + return providerRepos.flat(); + }; + + const fetchRecentRepos = async (): Promise => { + const workspaces = await params.workspacesPromise; + const recentRepos: SuggestedRepositoryWithSorting[] = []; + + for (const ws of workspaces) { + let repoUrl; + let repoName; + if (CommitContext.is(ws.workspace.context)) { + repoUrl = ws.workspace.context?.repository?.cloneUrl?.replace(/\.git$/, ""); + repoName = ws.workspace.context?.repository?.name; + } + if (!repoUrl) { + repoUrl = ws.workspace.contextURL; + } + if (repoUrl) { + const lastUse = WorkspaceInfo.lastActiveISODate(ws); + + recentRepos.push( + suggestionFromRecentWorkspace( + { + url: repoUrl, + projectId: ws.workspace.projectId, + repositoryName: repoName || "", + }, + lastUse, + ), + ); + } + } + return recentRepos; + }; + + const repoResults = await Promise.allSettled([ + fetchProjects().catch((e) => log.error(logCtx, "Could not fetch projects", e)), + fetchUserRepos().catch((e) => log.error(logCtx, "Could not fetch user repositories", e)), + fetchRecentRepos().catch((e) => log.error(logCtx, "Could not fetch recent repositories", e)), + ]); + + const sortedRepos = sortSuggestedRepositories( + repoResults.map((r) => (r.status === "fulfilled" ? r.value || [] : [])).flat(), + ); + + // Convert to SuggestedRepository (drops sorting props) + return sortedRepos.map( + (repo): SuggestedRepository => ({ + url: repo.url, + projectId: repo.projectId, + projectName: repo.projectName, + repositoryName: repo.repositoryName, + }), + ); + } +} diff --git a/components/server/src/user/token-provider.ts b/components/server/src/user/token-provider.ts index 822d40b1f76ca0..d003582fa40a64 100644 --- a/components/server/src/user/token-provider.ts +++ b/components/server/src/user/token-provider.ts @@ -13,5 +13,5 @@ export interface TokenProvider { * @param user * @param host */ - getTokenForHost(user: User, host: string): Promise; + getTokenForHost(user: User | string, host: string): Promise; } diff --git a/components/server/src/user/token-service.ts b/components/server/src/user/token-service.ts index 1ddeaba6b6c414..d328abcc1d3fe0 100644 --- a/components/server/src/user/token-service.ts +++ b/components/server/src/user/token-service.ts @@ -10,37 +10,43 @@ import { HostContextProvider } from "../auth/host-context-provider"; import { UserDB } from "@gitpod/gitpod-db/lib"; import { v4 as uuidv4 } from "uuid"; import { TokenProvider } from "./token-provider"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; @injectable() export class TokenService implements TokenProvider { static readonly GITPOD_AUTH_PROVIDER_ID = "Gitpod"; - static readonly GITPOD_PORT_AUTH_TOKEN_EXPIRY_MILLIS = 30 * 60 * 1000; @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; @inject(UserDB) protected readonly userDB: UserDB; protected getTokenForHostCache = new Map>(); - async getTokenForHost(user: User, host: string): Promise { + async getTokenForHost(user: User | string, host: string): Promise { + const userId = User.is(user) ? user.id : user; // (AT) when it comes to token renewal, the awaited http requests may // cause "parallel" calls to repeat the renewal, which will fail. // Caching for pending operations should solve this issue. - const key = `${host}-${user.id}`; + const key = `${host}-${userId}`; let promise = this.getTokenForHostCache.get(key); if (!promise) { - promise = this.doGetTokenForHost(user, host); + promise = this.doGetTokenForHost(userId, host); this.getTokenForHostCache.set(key, promise); promise = promise.finally(() => this.getTokenForHostCache.delete(key)); } return promise; } - async doGetTokenForHost(user: User, host: string): Promise { + private async doGetTokenForHost(userId: string, host: string): Promise { + const user = await this.userDB.findUserById(userId); + if (!user) { + throw new ApplicationError(ErrorCodes.NOT_FOUND, `User (${userId}) not found.`); + } const identity = this.getIdentityForHost(user, host); let token = await this.userDB.findTokenForIdentity(identity); if (!token) { - throw new Error( - `No token found for user ${identity.authProviderId}/${identity.authId}/${identity.authName}!`, + throw new ApplicationError( + ErrorCodes.NOT_FOUND, + `SCM Token not found: (${userId}/${identity?.authId}/${identity?.authName}).`, ); } const aboutToExpireTime = new Date(); @@ -84,16 +90,16 @@ export class TokenService implements TokenProvider { return await this.userDB.addToken(identity, token); } - protected getIdentityForHost(user: User, host: string): Identity { + private getIdentityForHost(user: User, host: string): Identity { const authProviderId = this.getAuthProviderId(host); const hostIdentity = authProviderId && User.getIdentity(user, authProviderId); if (!hostIdentity) { - throw new Error(`User ${user.name} has no identity for host: ${host}!`); + throw new ApplicationError(ErrorCodes.NOT_FOUND, `User (${user.id}) has no identity for host: ${host}.`); } return hostIdentity; } - protected getAuthProviderId(host: string): string | undefined { + private getAuthProviderId(host: string): string | undefined { const hostContext = this.hostContextProvider.get(host); if (!hostContext) { return undefined; diff --git a/components/server/src/workspace/git-token-scope-guesser.ts b/components/server/src/workspace/git-token-scope-guesser.ts index a355a39d0ce5b4..20061c215f97a5 100644 --- a/components/server/src/workspace/git-token-scope-guesser.ts +++ b/components/server/src/workspace/git-token-scope-guesser.ts @@ -11,15 +11,12 @@ import { GitTokenValidator } from "./git-token-validator"; @injectable() export class GitTokenScopeGuesser { - @inject(GitTokenValidator) tokenValidator: GitTokenValidator; + constructor(@inject(GitTokenValidator) private readonly tokenValidator: GitTokenValidator) {} async guessGitTokenScopes( - authProvider: AuthProviderInfo | undefined, - params: GuessGitTokenScopesParams, + authProvider: AuthProviderInfo, + params: GuessGitTokenScopesParams & { currentToken: string }, ): Promise { - if (!authProvider) { - return { message: "Unknown host" }; - } const { repoUrl, gitCommand, currentToken } = params; const parsedRepoUrl = repoUrl && RepoURL.parseRepoUrl(repoUrl); @@ -36,7 +33,7 @@ export class GitTokenScopeGuesser { owner, repo, repoKind, - token: currentToken.token, + token: currentToken, }); const hasWriteAccess = validationResult && validationResult.writeAccessToRepo === true; if (hasWriteAccess) { diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index e9370e3c06586a..3d6cafcf8de671 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -106,10 +106,8 @@ import { GuardedResource, ResourceAccessGuard, ResourceAccessOp } from "../auth/ import { Config } from "../config"; import { NotFoundError, UnauthorizedError } from "../errors"; import { AuthorizationService } from "../user/authorization-service"; -import { TokenProvider } from "../user/token-provider"; import { UserAuthentication } from "../user/user-authentication"; import { ContextParser } from "./context-parser-service"; -import { GitTokenScopeGuesser } from "./git-token-scope-guesser"; import { isClusterMaintenanceError } from "./workspace-starter"; import { HeadlessLogUrls } from "@gitpod/gitpod-protocol/lib/headless-workspace-log"; import { ConfigProvider, InvalidGitpodYMLError } from "./config-provider"; @@ -161,15 +159,9 @@ import { SSHKeyService } from "../user/sshkey-service"; import { StartWorkspaceOptions, WorkspaceService } from "./workspace-service"; import { GitpodTokenService } from "../user/gitpod-token-service"; import { EnvVarService } from "../user/env-var-service"; -import { - SuggestedRepositoryWithSorting, - sortSuggestedRepositories, - suggestionFromProject, - suggestionFromRecentWorkspace, - suggestionFromUserRepo, -} from "./suggested-repos-sorter"; import { SubjectId } from "../auth/subject-id"; import { runWithSubjectId } from "../util/request-context"; +import { ScmService } from "../scm/scm-service"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -201,7 +193,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(UserDB) private readonly userDB: UserDB, @inject(BlockedRepositoryDB) private readonly blockedRepostoryDB: BlockedRepositoryDB, - @inject(TokenProvider) private readonly tokenProvider: TokenProvider, @inject(UserAuthentication) private readonly userAuthentication: UserAuthentication, @inject(UserService) private readonly userService: UserService, @inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter, @@ -217,8 +208,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(AuthProviderService) private readonly authProviderService: AuthProviderService, - @inject(GitTokenScopeGuesser) private readonly gitTokenScopeGuesser: GitTokenScopeGuesser, - @inject(ProjectsService) private readonly projectsService: ProjectsService, @inject(IDEService) private readonly ideService: IDEService, @@ -227,6 +216,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(Authorizer) private readonly auth: Authorizer, + @inject(ScmService) private readonly scmService: ScmService, + @inject(BillingModes) private readonly billingModes: BillingModes, @inject(StripeService) private readonly stripeService: StripeService, @inject(UsageService) private readonly usageService: UsageService, @@ -640,16 +631,13 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { traceAPIParams(ctx, { query }); const user = await this.checkUser("getToken"); - const logCtx = { userId: user.id, host: query.host }; - const { host } = query; try { - const token = await this.tokenProvider.getTokenForHost(user, host); + const token = await this.scmService.getToken(user.id, { host }); await this.guardAccess({ kind: "token", subject: token, tokenOwnerID: user.id }, "get"); return token; } catch (error) { - log.error(logCtx, "failed to find token: ", error); return undefined; } } @@ -1294,167 +1282,24 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID"); } - const logCtx: LogContext = { userId: user.id }; - - const fetchProjects = async (): Promise => { - const span = TraceContext.startSpan("getSuggestedRepositories.fetchProjects", ctx); - const projects = await this.projectsService.getProjects(user.id, organizationId); - - const projectRepos = projects.map((project) => { - return suggestionFromProject({ - url: project.cloneUrl.replace(/\.git$/, ""), - projectId: project.id, - projectName: project.name, - }); - }); - - span.finish(); - - return projectRepos; - }; - - // Load user repositories (from Git hosts directly) - const fetchUserRepos = async (): Promise => { - const span = TraceContext.startSpan("getSuggestedRepositories.fetchUserRepos", ctx); - const authProviders = await this.getAuthProviders(ctx); - - const providerRepos = await Promise.all( - authProviders.map(async (p): Promise => { - try { - span.setTag("host", p.host); - - const hostContext = this.hostContextProvider.get(p.host); - const services = hostContext?.services; - if (!services) { - log.error(logCtx, "Unsupported repository host: " + p.host); - return []; - } - const userRepos = await services.repositoryProvider.getUserRepos(user); - - return userRepos.map((r) => - suggestionFromUserRepo({ - url: r.url.replace(/\.git$/, ""), - repositoryName: r.name, - }), - ); - } catch (error) { - log.debug(logCtx, "Could not get user repositories from host " + p.host, error); - } - - return []; - }), - ); - - span.finish(); - - return providerRepos.flat(); - }; - - const fetchRecentRepos = async (): Promise => { - const span = TraceContext.startSpan("getSuggestedRepositories.fetchRecentRepos", ctx); - - const workspaces = await this.getWorkspaces(ctx, { organizationId }); - const recentRepos: SuggestedRepositoryWithSorting[] = []; - - for (const ws of workspaces) { - let repoUrl; - let repoName; - if (CommitContext.is(ws.workspace.context)) { - repoUrl = ws.workspace.context?.repository?.cloneUrl?.replace(/\.git$/, ""); - repoName = ws.workspace.context?.repository?.name; - } - if (!repoUrl) { - repoUrl = ws.workspace.contextURL; - } - if (repoUrl) { - const lastUse = WorkspaceInfo.lastActiveISODate(ws); - - recentRepos.push( - suggestionFromRecentWorkspace( - { - url: repoUrl, - projectId: ws.workspace.projectId, - repositoryName: repoName || "", - }, - lastUse, - ), - ); - } - } - - span.finish(); - - return recentRepos; - }; - - const repoResults = await Promise.allSettled([ - fetchProjects().catch((e) => log.error(logCtx, "Could not fetch projects", e)), - fetchUserRepos().catch((e) => log.error(logCtx, "Could not fetch user repositories", e)), - fetchRecentRepos().catch((e) => log.error(logCtx, "Could not fetch recent repositories", e)), - ]); - - const sortedRepos = sortSuggestedRepositories( - repoResults.map((r) => (r.status === "fulfilled" ? r.value || [] : [])).flat(), - ); + if (!uuidValidate(organizationId)) { + throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID"); + } - // Convert to SuggestedRepository (drops sorting props) - return sortedRepos.map( - (repo): SuggestedRepository => ({ - url: repo.url, - projectId: repo.projectId, - projectName: repo.projectName, - repositoryName: repo.repositoryName, - }), - ); + const projectsPromise = this.projectsService.getProjects(user.id, organizationId); + const workspacesPromise = this.workspaceService.getWorkspaces(user.id, { organizationId }); + const repos = await this.scmService.listSuggestedRepositories(user.id, { projectsPromise, workspacesPromise }); + return repos; } public async searchRepositories( ctx: TraceContext, params: SearchRepositoriesParams, ): Promise { + traceAPIParams(ctx, { params }); const user = await this.checkAndBlockUser("searchRepositories"); - const logCtx: LogContext = { userId: user.id }; - const limit: number = params.limit || 30; - - // Search repos across scm providers for this user - // Will search personal, and org repos - const authProviders = await this.getAuthProviders(ctx); - - const providerRepos = await Promise.all( - authProviders.map(async (p): Promise => { - try { - const hostContext = this.hostContextProvider.get(p.host); - const services = hostContext?.services; - if (!services) { - log.error(logCtx, "Unsupported repository host: " + p.host); - return []; - } - const repos = await services.repositoryProvider.searchRepos(user, params.searchString, limit); - - return repos.map((r) => - suggestionFromUserRepo({ - url: r.url.replace(/\.git$/, ""), - repositoryName: r.name, - }), - ); - } catch (error) { - log.warn(logCtx, "Could not search repositories from host " + p.host, error); - } - - return []; - }), - ); - - const sortedRepos = sortSuggestedRepositories(providerRepos.flat()); - - //return only the first 'limit' results - return sortedRepos.slice(0, limit).map( - (repo): SuggestedRepository => ({ - url: repo.url, - repositoryName: repo.repositoryName, - }), - ); + return await this.scmService.searchRepositories(user.id, params); } public async setWorkspaceTimeout( @@ -2266,13 +2111,11 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } async guessGitTokenScopes(ctx: TraceContext, params: GuessGitTokenScopesParams): Promise { - traceAPIParams(ctx, { params: censor(params, "currentToken") }); + traceAPIParams(ctx, { params: censor(params as any, "currentToken") }); - const authProviders = await this.getAuthProviders(ctx); - return this.gitTokenScopeGuesser.guessGitTokenScopes( - authProviders.find((p) => p.host == params.host), - params, - ); + const user = await this.checkAndBlockUser("guessGitTokenScopes"); + + return await this.scmService.guessTokenScopes(user.id, { ...params }); } async adminGetUser(ctx: TraceContext, userId: string): Promise { From 59096d5e2640fa2d66d0bb36355ac3ab28e6d6d1 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 22 Nov 2023 10:18:41 +0000 Subject: [PATCH 2/6] add simple test for `getToken` --- .../server/src/scm/scm-service.spec.db.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 components/server/src/scm/scm-service.spec.db.ts diff --git a/components/server/src/scm/scm-service.spec.db.ts b/components/server/src/scm/scm-service.spec.db.ts new file mode 100644 index 00000000000000..ee2b42643eea5a --- /dev/null +++ b/components/server/src/scm/scm-service.spec.db.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { TypeORM } from "@gitpod/gitpod-db/lib"; +import { User } from "@gitpod/gitpod-protocol"; +import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server"; +import * as chai from "chai"; +import { Container } from "inversify"; +import "mocha"; +import { createTestContainer } from "../test/service-testing-container-module"; +import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db"; +import { UserService } from "../user/user-service"; +import { Config } from "../config"; +import { ScmService } from "./scm-service"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { expectError } from "../test/expect-utils"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; + +const expect = chai.expect; + +describe("ScmService", async () => { + let service: ScmService; + let userService: UserService; + let container: Container; + let currentUser: User; + + const addBuiltInProvider = (host: string = "github.com") => { + const config = container.get(Config); + config.builtinAuthProvidersConfigured = true; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + config.authProviderConfigs.push((>{ + host, + id: "Public-GitHub", + verified: true, + }) as any); + }; + + beforeEach(async () => { + container = createTestContainer(); + Experiments.configureTestingClient({ + centralizedPermissions: true, + }); + service = container.get(ScmService); + userService = container.get(UserService); + currentUser = await userService.createUser({ + identity: { + authId: "gh-user-1", + authName: "user", + authProviderId: "public-github", + }, + }); + addBuiltInProvider("github.com"); + }); + + afterEach(async () => { + // Clean-up database + await resetDB(container.get(TypeORM)); + }); + + describe("getToken", async () => { + it("should return current user's token", async () => { + const token = await service.getToken(currentUser.id, { host: "github.com" }); + expect(token?.value).to.equal("test"); + }); + it("should fail if user is not found", async () => { + const getToken = service.getToken("0000-0000-0000-0000", { host: "github.com" }); + await expectError(ErrorCodes.NOT_FOUND, () => getToken); + }); + it("should fail if token is not found", async () => { + const getToken = service.getToken(currentUser.id, { host: "unknown.com" }); + await expectError(ErrorCodes.NOT_FOUND, () => getToken); + }); + }); +}); From baaadc37b9b84dd84a82848d108006235559772f Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 22 Nov 2023 10:25:23 +0000 Subject: [PATCH 3/6] refactor `ScmService.getToken` to return token of undefined --- components/server/src/api/scm-service-api.ts | 6 ++---- components/server/src/auth/authenticator.ts | 10 ++++++---- .../bitbucket-server-token-handler.ts | 2 +- .../src/bitbucket/bitbucket-token-handler.ts | 2 +- .../server/src/github/github-token-helper.ts | 2 +- .../server/src/gitlab/gitlab-token-helper.ts | 2 +- components/server/src/scm/scm-service.ts | 13 +++++++++++-- .../src/test/service-testing-container-module.ts | 6 +++++- components/server/src/user/token-provider.ts | 2 +- components/server/src/user/token-service.ts | 15 ++++++++------- .../src/workspace/git-token-scope-guesser.ts | 4 ++-- .../server/src/workspace/gitpod-server-impl.ts | 4 +++- .../server/src/workspace/workspace-starter.ts | 10 +++------- 13 files changed, 45 insertions(+), 33 deletions(-) diff --git a/components/server/src/api/scm-service-api.ts b/components/server/src/api/scm-service-api.ts index edad687058e370..5e1488add0b7fb 100644 --- a/components/server/src/api/scm-service-api.ts +++ b/components/server/src/api/scm-service-api.ts @@ -38,11 +38,9 @@ export class ScmServiceAPI implements ServiceImpl { async searchSCMTokens(request: SearchSCMTokensRequest, _: HandlerContext): Promise { const userId = ctxUserId(); const response = new SearchSCMTokensResponse(); - try { - const token = await this.scmService.getToken(userId, request); + const token = await this.scmService.getToken(userId, request); + if (token) { response.tokens.push(this.apiConverter.toSCMToken(token)); - } catch (error) { - throw new ApplicationError(ErrorCodes.NOT_FOUND, "Token not found."); } return response; } diff --git a/components/server/src/auth/authenticator.ts b/components/server/src/auth/authenticator.ts index e2173ad2f1ceff..86ae3fca9ebe3b 100644 --- a/components/server/src/auth/authenticator.ts +++ b/components/server/src/auth/authenticator.ts @@ -293,23 +293,25 @@ export class Authenticator { const state = await this.signInJWT.sign({ host, returnTo, overrideScopes: override }); authProvider.authorize(req, res, next, this.deriveAuthState(state), wantedScopes); } - protected mergeScopes(a: string[], b: string[]) { + private mergeScopes(a: string[], b: string[]) { const set = new Set(a); b.forEach((s) => set.add(s)); return Array.from(set).sort(); } - protected async getCurrentScopes(user: any, authProvider: AuthProvider) { + private async getCurrentScopes(user: any, authProvider: AuthProvider) { if (User.is(user)) { try { const token = await this.tokenProvider.getTokenForHost(user, authProvider.params.host); - return token.scopes; + if (token) { + return token.scopes; + } } catch { // no token } } return []; } - protected getSorryUrl(message: string) { + private getSorryUrl(message: string) { return this.config.hostUrl.asSorry(message).toString(); } } diff --git a/components/server/src/bitbucket-server/bitbucket-server-token-handler.ts b/components/server/src/bitbucket-server/bitbucket-server-token-handler.ts index d611793bba2263..805c9dd11dbbab 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-token-handler.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-token-handler.ts @@ -30,7 +30,7 @@ export class BitbucketServerTokenHelper { const { host } = this.config; try { const token = await this.tokenProvider.getTokenForHost(user, host); - if (this.containsScopes(token, requiredScopes)) { + if (token && this.containsScopes(token, requiredScopes)) { return token; } } catch { diff --git a/components/server/src/bitbucket/bitbucket-token-handler.ts b/components/server/src/bitbucket/bitbucket-token-handler.ts index 9b9a80a6fd7e2c..882697e303ea3f 100644 --- a/components/server/src/bitbucket/bitbucket-token-handler.ts +++ b/components/server/src/bitbucket/bitbucket-token-handler.ts @@ -30,7 +30,7 @@ export class BitbucketTokenHelper { const { host } = this.config; try { const token = await this.tokenProvider.getTokenForHost(user, host); - if (this.containsScopes(token, requiredScopes)) { + if (token && this.containsScopes(token, requiredScopes)) { return token; } } catch { diff --git a/components/server/src/github/github-token-helper.ts b/components/server/src/github/github-token-helper.ts index 9aceb49f00421e..2d49b422e372db 100644 --- a/components/server/src/github/github-token-helper.ts +++ b/components/server/src/github/github-token-helper.ts @@ -30,7 +30,7 @@ export class GitHubTokenHelper { const { host } = this.config; try { const token = await this.tokenProvider.getTokenForHost(user, host); - if (this.containsScopes(token, requiredScopes)) { + if (token && this.containsScopes(token, requiredScopes)) { return token; } } catch { diff --git a/components/server/src/gitlab/gitlab-token-helper.ts b/components/server/src/gitlab/gitlab-token-helper.ts index d4e1a1d786c1f3..a57d5c501a5ee3 100644 --- a/components/server/src/gitlab/gitlab-token-helper.ts +++ b/components/server/src/gitlab/gitlab-token-helper.ts @@ -30,7 +30,7 @@ export class GitLabTokenHelper { const { host } = this.config; try { const token = await this.tokenProvider.getTokenForHost(user, host); - if (this.containsScopes(token, requiredScopes)) { + if (token && this.containsScopes(token, requiredScopes)) { return token; } } catch (e) { diff --git a/components/server/src/scm/scm-service.ts b/components/server/src/scm/scm-service.ts index b0845d00bd2e6f..ce0dc7cf5f7fed 100644 --- a/components/server/src/scm/scm-service.ts +++ b/components/server/src/scm/scm-service.ts @@ -36,7 +36,15 @@ export class ScmService { @inject(GitTokenScopeGuesser) private readonly gitTokenScopeGuesser: GitTokenScopeGuesser, ) {} - public async getToken(userId: string, query: { host: string }): Promise { + /** + * + * @param userId subject and current user. + * @param query specifies the `host` of the auth provider to search for a token. + * @returns promise which resolves to a `Token`, or `undefined` if no token for the specified user and host exists. + * + * @throws 404/NOT_FOUND if the user is not found. + */ + public async getToken(userId: string, query: { host: string }): Promise { // FIXME(at) this doesn't sound right. "token" is pretty overloaded, thus `read_scm_tokens` would be correct await this.auth.checkPermissionOnUser(userId, "read_tokens", userId); const { host } = query; @@ -73,7 +81,8 @@ export class ScmService { throw new ApplicationError(ErrorCodes.NOT_FOUND, `Auth provider not found.`); } - const { value: currentToken } = await this.getToken(userId, { host }); + const token = await this.getToken(userId, { host }); + const currentToken = token?.value; return await this.gitTokenScopeGuesser.guessGitTokenScopes(provider, { host, repoUrl, diff --git a/components/server/src/test/service-testing-container-module.ts b/components/server/src/test/service-testing-container-module.ts index 1602deb9ce41ae..5565234d0c5620 100644 --- a/components/server/src/test/service-testing-container-module.ts +++ b/components/server/src/test/service-testing-container-module.ts @@ -37,6 +37,7 @@ import { TokenProvider } from "../user/token-provider"; import { GitHubScope } from "../github/scopes"; import { GitpodHostUrl } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; import * as crypto from "crypto"; +import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; const signingKeyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); const validatingKeyPair1 = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); @@ -124,7 +125,10 @@ const mockApplyingContainerModule = new ContainerModule((bind, unbound, isbound, }, }); rebind(TokenProvider).toConstantValue({ - getTokenForHost: async () => { + getTokenForHost: async (user, host) => { + if (host != "github.com") { + throw new ApplicationError(ErrorCodes.NOT_FOUND, `SCM Token not found.`); + } return { value: "test", scopes: [GitHubScope.EMAIL, GitHubScope.PUBLIC, GitHubScope.PRIVATE], diff --git a/components/server/src/user/token-provider.ts b/components/server/src/user/token-provider.ts index d003582fa40a64..e44c7acaa59e90 100644 --- a/components/server/src/user/token-provider.ts +++ b/components/server/src/user/token-provider.ts @@ -13,5 +13,5 @@ export interface TokenProvider { * @param user * @param host */ - getTokenForHost(user: User | string, host: string): Promise; + getTokenForHost(user: User | string, host: string): Promise; } diff --git a/components/server/src/user/token-service.ts b/components/server/src/user/token-service.ts index d328abcc1d3fe0..72d72e3f705de4 100644 --- a/components/server/src/user/token-service.ts +++ b/components/server/src/user/token-service.ts @@ -11,6 +11,7 @@ import { UserDB } from "@gitpod/gitpod-db/lib"; import { v4 as uuidv4 } from "uuid"; import { TokenProvider } from "./token-provider"; import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; +import { GarbageCollectedCache } from "@gitpod/gitpod-protocol/lib/util/garbage-collected-cache"; @injectable() export class TokenService implements TokenProvider { @@ -19,9 +20,12 @@ export class TokenService implements TokenProvider { @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; @inject(UserDB) protected readonly userDB: UserDB; - protected getTokenForHostCache = new Map>(); + // Introducing GC to token cache to guard from potentialy stale fetch requests. This is setting + // a hard limit at 10s (+5s) after which after which compteting request will trigger a new request, + // if applicable. + private readonly getTokenForHostCache = new GarbageCollectedCache>(10, 5); - async getTokenForHost(user: User | string, host: string): Promise { + async getTokenForHost(user: User | string, host: string): Promise { const userId = User.is(user) ? user.id : user; // (AT) when it comes to token renewal, the awaited http requests may // cause "parallel" calls to repeat the renewal, which will fail. @@ -36,7 +40,7 @@ export class TokenService implements TokenProvider { return promise; } - private async doGetTokenForHost(userId: string, host: string): Promise { + private async doGetTokenForHost(userId: string, host: string): Promise { const user = await this.userDB.findUserById(userId); if (!user) { throw new ApplicationError(ErrorCodes.NOT_FOUND, `User (${userId}) not found.`); @@ -44,10 +48,7 @@ export class TokenService implements TokenProvider { const identity = this.getIdentityForHost(user, host); let token = await this.userDB.findTokenForIdentity(identity); if (!token) { - throw new ApplicationError( - ErrorCodes.NOT_FOUND, - `SCM Token not found: (${userId}/${identity?.authId}/${identity?.authName}).`, - ); + return undefined; } const aboutToExpireTime = new Date(); aboutToExpireTime.setTime(aboutToExpireTime.getTime() + 5 * 60 * 1000); diff --git a/components/server/src/workspace/git-token-scope-guesser.ts b/components/server/src/workspace/git-token-scope-guesser.ts index 20061c215f97a5..c5abe7e695bebc 100644 --- a/components/server/src/workspace/git-token-scope-guesser.ts +++ b/components/server/src/workspace/git-token-scope-guesser.ts @@ -15,7 +15,7 @@ export class GitTokenScopeGuesser { async guessGitTokenScopes( authProvider: AuthProviderInfo, - params: GuessGitTokenScopesParams & { currentToken: string }, + params: GuessGitTokenScopesParams & { currentToken?: string }, ): Promise { const { repoUrl, gitCommand, currentToken } = params; @@ -27,7 +27,7 @@ export class GitTokenScopeGuesser { const { host, repo, owner, repoKind } = parsedRepoUrl; // in case of git operation which require write access to a remote - if (gitCommand === "push") { + if (currentToken && gitCommand === "push") { const validationResult = await this.tokenValidator.checkWriteAccess({ host, owner, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 3d6cafcf8de671..0f6d12bd647fac 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -634,7 +634,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const { host } = query; try { const token = await this.scmService.getToken(user.id, { host }); - await this.guardAccess({ kind: "token", subject: token, tokenOwnerID: user.id }, "get"); + if (token) { + await this.guardAccess({ kind: "token", subject: token, tokenOwnerID: user.id }, "get"); + } return token; } catch (error) { diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 667413610ea169..ba71abb9094751 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -1822,6 +1822,9 @@ export class WorkspaceStarter { } const gitToken = await this.tokenProvider.getTokenForHost(user, host); + if (!gitToken) { + throw new Error(`No token for host: ${host}`); + } const username = gitToken.username || "oauth2"; const gitConfig = new GitConfig(); @@ -1829,13 +1832,6 @@ export class WorkspaceStarter { gitConfig.setAuthUser(username); gitConfig.setAuthPassword(gitToken.value); - if (this.config.insecureNoDomain) { - const token = await this.tokenProvider.getTokenForHost(user, host); - gitConfig.setAuthentication(GitAuthMethod.BASIC_AUTH); - gitConfig.setAuthUser(token.username || "oauth2"); - gitConfig.setAuthPassword(token.value); - } - const userGitConfig = workspace.config.gitConfig; if (!!userGitConfig) { Object.keys(userGitConfig) From 408f99720453b577ea0d3e0e17982f7613f08075 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 22 Nov 2023 10:40:07 +0000 Subject: [PATCH 4/6] fix duplicata validation --- components/server/src/workspace/gitpod-server-impl.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 0f6d12bd647fac..a4ef3a897c65a6 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -1284,10 +1284,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID"); } - if (!uuidValidate(organizationId)) { - throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID"); - } - const projectsPromise = this.projectsService.getProjects(user.id, organizationId); const workspacesPromise = this.workspaceService.getWorkspaces(user.id, { organizationId }); const repos = await this.scmService.listSuggestedRepositories(user.id, { projectsPromise, workspacesPromise }); From 8eed8a759d88e8520a0fa9336dc0988df0567b8d Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 22 Nov 2023 10:58:36 +0000 Subject: [PATCH 5/6] add api converter tests --- .../src/public-api-converter.spec.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/components/gitpod-protocol/src/public-api-converter.spec.ts b/components/gitpod-protocol/src/public-api-converter.spec.ts index cabcff1e057d5b..06e983411f99f1 100644 --- a/components/gitpod-protocol/src/public-api-converter.spec.ts +++ b/components/gitpod-protocol/src/public-api-converter.spec.ts @@ -22,7 +22,15 @@ import { PrebuildSettings, WorkspaceSettings, } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb"; -import { AuthProviderEntry, AuthProviderInfo, ProjectEnvVar, UserEnvVarValue, WithEnvvarsContext } from "./protocol"; +import { + AuthProviderEntry, + AuthProviderInfo, + ProjectEnvVar, + SuggestedRepository, + Token, + UserEnvVarValue, + WithEnvvarsContext, +} from "./protocol"; import { AuthProvider, AuthProviderDescription, @@ -951,4 +959,43 @@ describe("PublicAPIConverter", () => { }); }); }); + describe("toSCMToken", () => { + it("should convert a token", () => { + const t1 = new Date(); + const token: Token = { + scopes: ["foo"], + value: "secret", + refreshToken: "refresh!", + username: "root", + idToken: "nope", + expiryDate: t1.toISOString(), + updateDate: t1.toISOString(), + }; + expect(converter.toSCMToken(token).toJson()).to.deep.equal({ + expiryDate: t1.toISOString(), + idToken: "nope", + refreshToken: "refresh!", + scopes: ["foo"], + updateDate: t1.toISOString(), + username: "root", + value: "secret", + }); + }); + }); + describe("toSuggestedRepository", () => { + it("should convert a repo", () => { + const repo: SuggestedRepository = { + url: "https://github.com/gitpod-io/gitpod", + projectId: "123", + projectName: "Gitpod", + repositoryName: "gitpod", + }; + expect(converter.toSuggestedRepository(repo).toJson()).to.deep.equal({ + url: "https://github.com/gitpod-io/gitpod", + configurationId: "123", + configurationName: "Gitpod", + repoName: "gitpod", + }); + }); + }); }); From 4ce504476c2d622df36a0d316b1acc8328e07a80 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Wed, 22 Nov 2023 11:06:49 +0000 Subject: [PATCH 6/6] just some docs --- components/server/src/scm/scm-service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/components/server/src/scm/scm-service.ts b/components/server/src/scm/scm-service.ts index ce0dc7cf5f7fed..5f277d93a556d9 100644 --- a/components/server/src/scm/scm-service.ts +++ b/components/server/src/scm/scm-service.ts @@ -37,6 +37,7 @@ export class ScmService { ) {} /** + * `getToken` allows clients to retrieve SCM tokens based on the specified host. * * @param userId subject and current user. * @param query specifies the `host` of the auth provider to search for a token. @@ -66,7 +67,14 @@ export class ScmService { } /** - * `guessTokenScopes` requires the same permissions as `getToken`. + * `guessTokenScopes` allows clients to retrieve scopes that would be necessary for a specified + * git operation on a specified repository. + * + * This method requires the same permissions as `getToken`. If no token is found, this will + * return the default scopes for the provider of the specified host. + * + * @throws 404/NOT_FOUND if the user is not found. + * @throws 404/NOT_FOUND if the provider is not found. */ public async guessTokenScopes( userId: string,