diff --git a/chart/config/proxy/lib.locations.conf b/chart/config/proxy/lib.locations.conf index 620862ce9f256a..21c87db0123ce8 100644 --- a/chart/config/proxy/lib.locations.conf +++ b/chart/config/proxy/lib.locations.conf @@ -148,7 +148,7 @@ location /internal-wsdl { ##### API / auth -> server ### mapping $request_uri -> $request_uri_api_dropped: vhost.map-api-request-uri.conf -# Websocket connection +# Websocket connections location = /api/gitpod { include lib.proxy.conf; include lib.cors-headers.conf; @@ -156,6 +156,13 @@ location = /api/gitpod { proxy_pass http://ws-apiserver$request_uri_api_dropped; } +location = /api/v1 { + include lib.proxy.conf; + include lib.cors-headers.conf; + include lib.ws-sse.conf; + + proxy_pass http://ws-apiserver$request_uri_api_dropped; +} # Default api base route location /api { diff --git a/components/gitpod-db/src/typeorm/user-db-impl.ts b/components/gitpod-db/src/typeorm/user-db-impl.ts index ab5b896e25ef31..81ef163790751f 100644 --- a/components/gitpod-db/src/typeorm/user-db-impl.ts +++ b/components/gitpod-db/src/typeorm/user-db-impl.ts @@ -148,7 +148,7 @@ export class TypeORMUserDBImpl implements UserDB { return result.sort(order); } - public async findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise { + public async findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise<{user: User, token: GitpodToken} | undefined> { const repo = await this.getGitpodTokenRepo(); const qBuilder = repo.createQueryBuilder('gitpodToken') .leftJoinAndSelect("gitpodToken.user", "user"); @@ -159,7 +159,11 @@ export class TypeORMUserDBImpl implements UserDB { } qBuilder.andWhere("gitpodToken.deleted <> TRUE AND user.markedDeleted <> TRUE AND user.blocked <> TRUE"); const token = await qBuilder.getOne(); - return token && token.user; + if (!token) { + return; + } + + return {user: token.user, token}; } public async findAllGitpodTokensOfUser(userId: string): Promise { diff --git a/components/gitpod-db/src/user-db.ts b/components/gitpod-db/src/user-db.ts index 69223883ba7944..593335c32c704c 100644 --- a/components/gitpod-db/src/user-db.ts +++ b/components/gitpod-db/src/user-db.ts @@ -110,7 +110,7 @@ export interface UserDB { findAllUsers(offset: number, limit: number, orderBy: keyof User, orderDir: "ASC" | "DESC", searchTerm?: string, minCreationDate?: Date, maxCreationDate?: Date, excludeBuiltinUsers?: boolean): Promise<{total: number, rows: User[]}>; findUserByName(name: string): Promise; - findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise; + findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise<{user: User, token: GitpodToken} | undefined>; findAllGitpodTokensOfUser(userId: string): Promise; storeGitpodToken(token: GitpodToken & { user: DBUser }): Promise; deleteGitpodToken(tokenHash: string): Promise; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 4585b7e02858c9..41ca39e8ac828a 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -250,7 +250,8 @@ export interface GitpodToken { } export enum GitpodTokenType { - API_AUTH_TOKEN = 0 + API_AUTH_TOKEN = 0, + MACHINE_AUTH_TOKEN = 1 } export interface OneTimeSecret { diff --git a/components/server/BUILD.yaml b/components/server/BUILD.yaml index b0f2b34252efe6..f9eb7fdd44c531 100644 --- a/components/server/BUILD.yaml +++ b/components/server/BUILD.yaml @@ -60,4 +60,4 @@ scripts: telepresence --swap-deployment server \ --method vpn-tcp \ --run yarn start-ee-inspect | \ - leeway run gitpod-core/components:dejson-log-output + leeway run components:dejson-log-output diff --git a/components/server/ee/src/graphql/graphql-controller.ts b/components/server/ee/src/graphql/graphql-controller.ts index eb131b51b612f7..b292b4993bbd44 100644 --- a/components/server/ee/src/graphql/graphql-controller.ts +++ b/components/server/ee/src/graphql/graphql-controller.ts @@ -35,7 +35,10 @@ export class GraphQLController { const ctx = request as any as Context; ctx.authToken = this.bearerToken(request.headers); if (!ctx.user && !!ctx.authToken) { - ctx.user = await this.userDb.findUserByGitpodToken(ctx.authToken, GitpodTokenType.API_AUTH_TOKEN); + const ut = await this.userDb.findUserByGitpodToken(ctx.authToken, GitpodTokenType.API_AUTH_TOKEN); + if (!!ut) { + ctx.user = ut.user; + } } return { schema, diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 020f446d0a160c..d7134c450e4466 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -71,15 +71,12 @@ export class GitpodServerEEImpl } const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace({ span })); - if (user.id != workspace.ownerId) { - throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Only the owner may set the workspace timeout"); - } - const runningInstances = await this.workspaceDb.trace({ span }).findRegularRunningInstances(user.id); const runningInstance = runningInstances.find(i => i.workspaceId === workspaceId); if (!runningInstance) { throw new ResponseError(ErrorCodes.NOT_FOUND, "Can only set keep-alive for running workspaces"); } + await this.guardAccess({kind: "workspaceInstance", subject: runningInstance, workspaceOwnerID: workspace.ownerId}, "update"); // if any other running instance has a custom timeout other than the user's default, we'll reset that timeout const client = await this.workspaceManagerClientProvider.get(runningInstance.region); @@ -126,15 +123,12 @@ export class GitpodServerEEImpl const canChange = await this.maySetTimeout(user); const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace({ span })); - if (user.id != workspace.ownerId) { - throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Only the owner may get the workspace keep-alive."); - } - const runningInstance = await this.workspaceDb.trace({ span }).findRunningInstance(workspaceId); if (!runningInstance) { log.warn({ userId: user.id, workspaceId }, 'Can only get keep-alive for running workspaces'); return { duration: "30m", canChange }; } + await this.guardAccess({kind: "workspaceInstance", subject: runningInstance, workspaceOwnerID: workspace.ownerId}, "get"); const req = new DescribeWorkspaceRequest(); req.setId(runningInstance.id); @@ -200,12 +194,12 @@ export class GitpodServerEEImpl try { const workspace = await this.internalGetWorkspace(id, this.workspaceDb.trace({ span })); - if (user.id != workspace.ownerId) { - throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Only the owner may share/unshare a workspace."); - } + await this.guardAccess({kind: "workspace", subject: workspace}, "update"); const instance = await this.workspaceDb.trace({ span }).findRunningInstance(id); if (instance) { + await this.guardAccess({kind: "workspaceInstance", subject: instance, workspaceOwnerID: workspace.ownerId}, "update"); + const req = new ControlAdmissionRequest(); req.setId(instance.id); req.setLevel(lvlmap.get(level)!); @@ -247,6 +241,9 @@ export class GitpodServerEEImpl throw new ResponseError(ErrorCodes.NOT_FOUND, `Workspace ${workspaceId} has no running instance`); } + await this.guardAccess({kind: "workspaceInstance", subject: instance, workspaceOwnerID: workspace.ownerId}, "get"); + await this.guardAccess({kind: "snapshot", subject: undefined, workspaceOwnerID: workspaceId}, "create"); + const client = await this.workspaceManagerClientProvider.get(instance.region); const request = new TakeSnapshotRequest(); request.setId(instance.id); @@ -288,6 +285,8 @@ export class GitpodServerEEImpl } const snapshots = await this.workspaceDb.trace({ span }).findSnapshotsByWorkspaceId(workspaceId); + await Promise.all(snapshots.map(s => this.guardAccess({kind: "snapshot", subject: s, workspaceOwnerID: workspace.ownerId}, "get"))); + return snapshots.map(s => s.id); } catch (e) { TraceContext.logError({ span }, e); @@ -512,7 +511,7 @@ export class GitpodServerEEImpl throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "not allowed"); } const span = opentracing.globalTracer().startSpan("adminForceStopWorkspace"); - await this.internalStopWorkspace({ span }, id, StopWorkspacePolicy.IMMEDIATELY); + await this.internalStopWorkspace({ span }, id, undefined, StopWorkspacePolicy.IMMEDIATELY); } protected async findPrebuiltWorkspace(ctx: TraceContext, user: User, context: WorkspaceContext, mode: CreateWorkspaceMode): Promise { diff --git a/components/server/src/auth/authenticator.ts b/components/server/src/auth/authenticator.ts index e73c4fb1763b20..adede972554039 100644 --- a/components/server/src/auth/authenticator.ts +++ b/components/server/src/auth/authenticator.ts @@ -20,7 +20,7 @@ import { AuthProviderService } from './auth-provider-service'; export class Authenticator { protected passportInitialize: express.Handler; - protected passportsession: express.Handler; + protected passportSession: express.Handler; @inject(Env) protected env: Env; @inject(UserDB) protected userDb: UserDB; @@ -32,7 +32,7 @@ export class Authenticator { protected setup() { // Setup passport this.passportInitialize = passport.initialize(); - this.passportsession = passport.session(); + this.passportSession = passport.session(); passport.serializeUser((user: User, done) => { if (user) { done(null, user.id); @@ -57,7 +57,7 @@ export class Authenticator { get initHandlers(): express.Handler[] { return [ this.passportInitialize, // adds `passport.user` to session - this.passportsession // deserializes session user into `req.user` + this.passportSession // deserializes session user into `req.user` ]; } diff --git a/components/server/src/auth/bearer-authenticator.ts b/components/server/src/auth/bearer-authenticator.ts new file mode 100644 index 00000000000000..4634265821016d --- /dev/null +++ b/components/server/src/auth/bearer-authenticator.ts @@ -0,0 +1,57 @@ +import * as websocket from 'ws'; +import * as express from 'express'; +import * as crypto from 'crypto'; +import { Headers } from 'request'; +import { WsRequestHandler, WsNextFunction } from '../express/ws-handler'; +import { GitpodTokenType } from '@gitpod/gitpod-protocol'; +import { injectable, inject } from 'inversify'; +import { UserDB } from '@gitpod/gitpod-db/lib/user-db'; +import { WithResourceAccessGuard, TokenResourceGuard } from './resource-access'; +import { WithFunctionAccessGuard, ExplicitFunctionAccessGuard } from './function-access'; + +export function getBearerToken(headers: Headers): string | undefined { + const authorizationHeader = headers["authorization"]; + if (!authorizationHeader || !(typeof authorizationHeader === "string")) { + return; + } + if (!authorizationHeader.startsWith("Bearer ")) { + return; + } + + const token = authorizationHeader.substring("Bearer ".length); + const hash = crypto.createHash('sha256').update(token, 'utf8').digest("hex"); + return hash; +} + +@injectable() +export class BearerAuth { + @inject(UserDB) protected readonly userDB: UserDB; + + public get websocketHandler(): WsRequestHandler { + return async (ws: websocket, req: express.Request, next: WsNextFunction): Promise => { + const token = getBearerToken(req.headers) + if (!token) { + throw new Error("not authenticated"); + } + + const userAndToken = await this.userDB.findUserByGitpodToken(token, GitpodTokenType.API_AUTH_TOKEN); + if (!userAndToken) { + throw new Error("invalid Bearer token"); + } + + const resourceGuard = new TokenResourceGuard(userAndToken.user.id, userAndToken.token.scopes); + (req as WithResourceAccessGuard).resourceGuard = resourceGuard; + + const functionScopes = userAndToken.token.scopes + .filter(s => s.startsWith("function:")) + .map(s => s.substring("function:".length)); + // We always install a function access guard. If the token has no scopes, it's not allowed to do anything. + (req as WithFunctionAccessGuard).functionGuard = new ExplicitFunctionAccessGuard(functionScopes); + + req.user = userAndToken.user; + + return next(); + } + } + +} diff --git a/components/server/src/auth/function-access.ts b/components/server/src/auth/function-access.ts new file mode 100644 index 00000000000000..2f1ad8ee7f4c96 --- /dev/null +++ b/components/server/src/auth/function-access.ts @@ -0,0 +1,24 @@ +import { injectable } from "inversify"; + +export interface FunctionAccessGuard { + canAccess(name: string): boolean; +} + +export interface WithFunctionAccessGuard { + functionGuard?: FunctionAccessGuard; +} + +@injectable() +export class AllAccessFunctionGuard { + canAccess(name: string): boolean { + return true; + } +} + +export class ExplicitFunctionAccessGuard { + constructor(protected readonly allowedCalls: string[]) {} + + canAccess(name: string): boolean { + return this.allowedCalls.some(c => c === name); + } +} diff --git a/components/server/src/auth/resource-access.spec.ts b/components/server/src/auth/resource-access.spec.ts new file mode 100644 index 00000000000000..be7b09d880fda7 --- /dev/null +++ b/components/server/src/auth/resource-access.spec.ts @@ -0,0 +1,101 @@ +import { suite, test } from "mocha-typescript"; +import * as chai from 'chai'; +const expect = chai.expect; +import { TokenResourceGuard, ScopedResourceGuard, GuardedResource } from "./resource-access"; + +@suite class TestResourceAccess { + + @test public async areScopesSubsetOf() { + const tests: { + name: string + upper: string[] + lower: string[] + isSubset: boolean + }[] = [ + {name: "empty scopes", upper: [], lower: [], isSubset: true}, + {name: "empty upper, function lower", upper: [], lower: ["function:foo"], isSubset: false}, + {name: "empty upper, resource lower", upper: [], lower: ["resource:workspace::foobar::get"], isSubset: false}, + {name: "resource default upper, resource lower", upper: ["resource:default"], lower: ["resource:workspace::foobar::get"], isSubset: false}, + {name: "resource upper, empty lower", upper: ["resource:workspace::foobar::get"], lower: [], isSubset: true}, + {name: "resource upper, one op less lower", upper: ["resource:workspace::foobar::get,create"], lower: ["resource:workspace::foobar::get"], isSubset: true}, + {name: "resource upper, different resource lower", upper: ["resource:workspace::foobar::get,create"], lower: ["resource:workspace::blabla::get"], isSubset: false}, + {name: "function upper, empty lower", upper: ["function:foo"], lower: [], isSubset: true}, + {name: "function upper, function lower", upper: ["function:foo"], lower: ["function:foo"], isSubset: true}, + {name: "function upper, one function lower", upper: ["function:foo", "function:bar"], lower: ["function:foo"], isSubset: true}, + ]; + + tests.forEach(t => { + const res = TokenResourceGuard.areScopesSubsetOf(t.upper, t.lower); + expect(res).to.be.eq(t.isSubset, `"${t.name}" expected areScopesSubsetOf(upper, lower) === ${t.isSubset}, but was ${res}`); + }); + } + + @test public async scopedResourceGuardIsAllowedUnder() { + const tests: { + name: string + parent: ScopedResourceGuard.ResourceScope + child: ScopedResourceGuard.ResourceScope + isAllowed: boolean + }[] = [ + {name: "different kind", isAllowed: false, parent: {kind: "workspace", subjectID: "foo", operations: ["get"]}, child: {kind: "workspaceInstance", subjectID: "foo", operations: ["get"]}}, + {name: "different subject", isAllowed: false, parent: {kind: "workspace", subjectID: "foo", operations: ["get"]}, child: {kind: "workspace", subjectID: "somethingElse", operations: ["get"]}}, + {name: "new op", isAllowed: false, parent: {kind: "workspace", subjectID: "foo", operations: ["get"]}, child: {kind: "workspace", subjectID: "foo", operations: ["get", "create"]}}, + {name: "fewer ops", isAllowed: true, parent: {kind: "workspace", subjectID: "foo", operations: ["get", "create"]}, child: {kind: "workspace", subjectID: "foo", operations: ["get"]}}, + {name: "exact match", isAllowed: true, parent: {kind: "workspace", subjectID: "foo", operations: ["get"]}, child: {kind: "workspace", subjectID: "foo", operations: ["get"]}}, + {name: "no ops", isAllowed: true, parent: {kind: "workspace", subjectID: "foo", operations: []}, child: {kind: "workspace", subjectID: "foo", operations: []}}, + ]; + + tests.forEach(t => { + const res = ScopedResourceGuard.isAllowedUnder(t.parent, t.child); + expect(res).to.be.eq(t.isAllowed, `"${t.name}" expected isAllowedUnder(parent, child) === ${t.isAllowed}, but was ${res}`); + }); + } + + @test public async tokenResourceGuardCanAccess() { + const workspaceResource: GuardedResource = {kind: "workspace", subject: {id:"wsid", ownerId: "foo"} as any}; + const tests: { + name: string + guard: TokenResourceGuard + expectation: boolean + }[] = [ + { + name: "no scopes", + guard: new TokenResourceGuard(workspaceResource.subject.ownerId, []), + expectation: false, + }, + { + name: "default scope positive", + guard: new TokenResourceGuard(workspaceResource.subject.ownerId, [TokenResourceGuard.DefaultResourceScope]), + expectation: true, + }, + { + name: "default scope negative", + guard: new TokenResourceGuard("someoneElse", [TokenResourceGuard.DefaultResourceScope]), + expectation: false, + }, + { + name: "explicit scope", + guard: new TokenResourceGuard(workspaceResource.subject.ownerId, [ + "resource:"+ScopedResourceGuard.marshalResourceScope(workspaceResource, ["get"]), + ]), + expectation: true, + }, + { + name: "default and explicit scope", + guard: new TokenResourceGuard(workspaceResource.subject.ownerId, [ + "resource:default", + "resource:"+ScopedResourceGuard.marshalResourceScope(workspaceResource, ["create"]), + ]), + expectation: true, + }, + ] + + await Promise.all(tests.map(async t => { + const res = await t.guard.canAccess(workspaceResource, "get") + expect(res).to.be.eq(t.expectation, `"${t.name}" expected canAccess(...) === ${t.expectation}, but was ${res}`); + })) + } + +} + +module.exports = new TestResourceAccess(); \ No newline at end of file diff --git a/components/server/src/auth/resource-access.ts b/components/server/src/auth/resource-access.ts new file mode 100644 index 00000000000000..76dd47af2000c6 --- /dev/null +++ b/components/server/src/auth/resource-access.ts @@ -0,0 +1,264 @@ +import { Workspace, WorkspaceInstance, User, Snapshot, GitpodToken, Token } from "@gitpod/gitpod-protocol"; + +declare var resourceInstance: GuardedResource; +export type GuardedResourceKind = typeof resourceInstance.kind; + +export type GuardedResource = + GuardedWorkspace | + GuardedWorkspaceInstance | + GuardedUser | + GuardedSnapshot | + GuardedGitpodToken | + GuardedToken | + GuardedUserStorage +; + +export interface GuardedWorkspace { + kind: "workspace"; + subject: Workspace; +} + +export interface GuardedWorkspaceInstance { + kind: "workspaceInstance"; + subject: WorkspaceInstance | undefined; + workspaceOwnerID: string; +} + +export interface GuardedUser { + kind: "user"; + subject: User; +} + +export interface GuardedSnapshot { + kind: "snapshot"; + subject: Snapshot | undefined; + workspaceOwnerID: string; +} + +export interface GuardedUserStorage { + kind: "userStorage"; + userID: string; + uri: string; +} + +export interface GuardedGitpodToken { + kind: "gitpodToken"; + subject: GitpodToken; +} + +export interface GuardedToken { + kind: "token"; + subject: Token; + tokenOwnerID: string; +} + +export type ResourceAccessOp = + "create" | + "update" | + "get" | + "delete" +; + +export const ResourceAccessGuard = Symbol("ResourceAccessGuard"); + +export interface ResourceAccessGuard { + canAccess(resource: GuardedResource, operation: ResourceAccessOp): Promise; +} + + +export interface WithResourceAccessGuard { + resourceGuard?: ResourceAccessGuard; +} + +/** + * CompositeResourceAccessGuard grants access to resources if at least one of its children does. + */ +export class CompositeResourceAccessGuard implements ResourceAccessGuard { + + constructor(protected readonly children: ResourceAccessGuard[]) {} + + async canAccess(resource: GuardedResource, operation: ResourceAccessOp): Promise { + // if a single guard permitts access, we're good to go + return (await Promise.all(this.children.map(c => c.canAccess(resource, operation)))).some(x => x); + } + +} + +/** + * OwnerResourceGuard grants access to resources if the user asking for access is the owner of that + * resource. + */ +export class OwnerResourceGuard implements ResourceAccessGuard { + + constructor(readonly userId: string) {} + + async canAccess(resource: GuardedResource, operation: ResourceAccessOp): Promise { + switch (resource.kind) { + case "gitpodToken": + return resource.subject.user.id === this.userId; + case "snapshot": + return resource.workspaceOwnerID === this.userId; + case "token": + return resource.tokenOwnerID === this.userId; + case "user": + return resource.subject.id === this.userId; + case "userStorage": + return resource.userID === this.userId; + case "workspace": + return resource.subject.ownerId === this.userId; + case "workspaceInstance": + return resource.workspaceOwnerID === this.userId; + } + } + +} + +export class ScopedResourceGuard implements ResourceAccessGuard { + protected readonly scopes: { [index: string]: ScopedResourceGuard.ResourceScope } = {}; + + constructor(scopes: ScopedResourceGuard.ResourceScope[]) { + scopes.forEach(s => this.scopes[`${s.kind}::${s.subjectID}`] = s); + } + + async canAccess(resource: GuardedResource, operation: ResourceAccessOp): Promise { + const subjectID = ScopedResourceGuard.subjectID(resource); + if (!subjectID) { + return false; + } + + const scope = this.scopes[`${resource.kind}::${subjectID}`]; + if (!scope) { + return false; + } + + return scope.operations.some(op => op === operation); + } + +} + +export namespace ScopedResourceGuard { + export interface ResourceScope { + kind: GuardedResourceKind; + subjectID: string; + operations: ResourceAccessOp[]; + } + + export function isAllowedUnder(parent: ResourceScope, child: ResourceScope): boolean { + if (child.kind !== parent.kind) { + return false; + } + if (child.subjectID !== parent.subjectID) { + return false; + } + if (child.operations.some(co => !parent.operations.includes(co))) { + return false; + } + + return true; + } + + export function unmarshalResourceScope(scope: string): ResourceScope { + const segs = scope.split("::"); + if (segs.length != 3) { + throw new Error("invalid scope") + } + + return { + kind: segs[0] as GuardedResourceKind, + subjectID: segs[1], + operations: segs[2].split(",").map(o => o.trim()) as ResourceAccessOp[], + }; + } + + export function marshalResourceScope(resource: GuardedResource, ops: ResourceAccessOp[]): string { + const subjectID = ScopedResourceGuard.subjectID(resource); + if (!subjectID) { + throw new Error("resource has no subject ID"); + } + + return `${resource.kind}::${subjectID}::${ops.join(",")}`; + } + + export function subjectID(resource: GuardedResource): string | undefined { + switch (resource.kind) { + case "gitpodToken": + return resource.subject.tokenHash; + case "snapshot": + return resource.subject ? resource.subject.id : undefined; + case "token": + return; + case "user": + return resource.subject.id; + case "userStorage": + return `${resource.userID}:${resource.uri}`; + case "workspace": + return resource.subject.id; + case "workspaceInstance": + return resource.subject ? resource.subject.id : undefined; + } + } +} + +export class TokenResourceGuard implements ResourceAccessGuard { + protected readonly delegate: ResourceAccessGuard; + + constructor(userID: string, protected readonly allTokenScopes: string[]) { + const hasDefaultResourceScope = allTokenScopes.some(s => s === TokenResourceGuard.DefaultResourceScope); + if (hasDefaultResourceScope) { + this.delegate = new OwnerResourceGuard(userID); + } else { + const resourceScopes = TokenResourceGuard.getResourceScopes(allTokenScopes); + this.delegate = new ScopedResourceGuard(resourceScopes); + } + } + + async canAccess(resource: GuardedResource, operation: ResourceAccessOp): Promise { + if (resource.kind === "gitpodToken" && operation === "create") { + return TokenResourceGuard.areScopesSubsetOf(this.allTokenScopes, resource.subject.scopes); + } + + return this.delegate.canAccess(resource, operation); + } + +} + +export namespace TokenResourceGuard { + + export const DefaultResourceScope = "resource:default"; + + export function getResourceScopes(s: string[]): ScopedResourceGuard.ResourceScope[] { + return s.filter(s => s.startsWith("resource:") && s !== DefaultResourceScope) + .map(s => ScopedResourceGuard.unmarshalResourceScope(s.substring("resource:".length))); + } + + export function areScopesSubsetOf(upperScopes: string[], lowerScopes: string[]) { + /* + * We need to ensure that the new token we're about to create doesn't exceed our own privileges. + * For all "resource scopes" that means no new resource scope for which we don't have a corresponding one (ops_new <= ops_old). + * For all "function scopes" that means no new function scopes for which we don't have a corresponding one. + */ + + // special case: default resource scope + if (lowerScopes.includes(DefaultResourceScope) && !upperScopes.includes(DefaultResourceScope)) { + return false; + } + + const upperResourceScopes = TokenResourceGuard.getResourceScopes(upperScopes); + const lowerResourceScopes = TokenResourceGuard.getResourceScopes(lowerScopes); + + const allNewScopesAllowed = lowerResourceScopes.every(lrs => upperResourceScopes.some(urs => ScopedResourceGuard.isAllowedUnder(urs, lrs))); + if (!allNewScopesAllowed) { + return false; + } + + const functionsAllowed = lowerScopes + .filter(s => s.startsWith("function:")) + .every(ns => upperScopes.includes(ns)); + if (!functionsAllowed) { + return false; + } + + return true; + } + +} \ No newline at end of file diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 643d0a2eaacb47..e201974538820f 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -65,6 +65,7 @@ import { HostContextProviderImpl } from './auth/host-context-provider-impl'; import { AuthProviderParams } from './auth/auth-provider'; import { AuthErrorHandler } from './auth/auth-error-handler'; import { MonitoringEndpointsApp } from './monitoring-endpoints'; +import { BearerAuth } from './auth/bearer-authenticator'; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Env).toSelf().inSingletonScope(); @@ -189,4 +190,5 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(AuthProviderEntryDB).to(AuthProviderEntryDBImpl).inSingletonScope(); bind(AuthProviderService).toSelf().inSingletonScope(); + bind(BearerAuth).toSelf().inSingletonScope(); }); diff --git a/components/server/src/express-util.ts b/components/server/src/express-util.ts index 74a1a73b11db6f..4a7cf5e791cb0c 100644 --- a/components/server/src/express-util.ts +++ b/components/server/src/express-util.ts @@ -136,5 +136,3 @@ export function getRequestingClientInfo(req: express.Request) { const fingerprint = crypto.createHash('sha256').update(`${ip}–${ua}`).digest('hex'); return { ua, fingerprint }; } - - diff --git a/components/server/src/express/ws-handler.ts b/components/server/src/express/ws-handler.ts index e78fa1318d1fd7..8b255f162a4fec 100644 --- a/components/server/src/express/ws-handler.ts +++ b/components/server/src/express/ws-handler.ts @@ -55,7 +55,10 @@ export class WsExpressHandler { const stack = WsLayer.createStack(...handlers); const dispatch = (ws: websocket, request: express.Request) => { handler(ws, request); - stack.dispatch(ws, request); + stack.dispatch(ws, request).catch(err => { + log.error("websocket stack error", err); + ws.terminate(); + }); } this.httpServer.on('upgrade', (request: http.IncomingMessage, socket: net.Socket, head: Buffer) => { diff --git a/components/server/src/express/ws-layer.ts b/components/server/src/express/ws-layer.ts index 92389d4c20faa9..d107365a68ba64 100644 --- a/components/server/src/express/ws-layer.ts +++ b/components/server/src/express/ws-layer.ts @@ -12,7 +12,7 @@ import { log } from '@gitpod/gitpod-protocol/lib/util/logging'; export interface WsLayer { handleError: WsErrorHandler; handleRequest: WsRequestHandler; - dispatch: (ws: websocket, req: express.Request) => MaybePromise; + dispatch: (ws: websocket, req: express.Request) => Promise; next: (ws: websocket, req: express.Request, err?: any) => MaybePromise; } @@ -58,7 +58,7 @@ export class WsLayerImpl implements WsLayer { } } - async dispatch(ws: websocket, req: express.Request) { + async dispatch(ws: websocket, req: express.Request): Promise { try { return this.next(ws, req); } catch (err) { diff --git a/components/server/src/server.ts b/components/server/src/server.ts index e82d607c68f7bb..64698fe445ec92 100644 --- a/components/server/src/server.ts +++ b/components/server/src/server.ts @@ -35,6 +35,7 @@ import { DeletedEntryGC } from '@gitpod/gitpod-db/lib/typeorm/deleted-entry-gc'; import { PeriodicDbDeleter } from '@gitpod/gitpod-db/lib/periodic-deleter'; import { OneTimeSecretServer } from './one-time-secret-server'; import { GitpodClient, GitpodServer } from '@gitpod/gitpod-protocol'; +import { BearerAuth } from './auth/bearer-authenticator'; @injectable() export class Server { @@ -59,6 +60,8 @@ export class Server { @inject(PeriodicDbDeleter) protected readonly periodicDbDeleter: PeriodicDbDeleter; + @inject(BearerAuth) protected readonly bearerAuth: BearerAuth; + protected readonly eventEmitter = new EventEmitter(); protected app?: express.Application; protected httpServer?: http.Server; @@ -131,6 +134,12 @@ export class Server { }, handleSession, ...initSessionHandlers, handleError, pingPong, (ws: ws, req: express.Request) => { websocketConnectionHandler.onConnection((req as any).wsConnection, req); }); + wsHandler.ws("/v1", (ws, request) => { + const websocket = toIWebSocket(ws); + (request as any).wsConnection = createWebSocketConnection(websocket, console); + }, this.bearerAuth.websocketHandler, handleError, pingPong, (ws: ws, req: express.Request) => { + websocketConnectionHandler.onConnection((req as any).wsConnection, req); + }); }) // register routers diff --git a/components/server/src/user/enforcement-endpoint.ts b/components/server/src/user/enforcement-endpoint.ts index 0eacaca5bebd59..cc10f7ec17c744 100644 --- a/components/server/src/user/enforcement-endpoint.ts +++ b/components/server/src/user/enforcement-endpoint.ts @@ -18,6 +18,7 @@ import { Permission } from '@gitpod/gitpod-protocol/lib/permission'; import { ResponseError } from 'vscode-jsonrpc'; import { ErrorCodes } from '@gitpod/gitpod-protocol/lib/messaging/error'; import { GitpodServerImpl } from '../workspace/gitpod-server-impl'; +import { ResourceAccessGuard, OwnerResourceGuard } from '../auth/resource-access'; @injectable() export class EnforcementController { @@ -37,7 +38,7 @@ export class EnforcementController { return router; } - protected gitpodServer(user: User) { + protected gitpodServer(user: User, resourceAccessGuard: ResourceAccessGuard) { /* This initialize call is a hack. GitpodServer is intended to be accessed via Wbsocket/JsonRpc (see WebsocketConnectionManager). Thus, initialize() needs a GitpodClient. For the methods we use here from GitpodServer we do not need this client. @@ -45,11 +46,11 @@ export class EnforcementController { Since we want to get rid of this enforcement endpoint in the long term having this hack does not harm and looking for another architecture is not necessary. */ - this._gitpodServer.initialize({} as GitpodClient, undefined, user); + this._gitpodServer.initialize({} as GitpodClient, undefined, user, resourceAccessGuard); return this._gitpodServer; } - protected getAuthorizedUser(req: express.Request): User | undefined { + protected getAuthorizedUser(req: express.Request): { callingUser: User, resourceAccessGuard: ResourceAccessGuard } | undefined { if (!req.isAuthenticated() || !req.user) { return; } @@ -60,7 +61,7 @@ export class EnforcementController { } if (this.authService.hasPermission(user, Permission.ENFORCEMENT)) { - return user; + return { callingUser: user, resourceAccessGuard: new OwnerResourceGuard(user.id) }; } else { return undefined; } @@ -68,8 +69,8 @@ export class EnforcementController { protected addRouteToBlockUser(router: express.Router) { router.get("/block-user/:userid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); @@ -87,17 +88,18 @@ export class EnforcementController { res.send(`

Click button below

User will be blocked and all running workspaces will be stopped.

`); }); router.post("/block-user/:userid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); return; } + const { callingUser, resourceAccessGuard } = auth; const targetUserID = req.params.userid; try { - await this.gitpodServer(callingUser).adminBlockUser({ id: targetUserID, blocked: true }); + await this.gitpodServer(callingUser, resourceAccessGuard).adminBlockUser({ id: targetUserID, blocked: true }); res.sendStatus(200); } catch (e) { if (e instanceof ResponseError && e.code === ErrorCodes.NOT_FOUND) { @@ -115,8 +117,8 @@ export class EnforcementController { protected addRouteToKillWorkspace(router: express.Router) { router.get("/kill-workspace/:wsid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); @@ -127,17 +129,18 @@ export class EnforcementController { res.send(`

Click button below

`) }); router.post("/kill-workspace/:wsid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); return; } + const { callingUser, resourceAccessGuard } = auth; const targetWsID = req.params.wsid; try { - await this.gitpodServer(callingUser).adminForceStopWorkspace(targetWsID); + await this.gitpodServer(callingUser, resourceAccessGuard).adminForceStopWorkspace(targetWsID); const target = (await this.workspaceDb.findById(targetWsID))!; const owner = await this.userDB.findUserById(target!.ownerId); @@ -168,26 +171,29 @@ export class EnforcementController { protected addRouteToDeleteUser(router: express.Router) { router.get("/delete-user/:userid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); return; } + const targetUserID = req.params.userid; const actionUrl = this.deleteUserUrl(targetUserID); res.send(`

Click button below

`) }); router.post("/delete-user/:userid", async (req, res, next) => { - const callingUser = this.getAuthorizedUser(req); - if (!callingUser) { + const auth = this.getAuthorizedUser(req); + if (!auth) { log.warn("Unauthorized user attempted to access enforcement endpoint", req); // don't tell the world we exist res.sendStatus(404); return; } + const { callingUser } = auth; + const logCtx: LogContext = { userId: callingUser.id }; const targetUserID = req.params.userid; try { diff --git a/components/server/src/websocket-connection-manager.ts b/components/server/src/websocket-connection-manager.ts index 45c83e16a565e0..067eb1ca0d5ad7 100644 --- a/components/server/src/websocket-connection-manager.ts +++ b/components/server/src/websocket-connection-manager.ts @@ -6,11 +6,15 @@ import { GitpodServerImpl } from "./workspace/gitpod-server-impl"; import { GitpodServerPath, User, GitpodClient, Disposable, GitpodServer } from "@gitpod/gitpod-protocol"; -import { JsonRpcConnectionHandler, JsonRpcProxy } from "@gitpod/gitpod-protocol/lib/messaging/proxy-factory"; +import { JsonRpcConnectionHandler, JsonRpcProxy, JsonRpcProxyFactory } from "@gitpod/gitpod-protocol/lib/messaging/proxy-factory"; import { ConnectionHandler } from "@gitpod/gitpod-protocol/lib/messaging/handler"; -import { MessageConnection } from "vscode-jsonrpc"; +import { MessageConnection, ResponseError, ErrorCodes as RPCErrorCodes } from "vscode-jsonrpc"; import { EventEmitter } from "events"; import * as express from "express"; +import { OwnerResourceGuard, WithResourceAccessGuard } from "./auth/resource-access"; +import { WithFunctionAccessGuard, AllAccessFunctionGuard, FunctionAccessGuard } from "./auth/function-access"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; +import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; export type GitpodServiceFactory = () => GitpodServerImpl; @@ -28,20 +32,33 @@ export class WebsocketConnectionManager[] = []; constructor(protected readonly serverFactory: GitpodServiceFactory) { - this.jsonRpcConnectionHandler = new JsonRpcConnectionHandler(this.path, this.createProxyTarget.bind(this)); + this.jsonRpcConnectionHandler = new GitpodJsonRpcConnectionHandler( + this.path, + this.createProxyTarget.bind(this), + this.createAccessGuard.bind(this), + ); } public onConnection(connection: MessageConnection, session?: object) { this.jsonRpcConnectionHandler.onConnection(connection, session); } + protected createAccessGuard(request?: object): FunctionAccessGuard { + return (request && (request as WithFunctionAccessGuard).functionGuard) || new AllAccessFunctionGuard(); + } + protected createProxyTarget(client: JsonRpcProxy, request?: object): GitpodServerImpl { const expressReq = request as express.Request; const session = expressReq.session; const gitpodServer = this.serverFactory(); const clientRegion = (expressReq as any).headers["x-glb-client-region"]; - gitpodServer.initialize(client, clientRegion, expressReq.user as User); + const user = expressReq.user as User; + const resourceGuard = + (expressReq as WithResourceAccessGuard).resourceGuard || + (!!user ? new OwnerResourceGuard(user.id) : {canAccess: async () => false }); + + gitpodServer.initialize(client, clientRegion, user, resourceGuard); client.onDidCloseConnection(() => { gitpodServer.dispose(); @@ -54,9 +71,9 @@ export class WebsocketConnectionManager>(gitpodServer, { get: (target, property: keyof GitpodServerImpl) => { - const result = target[property]; if (session) session.touch(console.error); - return result; + + return target[property]; } }); } @@ -85,4 +102,51 @@ export class WebsocketConnectionManager extends JsonRpcConnectionHandler { + constructor( + readonly path: string, + readonly targetFactory: (proxy: JsonRpcProxy, request?: object) => any, + readonly accessGuard: (request?: object) => FunctionAccessGuard + ) { + super(path, targetFactory); + } + + onConnection(connection: MessageConnection, request?: object): void { + const factory = new GitpodJsonRpcProxyFactory(this.accessGuard(request)); + const proxy = factory.createProxy(); + factory.target = this.targetFactory(proxy, request); + factory.listen(connection); + } +} + +class GitpodJsonRpcProxyFactory extends JsonRpcProxyFactory { + + constructor(protected readonly accessGuard: FunctionAccessGuard) { + super(); + } + + protected async onRequest(method: string, ...args: any[]): Promise { + if (!this.accessGuard.canAccess(method)) { + log.error(`Request ${method} is not allowed`, {method, args}); + throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "not allowed"); + } + + try { + return await this.target[method](...args); + } catch (e) { + if (e instanceof ResponseError) { + log.info(`Request ${method} unsuccessful: ${e.code}/"${e.message}"`, { method, args }); + } else { + log.error(`Request ${method} failed with internal server error`, e, { method, args }); + } + throw e; + } + } + + protected onNotification(method: string, ...args: any[]): void { + throw new ResponseError(RPCErrorCodes.InvalidRequest, "notifications are not supported"); + } + } \ No newline at end of file diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 1df78b1ad19450..00ffabc81a1d8b 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -52,6 +52,7 @@ import { AdminGetListRequest, AdminGetListResult, AdminGetWorkspacesRequest, Wor import { DBGitpodToken } from '@gitpod/gitpod-db/lib/typeorm/entity/db-gitpod-token'; import { DBUser } from '@gitpod/gitpod-db/lib/typeorm/entity/db-user'; import { AuthProviderService } from '../auth/auth-provider-service'; +import { ResourceAccessGuard, GuardedResource, ResourceAccessOp } from '../auth/resource-access'; @injectable() @@ -91,16 +92,18 @@ export class GitpodServerImpl(); + protected resourceAccessGuard: ResourceAccessGuard; dispose(): void { this.disposables.dispose(); this.disposables = new DisposableCollection(); } - initialize(client: Client, clientRegion: string | undefined, user?: User): void { + initialize(client: Client, clientRegion: string | undefined, user: User, accessGuard: ResourceAccessGuard): void { this.client = client this.user = user; this.clientRegion = clientRegion; + this.resourceAccessGuard = accessGuard; if (this.user) { log.debug({ userId: this.user.id }, `clientRegion: ${this.clientRegion}`); log.info({ userId: this.user.id }, 'initializeClient'); @@ -134,6 +137,12 @@ export class GitpodServerImpl): Promise { const user = this.checkUser('updateLoggedInUser'); + await this.guardAccess({kind: "user", subject: user}, "update"); + const allowedFields: (keyof User)[] = ['avatarUrl', 'fullName', 'allowsMarketingCommunication', 'additionalData']; for (const p of allowedFields) { if (p in partialUser) { @@ -279,9 +290,13 @@ export class GitpodServerImpl { await this.doUpdateUser(); const user = this.checkUser("getToken"); + + const { host } = query; try { const token = await this.tokenProvider.getTokenForHost(user, host); + await this.guardAccess({kind: "token", subject: token, tokenOwnerID: user.id}, "get"); + return token; } catch (error) { // no token found @@ -293,8 +308,13 @@ export class GitpodServerImpl { const user = this.checkUser("deleteAccount"); + await this.guardAccess({kind: "user", subject: user!}, "delete"); + await this.userDeletionService.deleteUser(user.id); } @@ -313,9 +335,17 @@ export class GitpodServerImpl { + this.internalStopWorkspace({ span }, workspaceId, workspace.ownerId).catch(err => { log.error(logCtx, "stopWorkspace error: ", err); }); } catch (e) { @@ -406,12 +433,22 @@ export class GitpodServerImpl { + protected async internalStopWorkspace(ctx: TraceContext, workspaceId: string, ownerId?: string, policy?: StopWorkspacePolicy): Promise { const instance = await this.workspaceDb.trace(ctx).findRunningInstance(workspaceId); if (!instance) { // there's no instance running - we're done return; } + + if (!ownerId) { + const ws = await this.workspaceDb.trace(ctx).findById(workspaceId); + if (!ws) { + return; + } + ownerId = ws.ownerId; + } + + await this.guardAccess({kind: "workspaceInstance", subject: undefined, workspaceOwnerID: ownerId}, "update"); await this.internalStopWorkspaceInstance(ctx, instance.id, instance.region, policy); } @@ -434,9 +471,7 @@ export class GitpodServerImpl { const ws = await this.internalGetWorkspace(id, db); - if (user.id != ws.ownerId) { - throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Only the owner may update the workspace."); - } + await this.guardAccess({kind: "workspace", subject: ws}, "update"); switch (action) { case "pin": @@ -468,12 +503,10 @@ export class GitpodServerImpl { const user = this.checkUser("getWorkspaces"); - return this.workspaceDb.trace({}).find({ + const res = await this.workspaceDb.trace({}).find({ limit: 20, ...options, userId: user.id, includeHeadless: false, }); + await Promise.all(res.map(ws => this.guardAccess({kind: "workspace", subject: ws.workspace}, "get"))); + await Promise.all(res.map(ws => this.guardAccess({kind: "workspaceInstance", subject: ws.latestInstance, workspaceOwnerID: ws.workspace.ownerId}, "get"))); + return res; } public async isWorkspaceOwner(workspaceId: string): Promise { @@ -526,6 +560,7 @@ export class GitpodServerImpl { const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace({})); + await this.guardAccess({kind: "workspace", subject: workspace}, "get"); + const owner = await this.userDB.findUserById(workspace.ownerId); - return owner ? { name: owner.name } : undefined; + if (!owner) { + return undefined; + } + + await this.guardAccess({kind: "user", subject: owner}, "get"); + return { name: owner.name }; } public async getWorkspaceUsers(workspaceId: string): Promise { @@ -582,10 +630,10 @@ export class GitpodServerImpl { const contextUrl = options.contextUrl; const mode = options.mode || CreateWorkspaceMode.Default; - let workspacePromise: Promise | undefined; let normalizedContextUrl: string = ""; let logContext: LogContext = {}; @@ -711,6 +759,13 @@ export class GitpodServerImpl { const userId = this.checkUser("getUserStorageResource").id; const uri = options.uri; - const content = await this.userStorageResourcesDB.get(userId, uri); - return content; + + await this.guardAccess({kind: "userStorage", uri, userID: userId}, "get"); + + return await this.userStorageResourcesDB.get(userId, uri); } async updateUserStorageResource(options: GitpodServer.UpdateUserStorageResourceOptions): Promise { const userId = this.checkAndBlockUser("updateUserStorageResource").id; const uri = options.uri; const content = options.content; + + await this.guardAccess({kind: "userStorage", uri, userID: userId}, "update"); + await this.userStorageResourcesDB.update(userId, uri, content); } @@ -1125,6 +1179,9 @@ export class GitpodServerImpl { this.checkUser("storeLayout"); + + const workspace = await this.workspaceDb.trace({}).findById(workspaceId); + if (!workspace) { + return; + } + await this.guardAccess({kind: "workspace", subject: workspace}, "get"); + const layoutData = await this.workspaceDb.trace({}).findLayoutDataByWorkspaceId(workspaceId); - if (layoutData) { - return layoutData.layoutData; + if (!layoutData) { + return; } - return undefined; + return layoutData.layoutData; } async getEnvVars(): Promise { + // Note: this operation is per-user only, hence needs no resource guard + const user = this.checkUser("getEnvVars"); return (await this.userDB.getEnvVars(user.id)).map(v => { return { @@ -1157,6 +1223,7 @@ export class GitpodServerImpl { + // Note: this operation is per-user only, hence needs no resource guard const user = this.checkUser("setEnvVar"); variable.repositoryPattern = UserEnvVar.normalizeRepoPattern(variable.repositoryPattern); @@ -1184,6 +1251,7 @@ export class GitpodServerImpl { + // Note: this operation is per-user only, hence needs no resource guard const user = this.checkUser("deleteEnvVar"); if (!variable.id) { @@ -1200,7 +1268,9 @@ export class GitpodServerImpl { const user = this.checkAndBlockUser("getGitpodTokens"); - return (await this.userDB.findAllGitpodTokensOfUser(user.id)).filter(v => !v.deleted); + const res = (await this.userDB.findAllGitpodTokensOfUser(user.id)).filter(v => !v.deleted); + await Promise.all(res.map(tkn => this.guardAccess({kind: "gitpodToken", subject: tkn}, "get"))); + return res; } public async generateNewGitpodToken(options: { name?: string, type: GitpodTokenType, scopes?: [] }): Promise { @@ -1208,7 +1278,7 @@ export class GitpodServerImpl { const user = this.checkAndBlockUser("deleteGitpodToken"); const existingTokens = await this.getGitpodTokens(); // all tokens for logged in user - if (!existingTokens || !existingTokens.find(token => token.tokenHash === tokenHash)) { + const tkn = existingTokens.find(token => token.tokenHash === tokenHash); + if (!tkn) { throw new Error(`User ${user.id} tries to delete a token ${tokenHash} that does not exist.`); } + await this.guardAccess({kind: "gitpodToken", subject: tkn}, "delete"); return this.userDB.deleteGitpodToken(tokenHash); }