Skip to content

[server] Provide token-based API access #1868

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 3 commits into from
Sep 18, 2020
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
9 changes: 8 additions & 1 deletion chart/config/proxy/lib.locations.conf
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,21 @@ 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;
include lib.ws-sse.conf;

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 {
Expand Down
8 changes: 6 additions & 2 deletions components/gitpod-db/src/typeorm/user-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class TypeORMUserDBImpl implements UserDB {
return result.sort(order);
}

public async findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise<User | undefined> {
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");
Expand All @@ -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<GitpodToken[]> {
Expand Down
2 changes: 1 addition & 1 deletion components/gitpod-db/src/user-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User | undefined>;

findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise<MaybeUser>;
findUserByGitpodToken(tokenHash: string, tokenType?: GitpodTokenType): Promise<{user: User, token: GitpodToken} | undefined>;
findAllGitpodTokensOfUser(userId: string): Promise<GitpodToken[]>;
storeGitpodToken(token: GitpodToken & { user: DBUser }): Promise<void>;
deleteGitpodToken(tokenHash: string): Promise<void>;
Expand Down
3 changes: 2 additions & 1 deletion components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion components/server/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion components/server/ee/src/graphql/graphql-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 11 additions & 12 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,12 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>
}

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);
Expand Down Expand Up @@ -126,15 +123,12 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>
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);
Expand Down Expand Up @@ -200,12 +194,12 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>

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)!);
Expand Down Expand Up @@ -247,6 +241,9 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>
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);
Expand Down Expand Up @@ -288,6 +285,8 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>
}

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);
Expand Down Expand Up @@ -512,7 +511,7 @@ export class GitpodServerEEImpl<C extends GitpodClient, S extends GitpodServer>
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<WorkspaceCreationResult | PrebuiltWorkspaceContext | undefined> {
Expand Down
6 changes: 3 additions & 3 deletions components/server/src/auth/authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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`
];
}

Expand Down
57 changes: 57 additions & 0 deletions components/server/src/auth/bearer-authenticator.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
}
}

}
24 changes: 24 additions & 0 deletions components/server/src/auth/function-access.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
101 changes: 101 additions & 0 deletions components/server/src/auth/resource-access.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading