Skip to content

Commit 8da43bb

Browse files
committed
[server] Introduce WorkspaceService with:
- createWorkspace - getWorkspace - stopWorkspace - deleteWorkspace - hardDeleteWorkspace
1 parent 5026592 commit 8da43bb

File tree

12 files changed

+535
-119
lines changed

12 files changed

+535
-119
lines changed

components/server/src/authorization/authorizer.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Relation,
1717
ResourceType,
1818
UserPermission,
19+
WorkspacePermission,
1920
rel,
2021
} from "./definitions";
2122
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
@@ -101,11 +102,11 @@ export class Authorizer {
101102
);
102103
}
103104

104-
async hasPermissionOnUser(userId: string, permission: UserPermission, userResourceId: string): Promise<boolean> {
105+
async hasPermissionOnUser(userId: string, permission: UserPermission, resourceUserId: string): Promise<boolean> {
105106
const req = v1.CheckPermissionRequest.create({
106107
subject: subject("user", userId),
107108
permission,
108-
resource: object("user", userResourceId),
109+
resource: object("user", resourceUserId),
109110
consistency,
110111
});
111112

@@ -126,6 +127,35 @@ export class Authorizer {
126127
);
127128
}
128129

130+
async hasPermissionOnWorkspace(
131+
userId: string,
132+
permission: WorkspacePermission,
133+
workspaceId: string,
134+
): Promise<boolean> {
135+
const req = v1.CheckPermissionRequest.create({
136+
subject: subject("user", userId),
137+
permission,
138+
resource: object("workspace", workspaceId),
139+
consistency,
140+
});
141+
142+
return this.authorizer.check(req, { userId });
143+
}
144+
145+
async checkPermissionOnWorkspace(userId: string, permission: WorkspacePermission, workspaceId: string) {
146+
if (await this.hasPermissionOnWorkspace(userId, permission, workspaceId)) {
147+
return;
148+
}
149+
if ("read_info" === permission || !(await this.hasPermissionOnWorkspace(userId, "read_info", workspaceId))) {
150+
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} not found.`);
151+
}
152+
153+
throw new ApplicationError(
154+
ErrorCodes.PERMISSION_DENIED,
155+
`You do not have ${permission} on workspace ${workspaceId}`,
156+
);
157+
}
158+
129159
// write operations below
130160

131161
public async removeAllRelationships(type: ResourceType, id: string) {
@@ -158,7 +188,7 @@ export class Authorizer {
158188

159189
async addUser(userId: string, owningOrgId?: string) {
160190
await this.authorizer.writeRelationships(
161-
set(rel.user(userId).self.user(userId)), //
191+
set(rel.user(userId).self.user(userId)),
162192
set(
163193
owningOrgId
164194
? rel.user(userId).organization.organization(owningOrgId)
@@ -186,15 +216,11 @@ export class Authorizer {
186216
}
187217

188218
async addProjectToOrg(orgID: string, projectID: string): Promise<void> {
189-
await this.authorizer.writeRelationships(
190-
set(rel.project(projectID).org.organization(orgID)), //
191-
);
219+
await this.authorizer.writeRelationships(set(rel.project(projectID).org.organization(orgID)));
192220
}
193221

194222
async removeProjectFromOrg(orgID: string, projectID: string): Promise<void> {
195-
await this.authorizer.writeRelationships(
196-
remove(rel.project(projectID).org.organization(orgID)), //
197-
);
223+
await this.authorizer.writeRelationships(remove(rel.project(projectID).org.organization(orgID)));
198224
}
199225

200226
async addOrganization(org: Organization, members: TeamMemberInfo[], projects: Project[]): Promise<void> {
@@ -206,32 +232,36 @@ export class Authorizer {
206232
await this.addProjectToOrg(org.id, project.id);
207233
}
208234

209-
await this.authorizer.writeRelationships(
210-
set(rel.organization(org.id).installation.installation), //
211-
);
235+
await this.authorizer.writeRelationships(set(rel.organization(org.id).installation.installation));
212236
}
213237

214238
async addInstallationMemberRole(userID: string) {
215-
await this.authorizer.writeRelationships(
216-
set(rel.installation.member.user(userID)), //
217-
);
239+
await this.authorizer.writeRelationships(set(rel.installation.member.user(userID)));
218240
}
219241

220242
async removeInstallationMemberRole(userID: string) {
221-
await this.authorizer.writeRelationships(
222-
remove(rel.installation.member.user(userID)), //
223-
);
243+
await this.authorizer.writeRelationships(remove(rel.installation.member.user(userID)));
224244
}
225245

226246
async addInstallationAdminRole(userID: string) {
247+
await this.authorizer.writeRelationships(set(rel.installation.admin.user(userID)));
248+
}
249+
250+
async removeInstallationAdminRole(userID: string) {
251+
await this.authorizer.writeRelationships(remove(rel.installation.admin.user(userID)));
252+
}
253+
254+
async createWorkspaceInOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
227255
await this.authorizer.writeRelationships(
228-
set(rel.installation.admin.user(userID)), //
256+
set(rel.workspace(workspaceID).org.organization(orgID)),
257+
set(rel.workspace(workspaceID).owner.user(userID)),
229258
);
230259
}
231260

232-
async removeInstallationAdminRole(userID: string) {
261+
async deleteWorkspaceFromOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
233262
await this.authorizer.writeRelationships(
234-
remove(rel.installation.admin.user(userID)), //
263+
remove(rel.workspace(workspaceID).org.organization(orgID)),
264+
remove(rel.workspace(workspaceID).owner.user(userID)),
235265
);
236266
}
237267

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: 2 additions & 0 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();

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ 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";
1516

1617
/**
1718
* The WorkspaceGarbageCollector has two tasks:
@@ -20,6 +21,7 @@ import { Job } from "./runner";
2021
*/
2122
@injectable()
2223
export class WorkspaceGarbageCollector implements Job {
24+
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
2325
@inject(WorkspaceDeletionService) protected readonly deletionService: WorkspaceDeletionService;
2426
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
2527
@inject(Config) protected readonly config: Config;
@@ -70,7 +72,7 @@ export class WorkspaceGarbageCollector implements Job {
7072
);
7173
const afterSelect = new Date();
7274
const deletes = await Promise.all(
73-
workspaces.map((ws) => this.deletionService.softDeleteWorkspace({ span }, ws, "gc")),
75+
workspaces.map((ws) => this.workspaceService.deleteWorkspace(ws.ownerId, ws.id, "gc")), // TODO(gpl) This should be a system user/service account instead of ws owner
7476
);
7577
const afterDelete = new Date();
7678

@@ -125,7 +127,11 @@ export class WorkspaceGarbageCollector implements Job {
125127
now,
126128
);
127129
const deletes = await Promise.all(
128-
workspaces.map((ws) => this.deletionService.hardDeleteWorkspace({ span }, ws.id)),
130+
workspaces.map((ws) =>
131+
this.workspaceService
132+
.hardDeleteWorkspace(ws.ownerId, ws.id)
133+
.catch((err) => log.error("failed to hard-delete workspace", err)),
134+
), // TODO(gpl) This should be a system user/service account instead of ws owner
129135
);
130136

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

components/server/src/prebuilds/prebuild-manager.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import {
2121
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
2222
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
2323
import { getCommitInfo, HostContextProvider } from "../auth/host-context-provider";
24-
import { WorkspaceFactory } from "../workspace/workspace-factory";
2524
import { ConfigProvider } from "../workspace/config-provider";
2625
import { WorkspaceStarter } from "../workspace/workspace-starter";
2726
import { Config } from "../config";
@@ -38,6 +37,7 @@ import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messag
3837
import { UserAuthentication } from "../user/user-authentication";
3938
import { EntitlementService, MayStartWorkspaceResult } from "../billing/entitlement-service";
4039
import { EnvVarService } from "../workspace/env-var-service";
40+
import { WorkspaceService } from "../workspace/workspace-service";
4141

4242
export class WorkspaceRunningError extends Error {
4343
constructor(msg: string, public instance: WorkspaceInstance) {
@@ -56,7 +56,7 @@ export interface StartPrebuildParams {
5656
@injectable()
5757
export class PrebuildManager {
5858
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
59-
@inject(WorkspaceFactory) protected readonly workspaceFactory: WorkspaceFactory;
59+
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
6060
@inject(WorkspaceStarter) protected readonly workspaceStarter: WorkspaceStarter;
6161
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
6262
@inject(ConfigProvider) protected readonly configProvider: ConfigProvider;
@@ -77,21 +77,18 @@ export class PrebuildManager {
7777
const results: Promise<any>[] = [];
7878
for (const prebuild of prebuilds) {
7979
try {
80-
for (const instance of prebuild.instances) {
81-
log.info(
82-
{ userId: user.id, instanceId: instance.id, workspaceId: instance.workspaceId },
83-
"Cancelling Prebuild workspace because a newer commit was pushed to the same branch.",
84-
);
85-
results.push(
86-
this.workspaceStarter.stopWorkspaceInstance(
87-
{ span },
88-
instance.id,
89-
instance.region,
90-
"prebuild cancelled because a newer commit was pushed to the same branch",
91-
StopWorkspacePolicy.ABORT,
92-
),
93-
);
94-
}
80+
log.info(
81+
{ userId: user.id, workspaceId: prebuild.workspace.id },
82+
"Cancelling Prebuild workspace because a newer commit was pushed to the same branch.",
83+
);
84+
results.push(
85+
this.workspaceService.stopWorkspace(
86+
user.id,
87+
prebuild.workspace.id,
88+
"prebuild cancelled because a newer commit was pushed to the same branch",
89+
StopWorkspacePolicy.ABORT,
90+
),
91+
);
9592
prebuild.prebuild.state = "aborted";
9693
prebuild.prebuild.error = "A newer commit was pushed to the same branch.";
9794
results.push(this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild.prebuild));
@@ -224,7 +221,7 @@ export class PrebuildManager {
224221
}
225222
}
226223

227-
const workspace = await this.workspaceFactory.createForContext(
224+
const workspace = await this.workspaceService.createWorkspace(
228225
{ span },
229226
user,
230227
project.teamId,

components/server/src/test/expect-utils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
import { ApplicationError, ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
88
import { expect } from "chai";
99

10-
export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>)) {
10+
export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>), message?: string) {
11+
const msg = "expected error: " + errorCode + (message ? " - " + message : "");
1112
try {
1213
await (code instanceof Function ? code() : code);
13-
expect.fail("expected error: " + errorCode);
14+
expect.fail(msg);
1415
} catch (err) {
1516
if (!ApplicationError.hasErrorCode(err)) {
1617
throw err;
1718
}
18-
expect(err && ApplicationError.hasErrorCode(err) && err.code).to.equal(errorCode);
19+
expect(err && ApplicationError.hasErrorCode(err) && err.code, msg).to.equal(errorCode);
1920
}
2021
}

0 commit comments

Comments
 (0)