Skip to content

[fga] Workspace: create, get, stop and delete #18403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 4, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion components/gitpod-db/src/traced-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ export class DBWithTracing<T> {
return async (...args: any[]) => {
// do not try and trace calls with an empty trace context - the callers intention most likely was to omit the trace
// so as to not spam the trace logs
if (!ctx.span) {
// Also, opentracing makes some assumptions about the Span object, so this might fail under some circumstances
function isEmptyObject(obj: object): boolean {
return Object.keys(obj).length === 0;
}
if (!ctx.span || isEmptyObject(ctx.span)) {
return await f.bind(_target)(...args);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export class PrometheusClientCallMetrics implements IClientCallMetrics {
});
}

dispose(): void {
prometheusClient.register.removeSingleMetric("grpc_client_started_total");
prometheusClient.register.removeSingleMetric("grpc_client_msg_sent_total");
prometheusClient.register.removeSingleMetric("grpc_client_msg_received_total");
prometheusClient.register.removeSingleMetric("grpc_client_handled_total");
prometheusClient.register.removeSingleMetric("grpc_client_handling_seconds");
}

started(labels: IGrpcCallMetricsLabels): void {
this.startedCounter.inc({
grpc_service: labels.service,
Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/util/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { log, LogContext } from "./logging";

export interface TraceContext {
span?: opentracing.Span;
// TODO(gpl) We are missing this method, but won't add right now because of different focus, and it's unclear how we want to use tracing going forward
isDebugIDContainerOnly?: () => boolean;
}
export type TraceContextWithSpan = TraceContext & {
span: opentracing.Span;
Expand Down
4 changes: 2 additions & 2 deletions components/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
"clean:node": "rimraf node_modules",
"purge": "yarn clean && yarn clean:node && yarn run rimraf yarn.lock",
"test:leeway": "yarn build && yarn test",
"test": "cleanup() { echo 'Cleanup started'; yarn stop-services; }; trap cleanup EXIT; yarn test:unit && yarn start-services && yarn test:db",
"test": "cleanup() { echo 'Cleanup started'; yarn stop-services; }; trap cleanup EXIT; yarn test:unit && yarn test:db",
"test:unit": "mocha --opts mocha.opts './**/*.spec.js' --exclude './node_modules/**'",
"test:db": ". $(leeway run components/gitpod-db:db-test-env) && mocha --opts mocha.opts './**/*.spec.db.js' --exclude './node_modules/**'",
"test:db": ". $(leeway run components/gitpod-db:db-test-env) && yarn start-services && mocha --opts mocha.opts './**/*.spec.db.js' --exclude './node_modules/**'",
"start-services": "yarn start-testdb && yarn start-redis && yarn start-spicedb",
"stop-services": "yarn stop-redis && yarn stop-spicedb",
"start-testdb": "if netstat -tuln | grep ':23306 '; then echo 'Mysql is already running.'; else leeway run components/gitpod-db:init-testdb; fi",
Expand Down
54 changes: 52 additions & 2 deletions components/server/src/authorization/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Relation,
ResourceType,
UserPermission,
WorkspacePermission,
rel,
} from "./definitions";
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
Expand Down Expand Up @@ -102,11 +103,11 @@ export class Authorizer {
);
}

async hasPermissionOnUser(userId: string, permission: UserPermission, userResourceId: string): Promise<boolean> {
async hasPermissionOnUser(userId: string, permission: UserPermission, resourceUserId: string): Promise<boolean> {
const req = v1.CheckPermissionRequest.create({
subject: subject("user", userId),
permission,
resource: object("user", userResourceId),
resource: object("user", resourceUserId),
consistency,
});

Expand All @@ -127,6 +128,35 @@ export class Authorizer {
);
}

async hasPermissionOnWorkspace(
userId: string,
permission: WorkspacePermission,
workspaceId: string,
): Promise<boolean> {
const req = v1.CheckPermissionRequest.create({
subject: subject("user", userId),
permission,
resource: object("workspace", workspaceId),
consistency,
});

return this.authorizer.check(req, { userId });
}

async checkPermissionOnWorkspace(userId: string, permission: WorkspacePermission, workspaceId: string) {
if (await this.hasPermissionOnWorkspace(userId, permission, workspaceId)) {
return;
}
if ("read_info" === permission || !(await this.hasPermissionOnWorkspace(userId, "read_info", workspaceId))) {
throw new ApplicationError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} not found.`);
}

throw new ApplicationError(
ErrorCodes.PERMISSION_DENIED,
`You do not have ${permission} on workspace ${workspaceId}`,
);
}

// write operations below
public async removeAllRelationships(userId: string, type: ResourceType, id: string) {
if (await this.isDisabled(userId)) {
Expand Down Expand Up @@ -309,6 +339,26 @@ export class Authorizer {
);
}

async createWorkspaceInOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
if (await this.isDisabled(userID)) {
return;
}
await this.authorizer.writeRelationships(
set(rel.workspace(workspaceID).org.organization(orgID)),
set(rel.workspace(workspaceID).owner.user(userID)),
);
}

async deleteWorkspaceFromOrg(orgID: string, userID: string, workspaceID: string): Promise<void> {
if (await this.isDisabled(userID)) {
return;
}
await this.authorizer.writeRelationships(
remove(rel.workspace(workspaceID).org.organization(orgID)),
remove(rel.workspace(workspaceID).owner.user(userID)),
);
}

public async find(relation: v1.Relationship): Promise<v1.Relationship | undefined> {
const relationships = await this.authorizer.readRelationships({
consistency: v1.Consistency.create({
Expand Down
75 changes: 72 additions & 3 deletions components/server/src/authorization/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,23 @@ import { v1 } from "@authzed/authzed-node";

const InstallationID = "1";

export type ResourceType = UserResourceType | InstallationResourceType | OrganizationResourceType | ProjectResourceType;
export type ResourceType =
| UserResourceType
| InstallationResourceType
| OrganizationResourceType
| ProjectResourceType
| WorkspaceResourceType;

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

export type Permission = UserPermission | InstallationPermission | OrganizationPermission | ProjectPermission;
export type Relation = UserRelation | InstallationRelation | OrganizationRelation | ProjectRelation | WorkspaceRelation;

export type Permission =
| UserPermission
| InstallationPermission
| OrganizationPermission
| ProjectPermission
| WorkspacePermission;

export type UserResourceType = "user";

Expand Down Expand Up @@ -48,6 +60,7 @@ export type OrganizationPermission =
| "write_git_provider"
| "read_billing"
| "write_billing"
| "create_workspace"
| "write_billing_admin";

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

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

export type WorkspaceResourceType = "workspace";

export type WorkspaceRelation = "org" | "owner";

export type WorkspacePermission = "access" | "stop" | "delete" | "read_info";

export const rel = {
user(id: string) {
const result: Partial<v1.Relationship> = {
Expand Down Expand Up @@ -327,4 +346,54 @@ export const rel = {
},
};
},

workspace(id: string) {
const result: Partial<v1.Relationship> = {
resource: {
objectType: "workspace",
objectId: id,
},
};
return {
get org() {
const result2 = {
...result,
relation: "org",
};
return {
organization(objectId: string) {
return {
...result2,
subject: {
object: {
objectType: "organization",
objectId: objectId,
},
},
} as v1.Relationship;
},
};
},

get owner() {
const result2 = {
...result,
relation: "owner",
};
return {
user(objectId: string) {
return {
...result2,
subject: {
object: {
objectType: "user",
objectId: objectId,
},
},
} as v1.Relationship;
},
};
},
};
},
};
9 changes: 7 additions & 2 deletions components/server/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ import { Redis } from "ioredis";
import { RedisPublisher, newRedisClient } from "@gitpod/gitpod-db/lib";
import { UserService } from "./user/user-service";
import { RelationshipUpdater } from "./authorization/relationship-updater";
import { WorkspaceService } from "./workspace/workspace-service";

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

bind(SnapshotService).toSelf().inSingletonScope();
bind(WorkspaceService).toSelf().inSingletonScope();
bind(WorkspaceFactory).toSelf().inSingletonScope();
bind(WorkspaceDeletionService).toSelf().inSingletonScope();
bind(WorkspaceStarter).toSelf().inSingletonScope();
Expand All @@ -177,8 +179,11 @@ export const productionContainerModule = new ContainerModule(
})
.inSingletonScope();

bind(PrometheusClientCallMetrics).toSelf().inSingletonScope();
bind(IClientCallMetrics).to(PrometheusClientCallMetrics).inSingletonScope();
bind(PrometheusClientCallMetrics)
.toSelf()
.inSingletonScope()
.onDeactivation((metrics) => metrics.dispose());
bind(IClientCallMetrics).toService(PrometheusClientCallMetrics);

bind(WorkspaceClusterImagebuilderClientProvider).toSelf().inSingletonScope();
bind(ImageBuilderClientProvider).toService(WorkspaceClusterImagebuilderClientProvider);
Expand Down
16 changes: 14 additions & 2 deletions components/server/src/jobs/workspace-gc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TracedWorkspaceDB, DBWithTracing, WorkspaceDB } from "@gitpod/gitpod-db
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { Config } from "../config";
import { Job } from "./runner";
import { WorkspaceService } from "../workspace/workspace-service";

/**
* The WorkspaceGarbageCollector has two tasks:
Expand All @@ -20,6 +21,7 @@ import { Job } from "./runner";
*/
@injectable()
export class WorkspaceGarbageCollector implements Job {
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(WorkspaceDeletionService) protected readonly deletionService: WorkspaceDeletionService;
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
@inject(Config) protected readonly config: Config;
Expand Down Expand Up @@ -70,7 +72,7 @@ export class WorkspaceGarbageCollector implements Job {
);
const afterSelect = new Date();
const deletes = await Promise.all(
workspaces.map((ws) => this.deletionService.softDeleteWorkspace({ span }, ws, "gc")),
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would work, but is not correct as this is not triggered by the owner but by the system.
In another place (UserService#findById) I have used undefined as the userId to indicate the request is coming from the system.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I was not aware that we have a way (undefined) to express "the system does it".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm not particularly satisfied with that choice for the protocol. But I liked it better than introducing a system ID or anything else I could think of. 🤷

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svenefftinge Could you shoot me a link to an example? I can't find one on main atm, and wonder if you meant setting all userId types to string | undefined? 🤔

Copy link
Contributor

@svenefftinge svenefftinge Aug 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await this.orgService.addOrUpdateMember(undefined, organizationId, user.id, "member", ctx);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking at the change, I really thing we should make this a separate argument, instead of overriding userId for the security reasons. Let's discuss in sync. 👍

);
const afterDelete = new Date();

Expand Down Expand Up @@ -125,7 +127,17 @@ export class WorkspaceGarbageCollector implements Job {
now,
);
const deletes = await Promise.all(
workspaces.map((ws) => this.deletionService.hardDeleteWorkspace({ span }, ws.id)),
workspaces.map((ws) =>
this.workspaceService
.hardDeleteWorkspace(ws.ownerId, ws.id)
.catch((err) =>
log.error(
{ userId: ws.ownerId, workspaceId: ws.id },
"failed to hard-delete workspace",
err,
),
),
), // TODO(gpl) This should be a system user/service account instead of ws owner
);

log.info(`workspace-gc: successfully purged ${deletes.length} workspaces`);
Expand Down
33 changes: 15 additions & 18 deletions components/server/src/prebuilds/prebuild-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
import { getCommitInfo, HostContextProvider } from "../auth/host-context-provider";
import { WorkspaceFactory } from "../workspace/workspace-factory";
import { ConfigProvider } from "../workspace/config-provider";
import { WorkspaceStarter } from "../workspace/workspace-starter";
import { Config } from "../config";
Expand All @@ -38,6 +37,7 @@ import { ErrorCodes, ApplicationError } from "@gitpod/gitpod-protocol/lib/messag
import { UserAuthentication } from "../user/user-authentication";
import { EntitlementService, MayStartWorkspaceResult } from "../billing/entitlement-service";
import { EnvVarService } from "../workspace/env-var-service";
import { WorkspaceService } from "../workspace/workspace-service";

export class WorkspaceRunningError extends Error {
constructor(msg: string, public instance: WorkspaceInstance) {
Expand All @@ -56,7 +56,7 @@ export interface StartPrebuildParams {
@injectable()
export class PrebuildManager {
@inject(TracedWorkspaceDB) protected readonly workspaceDB: DBWithTracing<WorkspaceDB>;
@inject(WorkspaceFactory) protected readonly workspaceFactory: WorkspaceFactory;
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
@inject(WorkspaceStarter) protected readonly workspaceStarter: WorkspaceStarter;
@inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
@inject(ConfigProvider) protected readonly configProvider: ConfigProvider;
Expand All @@ -77,21 +77,18 @@ export class PrebuildManager {
const results: Promise<any>[] = [];
for (const prebuild of prebuilds) {
try {
for (const instance of prebuild.instances) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No clue why we would cycle over instances here before. I could imagine this was to mitigate some weird behavior we had in the past. 🤔
We only every have one WorkspaceInstance for every Workspace of type = 'prebuilt'. And even if we'd change that in the future: There is always ever one WorkspaceInstance "running" for each given Workspace. 🤷

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I just noticed that same code paths did not emit ApplicationError but just Error instead. But I don't expect big issues there.

log.info(
{ userId: user.id, instanceId: instance.id, workspaceId: instance.workspaceId },
"Cancelling Prebuild workspace because a newer commit was pushed to the same branch.",
);
results.push(
this.workspaceStarter.stopWorkspaceInstance(
{ span },
instance.id,
instance.region,
"prebuild cancelled because a newer commit was pushed to the same branch",
StopWorkspacePolicy.ABORT,
),
);
}
log.info(
{ userId: user.id, workspaceId: prebuild.workspace.id },
"Cancelling Prebuild workspace because a newer commit was pushed to the same branch.",
);
results.push(
this.workspaceService.stopWorkspace(
user.id,
prebuild.workspace.id,
"prebuild cancelled because a newer commit was pushed to the same branch",
StopWorkspacePolicy.ABORT,
),
);
prebuild.prebuild.state = "aborted";
prebuild.prebuild.error = "A newer commit was pushed to the same branch.";
results.push(this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild.prebuild));
Expand Down Expand Up @@ -224,7 +221,7 @@ export class PrebuildManager {
}
}

const workspace = await this.workspaceFactory.createForContext(
const workspace = await this.workspaceService.createWorkspace(
{ span },
user,
project.teamId,
Expand Down
7 changes: 4 additions & 3 deletions components/server/src/test/expect-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
import { ApplicationError, ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { expect } from "chai";

export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>)) {
export async function expectError(errorCode: ErrorCode, code: Promise<any> | (() => Promise<any>), message?: string) {
const msg = "expected error: " + errorCode + (message ? " - " + message : "");
try {
await (code instanceof Function ? code() : code);
expect.fail("expected error: " + errorCode);
expect.fail(msg);
} catch (err) {
if (!ApplicationError.hasErrorCode(err)) {
throw err;
}
expect(err && ApplicationError.hasErrorCode(err) && err.code).to.equal(errorCode);
expect(err && ApplicationError.hasErrorCode(err) && err.code, msg).to.equal(errorCode);
}
}
Loading