Skip to content

Commit 6cf3aa2

Browse files
authored
[fga] Introduce GitpodTokenService (#18502)
1 parent 62a0298 commit 6cf3aa2

File tree

6 files changed

+244
-38
lines changed

6 files changed

+244
-38
lines changed

components/server/src/authorization/definitions.ts

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

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

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

3745
export type InstallationResourceType = "installation";
3846

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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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", 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 token = await gs.findGitpodToken(member.id, member.id, tokens[0].tokenHash);
107+
expect(token).to.not.be.undefined;
108+
109+
await expectError(ErrorCodes.NOT_FOUND, gs.findGitpodToken(stranger.id, member.id, tokens[0].tokenHash));
110+
});
111+
112+
it("should delete gitpod tokens", async () => {
113+
await gs.generateNewGitpodToken(member.id, member.id, {
114+
name: "token1",
115+
type: GitpodTokenType.API_AUTH_TOKEN,
116+
});
117+
await gs.generateNewGitpodToken(member.id, member.id, {
118+
name: "token2",
119+
type: GitpodTokenType.API_AUTH_TOKEN,
120+
});
121+
122+
const tokens = await gs.getGitpodTokens(member.id, member.id);
123+
expect(tokens.length).to.equal(2);
124+
125+
await gs.deleteGitpodToken(member.id, member.id, tokens[0].tokenHash);
126+
127+
const tokens2 = await gs.getGitpodTokens(member.id, member.id);
128+
expect(tokens2.length).to.equal(1);
129+
130+
await expectError(ErrorCodes.NOT_FOUND, gs.deleteGitpodToken(stranger.id, member.id, tokens[1].tokenHash));
131+
});
132+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 gitpodTokens = await this.userDB.findAllGitpodTokensOfUser(userId);
24+
return gitpodTokens.filter((v) => !v.deleted);
25+
}
26+
27+
async generateNewGitpodToken(
28+
requestorId: string,
29+
userId: string,
30+
options: { name?: string; type: GitpodTokenType; scopes?: string[] },
31+
oldPermissionCheck?: (dbToken: DBGitpodToken) => Promise<void>, // @deprecated
32+
): Promise<string> {
33+
await this.auth.checkPermissionOnUser(requestorId, "write_tokens", userId);
34+
const token = crypto.randomBytes(30).toString("hex");
35+
const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest("hex");
36+
const dbToken: DBGitpodToken = {
37+
tokenHash,
38+
name: options.name,
39+
type: options.type,
40+
userId,
41+
scopes: options.scopes || [],
42+
created: new Date().toISOString(),
43+
};
44+
if (oldPermissionCheck) {
45+
await oldPermissionCheck(dbToken);
46+
}
47+
await this.userDB.storeGitpodToken(dbToken);
48+
return token;
49+
}
50+
51+
async findGitpodToken(requestorId: string, userId: string, tokenHash: string): Promise<GitpodToken | undefined> {
52+
await this.auth.checkPermissionOnUser(requestorId, "read_tokens", userId);
53+
let token: GitpodToken | undefined;
54+
try {
55+
token = await this.userDB.findGitpodTokensOfUser(userId, tokenHash);
56+
} catch (error) {
57+
log.error({ userId }, "failed to resolve gitpod token: ", error);
58+
}
59+
if (token?.deleted) {
60+
token = undefined;
61+
}
62+
return token;
63+
}
64+
65+
async deleteGitpodToken(
66+
requestorId: string,
67+
userId: string,
68+
tokenHash: string,
69+
oldPermissionCheck?: (token: GitpodToken) => Promise<void>, // @deprecated
70+
): Promise<void> {
71+
await this.auth.checkPermissionOnUser(requestorId, "write_tokens", userId);
72+
const existingTokens = await this.getGitpodTokens(requestorId, userId);
73+
const tkn = existingTokens.find((token) => token.tokenHash === tokenHash);
74+
if (!tkn) {
75+
throw new Error(`User ${requestorId} tries to delete a token ${tokenHash} that does not exist.`);
76+
}
77+
if (oldPermissionCheck) {
78+
await oldPermissionCheck(tkn);
79+
}
80+
await this.userDB.deleteGitpodToken(tokenHash);
81+
}
82+
}

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

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import {
1010
WorkspaceDB,
1111
DBWithTracing,
1212
TracedWorkspaceDB,
13-
DBGitpodToken,
1413
EmailDomainFilterDB,
1514
TeamDB,
1615
RedisPublisher,
16+
DBGitpodToken,
1717
} from "@gitpod/gitpod-db/lib";
1818
import { BlockedRepositoryDB } from "@gitpod/gitpod-db/lib/blocked-repository-db";
1919
import {
@@ -112,7 +112,6 @@ import {
112112
StopWorkspacePolicy,
113113
TakeSnapshotRequest,
114114
} from "@gitpod/ws-manager/lib/core_pb";
115-
import * as crypto from "crypto";
116115
import { inject, injectable } from "inversify";
117116
import { URL } from "url";
118117
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
@@ -192,6 +191,7 @@ import { UsageService } from "../orgs/usage-service";
192191
import { UserService } from "../user/user-service";
193192
import { SSHKeyService } from "../user/sshkey-service";
194193
import { StartWorkspaceOptions, WorkspaceService } from "./workspace-service";
194+
import { GitpodTokenService } from "../user/gitpod-token-service";
195195

196196
// shortcut
197197
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -234,6 +234,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
234234
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
235235
@inject(AuthorizationService) private readonly authorizationService: AuthorizationService,
236236
@inject(SSHKeyService) private readonly sshKeyservice: SSHKeyService,
237+
@inject(GitpodTokenService) private readonly gitpodTokenService: GitpodTokenService,
237238

238239
@inject(TeamDB) private readonly teamDB: TeamDB,
239240
@inject(OrganizationService) private readonly organizationService: OrganizationService,
@@ -2892,9 +2893,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
28922893

28932894
public async getGitpodTokens(ctx: TraceContext): Promise<GitpodToken[]> {
28942895
const user = await this.checkAndBlockUser("getGitpodTokens");
2895-
const res = (await this.userDB.findAllGitpodTokensOfUser(user.id)).filter((v) => !v.deleted);
2896-
await Promise.all(res.map((tkn) => this.guardAccess({ kind: "gitpodToken", subject: tkn }, "get")));
2897-
return res;
2896+
const gitpodTokens = await this.gitpodTokenService.getGitpodTokens(user.id, user.id);
2897+
await Promise.all(gitpodTokens.map((tkn) => this.guardAccess({ kind: "gitpodToken", subject: tkn }, "get")));
2898+
return gitpodTokens;
28982899
}
28992900

29002901
public async generateNewGitpodToken(
@@ -2904,51 +2905,29 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
29042905
traceAPIParams(ctx, { options });
29052906

29062907
const user = await this.checkAndBlockUser("generateNewGitpodToken");
2907-
const token = crypto.randomBytes(30).toString("hex");
2908-
const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest("hex");
2909-
const dbToken: DBGitpodToken = {
2910-
tokenHash,
2911-
name: options.name,
2912-
type: options.type,
2913-
userId: user.id,
2914-
scopes: options.scopes || [],
2915-
created: new Date().toISOString(),
2916-
};
2917-
await this.guardAccess({ kind: "gitpodToken", subject: dbToken }, "create");
2918-
2919-
await this.userDB.storeGitpodToken(dbToken);
2920-
return token;
2908+
return this.gitpodTokenService.generateNewGitpodToken(user.id, user.id, options, (dbToken: DBGitpodToken) => {
2909+
return this.guardAccess({ kind: "gitpodToken", subject: dbToken }, "create");
2910+
});
29212911
}
29222912

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

29262916
const user = await this.checkAndBlockUser("getGitpodTokenScopes");
2927-
let token: GitpodToken | undefined;
2928-
try {
2929-
token = await this.userDB.findGitpodTokensOfUser(user.id, tokenHash);
2930-
} catch (error) {
2931-
log.error({ userId: user.id }, "failed to resolve gitpod token: ", error);
2932-
return [];
2917+
const gitpodToken = await this.gitpodTokenService.findGitpodToken(user.id, user.id, tokenHash);
2918+
if (gitpodToken) {
2919+
await this.guardAccess({ kind: "gitpodToken", subject: gitpodToken }, "get");
29332920
}
2934-
if (!token || token.deleted) {
2935-
return [];
2936-
}
2937-
await this.guardAccess({ kind: "gitpodToken", subject: token }, "get");
2938-
return token.scopes;
2921+
return gitpodToken?.scopes ?? [];
29392922
}
29402923

29412924
public async deleteGitpodToken(ctx: TraceContext, tokenHash: string): Promise<void> {
29422925
traceAPIParams(ctx, {}); // do not trace tokenHash
29432926

29442927
const user = await this.checkAndBlockUser("deleteGitpodToken");
2945-
const existingTokens = await this.getGitpodTokens(ctx); // all tokens for logged in user
2946-
const tkn = existingTokens.find((token) => token.tokenHash === tokenHash);
2947-
if (!tkn) {
2948-
throw new Error(`User ${user.id} tries to delete a token ${tokenHash} that does not exist.`);
2949-
}
2950-
await this.guardAccess({ kind: "gitpodToken", subject: tkn }, "delete");
2951-
return this.userDB.deleteGitpodToken(tokenHash);
2928+
return this.gitpodTokenService.deleteGitpodToken(user.id, user.id, tokenHash, (token: GitpodToken) => {
2929+
return this.guardAccess({ kind: "gitpodToken", subject: token }, "delete");
2930+
});
29522931
}
29532932

29542933
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
@@ -18,6 +18,9 @@ schema: |-
1818
1919
permission read_ssh = self
2020
permission write_ssh = self
21+
22+
permission read_tokens = self
23+
permission write_tokens = self
2124
}
2225
2326
// There's only one global installation

0 commit comments

Comments
 (0)