Skip to content

Commit

Permalink
Merge branch 'main' into exp/local-app-v2
Browse files Browse the repository at this point in the history
  • Loading branch information
filiptronicek authored Oct 29, 2023
2 parents 306908a + c581b09 commit 6d60492
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 10 deletions.
2 changes: 1 addition & 1 deletion components/dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const ModalBody: FC<ModalBodyProps> = ({ children, hideDivider = false, n
return (
// Allows the first tabbable element in the body to receive focus on mount
<AutoFocusInside
className={cn("flex-grow relative border-gray-200 dark:border-gray-800 -mx-6 px-6 pb-6", {
className={cn("md:flex-grow relative border-gray-200 dark:border-gray-800 -mx-6 px-6 pb-6", {
"border-t border-b mt-2 py-4": !hideDivider,
"overflow-y-auto": !noScroll,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ class GitpodConnectionProvider : GatewayConnectionProvider {
}

var userName = keyPair.userName
if (userName == null) {
if (userName.isNullOrBlank()) {
userName = "gitpod"
}

Expand Down
23 changes: 23 additions & 0 deletions components/server/src/api/rate-limited.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { IRateLimiterOptions } from "rate-limiter-flexible";

const RATE_LIMIT_METADATA_KEY = Symbol("RateLimited");

export function RateLimited(options: IRateLimiterOptions) {
return Reflect.metadata(RATE_LIMIT_METADATA_KEY, options);
}

export namespace RateLimited {
const defaultOptions: IRateLimiterOptions = {
points: 200,
duration: 60,
};
export function getOptions(target: Object, properyKey: string | symbol): IRateLimiterOptions {
return Reflect.getMetadata(RATE_LIMIT_METADATA_KEY, target, properyKey) || defaultOptions;
}
}
80 changes: 72 additions & 8 deletions components/server/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ import { APIStatsService as StatsServiceAPI } from "./stats";
import { APITeamsService as TeamsServiceAPI } from "./teams";
import { APIUserService as UserServiceAPI } from "./user";
import { WorkspaceServiceAPI } from "./workspace-service-api";
import { IRateLimiterOptions, RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import { Redis } from "ioredis";
import { RateLimited } from "./rate-limited";
import { Config } from "../config";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { UserService } from "../user/user-service";
import { User } from "@gitpod/gitpod-protocol";

decorate(injectable(), PublicAPIConverter);

Expand All @@ -46,6 +53,9 @@ export class API {
@inject(HelloServiceAPI) private readonly helloServiceApi: HelloServiceAPI;
@inject(SessionHandler) private readonly sessionHandler: SessionHandler;
@inject(PublicAPIConverter) private readonly apiConverter: PublicAPIConverter;
@inject(Redis) private readonly redis: Redis;
@inject(Config) private readonly config: Config;
@inject(UserService) private readonly userService: UserService;

listenPrivate(): http.Server {
const app = express();
Expand Down Expand Up @@ -174,9 +184,30 @@ export class API {

const context = args[1] as HandlerContext;

const rateLimit = async (subjectId: string) => {
const key = `${grpc_service}/${grpc_method}`;
const options = self.config.rateLimits?.[key] || RateLimited.getOptions(target, prop);
try {
await self.getRateLimitter(options).consume(`${subjectId}_${key}`);
} catch (e) {
if (e instanceof RateLimiterRes) {
throw new ConnectError("rate limit exceeded", Code.ResourceExhausted, {
// http compatibility, can be respected by gRPC clients as well
// instead of doing an ad-hoc retry, the client can wait for the given amount of seconds
"Retry-After": e.msBeforeNext / 1000,
"X-RateLimit-Limit": options.points,
"X-RateLimit-Remaining": e.remainingPoints,
"X-RateLimit-Reset": new Date(Date.now() + e.msBeforeNext),
});
}
throw e;
}
};

const apply = async <T>(): Promise<T> => {
const user = await self.verify(context);
context.user = user;
const subjectId = await self.verify(context);
await rateLimit(subjectId);
context.user = await self.ensureFgaMigration(subjectId);

return Reflect.apply(target[prop as any], target, args);
};
Expand Down Expand Up @@ -211,17 +242,50 @@ export class API {
};
}

private async verify(context: HandlerContext) {
const cookieHeader: string = context.requestHeader.get("cookie") || "";
const user = await this.sessionHandler.verify(cookieHeader);
if (!user) {
private async verify(context: HandlerContext): Promise<string> {
const cookieHeader = (context.requestHeader.get("cookie") || "") as string;
const claims = await this.sessionHandler.verifyJWTCookie(cookieHeader);
const subjectId = claims?.sub;
if (!subjectId) {
throw new ConnectError("unauthenticated", Code.Unauthenticated);
}
const fgaChecksEnabled = await isFgaChecksEnabled(user.id);
return subjectId;
}

private async ensureFgaMigration(userId: string): Promise<User> {
const fgaChecksEnabled = await isFgaChecksEnabled(userId);
if (!fgaChecksEnabled) {
throw new ConnectError("unauthorized", Code.PermissionDenied);
}
return user;
try {
return await this.userService.findUserById(userId, userId);
} catch (e) {
if (e instanceof ApplicationError && e.code === ErrorCodes.NOT_FOUND) {
throw new ConnectError("unauthorized", Code.PermissionDenied);
}
throw e;
}
}

private readonly rateLimiters = new Map<string, RateLimiterRedis>();
private getRateLimitter(options: IRateLimiterOptions): RateLimiterRedis {
const sortedKeys = Object.keys(options).sort();
const sortedObject: { [key: string]: any } = {};
for (const key of sortedKeys) {
sortedObject[key] = options[key as keyof IRateLimiterOptions];
}
const key = JSON.stringify(sortedObject);

let rateLimiter = this.rateLimiters.get(key);
if (!rateLimiter) {
rateLimiter = new RateLimiterRedis({
storeClient: this.redis,
...options,
insuranceLimiter: new RateLimiterMemory(options),
});
this.rateLimiters.set(key, rateLimiter);
}
return rateLimiter;
}

static bindAPI(bind: interfaces.Bind): void {
Expand Down
6 changes: 6 additions & 0 deletions components/server/src/api/teams.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { UserAuthentication } from "../user/user-authentication";
import { WorkspaceService } from "../workspace/workspace-service";
import { API } from "./server";
import { SessionHandler } from "../session-handler";
import { Redis } from "ioredis";
import { UserService } from "../user/user-service";
import { Config } from "../config";

const expect = chai.expect;

Expand All @@ -39,6 +42,9 @@ export class APITeamsServiceSpec {
this.container.bind(WorkspaceService).toConstantValue({} as WorkspaceService);
this.container.bind(UserAuthentication).toConstantValue({} as UserAuthentication);
this.container.bind(SessionHandler).toConstantValue({} as SessionHandler);
this.container.bind(Config).toConstantValue({} as Config);
this.container.bind(Redis).toConstantValue({} as Redis);
this.container.bind(UserService).toConstantValue({} as UserService);

// Clean-up database
const typeorm = testContainer.get<TypeORM>(TypeORM);
Expand Down
12 changes: 12 additions & 0 deletions components/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env";
import { WorkspaceClassesConfig } from "./workspace/workspace-classes";
import { PrebuildRateLimiters } from "./workspace/prebuild-rate-limiter";
import { IRateLimiterOptions } from "rate-limiter-flexible";

export const Config = Symbol("Config");
export type Config = Omit<
Expand Down Expand Up @@ -174,9 +175,20 @@ export interface ConfigSerialized {

/**
* The configuration for the rate limiter we (mainly) use for the websocket API
* @deprecated used for JSON-RPC API, for gRPC use rateLimits
*/
rateLimiter: RateLimiterConfig;

/**
* The configuration for the rate limiter we use for the gRPC API.
* As a primary means use RateLimited decorator.
* Only use this if you need to adjst in production, make sure to apply changes to the decorator as well.
* Key is of the form `<grpc_service>/<grpc_method>`
*/
rateLimits?: {
[key: string]: IRateLimiterOptions;
};

/**
* The address content service clients connect to
* Example: content-service:8080
Expand Down
1 change: 1 addition & 0 deletions components/supervisor/pkg/supervisor/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,7 @@ func (ss *ControlService) CreateSSHKeyPair(ctx context.Context, req *api.CreateS
return &api.CreateSSHKeyPairResponse{
PrivateKey: ss.privateKey,
HostKey: ss.hostKey,
UserName: userName,
}, nil
}
log.WithError(err).Error("check authorized_keys failed, will recreate")
Expand Down

0 comments on commit 6d60492

Please sign in to comment.