-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Support GitHub Enterprise #8574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
537b4ee
19ff6e5
3fc47b4
0c83914
b0b6761
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
/** | ||
* Copyright (c) 2022 Gitpod GmbH. All rights reserved. | ||
* Licensed under the Gitpod Enterprise Source Code License, | ||
* See License.enterprise.txt in the project root folder. | ||
*/ | ||
|
||
import { ContainerModule } from "inversify"; | ||
import { RepositoryService } from "../../../src/repohost/repo-service"; | ||
import { GitHubService } from "../prebuilds/github-service"; | ||
|
||
export const gitHubContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => { | ||
rebind(RepositoryService).to(GitHubService).inSingletonScope(); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
/** | ||
* Copyright (c) 2022 Gitpod GmbH. All rights reserved. | ||
* Licensed under the Gitpod Enterprise Source Code License, | ||
* See License.enterprise.txt in the project root folder. | ||
*/ | ||
|
||
import * as express from 'express'; | ||
import { createHmac } from 'crypto'; | ||
import { postConstruct, injectable, inject } from 'inversify'; | ||
import { ProjectDB, TeamDB, UserDB } from '@gitpod/gitpod-db/lib'; | ||
import { PrebuildManager } from '../prebuilds/prebuild-manager'; | ||
import { TraceContext } from '@gitpod/gitpod-protocol/lib/util/tracing'; | ||
import { TokenService } from '../../../src/user/token-service'; | ||
import { HostContextProvider } from '../../../src/auth/host-context-provider'; | ||
import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; | ||
import { Project, StartPrebuildResult, User } from '@gitpod/gitpod-protocol'; | ||
import { GitHubService } from './github-service'; | ||
import { URL } from 'url'; | ||
|
||
@injectable() | ||
export class GitHubEnterpriseApp { | ||
|
||
@inject(UserDB) protected readonly userDB: UserDB; | ||
@inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager; | ||
@inject(TokenService) protected readonly tokenService: TokenService; | ||
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; | ||
@inject(ProjectDB) protected readonly projectDB: ProjectDB; | ||
@inject(TeamDB) protected readonly teamDB: TeamDB; | ||
|
||
protected _router = express.Router(); | ||
public static path = '/apps/ghe/'; | ||
|
||
@postConstruct() | ||
protected init() { | ||
this._router.post('/', async (req, res) => { | ||
const event = req.header('X-Github-Event'); | ||
if (event === 'push') { | ||
const payload = req.body as GitHubEnterprisePushPayload; | ||
const span = TraceContext.startSpan("GitHubEnterpriseApp.handleEvent", {}); | ||
span.setTag("payload", payload); | ||
let user: User | undefined; | ||
try { | ||
user = await this.findUser({ span }, payload, req); | ||
} catch (error) { | ||
log.error("Cannot find user.", error, { req }) | ||
} | ||
if (!user) { | ||
res.statusCode = 401; | ||
res.send(); | ||
return; | ||
} | ||
await this.handlePushHook({ span }, payload, user); | ||
} else { | ||
log.info("Unknown GitHub Enterprise event received", { event }); | ||
} | ||
res.send('OK'); | ||
}); | ||
} | ||
|
||
protected async findUser(ctx: TraceContext, payload: GitHubEnterprisePushPayload, req: express.Request): Promise<User> { | ||
const span = TraceContext.startSpan("GitHubEnterpriseApp.findUser", ctx); | ||
try { | ||
const host = req.header('X-Github-Enterprise-Host'); | ||
const hostContext = this.hostContextProvider.get(host || ''); | ||
if (!host || !hostContext) { | ||
throw new Error('Unsupported GitHub Enterprise host: ' + host); | ||
} | ||
const { authProviderId } = hostContext.authProvider; | ||
const authId = payload.sender.id; | ||
const user = await this.userDB.findUserByIdentity({ authProviderId, authId }); | ||
if (!user) { | ||
throw new Error(`No user found with identity ${authProviderId}/${authId}.`); | ||
} else if (!!user.blocked) { | ||
throw new Error(`Blocked user ${user.id} tried to start prebuild.`); | ||
} | ||
const gitpodIdentity = user.identities.find(i => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID); | ||
if (!gitpodIdentity) { | ||
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`); | ||
} | ||
// Verify the webhook signature | ||
const signature = req.header('X-Hub-Signature-256'); | ||
const body = (req as any).rawBody; | ||
const tokenEntries = (await this.userDB.findTokensForIdentity(gitpodIdentity)).filter(tokenEntry => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how many There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As many I didn't filter on the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh! that sounds really expensive. if we get many of them, such operations will drive the event loop lag even higher. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How many GitHub Enterprise repositories do we expect a single user to enable prebuilds on? And can this number realistically ever get sufficiently high to make this loop's runtime cost significant? But you're right, maybe we should add more tracing here. EDIT: Looks like the current tracing is already sufficient. |
||
return tokenEntry.token.scopes.includes(GitHubService.PREBUILD_TOKEN_SCOPE); | ||
}); | ||
const signingToken = tokenEntries.find(tokenEntry => { | ||
const sig = 'sha256=' + createHmac('sha256', user.id + '|' + tokenEntry.token.value) | ||
.update(body) | ||
.digest('hex'); | ||
return sig === signature; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably pedantry at this stage, but comparing the HMAC signatures using a simple string comparison is considered insecure because of the potential for timing attacks. See the github webhook docs. Seems the best way to do it is with |
||
}); | ||
if (!signingToken) { | ||
throw new Error(`User ${user.id} has no token matching the payload signature.`); | ||
} | ||
return user; | ||
} finally { | ||
span.finish(); | ||
} | ||
} | ||
|
||
protected async handlePushHook(ctx: TraceContext, payload: GitHubEnterprisePushPayload, user: User): Promise<StartPrebuildResult | undefined> { | ||
const span = TraceContext.startSpan("GitHubEnterpriseApp.handlePushHook", ctx); | ||
try { | ||
const contextURL = this.createContextUrl(payload); | ||
span.setTag('contextURL', contextURL); | ||
const config = await this.prebuildManager.fetchConfig({ span }, user, contextURL); | ||
if (!this.prebuildManager.shouldPrebuild(config)) { | ||
log.info('GitHub Enterprise push event: No config. No prebuild.'); | ||
return undefined; | ||
} | ||
|
||
log.debug('GitHub Enterprise push event: Starting prebuild.', { contextURL }); | ||
|
||
const cloneURL = payload.repository.clone_url; | ||
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user); | ||
|
||
const ws = await this.prebuildManager.startPrebuild({ span }, { | ||
user: projectAndOwner.user, | ||
project: projectAndOwner?.project, | ||
branch: this.getBranchFromRef(payload.ref), | ||
contextURL, | ||
cloneURL, | ||
commit: payload.after, | ||
}); | ||
return ws; | ||
} finally { | ||
span.finish(); | ||
} | ||
} | ||
|
||
/** | ||
* Finds the relevant user account and project to the provided webhook event information. | ||
* | ||
* First of all it tries to find the project for the given `cloneURL`, then it tries to | ||
* find the installer, which is also supposed to be a team member. As a fallback, it | ||
* looks for a team member which also has a connection with this GitHub Enterprise server. | ||
* | ||
* @param cloneURL of the webhook event | ||
* @param webhookInstaller the user account known from the webhook installation | ||
* @returns a promise which resolves to a user account and an optional project. | ||
*/ | ||
protected async findProjectAndOwner(cloneURL: string, webhookInstaller: User): Promise<{ user: User, project?: Project }> { | ||
const project = await this.projectDB.findProjectByCloneUrl(cloneURL); | ||
if (project) { | ||
if (project.userId) { | ||
const user = await this.userDB.findUserById(project.userId); | ||
if (user) { | ||
return { user, project }; | ||
} | ||
} else if (project.teamId) { | ||
const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || ''); | ||
if (teamMembers.some(t => t.userId === webhookInstaller.id)) { | ||
return { user: webhookInstaller, project }; | ||
} | ||
const hostContext = this.hostContextProvider.get(new URL(cloneURL).host); | ||
const authProviderId = hostContext?.authProvider.authProviderId; | ||
for (const teamMember of teamMembers) { | ||
const user = await this.userDB.findUserById(teamMember.userId); | ||
if (user && user.identities.some(i => i.authProviderId === authProviderId)) { | ||
return { user, project }; | ||
} | ||
} | ||
} | ||
} | ||
return { user: webhookInstaller }; | ||
} | ||
|
||
protected getBranchFromRef(ref: string): string | undefined { | ||
const headsPrefix = "refs/heads/"; | ||
if (ref.startsWith(headsPrefix)) { | ||
return ref.substring(headsPrefix.length); | ||
} | ||
|
||
return undefined; | ||
} | ||
|
||
protected createContextUrl(payload: GitHubEnterprisePushPayload) { | ||
return `${payload.repository.url}/tree/${this.getBranchFromRef(payload.ref)}`; | ||
} | ||
|
||
get router(): express.Router { | ||
return this._router; | ||
} | ||
} | ||
|
||
interface GitHubEnterprisePushPayload { | ||
ref: string; | ||
after: string; | ||
repository: { | ||
url: string; | ||
clone_url: string; | ||
}; | ||
sender: { | ||
login: string; | ||
id: string; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
/** | ||
* Copyright (c) 2022 Gitpod GmbH. All rights reserved. | ||
* Licensed under the Gitpod Enterprise Source Code License, | ||
* See License.enterprise.txt in the project root folder. | ||
*/ | ||
|
||
import { RepositoryService } from "../../../src/repohost/repo-service"; | ||
import { inject, injectable } from "inversify"; | ||
import { GitHubGraphQlEndpoint, GitHubRestApi } from "../../../src/github/api"; | ||
import { GitHubEnterpriseApp } from "./github-enterprise-app"; | ||
import { AuthProviderParams } from "../../../src/auth/auth-provider"; | ||
import { GithubContextParser } from "../../../src/github/github-context-parser"; | ||
import { ProviderRepository, User } from "@gitpod/gitpod-protocol"; | ||
import { Config } from "../../../src/config"; | ||
import { TokenService } from "../../../src/user/token-service"; | ||
|
||
@injectable() | ||
export class GitHubService extends RepositoryService { | ||
|
||
static PREBUILD_TOKEN_SCOPE = 'prebuilds'; | ||
|
||
@inject(GitHubGraphQlEndpoint) protected readonly githubQueryApi: GitHubGraphQlEndpoint; | ||
@inject(GitHubRestApi) protected readonly githubApi: GitHubRestApi; | ||
@inject(Config) protected readonly config: Config; | ||
@inject(AuthProviderParams) protected authProviderConfig: AuthProviderParams; | ||
@inject(TokenService) protected tokenService: TokenService; | ||
@inject(GithubContextParser) protected githubContextParser: GithubContextParser; | ||
|
||
async getRepositoriesForAutomatedPrebuilds(user: User): Promise<ProviderRepository[]> { | ||
const repositories = (await this.githubApi.run(user, gh => gh.repos.listForAuthenticatedUser({}))).data; | ||
const adminRepositories = repositories.filter(r => !!r.permissions?.admin) | ||
return adminRepositories.map(r => { | ||
return <ProviderRepository>{ | ||
name: r.name, | ||
cloneUrl: r.clone_url, | ||
account: r.owner?.login, | ||
accountAvatarUrl: r.owner?.avatar_url, | ||
updatedAt: r.updated_at | ||
}; | ||
}); | ||
} | ||
|
||
async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> { | ||
const { host, owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl); | ||
if (host !== this.authProviderConfig.host) { | ||
return false; | ||
} | ||
try { | ||
// You need "ADMIN" permission on a repository to be able to install a webhook. | ||
// Ref: https://docs.github.com/en/organizations/managing-access-to-your-organizations-repositories/repository-roles-for-an-organization#permissions-for-each-role | ||
// Ref: https://docs.github.com/en/graphql/reference/enums#repositorypermission | ||
const result: any = await this.githubQueryApi.runQuery(user, ` | ||
query { | ||
repository(name: "${repo}", owner: "${owner}") { | ||
viewerPermission | ||
} | ||
} | ||
`); | ||
return result.data.repository && result.data.repository.viewerPermission === 'ADMIN'; | ||
} catch (err) { | ||
return false; | ||
} | ||
} | ||
|
||
async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> { | ||
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl); | ||
const webhooks = (await this.githubApi.run(user, gh => gh.repos.listWebhooks({ owner, repo }))).data; | ||
jankeromnes marked this conversation as resolved.
Show resolved
Hide resolved
|
||
for (const webhook of webhooks) { | ||
if (webhook.config.url === this.getHookUrl()) { | ||
await this.githubApi.run(user, gh => gh.repos.deleteWebhook({ owner, repo, hook_id: webhook.id })); | ||
} | ||
} | ||
const tokenEntry = await this.tokenService.createGitpodToken(user, GitHubService.PREBUILD_TOKEN_SCOPE, cloneUrl); | ||
const config = { | ||
url: this.getHookUrl(), | ||
content_type: 'json', | ||
secret: user.id + '|' + tokenEntry.token.value, | ||
}; | ||
await this.githubApi.run(user, gh => gh.repos.createWebhook({ owner, repo, config })); | ||
} | ||
|
||
protected getHookUrl() { | ||
return this.config.hostUrl.with({ | ||
pathname: GitHubEnterpriseApp.path | ||
}).toString(); | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.