Skip to content

Commit 0d36c68

Browse files
authored
[fga] Workspace: create, get, stop and delete (#18403)
* [server] Cleanup to make tests pass * [server] Introduce WorkspaceService with: - createWorkspace - getWorkspace - stopWorkspace - deleteWorkspace - hardDeleteWorkspace * add log info * fix after rebase * review comments * address review comment: system user
1 parent 888f9b9 commit 0d36c68

19 files changed

+583
-115
lines changed

components/gitpod-db/src/traced-db.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ export class DBWithTracing<T> {
2525
return async (...args: any[]) => {
2626
// do not try and trace calls with an empty trace context - the callers intention most likely was to omit the trace
2727
// so as to not spam the trace logs
28-
if (!ctx.span) {
28+
// Also, opentracing makes some assumptions about the Span object, so this might fail under some circumstances
29+
function isEmptyObject(obj: object): boolean {
30+
return Object.keys(obj).length === 0;
31+
}
32+
if (!ctx.span || isEmptyObject(ctx.span)) {
2933
return await f.bind(_target)(...args);
3034
}
3135

components/gitpod-protocol/src/messaging/client-call-metrics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export class PrometheusClientCallMetrics implements IClientCallMetrics {
4949
});
5050
}
5151

52+
dispose(): void {
53+
prometheusClient.register.removeSingleMetric("grpc_client_started_total");
54+
prometheusClient.register.removeSingleMetric("grpc_client_msg_sent_total");
55+
prometheusClient.register.removeSingleMetric("grpc_client_msg_received_total");
56+
prometheusClient.register.removeSingleMetric("grpc_client_handled_total");
57+
prometheusClient.register.removeSingleMetric("grpc_client_handling_seconds");
58+
}
59+
5260
started(labels: IGrpcCallMetricsLabels): void {
5361
this.startedCounter.inc({
5462
grpc_service: labels.service,

components/gitpod-protocol/src/util/tracing.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { log, LogContext } from "./logging";
1313

1414
export interface TraceContext {
1515
span?: opentracing.Span;
16+
// TODO(gpl) We are missing this method from type opentracing.SpanContext, which breaks our code under some circumstances (testing).
17+
// We should add it, but I won't add right now because of different focus, and it's unclear how we want to use tracing going forward
18+
isDebugIDContainerOnly?: () => boolean;
1619
}
1720
export type TraceContextWithSpan = TraceContext & {
1821
span: opentracing.Span;

components/server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
"clean:node": "rimraf node_modules",
1818
"purge": "yarn clean && yarn clean:node && yarn run rimraf yarn.lock",
1919
"test:leeway": "yarn build && yarn test",
20-
"test": "cleanup() { echo 'Cleanup started'; yarn stop-services; }; trap cleanup EXIT; yarn test:unit && yarn start-services && yarn test:db",
20+
"test": "cleanup() { echo 'Cleanup started'; yarn stop-services; }; trap cleanup EXIT; yarn test:unit && yarn test:db",
2121
"test:unit": "mocha --opts mocha.opts './**/*.spec.js' --exclude './node_modules/**'",
22-
"test:db": ". $(leeway run components/gitpod-db:db-test-env) && mocha --opts mocha.opts './**/*.spec.db.js' --exclude './node_modules/**'",
22+
"test:db": ". $(leeway run components/gitpod-db:db-test-env) && yarn start-services && mocha --opts mocha.opts './**/*.spec.db.js' --exclude './node_modules/**'",
2323
"start-services": "yarn start-testdb && yarn start-redis && yarn start-spicedb",
2424
"stop-services": "yarn stop-redis && yarn stop-spicedb",
2525
"start-testdb": "if netstat -tuln | grep ':23306 '; then echo 'Mysql is already running.'; else leeway run components/gitpod-db:init-testdb; fi",

components/server/src/authorization/authorizer.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import { BUILTIN_INSTLLATION_ADMIN_USER_ID } from "@gitpod/gitpod-db/lib";
1010
import { TeamMemberRole } from "@gitpod/gitpod-protocol";
1111
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1212
import {
13+
AllResourceTypes,
1314
OrganizationPermission,
1415
Permission,
1516
ProjectPermission,
1617
Relation,
1718
ResourceType,
1819
UserPermission,
20+
WorkspacePermission,
1921
rel,
2022
} from "./definitions";
2123
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
@@ -43,6 +45,12 @@ export function createInitializingAuthorizer(spiceDbAuthorizer: SpiceDBAuthorize
4345
});
4446
}
4547

48+
/**
49+
* We need to call our internal API with system permissions in some cases.
50+
* As we don't have other ways to represent that (e.g. ServiceAccounts), we use this magic constant to designated it.
51+
*/
52+
export const SYSTEM_USER = "SYSTEM_USER";
53+
4654
export class Authorizer {
4755
constructor(private authorizer: SpiceDBAuthorizer) {}
4856

@@ -51,6 +59,10 @@ export class Authorizer {
5159
permission: OrganizationPermission,
5260
orgId: string,
5361
): Promise<boolean> {
62+
if (userId === "SYSTEM_USER") {
63+
return true;
64+
}
65+
5466
const req = v1.CheckPermissionRequest.create({
5567
subject: subject("user", userId),
5668
permission,
@@ -77,6 +89,10 @@ export class Authorizer {
7789
}
7890

7991
async hasPermissionOnProject(userId: string, permission: ProjectPermission, projectId: string): Promise<boolean> {
92+
if (userId === "SYSTEM_USER") {
93+
return true;
94+
}
95+
8096
const req = v1.CheckPermissionRequest.create({
8197
subject: subject("user", userId),
8298
permission,
@@ -102,11 +118,15 @@ export class Authorizer {
102118
);
103119
}
104120

105-
async hasPermissionOnUser(userId: string, permission: UserPermission, userResourceId: string): Promise<boolean> {
121+
async hasPermissionOnUser(userId: string, permission: UserPermission, resourceUserId: string): Promise<boolean> {
122+
if (userId === "SYSTEM_USER") {
123+
return true;
124+
}
125+
106126
const req = v1.CheckPermissionRequest.create({
107127
subject: subject("user", userId),
108128
permission,
109-
resource: object("user", userResourceId),
129+
resource: object("user", resourceUserId),
110130
consistency,
111131
});
112132

@@ -127,6 +147,39 @@ export class Authorizer {
127147
);
128148
}
129149

150+
async hasPermissionOnWorkspace(
151+
userId: string,
152+
permission: WorkspacePermission,
153+
workspaceId: string,
154+
): Promise<boolean> {
155+
if (userId === "SYSTEM_USER") {
156+
return true;
157+
}
158+
159+
const req = v1.CheckPermissionRequest.create({
160+
subject: subject("user", userId),
161+
permission,
162+
resource: object("workspace", workspaceId),
163+
consistency,
164+
});
165+
166+
return this.authorizer.check(req, { userId });
167+
}
168+
169+
async checkPermissionOnWorkspace(userId: string, permission: WorkspacePermission, workspaceId: string) {
170+
if (await this.hasPermissionOnWorkspace(userId, permission, workspaceId)) {
171+
return;
172+
}
173+
if ("read_info" === permission || !(await this.hasPermissionOnWorkspace(userId, "read_info", workspaceId))) {
174+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} not found.`);
175+
}
176+
177+
throw new ApplicationError(
178+
ErrorCodes.PERMISSION_DENIED,
179+
`You do not have ${permission} on workspace ${workspaceId}`,
180+
);
181+
}
182+
130183
// write operations below
131184
public async removeAllRelationships(userId: string, type: ResourceType, id: string) {
132185
if (await this.isDisabled(userId)) {
@@ -142,7 +195,7 @@ export class Authorizer {
142195
);
143196

144197
// iterate over all resource types and remove by subject
145-
for (const resourcetype of ["installation", "user", "organization", "project"] as ResourceType[]) {
198+
for (const resourcetype of AllResourceTypes as ResourceType[]) {
146199
await this.authorizer.deleteRelationships(
147200
v1.DeleteRelationshipsRequest.create({
148201
relationshipFilter: {
@@ -309,6 +362,26 @@ export class Authorizer {
309362
);
310363
}
311364

365+
async addWorkspaceToOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
366+
if (await this.isDisabled(userID)) {
367+
return;
368+
}
369+
await this.authorizer.writeRelationships(
370+
set(rel.workspace(workspaceID).org.organization(orgID)),
371+
set(rel.workspace(workspaceID).owner.user(userID)),
372+
);
373+
}
374+
375+
async removeWorkspaceFromOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
376+
if (await this.isDisabled(userID)) {
377+
return;
378+
}
379+
await this.authorizer.writeRelationships(
380+
remove(rel.workspace(workspaceID).org.organization(orgID)),
381+
remove(rel.workspace(workspaceID).owner.user(userID)),
382+
);
383+
}
384+
312385
public async find(relation: v1.Relationship): Promise<v1.Relationship | undefined> {
313386
const relationships = await this.authorizer.readRelationships({
314387
consistency: v1.Consistency.create({

components/server/src/authorization/definitions.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,23 @@ import { v1 } from "@authzed/authzed-node";
1010

1111
const InstallationID = "1";
1212

13-
export type ResourceType = UserResourceType | InstallationResourceType | OrganizationResourceType | ProjectResourceType;
13+
export type ResourceType =
14+
| UserResourceType
15+
| InstallationResourceType
16+
| OrganizationResourceType
17+
| ProjectResourceType
18+
| WorkspaceResourceType;
1419

15-
export type Relation = UserRelation | InstallationRelation | OrganizationRelation | ProjectRelation;
20+
export const AllResourceTypes: ResourceType[] = ["user", "installation", "organization", "project", "workspace"];
1621

17-
export type Permission = UserPermission | InstallationPermission | OrganizationPermission | ProjectPermission;
22+
export type Relation = UserRelation | InstallationRelation | OrganizationRelation | ProjectRelation | WorkspaceRelation;
23+
24+
export type Permission =
25+
| UserPermission
26+
| InstallationPermission
27+
| OrganizationPermission
28+
| ProjectPermission
29+
| WorkspacePermission;
1830

1931
export type UserResourceType = "user";
2032

@@ -48,6 +60,7 @@ export type OrganizationPermission =
4860
| "write_git_provider"
4961
| "read_billing"
5062
| "write_billing"
63+
| "create_workspace"
5164
| "write_billing_admin";
5265

5366
export type ProjectResourceType = "project";
@@ -56,6 +69,12 @@ export type ProjectRelation = "org" | "editor" | "viewer";
5669

5770
export type ProjectPermission = "read_info" | "write_info" | "delete";
5871

72+
export type WorkspaceResourceType = "workspace";
73+
74+
export type WorkspaceRelation = "org" | "owner";
75+
76+
export type WorkspacePermission = "access" | "stop" | "delete" | "read_info";
77+
5978
export const rel = {
6079
user(id: string) {
6180
const result: Partial<v1.Relationship> = {
@@ -327,4 +346,54 @@ export const rel = {
327346
},
328347
};
329348
},
349+
350+
workspace(id: string) {
351+
const result: Partial<v1.Relationship> = {
352+
resource: {
353+
objectType: "workspace",
354+
objectId: id,
355+
},
356+
};
357+
return {
358+
get org() {
359+
const result2 = {
360+
...result,
361+
relation: "org",
362+
};
363+
return {
364+
organization(objectId: string) {
365+
return {
366+
...result2,
367+
subject: {
368+
object: {
369+
objectType: "organization",
370+
objectId: objectId,
371+
},
372+
},
373+
} as v1.Relationship;
374+
},
375+
};
376+
},
377+
378+
get owner() {
379+
const result2 = {
380+
...result,
381+
relation: "owner",
382+
};
383+
return {
384+
user(objectId: string) {
385+
return {
386+
...result2,
387+
subject: {
388+
object: {
389+
objectType: "user",
390+
objectId: objectId,
391+
},
392+
},
393+
} as v1.Relationship;
394+
},
395+
};
396+
},
397+
};
398+
},
330399
};

components/server/src/container-module.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ import { Redis } from "ioredis";
129129
import { RedisPublisher, newRedisClient } from "@gitpod/gitpod-db/lib";
130130
import { UserService } from "./user/user-service";
131131
import { RelationshipUpdater } from "./authorization/relationship-updater";
132+
import { WorkspaceService } from "./workspace/workspace-service";
132133

133134
export const productionContainerModule = new ContainerModule(
134135
(bind, unbind, isBound, rebind, unbindAsync, onActivation, onDeactivation) => {
@@ -159,6 +160,7 @@ export const productionContainerModule = new ContainerModule(
159160
bind(ConfigurationService).toSelf().inSingletonScope();
160161

161162
bind(SnapshotService).toSelf().inSingletonScope();
163+
bind(WorkspaceService).toSelf().inSingletonScope();
162164
bind(WorkspaceFactory).toSelf().inSingletonScope();
163165
bind(WorkspaceDeletionService).toSelf().inSingletonScope();
164166
bind(WorkspaceStarter).toSelf().inSingletonScope();
@@ -177,8 +179,11 @@ export const productionContainerModule = new ContainerModule(
177179
})
178180
.inSingletonScope();
179181

180-
bind(PrometheusClientCallMetrics).toSelf().inSingletonScope();
181-
bind(IClientCallMetrics).to(PrometheusClientCallMetrics).inSingletonScope();
182+
bind(PrometheusClientCallMetrics)
183+
.toSelf()
184+
.inSingletonScope()
185+
.onDeactivation((metrics) => metrics.dispose());
186+
bind(IClientCallMetrics).toService(PrometheusClientCallMetrics);
182187

183188
bind(WorkspaceClusterImagebuilderClientProvider).toSelf().inSingletonScope();
184189
bind(ImageBuilderClientProvider).toService(WorkspaceClusterImagebuilderClientProvider);

components/server/src/iam/iam-session-app.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
1717
import { OrganizationService } from "../orgs/organization-service";
1818
import { UserService } from "../user/user-service";
1919
import { UserDB } from "@gitpod/gitpod-db/lib";
20+
import { SYSTEM_USER } from "../authorization/authorizer";
2021

2122
@injectable()
2223
export class IamSessionApp {
@@ -147,7 +148,7 @@ export class IamSessionApp {
147148
ctx,
148149
);
149150

150-
await this.orgService.addOrUpdateMember(undefined, organizationId, user.id, "member", ctx);
151+
await this.orgService.addOrUpdateMember(SYSTEM_USER, organizationId, user.id, "member", ctx);
151152
return user;
152153
});
153154
}

components/server/src/jobs/workspace-gc.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { TracedWorkspaceDB, DBWithTracing, WorkspaceDB } from "@gitpod/gitpod-db
1212
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1313
import { Config } from "../config";
1414
import { Job } from "./runner";
15+
import { WorkspaceService } from "../workspace/workspace-service";
16+
import { SYSTEM_USER } from "../authorization/authorizer";
1517

1618
/**
1719
* The WorkspaceGarbageCollector has two tasks:
@@ -20,6 +22,7 @@ import { Job } from "./runner";
2022
*/
2123
@injectable()
2224
export class WorkspaceGarbageCollector implements Job {
25+
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
2326
@inject(WorkspaceDeletionService) protected readonly deletionService: WorkspaceDeletionService;
2427
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
2528
@inject(Config) protected readonly config: Config;
@@ -70,7 +73,7 @@ export class WorkspaceGarbageCollector implements Job {
7073
);
7174
const afterSelect = new Date();
7275
const deletes = await Promise.all(
73-
workspaces.map((ws) => this.deletionService.softDeleteWorkspace({ span }, ws, "gc")),
76+
workspaces.map((ws) => this.workspaceService.deleteWorkspace(SYSTEM_USER, ws.id, "gc")),
7477
);
7578
const afterDelete = new Date();
7679

@@ -125,7 +128,17 @@ export class WorkspaceGarbageCollector implements Job {
125128
now,
126129
);
127130
const deletes = await Promise.all(
128-
workspaces.map((ws) => this.deletionService.hardDeleteWorkspace({ span }, ws.id)),
131+
workspaces.map((ws) =>
132+
this.workspaceService
133+
.hardDeleteWorkspace(SYSTEM_USER, ws.id)
134+
.catch((err) =>
135+
log.error(
136+
{ userId: ws.ownerId, workspaceId: ws.id },
137+
"failed to hard-delete workspace",
138+
err,
139+
),
140+
),
141+
),
129142
);
130143

131144
log.info(`workspace-gc: successfully purged ${deletes.length} workspaces`);

0 commit comments

Comments
 (0)