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 all 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
3 changes: 3 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,9 @@ import { log, LogContext } from "./logging";

export interface TraceContext {
span?: opentracing.Span;
// TODO(gpl) We are missing this method from type opentracing.SpanContext, which breaks our code under some circumstances (testing).
// 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
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
79 changes: 76 additions & 3 deletions components/server/src/authorization/authorizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { BUILTIN_INSTLLATION_ADMIN_USER_ID } from "@gitpod/gitpod-db/lib";
import { TeamMemberRole } from "@gitpod/gitpod-protocol";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import {
AllResourceTypes,
OrganizationPermission,
Permission,
ProjectPermission,
Relation,
ResourceType,
UserPermission,
WorkspacePermission,
rel,
} from "./definitions";
import { SpiceDBAuthorizer } from "./spicedb-authorizer";
Expand Down Expand Up @@ -43,6 +45,12 @@ export function createInitializingAuthorizer(spiceDbAuthorizer: SpiceDBAuthorize
});
}

/**
* We need to call our internal API with system permissions in some cases.
* As we don't have other ways to represent that (e.g. ServiceAccounts), we use this magic constant to designated it.
*/
export const SYSTEM_USER = "SYSTEM_USER";

export class Authorizer {
constructor(private authorizer: SpiceDBAuthorizer) {}

Expand All @@ -51,6 +59,10 @@ export class Authorizer {
permission: OrganizationPermission,
orgId: string,
): Promise<boolean> {
if (userId === "SYSTEM_USER") {
return true;
}

const req = v1.CheckPermissionRequest.create({
subject: subject("user", userId),
permission,
Expand All @@ -77,6 +89,10 @@ export class Authorizer {
}

async hasPermissionOnProject(userId: string, permission: ProjectPermission, projectId: string): Promise<boolean> {
if (userId === "SYSTEM_USER") {
return true;
}

const req = v1.CheckPermissionRequest.create({
subject: subject("user", userId),
permission,
Expand All @@ -102,11 +118,15 @@ export class Authorizer {
);
}

async hasPermissionOnUser(userId: string, permission: UserPermission, userResourceId: string): Promise<boolean> {
async hasPermissionOnUser(userId: string, permission: UserPermission, resourceUserId: string): Promise<boolean> {
if (userId === "SYSTEM_USER") {
return true;
}

const req = v1.CheckPermissionRequest.create({
subject: subject("user", userId),
permission,
resource: object("user", userResourceId),
resource: object("user", resourceUserId),
consistency,
});

Expand All @@ -127,6 +147,39 @@ export class Authorizer {
);
}

async hasPermissionOnWorkspace(
userId: string,
permission: WorkspacePermission,
workspaceId: string,
): Promise<boolean> {
if (userId === "SYSTEM_USER") {
return true;
}

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 All @@ -142,7 +195,7 @@ export class Authorizer {
);

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

async addWorkspaceToOrg(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 removeWorkspaceFromOrg(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
3 changes: 2 additions & 1 deletion components/server/src/iam/iam-session-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ApplicationError } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { OrganizationService } from "../orgs/organization-service";
import { UserService } from "../user/user-service";
import { UserDB } from "@gitpod/gitpod-db/lib";
import { SYSTEM_USER } from "../authorization/authorizer";

@injectable()
export class IamSessionApp {
Expand Down Expand Up @@ -147,7 +148,7 @@ export class IamSessionApp {
ctx,
);

await this.orgService.addOrUpdateMember(undefined, organizationId, user.id, "member", ctx);
await this.orgService.addOrUpdateMember(SYSTEM_USER, organizationId, user.id, "member", ctx);
return user;
});
}
Expand Down
17 changes: 15 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,8 @@ 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";
import { SYSTEM_USER } from "../authorization/authorizer";

/**
* The WorkspaceGarbageCollector has two tasks:
Expand All @@ -20,6 +22,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 +73,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(SYSTEM_USER, ws.id, "gc")),
);
const afterDelete = new Date();

Expand Down Expand Up @@ -125,7 +128,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(SYSTEM_USER, ws.id)
.catch((err) =>
log.error(
{ userId: ws.ownerId, workspaceId: ws.id },
"failed to hard-delete workspace",
err,
),
),
),
);

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