Skip to content

Commit

Permalink
[server] Extract ScmService to be used by both APIs (#19098)
Browse files Browse the repository at this point in the history
* [server] add ScmService to be used by ScmServiceAPI (and WS API)

* add simple test for `getToken`

* refactor `ScmService.getToken` to return token of undefined

* fix duplicata validation

* add api converter tests

* just some docs
  • Loading branch information
AlexTugarev authored Nov 22, 2023
1 parent 0fba169 commit 612b919
Show file tree
Hide file tree
Showing 23 changed files with 590 additions and 272 deletions.
7 changes: 0 additions & 7 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 48 additions & 1 deletion components/gitpod-protocol/src/public-api-converter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
});
});
});
});
24 changes: 24 additions & 0 deletions components/gitpod-protocol/src/public-api-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -65,6 +66,8 @@ import {
UserEnvVarValue,
ProjectEnvVar,
PrebuiltWorkspaceState,
Token,
SuggestedRepository as SuggestedRepositoryProtocol,
} from "./protocol";
import {
OrgMemberInfo,
Expand Down Expand Up @@ -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,
});
}
}
93 changes: 93 additions & 0 deletions components/server/src/api/scm-service-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* 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<typeof ScmServiceInterface> {
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<SearchSCMTokensResponse> {
const userId = ctxUserId();
const response = new SearchSCMTokensResponse();
const token = await this.scmService.getToken(userId, request);
if (token) {
response.tokens.push(this.apiConverter.toSCMToken(token));
}
return response;
}

async guessTokenScopes(request: GuessTokenScopesRequest, _: HandlerContext): Promise<GuessTokenScopesResponse> {
const userId = ctxUserId();
const { scopes, message } = await this.scmService.guessTokenScopes(userId, request);
return new GuessTokenScopesResponse({
scopes,
message,
});
}

async searchRepositories(
request: SearchRepositoriesRequest,
_: HandlerContext,
): Promise<SearchRepositoriesResponse> {
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<ListSuggestedRepositoriesResponse> {
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,
}),
});
}
}
5 changes: 5 additions & 0 deletions components/server/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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();
}
Expand Down
2 changes: 2 additions & 0 deletions components/server/src/api/teams.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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>(TypeORM);
Expand Down
48 changes: 28 additions & 20 deletions components/server/src/auth/auth-provider-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,44 +86,52 @@ export class AuthProviderService {
return result.map(toPublic);
}

async findAuthProviderDescription(user: User, host: string): Promise<AuthProviderInfo | undefined> {
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<AuthProviderInfo[]> {
const { builtinAuthProvidersConfigured } = this.config;

const authProviders = [...(await this.getAllAuthProviderParams()), ...this.config.authProviderConfigs];

// explicitly copy to avoid bleeding sensitive details
const toInfo = (ap: AuthProviderParams) =>
<AuthProviderInfo>{
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;
Expand Down
10 changes: 6 additions & 4 deletions components/server/src/auth/authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion components/server/src/bitbucket/bitbucket-token-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
Loading

0 comments on commit 612b919

Please sign in to comment.