Skip to content

Commit 0e48ca9

Browse files
committed
[fga] Introduce EnvVarService
1 parent 2d90f77 commit 0e48ca9

File tree

4 files changed

+224
-85
lines changed

4 files changed

+224
-85
lines changed

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 { EnvVarService } from "./user/env-var-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(EnvVarService).toSelf().inSingletonScope();
148150

149151
bind(TokenService).toSelf().inSingletonScope();
150152
bind(TokenProvider).toService(TokenService);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 { 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 { EnvVarService } from "./env-var-service";
20+
21+
const expect = chai.expect;
22+
23+
describe("EnvVarService", async () => {
24+
let container: Container;
25+
let es: EnvVarService;
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+
es = container.get(EnvVarService);
61+
});
62+
63+
afterEach(async () => {
64+
// Clean-up database
65+
await resetDB(container.get(TypeORM));
66+
});
67+
68+
it("should add env variable", async () => {
69+
const resp1 = await es.getAllEnvVars(member.id, member.id);
70+
expect(resp1.length).to.equal(0);
71+
72+
await es.setEnvVar(member.id, member.id, { name: "var1", value: "foo", repositoryPattern: "*/*" });
73+
74+
const resp2 = await es.getAllEnvVars(member.id, member.id);
75+
expect(resp2.length).to.equal(1);
76+
77+
await expectError(ErrorCodes.NOT_FOUND, es.getAllEnvVars(stranger.id, member.id));
78+
await expectError(
79+
ErrorCodes.NOT_FOUND,
80+
es.setEnvVar(stranger.id, member.id, { name: "var2", value: "bar", repositoryPattern: "*/*" }),
81+
);
82+
});
83+
84+
it("should list all env vars", async () => {
85+
await es.setEnvVar(member.id, member.id, { name: "var1", value: "foo", repositoryPattern: "*/*" });
86+
await es.setEnvVar(member.id, member.id, { name: "var2", value: "bar", repositoryPattern: "*/*" });
87+
88+
const envVars = await es.getAllEnvVars(member.id, member.id);
89+
expect(envVars.length).to.equal(2);
90+
expect(envVars.some((e) => e.name === "var1" && e.value === "foo")).to.be.true;
91+
expect(envVars.some((e) => e.name === "var2" && e.value === "bar")).to.be.true;
92+
93+
await expectError(ErrorCodes.NOT_FOUND, es.getAllEnvVars(stranger.id, member.id));
94+
});
95+
96+
it("should delete env vars", async () => {
97+
await es.setEnvVar(member.id, member.id, { name: "var1", value: "foo", repositoryPattern: "*/*" });
98+
await es.setEnvVar(member.id, member.id, { name: "var2", value: "bar", repositoryPattern: "*/*" });
99+
100+
const envVars = await es.getAllEnvVars(member.id, member.id);
101+
expect(envVars.length).to.equal(2);
102+
103+
await es.deleteEnvVar(member.id, member.id, envVars[0]);
104+
105+
const envVars2 = await es.getAllEnvVars(member.id, member.id);
106+
expect(envVars2.length).to.equal(1);
107+
108+
await expectError(ErrorCodes.NOT_FOUND, es.deleteEnvVar(stranger.id, member.id, envVars2[0]));
109+
});
110+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 { UserDB } from "@gitpod/gitpod-db/lib";
8+
import { UserEnvVar, UserEnvVarValue } from "@gitpod/gitpod-protocol";
9+
import { inject, injectable } from "inversify";
10+
import { Authorizer } from "../authorization/authorizer";
11+
import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics";
12+
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
13+
import { v4 as uuidv4 } from "uuid";
14+
import { Config } from "../config";
15+
16+
@injectable()
17+
export class EnvVarService {
18+
constructor(
19+
@inject(Config) private readonly config: Config,
20+
@inject(UserDB) private readonly userDB: UserDB,
21+
@inject(Authorizer) private readonly auth: Authorizer,
22+
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
23+
) {}
24+
25+
async getAllEnvVars(requestorId: string, userId: string): Promise<UserEnvVarValue[]> {
26+
await this.auth.checkPermissionOnUser(requestorId, "read_info", userId);
27+
const result: UserEnvVarValue[] = [];
28+
for (const value of await this.userDB.getEnvVars(userId)) {
29+
result.push({
30+
id: value.id,
31+
name: value.name,
32+
value: value.value,
33+
repositoryPattern: value.repositoryPattern,
34+
});
35+
}
36+
return result;
37+
}
38+
39+
async setEnvVar(requestorId: string, userId: string, variable: UserEnvVarValue): Promise<void> {
40+
await this.auth.checkPermissionOnUser(requestorId, "write_info", userId);
41+
const validationError = UserEnvVar.validate(variable);
42+
if (validationError) {
43+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, validationError);
44+
}
45+
46+
variable.repositoryPattern = UserEnvVar.normalizeRepoPattern(variable.repositoryPattern);
47+
const existingVars = (await this.userDB.getEnvVars(userId)).filter((v) => !v.deleted);
48+
49+
const existingVar = existingVars.find(
50+
(v) => v.name == variable.name && v.repositoryPattern == variable.repositoryPattern,
51+
);
52+
if (!!existingVar) {
53+
// overwrite existing variable rather than introduce a duplicate
54+
variable.id = existingVar.id;
55+
}
56+
57+
if (!variable.id) {
58+
// this is a new variable - make sure the user does not have too many (don't DOS our database using gp env)
59+
const varCount = existingVars.length;
60+
if (varCount > this.config.maxEnvvarPerUserCount) {
61+
throw new ApplicationError(
62+
ErrorCodes.PERMISSION_DENIED,
63+
`cannot have more than ${this.config.maxEnvvarPerUserCount} environment variables`,
64+
);
65+
}
66+
}
67+
68+
const envvar: UserEnvVar = {
69+
id: variable.id || uuidv4(),
70+
name: variable.name,
71+
repositoryPattern: variable.repositoryPattern,
72+
value: variable.value,
73+
userId,
74+
};
75+
this.analytics.track({ event: "envvar-set", userId });
76+
77+
await this.userDB.setEnvVar(envvar);
78+
}
79+
80+
async deleteEnvVar(requestorId: string, userId: string, variable: UserEnvVarValue): Promise<void> {
81+
await this.auth.checkPermissionOnUser(requestorId, "write_info", userId);
82+
if (!variable.id && variable.name && variable.repositoryPattern) {
83+
variable.repositoryPattern = UserEnvVar.normalizeRepoPattern(variable.repositoryPattern);
84+
const existingVars = (await this.userDB.getEnvVars(userId)).filter((v) => !v.deleted);
85+
const existingVar = existingVars.find(
86+
(v) => v.name == variable.name && v.repositoryPattern == variable.repositoryPattern,
87+
);
88+
variable.id = existingVar?.id;
89+
}
90+
91+
if (!variable.id) {
92+
throw new ApplicationError(
93+
ErrorCodes.NOT_FOUND,
94+
`cannot delete '${variable.name}' in scope '${variable.repositoryPattern}'`,
95+
);
96+
}
97+
98+
const envvar: UserEnvVar = {
99+
...variable,
100+
id: variable.id!,
101+
userId,
102+
};
103+
this.analytics.track({ event: "envvar-deleted", userId });
104+
105+
await this.userDB.deleteEnvVar(envvar);
106+
}
107+
}

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

Lines changed: 5 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import {
3636
StartWorkspaceResult,
3737
Token,
3838
User,
39-
UserEnvVar,
4039
UserEnvVarValue,
4140
UserInfo,
4241
WhitelistedRepository,
@@ -194,6 +193,7 @@ import { UsageService } from "../orgs/usage-service";
194193
import { UserService } from "../user/user-service";
195194
import { WorkspaceService } from "./workspace-service";
196195
import { SSHKeyService } from "../user/sshkey-service";
196+
import { EnvVarService } from "../user/env-var-service";
197197

198198
// shortcut
199199
export const traceWI = (ctx: TraceContext, wi: Omit<LogContext, "userId">) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager
@@ -237,6 +237,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
237237
@inject(IAnalyticsWriter) private readonly analytics: IAnalyticsWriter,
238238
@inject(AuthorizationService) private readonly authorizationService: AuthorizationService,
239239
@inject(SSHKeyService) private readonly sshKeyservice: SSHKeyService,
240+
@inject(EnvVarService) private readonly envVarService: EnvVarService,
240241

241242
@inject(TeamDB) private readonly teamDB: TeamDB,
242243
@inject(OrganizationService) private readonly organizationService: OrganizationService,
@@ -2344,104 +2345,23 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
23442345
// Get all environment variables (unfiltered)
23452346
async getAllEnvVars(ctx: TraceContext): Promise<UserEnvVarValue[]> {
23462347
const user = await this.checkUser("getAllEnvVars");
2347-
const result: UserEnvVarValue[] = [];
2348-
for (const value of await this.userDB.getEnvVars(user.id)) {
2349-
if (!(await this.resourceAccessGuard.canAccess({ kind: "envVar", subject: value }, "get"))) {
2350-
continue;
2351-
}
2352-
result.push({
2353-
id: value.id,
2354-
name: value.name,
2355-
value: value.value,
2356-
repositoryPattern: value.repositoryPattern,
2357-
});
2358-
}
2359-
return result;
2348+
return this.envVarService.getAllEnvVars(user.id, user.id);
23602349
}
23612350

23622351
async setEnvVar(ctx: TraceContext, variable: UserEnvVarValue): Promise<void> {
23632352
traceAPIParams(ctx, { variable: censor(variable, "value") }); // filter content because of PII
23642353

23652354
// Note: this operation is per-user only, hence needs no resource guard
23662355
const user = await this.checkAndBlockUser("setEnvVar");
2367-
const userId = user.id;
2368-
2369-
// validate input
2370-
const validationError = UserEnvVar.validate(variable);
2371-
if (validationError) {
2372-
throw new ApplicationError(ErrorCodes.BAD_REQUEST, validationError);
2373-
}
2374-
2375-
variable.repositoryPattern = UserEnvVar.normalizeRepoPattern(variable.repositoryPattern);
2376-
const existingVars = (await this.userDB.getEnvVars(user.id)).filter((v) => !v.deleted);
2377-
2378-
const existingVar = existingVars.find(
2379-
(v) => v.name == variable.name && v.repositoryPattern == variable.repositoryPattern,
2380-
);
2381-
if (!!existingVar) {
2382-
// overwrite existing variable rather than introduce a duplicate
2383-
variable.id = existingVar.id;
2384-
}
2385-
2386-
if (!variable.id) {
2387-
// this is a new variable - make sure the user does not have too many (don't DOS our database using gp env)
2388-
const varCount = existingVars.length;
2389-
if (varCount > this.config.maxEnvvarPerUserCount) {
2390-
throw new ApplicationError(
2391-
ErrorCodes.PERMISSION_DENIED,
2392-
`cannot have more than ${this.config.maxEnvvarPerUserCount} environment variables`,
2393-
);
2394-
}
2395-
}
2396-
2397-
const envvar: UserEnvVar = {
2398-
id: variable.id || uuidv4(),
2399-
name: variable.name,
2400-
repositoryPattern: variable.repositoryPattern,
2401-
value: variable.value,
2402-
userId,
2403-
};
2404-
await this.guardAccess(
2405-
{ kind: "envVar", subject: envvar },
2406-
typeof variable.id === "string" ? "update" : "create",
2407-
);
2408-
this.analytics.track({ event: "envvar-set", userId });
2409-
2410-
await this.userDB.setEnvVar(envvar);
2356+
return this.envVarService.setEnvVar(user.id, user.id, variable);
24112357
}
24122358

24132359
async deleteEnvVar(ctx: TraceContext, variable: UserEnvVarValue): Promise<void> {
24142360
traceAPIParams(ctx, { variable: censor(variable, "value") });
24152361

24162362
// Note: this operation is per-user only, hence needs no resource guard
24172363
const user = await this.checkAndBlockUser("deleteEnvVar");
2418-
const userId = user.id;
2419-
2420-
if (!variable.id && variable.name && variable.repositoryPattern) {
2421-
variable.repositoryPattern = UserEnvVar.normalizeRepoPattern(variable.repositoryPattern);
2422-
const existingVars = (await this.userDB.getEnvVars(user.id)).filter((v) => !v.deleted);
2423-
const existingVar = existingVars.find(
2424-
(v) => v.name == variable.name && v.repositoryPattern == variable.repositoryPattern,
2425-
);
2426-
variable.id = existingVar?.id;
2427-
}
2428-
2429-
if (!variable.id) {
2430-
throw new ApplicationError(
2431-
ErrorCodes.NOT_FOUND,
2432-
`cannot delete '${variable.name}' in scope '${variable.repositoryPattern}'`,
2433-
);
2434-
}
2435-
2436-
const envvar: UserEnvVar = {
2437-
...variable,
2438-
id: variable.id!,
2439-
userId,
2440-
};
2441-
await this.guardAccess({ kind: "envVar", subject: envvar }, "delete");
2442-
this.analytics.track({ event: "envvar-deleted", userId });
2443-
2444-
await this.userDB.deleteEnvVar(envvar);
2364+
return this.envVarService.deleteEnvVar(user.id, user.id, variable);
24452365
}
24462366

24472367
async hasSSHPublicKey(ctx: TraceContext): Promise<boolean> {

0 commit comments

Comments
 (0)