Skip to content

Commit b0b6761

Browse files
committed
[server] Support Projects and Prebuilds with GitHub Enterprise repositories
1 parent 0c83914 commit b0b6761

File tree

12 files changed

+323
-8
lines changed

12 files changed

+323
-8
lines changed

components/dashboard/src/projects/NewProject.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,7 @@ function GitProviders(props: {
525525
});
526526
}
527527

528-
// for now we exclude GitHub Enterprise
529-
const filteredProviders = () => props.authProviders.filter(p => p.host === "github.com" || p.host === "bitbucket.org" || p.authProviderType === "GitLab");
528+
const filteredProviders = () => props.authProviders.filter(p => p.authProviderType === "GitHub" || p.host === "bitbucket.org" || p.authProviderType === "GitLab");
530529

531530
return (
532531
<div className="mt-8 border rounded-t-xl border-gray-100 dark:border-gray-800 flex-col">

components/server/ee/src/auth/host-container-mapping.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { injectable, interfaces } from "inversify";
88
import { HostContainerMapping } from "../../../src/auth/host-container-mapping";
99
import { gitlabContainerModuleEE } from "../gitlab/container-module";
1010
import { bitbucketContainerModuleEE } from "../bitbucket/container-module";
11+
import { gitHubContainerModuleEE } from "../github/container-module";
1112

1213
@injectable()
1314
export class HostContainerMappingEE extends HostContainerMapping {
@@ -23,6 +24,8 @@ export class HostContainerMappingEE extends HostContainerMapping {
2324
// case "BitbucketServer":
2425
// FIXME
2526
// return (modules || []).concat([bitbucketContainerModuleEE]);
27+
case "GitHub":
28+
return (modules || []).concat([gitHubContainerModuleEE]);
2629
default:
2730
return modules;
2831
}

components/server/ee/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { GithubAppRules } from "./prebuilds/github-app-rules";
2323
import { PrebuildStatusMaintainer } from "./prebuilds/prebuilt-status-maintainer";
2424
import { GitLabApp } from "./prebuilds/gitlab-app";
2525
import { BitbucketApp } from "./prebuilds/bitbucket-app";
26+
import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app";
2627
import { IPrefixContextParser } from "../../src/workspace/context-parser";
2728
import { StartPrebuildContextParser } from "./prebuilds/start-prebuild-context-parser";
2829
import { StartIncrementalPrebuildContextParser } from "./prebuilds/start-incremental-prebuild-context-parser";
@@ -68,6 +69,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
6869
bind(GitLabAppSupport).toSelf().inSingletonScope();
6970
bind(BitbucketApp).toSelf().inSingletonScope();
7071
bind(BitbucketAppSupport).toSelf().inSingletonScope();
72+
bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
7173

7274
bind(LicenseEvaluator).toSelf().inSingletonScope();
7375
bind(LicenseKeySource).to(DBLicenseKeySource).inSingletonScope();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { ContainerModule } from "inversify";
8+
import { RepositoryService } from "../../../src/repohost/repo-service";
9+
import { GitHubService } from "../prebuilds/github-service";
10+
11+
export const gitHubContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => {
12+
rebind(RepositoryService).to(GitHubService).inSingletonScope();
13+
});
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import * as express from 'express';
8+
import { createHmac } from 'crypto';
9+
import { postConstruct, injectable, inject } from 'inversify';
10+
import { ProjectDB, TeamDB, UserDB } from '@gitpod/gitpod-db/lib';
11+
import { PrebuildManager } from '../prebuilds/prebuild-manager';
12+
import { TraceContext } from '@gitpod/gitpod-protocol/lib/util/tracing';
13+
import { TokenService } from '../../../src/user/token-service';
14+
import { HostContextProvider } from '../../../src/auth/host-context-provider';
15+
import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
16+
import { Project, StartPrebuildResult, User } from '@gitpod/gitpod-protocol';
17+
import { GitHubService } from './github-service';
18+
import { URL } from 'url';
19+
20+
@injectable()
21+
export class GitHubEnterpriseApp {
22+
23+
@inject(UserDB) protected readonly userDB: UserDB;
24+
@inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager;
25+
@inject(TokenService) protected readonly tokenService: TokenService;
26+
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
27+
@inject(ProjectDB) protected readonly projectDB: ProjectDB;
28+
@inject(TeamDB) protected readonly teamDB: TeamDB;
29+
30+
protected _router = express.Router();
31+
public static path = '/apps/ghe/';
32+
33+
@postConstruct()
34+
protected init() {
35+
this._router.post('/', async (req, res) => {
36+
const event = req.header('X-Github-Event');
37+
if (event === 'push') {
38+
const payload = req.body as GitHubEnterprisePushPayload;
39+
const span = TraceContext.startSpan("GitHubEnterpriseApp.handleEvent", {});
40+
span.setTag("payload", payload);
41+
let user: User | undefined;
42+
try {
43+
user = await this.findUser({ span }, payload, req);
44+
} catch (error) {
45+
log.error("Cannot find user.", error, { req })
46+
}
47+
if (!user) {
48+
res.statusCode = 401;
49+
res.send();
50+
return;
51+
}
52+
await this.handlePushHook({ span }, payload, user);
53+
} else {
54+
log.info("Unknown GitHub Enterprise event received", { event });
55+
}
56+
res.send('OK');
57+
});
58+
}
59+
60+
protected async findUser(ctx: TraceContext, payload: GitHubEnterprisePushPayload, req: express.Request): Promise<User> {
61+
const span = TraceContext.startSpan("GitHubEnterpriseApp.findUser", ctx);
62+
try {
63+
const host = req.header('X-Github-Enterprise-Host');
64+
const hostContext = this.hostContextProvider.get(host || '');
65+
if (!host || !hostContext) {
66+
throw new Error('Unsupported GitHub Enterprise host: ' + host);
67+
}
68+
const { authProviderId } = hostContext.authProvider;
69+
const authId = payload.sender.id;
70+
const user = await this.userDB.findUserByIdentity({ authProviderId, authId });
71+
if (!user) {
72+
throw new Error(`No user found with identity ${authProviderId}/${authId}.`);
73+
} else if (!!user.blocked) {
74+
throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
75+
}
76+
const gitpodIdentity = user.identities.find(i => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
77+
if (!gitpodIdentity) {
78+
throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
79+
}
80+
// Verify the webhook signature
81+
const signature = req.header('X-Hub-Signature-256');
82+
const body = (req as any).rawBody;
83+
const tokenEntries = (await this.userDB.findTokensForIdentity(gitpodIdentity)).filter(tokenEntry => {
84+
return tokenEntry.token.scopes.includes(GitHubService.PREBUILD_TOKEN_SCOPE);
85+
});
86+
const signingToken = tokenEntries.find(tokenEntry => {
87+
const sig = 'sha256=' + createHmac('sha256', user.id + '|' + tokenEntry.token.value)
88+
.update(body)
89+
.digest('hex');
90+
return sig === signature;
91+
});
92+
if (!signingToken) {
93+
throw new Error(`User ${user.id} has no token matching the payload signature.`);
94+
}
95+
return user;
96+
} finally {
97+
span.finish();
98+
}
99+
}
100+
101+
protected async handlePushHook(ctx: TraceContext, payload: GitHubEnterprisePushPayload, user: User): Promise<StartPrebuildResult | undefined> {
102+
const span = TraceContext.startSpan("GitHubEnterpriseApp.handlePushHook", ctx);
103+
try {
104+
const contextURL = this.createContextUrl(payload);
105+
span.setTag('contextURL', contextURL);
106+
const config = await this.prebuildManager.fetchConfig({ span }, user, contextURL);
107+
if (!this.prebuildManager.shouldPrebuild(config)) {
108+
log.info('GitHub Enterprise push event: No config. No prebuild.');
109+
return undefined;
110+
}
111+
112+
log.debug('GitHub Enterprise push event: Starting prebuild.', { contextURL });
113+
114+
const cloneURL = payload.repository.clone_url;
115+
const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
116+
117+
const ws = await this.prebuildManager.startPrebuild({ span }, {
118+
user: projectAndOwner.user,
119+
project: projectAndOwner?.project,
120+
branch: this.getBranchFromRef(payload.ref),
121+
contextURL,
122+
cloneURL,
123+
commit: payload.after,
124+
});
125+
return ws;
126+
} finally {
127+
span.finish();
128+
}
129+
}
130+
131+
/**
132+
* Finds the relevant user account and project to the provided webhook event information.
133+
*
134+
* First of all it tries to find the project for the given `cloneURL`, then it tries to
135+
* find the installer, which is also supposed to be a team member. As a fallback, it
136+
* looks for a team member which also has a connection with this GitHub Enterprise server.
137+
*
138+
* @param cloneURL of the webhook event
139+
* @param webhookInstaller the user account known from the webhook installation
140+
* @returns a promise which resolves to a user account and an optional project.
141+
*/
142+
protected async findProjectAndOwner(cloneURL: string, webhookInstaller: User): Promise<{ user: User, project?: Project }> {
143+
const project = await this.projectDB.findProjectByCloneUrl(cloneURL);
144+
if (project) {
145+
if (project.userId) {
146+
const user = await this.userDB.findUserById(project.userId);
147+
if (user) {
148+
return { user, project };
149+
}
150+
} else if (project.teamId) {
151+
const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || '');
152+
if (teamMembers.some(t => t.userId === webhookInstaller.id)) {
153+
return { user: webhookInstaller, project };
154+
}
155+
const hostContext = this.hostContextProvider.get(new URL(cloneURL).host);
156+
const authProviderId = hostContext?.authProvider.authProviderId;
157+
for (const teamMember of teamMembers) {
158+
const user = await this.userDB.findUserById(teamMember.userId);
159+
if (user && user.identities.some(i => i.authProviderId === authProviderId)) {
160+
return { user, project };
161+
}
162+
}
163+
}
164+
}
165+
return { user: webhookInstaller };
166+
}
167+
168+
protected getBranchFromRef(ref: string): string | undefined {
169+
const headsPrefix = "refs/heads/";
170+
if (ref.startsWith(headsPrefix)) {
171+
return ref.substring(headsPrefix.length);
172+
}
173+
174+
return undefined;
175+
}
176+
177+
protected createContextUrl(payload: GitHubEnterprisePushPayload) {
178+
return `${payload.repository.url}/tree/${this.getBranchFromRef(payload.ref)}`;
179+
}
180+
181+
get router(): express.Router {
182+
return this._router;
183+
}
184+
}
185+
186+
interface GitHubEnterprisePushPayload {
187+
ref: string;
188+
after: string;
189+
repository: {
190+
url: string;
191+
clone_url: string;
192+
};
193+
sender: {
194+
login: string;
195+
id: string;
196+
};
197+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the Gitpod Enterprise Source Code License,
4+
* See License.enterprise.txt in the project root folder.
5+
*/
6+
7+
import { RepositoryService } from "../../../src/repohost/repo-service";
8+
import { inject, injectable } from "inversify";
9+
import { GitHubGraphQlEndpoint, GitHubRestApi } from "../../../src/github/api";
10+
import { GitHubEnterpriseApp } from "./github-enterprise-app";
11+
import { AuthProviderParams } from "../../../src/auth/auth-provider";
12+
import { GithubContextParser } from "../../../src/github/github-context-parser";
13+
import { ProviderRepository, User } from "@gitpod/gitpod-protocol";
14+
import { Config } from "../../../src/config";
15+
import { TokenService } from "../../../src/user/token-service";
16+
17+
@injectable()
18+
export class GitHubService extends RepositoryService {
19+
20+
static PREBUILD_TOKEN_SCOPE = 'prebuilds';
21+
22+
@inject(GitHubGraphQlEndpoint) protected readonly githubQueryApi: GitHubGraphQlEndpoint;
23+
@inject(GitHubRestApi) protected readonly githubApi: GitHubRestApi;
24+
@inject(Config) protected readonly config: Config;
25+
@inject(AuthProviderParams) protected authProviderConfig: AuthProviderParams;
26+
@inject(TokenService) protected tokenService: TokenService;
27+
@inject(GithubContextParser) protected githubContextParser: GithubContextParser;
28+
29+
async getRepositoriesForAutomatedPrebuilds(user: User): Promise<ProviderRepository[]> {
30+
const repositories = (await this.githubApi.run(user, gh => gh.repos.listForAuthenticatedUser({}))).data;
31+
const adminRepositories = repositories.filter(r => !!r.permissions?.admin)
32+
return adminRepositories.map(r => {
33+
return <ProviderRepository>{
34+
name: r.name,
35+
cloneUrl: r.clone_url,
36+
account: r.owner?.login,
37+
accountAvatarUrl: r.owner?.avatar_url,
38+
updatedAt: r.updated_at
39+
};
40+
});
41+
}
42+
43+
async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise<boolean> {
44+
const { host, owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
45+
if (host !== this.authProviderConfig.host) {
46+
return false;
47+
}
48+
try {
49+
// You need "ADMIN" permission on a repository to be able to install a webhook.
50+
// Ref: https://docs.github.com/en/organizations/managing-access-to-your-organizations-repositories/repository-roles-for-an-organization#permissions-for-each-role
51+
// Ref: https://docs.github.com/en/graphql/reference/enums#repositorypermission
52+
const result: any = await this.githubQueryApi.runQuery(user, `
53+
query {
54+
repository(name: "${repo}", owner: "${owner}") {
55+
viewerPermission
56+
}
57+
}
58+
`);
59+
return result.data.repository && result.data.repository.viewerPermission === 'ADMIN';
60+
} catch (err) {
61+
return false;
62+
}
63+
}
64+
65+
async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise<void> {
66+
const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
67+
const webhooks = (await this.githubApi.run(user, gh => gh.repos.listWebhooks({ owner, repo }))).data;
68+
for (const webhook of webhooks) {
69+
if (webhook.config.url === this.getHookUrl()) {
70+
await this.githubApi.run(user, gh => gh.repos.deleteWebhook({ owner, repo, hook_id: webhook.id }));
71+
}
72+
}
73+
const tokenEntry = await this.tokenService.createGitpodToken(user, GitHubService.PREBUILD_TOKEN_SCOPE, cloneUrl);
74+
const config = {
75+
url: this.getHookUrl(),
76+
content_type: 'json',
77+
secret: user.id + '|' + tokenEntry.token.value,
78+
};
79+
await this.githubApi.run(user, gh => gh.repos.createWebhook({ owner, repo, config }));
80+
}
81+
82+
protected getHookUrl() {
83+
return this.config.hostUrl.with({
84+
pathname: GitHubEnterpriseApp.path
85+
}).toString();
86+
}
87+
}

components/server/ee/src/server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { GitLabApp } from './prebuilds/gitlab-app';
1313
import { BitbucketApp } from './prebuilds/bitbucket-app';
1414
import { GithubApp } from './prebuilds/github-app';
1515
import { SnapshotService } from './workspace/snapshot-service';
16+
import { GitHubEnterpriseApp } from './prebuilds/github-enterprise-app';
1617

1718
export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Server<C, S> {
1819
@inject(GithubApp) protected readonly githubApp: GithubApp;
1920
@inject(GitLabApp) protected readonly gitLabApp: GitLabApp;
2021
@inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp;
2122
@inject(SnapshotService) protected readonly snapshotService: SnapshotService;
23+
@inject(GitHubEnterpriseApp) protected readonly gitHubEnterpriseApp: GitHubEnterpriseApp;
2224

2325
public async init(app: express.Application) {
2426
await super.init(app);
@@ -43,5 +45,8 @@ export class ServerEE<C extends GitpodClient, S extends GitpodServer> extends Se
4345

4446
log.info("Registered Bitbucket app at " + BitbucketApp.path);
4547
app.use(BitbucketApp.path, this.bitbucketApp.router);
48+
49+
log.info("Registered GitHub EnterpriseApp app at " + GitHubEnterpriseApp.path);
50+
app.use(GitHubEnterpriseApp.path, this.gitHubEnterpriseApp.router);
4651
}
4752
}

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,6 +1476,11 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
14761476

14771477
if (providerHost === "github.com") {
14781478
repositories.push(...(await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params })));
1479+
} else if (provider?.authProviderType === "GitHub") {
1480+
const hostContext = this.hostContextProvider.get(providerHost);
1481+
if (hostContext?.services) {
1482+
repositories.push(...(await hostContext.services.repositoryService.getRepositoriesForAutomatedPrebuilds(user)));
1483+
}
14791484
} else if (providerHost === "bitbucket.org" && provider) {
14801485
repositories.push(...(await this.bitbucketAppSupport.getProviderRepositoriesForUser({ user, provider })));
14811486
} else if (provider?.authProviderType === "GitLab") {

components/server/src/github/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export class GitHubRestApi {
174174
try {
175175
const response = (await operation(userApi));
176176
const statusCode = response.status;
177-
if (statusCode !== 200) {
177+
if (!(statusCode >= 200 && statusCode < 300)) {
178178
throw new GitHubApiError(response);
179179
}
180180
return response;

components/server/src/projects/projects-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export class ProjectsService {
137137
const parsedUrl = RepoURL.parseRepoUrl(project.cloneUrl);
138138
const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined;
139139
const type = hostContext && hostContext.authProvider.info.authProviderType;
140-
if (type === "GitLab" || type === "Bitbucket") {
140+
if (type !== "github.com") {
141141
const repositoryService = hostContext?.services?.repositoryService;
142142
if (repositoryService) {
143143
// Note: For GitLab, we expect .canInstallAutomatedPrebuilds() to always return true, because earlier

0 commit comments

Comments
 (0)