From c7dffebe58b5328fa42a44ef99b13fda49a70025 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Mon, 4 Apr 2022 14:45:28 +0000 Subject: [PATCH] [bitbucket-server] add token validator --- .../bitbucket-server/bitbucket-server-api.ts | 10 +- .../bitbucket-server-container-module.ts | 4 + .../bitbucket-server-token-validator.spec.ts | 98 +++++++++++++++++++ .../bitbucket-server-token-validator.ts | 62 ++++++++++++ .../bitbucket/bitbucket-token-validator.ts | 3 +- .../src/github/github-token-validator.ts | 17 ++-- .../src/gitlab/gitlab-token-validator.ts | 4 +- .../server/src/repohost/repo-url.spec.ts | 20 +++- components/server/src/repohost/repo-url.ts | 19 +++- .../src/workspace/git-token-scope-guesser.ts | 29 ++---- .../src/workspace/git-token-validator.ts | 4 +- 11 files changed, 223 insertions(+), 47 deletions(-) create mode 100644 components/server/src/bitbucket-server/bitbucket-server-token-validator.spec.ts create mode 100644 components/server/src/bitbucket-server/bitbucket-server-token-validator.ts diff --git a/components/server/src/bitbucket-server/bitbucket-server-api.ts b/components/server/src/bitbucket-server/bitbucket-server-api.ts index 289fc708a2e201..e7067a28ae17bb 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-api.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-api.ts @@ -120,13 +120,13 @@ export class BitbucketServerApi { } async getPermission( - user: User, + userOrToken: User | string, params: { username: string; repoKind: BitbucketServer.RepoKind; owner: string; repoName?: string }, ): Promise { const { username, repoKind, owner, repoName } = params; if (repoName) { const repoPermissions = await this.runQuery>( - user, + userOrToken, `/${repoKind}/${owner}/repos/${repoName}/permissions/users`, ); const repoPermission = repoPermissions.values?.find((p) => p.user.name === username)?.permission; @@ -136,7 +136,7 @@ export class BitbucketServerApi { } if (repoKind === "projects") { const projectPermissions = await this.runQuery>( - user, + userOrToken, `/${repoKind}/${owner}/permissions/users`, ); const projectPermission = projectPermissions.values?.find((p) => p.user.name === username)?.permission; @@ -149,11 +149,11 @@ export class BitbucketServerApi { } async getRepository( - user: User, + userOrToken: User | string, params: { repoKind: "projects" | "users"; owner: string; repositorySlug: string }, ): Promise { return this.runQuery( - user, + userOrToken, `/${params.repoKind}/${params.owner}/repos/${params.repositorySlug}`, ); } diff --git a/components/server/src/bitbucket-server/bitbucket-server-container-module.ts b/components/server/src/bitbucket-server/bitbucket-server-container-module.ts index 76bc02bc139373..09cd25ebd508ea 100644 --- a/components/server/src/bitbucket-server/bitbucket-server-container-module.ts +++ b/components/server/src/bitbucket-server/bitbucket-server-container-module.ts @@ -8,6 +8,7 @@ import { ContainerModule } from "inversify"; import { AuthProvider } from "../auth/auth-provider"; import { FileProvider, LanguagesProvider, RepositoryHost, RepositoryProvider } from "../repohost"; import { IContextParser } from "../workspace/context-parser"; +import { IGitTokenValidator } from "../workspace/git-token-validator"; import { BitbucketServerApi } from "./bitbucket-server-api"; import { BitbucketServerAuthProvider } from "./bitbucket-server-auth-provider"; import { BitbucketServerContextParser } from "./bitbucket-server-context-parser"; @@ -15,6 +16,7 @@ import { BitbucketServerFileProvider } from "./bitbucket-server-file-provider"; import { BitbucketServerLanguagesProvider } from "./bitbucket-server-language-provider"; import { BitbucketServerRepositoryProvider } from "./bitbucket-server-repository-provider"; import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { BitbucketServerTokenValidator } from "./bitbucket-server-token-validator"; export const bitbucketServerContainerModule = new ContainerModule((bind, _unbind, _isBound, _rebind) => { bind(RepositoryHost).toSelf().inSingletonScope(); @@ -30,4 +32,6 @@ export const bitbucketServerContainerModule = new ContainerModule((bind, _unbind bind(BitbucketServerAuthProvider).toSelf().inSingletonScope(); bind(AuthProvider).to(BitbucketServerAuthProvider).inSingletonScope(); bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + bind(BitbucketServerTokenValidator).toSelf().inSingletonScope(); + bind(IGitTokenValidator).toService(BitbucketServerTokenValidator); }); diff --git a/components/server/src/bitbucket-server/bitbucket-server-token-validator.spec.ts b/components/server/src/bitbucket-server/bitbucket-server-token-validator.spec.ts new file mode 100644 index 00000000000000..2ebf990c1c6c69 --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-token-validator.spec.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2022 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 { skipIfEnvVarNotSet } from "@gitpod/gitpod-protocol/lib/util/skip-if"; +import { Container, ContainerModule } from "inversify"; +import { retries, suite, test, timeout } from "mocha-typescript"; +import { expect } from "chai"; +import { BitbucketServerApi } from "./bitbucket-server-api"; +import { BitbucketServerTokenValidator } from "./bitbucket-server-token-validator"; +import { AuthProviderParams } from "../auth/auth-provider"; +import { BitbucketServerTokenHelper } from "./bitbucket-server-token-handler"; +import { TokenProvider } from "../user/token-provider"; + +@suite(timeout(10000), retries(0), skipIfEnvVarNotSet("GITPOD_TEST_TOKEN_BITBUCKET_SERVER")) +class TestBitbucketServerTokenValidator { + protected validator: BitbucketServerTokenValidator; + + static readonly AUTH_HOST_CONFIG: Partial = { + id: "MyBitbucketServer", + type: "BitbucketServer", + verified: true, + description: "", + icon: "", + host: "bitbucket.gitpod-self-hosted.com", + oauth: { + callBackUrl: "", + clientId: "not-used", + clientSecret: "", + tokenUrl: "", + scope: "", + authorizationUrl: "", + }, + }; + + public before() { + const container = new Container(); + container.load( + new ContainerModule((bind, unbind, isBound, rebind) => { + bind(BitbucketServerTokenValidator).toSelf().inSingletonScope(); + bind(AuthProviderParams).toConstantValue(TestBitbucketServerTokenValidator.AUTH_HOST_CONFIG); + bind(BitbucketServerTokenHelper).toSelf().inSingletonScope(); + // bind(TokenService).toConstantValue({ + // createGitpodToken: async () => ({ token: { value: "foobar123-token" } }), + // } as any); + // bind(Config).toConstantValue({ + // hostUrl: new GitpodHostUrl(), + // }); + bind(TokenProvider).toConstantValue({ + getTokenForHost: async () => { + return { + value: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"] || "undefined", + scopes: [], + }; + }, + getFreshPortAuthenticationToken: undefined as any, + }); + bind(BitbucketServerApi).toSelf().inSingletonScope(); + // bind(HostContextProvider).toConstantValue({}); + }), + ); + this.validator = container.get(BitbucketServerTokenValidator); + } + + @test async test_checkWriteAccess_read_only() { + const result = await this.validator.checkWriteAccess({ + host: "bitbucket.gitpod-self-hosted.com", + owner: "mil", + repo: "gitpod-large-image", + repoKind: "projects", + token: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"]!, + }); + expect(result).to.deep.equal({ + found: true, + isPrivateRepo: true, + writeAccessToRepo: false, + }); + } + + @test async test_checkWriteAccess_write_permissions() { + const result = await this.validator.checkWriteAccess({ + host: "bitbucket.gitpod-self-hosted.com", + owner: "alextugarev", + repo: "yolo", + repoKind: "users", + token: process.env["GITPOD_TEST_TOKEN_BITBUCKET_SERVER"]!, + }); + expect(result).to.deep.equal({ + found: true, + isPrivateRepo: false, + writeAccessToRepo: true, + }); + } +} + +module.exports = new TestBitbucketServerTokenValidator(); diff --git a/components/server/src/bitbucket-server/bitbucket-server-token-validator.ts b/components/server/src/bitbucket-server/bitbucket-server-token-validator.ts new file mode 100644 index 00000000000000..f0066e4637b8c2 --- /dev/null +++ b/components/server/src/bitbucket-server/bitbucket-server-token-validator.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2022 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 { CheckWriteAccessResult, IGitTokenValidator, IGitTokenValidatorParams } from "../workspace/git-token-validator"; +import { BitbucketServerApi } from "./bitbucket-server-api"; + +@injectable() +export class BitbucketServerTokenValidator implements IGitTokenValidator { + @inject(BitbucketServerApi) protected readonly api: BitbucketServerApi; + + async checkWriteAccess(params: IGitTokenValidatorParams): Promise { + const { token, owner, repo, repoKind } = params; + if (!repoKind || !["users", "projects"].includes(repoKind)) { + throw new Error("repo kind is missing"); + } + + let found = false; + let isPrivateRepo: boolean | undefined; + let writeAccessToRepo: boolean | undefined; + + try { + const repository = await this.api.getRepository(token, { + repoKind: repoKind as any, + owner, + repositorySlug: repo, + }); + found = true; + isPrivateRepo = !repository.public; + } catch (error) { + console.error(error); + } + + if (found) { + writeAccessToRepo = false; + const username = await this.api.currentUsername(token); + const userProfile = await this.api.getUserProfile(token, username); + if (owner === userProfile.slug) { + writeAccessToRepo = true; + } else { + let permission = await this.api.getPermission(token, { + repoKind: repoKind as any, + owner, + username, + repoName: repo, + }); + if (permission && ["REPO_WRITE", "REPO_ADMIN", "PROJECT_ADMIN", ""].includes(permission)) { + writeAccessToRepo = true; + } + } + } + + return { + found, + isPrivateRepo, + writeAccessToRepo, + }; + } +} diff --git a/components/server/src/bitbucket/bitbucket-token-validator.ts b/components/server/src/bitbucket/bitbucket-token-validator.ts index 93efd3732f74c6..941d44a0eaf73e 100644 --- a/components/server/src/bitbucket/bitbucket-token-validator.ts +++ b/components/server/src/bitbucket/bitbucket-token-validator.ts @@ -11,7 +11,8 @@ import { CheckWriteAccessResult, IGitTokenValidator, IGitTokenValidatorParams } @injectable() export class BitbucketTokenValidator implements IGitTokenValidator { async checkWriteAccess(params: IGitTokenValidatorParams): Promise { - const { token, host, repoFullName } = params; + const { token, host, owner, repo } = params; + const repoFullName = `${owner}/${repo}`; const result: CheckWriteAccessResult = { found: false, diff --git a/components/server/src/github/github-token-validator.ts b/components/server/src/github/github-token-validator.ts index 89eaf4569e8c91..1d850f8dbbd9a3 100644 --- a/components/server/src/github/github-token-validator.ts +++ b/components/server/src/github/github-token-validator.ts @@ -15,15 +15,16 @@ export class GitHubTokenValidator implements IGitTokenValidator { @inject(GitHubGraphQlEndpoint) githubGraphQLEndpoint: GitHubGraphQlEndpoint; async checkWriteAccess(params: IGitTokenValidatorParams): Promise { - const { token, repoFullName } = params; + const { token, owner, repo } = params; + const repoFullName = `${owner}/${repo}`; const parsedRepoName = this.parseGitHubRepoName(repoFullName); if (!parsedRepoName) { throw new Error(`Could not parse repo name: ${repoFullName}`); } - let repo; + let gitHubRepo; try { - repo = await this.githubRestApi.run(token, (api) => api.repos.get(parsedRepoName)); + gitHubRepo = await this.githubRestApi.run(token, (api) => api.repos.get(parsedRepoName)); } catch (error) { if (GitHubApiError.is(error) && error.response?.status === 404) { return { found: false }; @@ -32,12 +33,12 @@ export class GitHubTokenValidator implements IGitTokenValidator { return { found: false, error }; } - const mayWritePrivate = GitHubResult.mayWritePrivate(repo); - const mayWritePublic = GitHubResult.mayWritePublic(repo); + const mayWritePrivate = GitHubResult.mayWritePrivate(gitHubRepo); + const mayWritePublic = GitHubResult.mayWritePublic(gitHubRepo); - const isPrivateRepo = repo.data.private; - let writeAccessToRepo = repo.data.permissions?.push; - const inOrg = repo.data.owner?.type === "Organization"; + const isPrivateRepo = gitHubRepo.data.private; + let writeAccessToRepo = gitHubRepo.data.permissions?.push; + const inOrg = gitHubRepo.data.owner?.type === "Organization"; if (inOrg) { // if this repository belongs to an organization and Gitpod is not authorized, diff --git a/components/server/src/gitlab/gitlab-token-validator.ts b/components/server/src/gitlab/gitlab-token-validator.ts index a6d95b0680a703..daa529a89e7764 100644 --- a/components/server/src/gitlab/gitlab-token-validator.ts +++ b/components/server/src/gitlab/gitlab-token-validator.ts @@ -14,7 +14,8 @@ export class GitLabTokenValidator implements IGitTokenValidator { let found = false; let isPrivateRepo: boolean | undefined; let writeAccessToRepo: boolean | undefined; - const { token, host, repoFullName } = params; + const { token, host, owner, repo } = params; + const repoFullName = `${owner}/${repo}`; try { const request = { @@ -42,7 +43,6 @@ export class GitLabTokenValidator implements IGitTokenValidator { throw new Error(response.statusText); } } catch (e) { - console.error(e); throw e; } diff --git a/components/server/src/repohost/repo-url.spec.ts b/components/server/src/repohost/repo-url.spec.ts index 1b07ce9b54d0e1..04cef3ef03f20d 100644 --- a/components/server/src/repohost/repo-url.spec.ts +++ b/components/server/src/repohost/repo-url.spec.ts @@ -14,7 +14,7 @@ const expect = chai.expect; export class RepoUrlTest { @test public parseRepoUrl() { const testUrl = RepoURL.parseRepoUrl("https://gitlab.com/hello-group/my-cool-project.git"); - expect(testUrl).to.deep.equal({ + expect(testUrl).to.deep.include({ host: "gitlab.com", owner: "hello-group", repo: "my-cool-project", @@ -23,7 +23,7 @@ export class RepoUrlTest { @test public parseSubgroupOneLevel() { const testUrl = RepoURL.parseRepoUrl("https://gitlab.com/hello-group/my-subgroup/my-cool-project.git"); - expect(testUrl).to.deep.equal({ + expect(testUrl).to.deep.include({ host: "gitlab.com", owner: "hello-group/my-subgroup", repo: "my-cool-project", @@ -34,7 +34,7 @@ export class RepoUrlTest { const testUrl = RepoURL.parseRepoUrl( "https://gitlab.com/hello-group/my-subgroup/my-sub-subgroup/my-cool-project.git", ); - expect(testUrl).to.deep.equal({ + expect(testUrl).to.deep.include({ host: "gitlab.com", owner: "hello-group/my-subgroup/my-sub-subgroup", repo: "my-cool-project", @@ -45,7 +45,7 @@ export class RepoUrlTest { const testUrl = RepoURL.parseRepoUrl( "https://gitlab.com/hello-group/my-subgroup/my-sub-subgroup/my-sub-sub-subgroup/my-cool-project.git", ); - expect(testUrl).to.deep.equal({ + expect(testUrl).to.deep.include({ host: "gitlab.com", owner: "hello-group/my-subgroup/my-sub-subgroup/my-sub-sub-subgroup", repo: "my-cool-project", @@ -56,12 +56,22 @@ export class RepoUrlTest { const testUrl = RepoURL.parseRepoUrl( "https://gitlab.com/hello-group/my-subgroup/my-sub-subgroup/my-sub-sub-subgroup/my-sub-sub-sub-subgroup/my-cool-project.git", ); - expect(testUrl).to.deep.equal({ + expect(testUrl).to.deep.include({ host: "gitlab.com", owner: "hello-group/my-subgroup/my-sub-subgroup/my-sub-sub-subgroup/my-sub-sub-sub-subgroup", repo: "my-cool-project", }); } + + @test public parseScmCloneUrl() { + const testUrl = RepoURL.parseRepoUrl("https://bitbucket.gitpod-self-hosted.com/scm/~jan/yolo.git"); + expect(testUrl).to.deep.include({ + host: "bitbucket.gitpod-self-hosted.com", + repoKind: "users", + owner: "jan", + repo: "yolo", + }); + } } module.exports = new RepoUrlTest(); diff --git a/components/server/src/repohost/repo-url.ts b/components/server/src/repohost/repo-url.ts index 69e3aa676b9102..375f36676a50ab 100644 --- a/components/server/src/repohost/repo-url.ts +++ b/components/server/src/repohost/repo-url.ts @@ -4,10 +4,12 @@ * See License-AGPL.txt in the project root for license information. */ -import * as url from "url"; +import { URL } from "url"; export namespace RepoURL { - export function parseRepoUrl(repoUrl: string): { host: string; owner: string; repo: string } | undefined { - const u = url.parse(repoUrl); + export function parseRepoUrl( + repoUrl: string, + ): { host: string; owner: string; repo: string; repoKind?: string } | undefined { + const u = new URL(repoUrl); const host = u.hostname || ""; const path = u.pathname || ""; const segments = path.split("/").filter((s) => !!s); // e.g. [ 'gitpod-io', 'gitpod.git' ] @@ -19,12 +21,19 @@ export namespace RepoURL { if (segments.length > 2) { const endSegment = segments[segments.length - 1]; let ownerSegments = segments.slice(0, segments.length - 1); + let repoKind: string | undefined; if (ownerSegments[0] === "scm") { ownerSegments = ownerSegments.slice(1); + repoKind = "projects"; + } + + let owner = ownerSegments.join("/"); + if (owner.startsWith("~")) { + repoKind = "users"; + owner = owner.substring(1); } - const owner = ownerSegments.join("/"); const repo = endSegment.endsWith(".git") ? endSegment.slice(0, -4) : endSegment; - return { host, owner, repo }; + return { host, owner, repo, repoKind }; } 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 e26fb1d37a1a24..e501445b4d4a4b 100644 --- a/components/server/src/workspace/git-token-scope-guesser.ts +++ b/components/server/src/workspace/git-token-scope-guesser.ts @@ -6,6 +6,7 @@ import { AuthProviderInfo, GuessedGitTokenScopes, GuessGitTokenScopesParams } from "@gitpod/gitpod-protocol"; import { inject, injectable } from "inversify"; +import { RepoURL } from "../repohost"; import { GitTokenValidator } from "./git-token-validator"; @injectable() @@ -21,16 +22,20 @@ export class GitTokenScopeGuesser { } const { repoUrl, gitCommand, currentToken } = params; - const repoFullName = repoUrl && this.parseRepoFull(repoUrl); - if (!repoFullName) { + const parsedRepoUrl = repoUrl && RepoURL.parseRepoUrl(repoUrl); + if (!parsedRepoUrl) { return { message: `Unknown repository '${repoUrl}'` }; } + const { host, repo, owner, repoKind } = parsedRepoUrl; + // in case of git operation which require write access to a remote if (gitCommand === "push") { const validationResult = await this.tokenValidator.checkWriteAccess({ - host: authProvider.host, - repoFullName, + host, + owner, + repo, + repoKind, token: currentToken.token, }); const hasWriteAccess = validationResult && validationResult.writeAccessToRepo === true; @@ -46,20 +51,4 @@ export class GitTokenScopeGuesser { } return { scopes: authProvider.requirements!.default }; } - - /** - * @returns full name of the repo, e.g. group/subgroup1/subgroug2/project-repo - * - * @param repoUrl e.g. https://gitlab.domain.com/group/subgroup1/subgroug2/project-repo.git - */ - protected parseRepoFull(repoUrl: string | undefined): string | undefined { - if (repoUrl && repoUrl.startsWith("https://") && repoUrl.endsWith(".git")) { - const parts = repoUrl.substr("https://".length).split("/").splice(1); // without host parts - if (parts.length >= 2) { - parts[parts.length - 1] = parts[parts.length - 1].slice(0, -1 * ".git".length); - return parts.join("/"); - } - } - return undefined; - } } diff --git a/components/server/src/workspace/git-token-validator.ts b/components/server/src/workspace/git-token-validator.ts index 89a7e23c8ea941..65ad1c7a3b1b95 100644 --- a/components/server/src/workspace/git-token-validator.ts +++ b/components/server/src/workspace/git-token-validator.ts @@ -19,7 +19,9 @@ export interface CheckWriteAccessResult { export interface IGitTokenValidatorParams { token: string; host: string; - repoFullName: string; + owner: string; + repo: string; + repoKind?: string; } export interface IGitTokenValidator {