Skip to content

Commit 64a3604

Browse files
committed
[fga] Introduce GitpodTokenService
1 parent 92853b5 commit 64a3604

File tree

6 files changed

+224
-39
lines changed

6 files changed

+224
-39
lines changed

components/server/src/authorization/definitions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ export type UserResourceType = "user";
3232

3333
export type UserRelation = "self" | "organization" | "installation";
3434

35-
export type UserPermission = "read_info" | "write_info" | "make_admin" | "read_ssh" | "write_ssh";
35+
export type UserPermission =
36+
| "read_info"
37+
| "write_info"
38+
| "make_admin"
39+
| "read_ssh"
40+
| "write_ssh"
41+
| "read_tokens"
42+
| "write_tokens";
3643

3744
export type InstallationResourceType = "installation";
3845

components/server/src/container-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ import { UserService } from "./user/user-service";
130130
import { RelationshipUpdater } from "./authorization/relationship-updater";
131131
import { WorkspaceService } from "./workspace/workspace-service";
132132
import { SSHKeyService } from "./user/sshkey-service";
133+
import { GitpodTokenService } from "./user/gitpod-token-service";
133134

134135
export const productionContainerModule = new ContainerModule(
135136
(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => {
@@ -145,6 +146,7 @@ export const productionContainerModule = new ContainerModule(
145146
bind(AuthorizationService).to(AuthorizationServiceImpl).inSingletonScope();
146147

147148
bind(SSHKeyService).toSelf().inSingletonScope();
149+
bind(GitpodTokenService).toSelf().inSingletonScope();
148150

149151
bind(TokenService).toSelf().inSingletonScope();
150152
bind(TokenProvider).toService(TokenService);
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { BUILTIN_INSTLLATION_ADMIN_USER_ID, TypeORM } from "@gitpod/gitpod-db/lib";
8+
import { GitpodTokenType, Organization, User } from "@gitpod/gitpod-protocol";
9+
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
10+
import * as chai from "chai";
11+
import { Container } from "inversify";
12+
import "mocha";
13+
import { createTestContainer } from "../test/service-testing-container-module";
14+
import { resetDB } from "@gitpod/gitpod-db/lib/test/reset-db";
15+
import { OrganizationService } from "../orgs/organization-service";
16+
import { UserService } from "./user-service";
17+
import { expectError } from "../test/expect-utils";
18+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
19+
import { GitpodTokenService } from "./gitpod-token-service";
20+
21+
const expect = chai.expect;
22+
23+
describe("GitpodTokenService", async () => {
24+
let container: Container;
25+
let gs: GitpodTokenService;
26+
27+
let member: User;
28+
let stranger: User;
29+
let org: Organization;
30+
31+
beforeEach(async () => {
32+
container = createTestContainer();
33+
Experiments.configureTestingClient({
34+
centralizedPermissions: true,
35+
});
36+
37+
const orgService = container.get<OrganizationService>(OrganizationService);
38+
org = await orgService.createOrganization(BUILTIN_INSTLLATION_ADMIN_USER_ID, "myOrg");
39+
const invite = await orgService.getOrCreateInvite(BUILTIN_INSTLLATION_ADMIN_USER_ID, org.id);
40+
41+
const userService = container.get<UserService>(UserService);
42+
member = await userService.createUser({
43+
organizationId: org.id,
44+
identity: {
45+
authId: "foo",
46+
authName: "bar",
47+
authProviderId: "github",
48+
primaryEmail: "[email protected]",
49+
},
50+
});
51+
await orgService.joinOrganization(member.id, invite.id);
52+
stranger = await userService.createUser({
53+
identity: {
54+
authId: "foo2",
55+
authName: "bar2",
56+
authProviderId: "github",
57+
},
58+
});
59+
60+
gs = container.get(GitpodTokenService);
61+
});
62+
63+
afterEach(async () => {
64+
// Clean-up database
65+
await resetDB(container.get(TypeORM));
66+
});
67+
68+
it("should generate a new gitpod token", async () => {
69+
const resp1 = await gs.getGitpodTokens(member.id, member.id);
70+
expect(resp1.length).to.equal(0);
71+
72+
await gs.generateNewGitpodToken(member.id, member.id, { name: "token1", type: GitpodTokenType.API_AUTH_TOKEN });
73+
74+
const resp2 = await gs.getGitpodTokens(member.id, member.id);
75+
expect(resp2.length).to.equal(1);
76+
77+
await expectError(ErrorCodes.NOT_FOUND, gs.getGitpodTokens(stranger.id, member.id));
78+
await expectError(
79+
ErrorCodes.NOT_FOUND,
80+
gs.generateNewGitpodToken(stranger.id, member.id, { name: "token2", type: GitpodTokenType.API_AUTH_TOKEN }),
81+
);
82+
});
83+
84+
it("should list gitpod tokens", async () => {
85+
await gs.generateNewGitpodToken(member.id, member.id, { name: "token1", type: GitpodTokenType.API_AUTH_TOKEN });
86+
await gs.generateNewGitpodToken(member.id, member.id, { name: "token2", type: GitpodTokenType.API_AUTH_TOKEN });
87+
88+
const tokens = await gs.getGitpodTokens(member.id, member.id);
89+
expect(tokens.length).to.equal(2);
90+
expect(tokens.some((t) => t.name === "token1")).to.be.true;
91+
expect(tokens.some((t) => t.name === "token2")).to.be.true;
92+
93+
await expectError(ErrorCodes.NOT_FOUND, gs.getGitpodTokens(stranger.id, member.id));
94+
});
95+
96+
it("should return gitpod token scopes", async () => {
97+
await gs.generateNewGitpodToken(member.id, member.id, {
98+
name: "token1",
99+
type: GitpodTokenType.API_AUTH_TOKEN,
100+
scopes: ["user:email", "read:user"],
101+
});
102+
103+
const tokens = await gs.getGitpodTokens(member.id, member.id);
104+
expect(tokens.length).to.equal(1);
105+
106+
const scopes = await gs.getGitpodTokenScopes(member.id, member.id, tokens[0].tokenHash);
107+
expect(scopes.length).to.equal(2);
108+
expect(scopes.some((s) => s === "user:email")).to.be.true;
109+
expect(scopes.some((s) => s === "read:user")).to.be.true;
110+
111+
await expectError(ErrorCodes.NOT_FOUND, gs.getGitpodTokenScopes(stranger.id, member.id, tokens[0].tokenHash));
112+
});
113+
114+
it("should delete gitpod tokens", async () => {
115+
await gs.generateNewGitpodToken(member.id, member.id, {
116+
name: "token1",
117+
type: GitpodTokenType.API_AUTH_TOKEN,
118+
});
119+
await gs.generateNewGitpodToken(member.id, member.id, {
120+
name: "token2",
121+
type: GitpodTokenType.API_AUTH_TOKEN,
122+
});
123+
124+
const tokens = await gs.getGitpodTokens(member.id, member.id);
125+
expect(tokens.length).to.equal(2);
126+
127+
await gs.deleteGitpodToken(member.id, member.id, tokens[0].tokenHash);
128+
129+
const tokens2 = await gs.getGitpodTokens(member.id, member.id);
130+
expect(tokens2.length).to.equal(1);
131+
132+
await expectError(ErrorCodes.NOT_FOUND, gs.deleteGitpodToken(stranger.id, member.id, tokens[1].tokenHash));
133+
});
134+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import * as crypto from "crypto";
8+
import { DBGitpodToken, UserDB } from "@gitpod/gitpod-db/lib";
9+
import { GitpodToken, GitpodTokenType } from "@gitpod/gitpod-protocol";
10+
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
11+
import { inject, injectable } from "inversify";
12+
import { Authorizer } from "../authorization/authorizer";
13+
14+
@injectable()
15+
export class GitpodTokenService {
16+
constructor(
17+
@inject(UserDB) private readonly userDB: UserDB,
18+
@inject(Authorizer) private readonly auth: Authorizer,
19+
) {}
20+
21+
async getGitpodTokens(requestorId: string, userId: string): Promise<GitpodToken[]> {
22+
await this.auth.checkPermissionOnUser(requestorId, "read_tokens", userId);
23+
const res = (await this.userDB.findAllGitpodTokensOfUser(userId)).filter((v) => !v.deleted);
24+
return res;
25+
}
26+
27+
async generateNewGitpodToken(
28+
requestorId: string,
29+
userId: string,
30+
options: { name?: string; type: GitpodTokenType; scopes?: string[] },
31+
): Promise<string> {
32+
await this.auth.checkPermissionOnUser(requestorId, "write_tokens", userId);
33+
const token = crypto.randomBytes(30).toString("hex");
34+
const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest("hex");
35+
const dbToken: DBGitpodToken = {
36+
tokenHash,
37+
name: options.name,
38+
type: options.type,
39+
userId,
40+
scopes: options.scopes || [],
41+
created: new Date().toISOString(),
42+
};
43+
await this.userDB.storeGitpodToken(dbToken);
44+
return token;
45+
}
46+
47+
async getGitpodTokenScopes(requestorId: string, userId: string, tokenHash: string): Promise<string[]> {
48+
await this.auth.checkPermissionOnUser(requestorId, "read_tokens", userId);
49+
let token: GitpodToken | undefined;
50+
try {
51+
token = await this.userDB.findGitpodTokensOfUser(userId, tokenHash);
52+
} catch (error) {
53+
log.error({ userId }, "failed to resolve gitpod token: ", error);
54+
return [];
55+
}
56+
if (!token || token.deleted) {
57+
return [];
58+
}
59+
return token.scopes;
60+
}
61+
62+
async deleteGitpodToken(requestorId: string, userId: string, tokenHash: string): Promise<void> {
63+
await this.auth.checkPermissionOnUser(requestorId, "write_tokens", userId);
64+
const existingTokens = await this.getGitpodTokens(requestorId, userId);
65+
const tkn = existingTokens.find((token) => token.tokenHash === tokenHash);
66+
if (!tkn) {
67+
throw new Error(`User ${requestorId} tries to delete a token ${tokenHash} that does not exist.`);
68+
}
69+
await this.userDB.deleteGitpodToken(tokenHash);
70+
}
71+
}

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

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
WorkspaceDB,
1111
DBWithTracing,
1212
TracedWorkspaceDB,
13-
DBGitpodToken,
1413
EmailDomainFilterDB,
1514
TeamDB,
1615
RedisPublisher,
@@ -112,7 +111,6 @@ import {
112111
StopWorkspacePolicy,
113112
TakeSnapshotRequest,
114113
} from "@gitpod/ws-manager/lib/core_pb";
115-
import * as crypto from "crypto";
116114
import { inject, injectable } from "inversify";
117115
import { URL } from "url";
118116
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
@@ -194,6 +192,7 @@ import { UsageService } from "../orgs/usage-service";
194192
import { UserService } from "../user/user-service";
195193
import { WorkspaceService } from "./workspace-service";
196194
import { SSHKeyService } from "../user/sshkey-service";
195+
import { GitpodTokenService } from "../user/gitpod-token-service";
197196

198197
// shortcut
199198
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -237,6 +236,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
237236
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
238237
@inject(AuthorizationService) private readonly authorizationService: AuthorizationService,
239238
@inject(SSHKeyService) private readonly sshKeyservice: SSHKeyService,
239+
@inject(GitpodTokenService) private readonly gitpodTokenService: GitpodTokenService,
240240

241241
@inject(TeamDB) private readonly teamDB: TeamDB,
242242
@inject(OrganizationService) private readonly organizationService: OrganizationService,
@@ -2958,9 +2958,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
29582958

29592959
public async getGitpodTokens(ctx: TraceContext): Promise<GitpodToken[]> {
29602960
const user = await this.checkAndBlockUser("getGitpodTokens");
2961-
const res = (await this.userDB.findAllGitpodTokensOfUser(user.id)).filter((v) => !v.deleted);
2962-
await Promise.all(res.map((tkn) => this.guardAccess({ kind: "gitpodToken", subject: tkn }, "get")));
2963-
return res;
2961+
return this.gitpodTokenService.getGitpodTokens(user.id, user.id);
29642962
}
29652963

29662964
public async generateNewGitpodToken(
@@ -2970,51 +2968,21 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
29702968
traceAPIParams(ctx, { options });
29712969

29722970
const user = await this.checkAndBlockUser("generateNewGitpodToken");
2973-
const token = crypto.randomBytes(30).toString("hex");
2974-
const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest("hex");
2975-
const dbToken: DBGitpodToken = {
2976-
tokenHash,
2977-
name: options.name,
2978-
type: options.type,
2979-
userId: user.id,
2980-
scopes: options.scopes || [],
2981-
created: new Date().toISOString(),
2982-
};
2983-
await this.guardAccess({ kind: "gitpodToken", subject: dbToken }, "create");
2984-
2985-
await this.userDB.storeGitpodToken(dbToken);
2986-
return token;
2971+
return this.gitpodTokenService.generateNewGitpodToken(user.id, user.id, options);
29872972
}
29882973

29892974
public async getGitpodTokenScopes(ctx: TraceContext, tokenHash: string): Promise<string[]> {
29902975
traceAPIParams(ctx, {}); // do not trace tokenHash
29912976

29922977
const user = await this.checkAndBlockUser("getGitpodTokenScopes");
2993-
let token: GitpodToken | undefined;
2994-
try {
2995-
token = await this.userDB.findGitpodTokensOfUser(user.id, tokenHash);
2996-
} catch (error) {
2997-
log.error({ userId: user.id }, "failed to resolve gitpod token: ", error);
2998-
return [];
2999-
}
3000-
if (!token || token.deleted) {
3001-
return [];
3002-
}
3003-
await this.guardAccess({ kind: "gitpodToken", subject: token }, "get");
3004-
return token.scopes;
2978+
return this.gitpodTokenService.getGitpodTokenScopes(user.id, user.id, tokenHash);
30052979
}
30062980

30072981
public async deleteGitpodToken(ctx: TraceContext, tokenHash: string): Promise<void> {
30082982
traceAPIParams(ctx, {}); // do not trace tokenHash
30092983

30102984
const user = await this.checkAndBlockUser("deleteGitpodToken");
3011-
const existingTokens = await this.getGitpodTokens(ctx); // all tokens for logged in user
3012-
const tkn = existingTokens.find((token) => token.tokenHash === tokenHash);
3013-
if (!tkn) {
3014-
throw new Error(`User ${user.id} tries to delete a token ${tokenHash} that does not exist.`);
3015-
}
3016-
await this.guardAccess({ kind: "gitpodToken", subject: tkn }, "delete");
3017-
return this.userDB.deleteGitpodToken(tokenHash);
2985+
return this.gitpodTokenService.deleteGitpodToken(user.id, user.id, tokenHash);
30182986
}
30192987

30202988
async guessGitTokenScopes(ctx: TraceContext, params: GuessGitTokenScopesParams): Promise<GuessedGitTokenScopes> {

components/spicedb/schema/schema.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ schema: |-
1717
1818
permission read_ssh = self
1919
permission write_ssh = self
20+
21+
permission read_tokens = self
22+
permission write_tokens = self
2023
}
2124
2225
// There's only one global installation

0 commit comments

Comments
 (0)