mirror of
https://github.com/graphql-hive/console
synced 2026-05-23 09:08:34 +00:00
Rate limit Mutation.inviteToOrganizationByEmail (#4017)
This commit is contained in:
parent
cc1635da45
commit
34e1ca40ba
3 changed files with 129 additions and 1 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '../auth/providers/organization-access';
|
||||
import { isProjectScope, ProjectAccessScope } from '../auth/providers/project-access';
|
||||
import { isTargetScope, TargetAccessScope } from '../auth/providers/target-access';
|
||||
import { InMemoryRateLimiter } from '../rate-limit/providers/in-memory-rate-limiter';
|
||||
import { IdTranslator } from '../shared/providers/id-translator';
|
||||
import { Logger } from '../shared/providers/logger';
|
||||
import type { OrganizationModule } from './__generated__/types';
|
||||
|
|
@ -255,6 +256,13 @@ export const resolvers: OrganizationModule.Resolvers = {
|
|||
};
|
||||
},
|
||||
async inviteToOrganizationByEmail(_, { input }, { injector }) {
|
||||
await injector.get(InMemoryRateLimiter).check(
|
||||
'inviteToOrganizationByEmail',
|
||||
5_000, // 5 seconds
|
||||
6, // 6 invites
|
||||
`Exceeded rate limit for inviting to organization by email.`,
|
||||
);
|
||||
|
||||
const InputModel = z.object({
|
||||
email: z.string().email().max(128, 'Email must be at most 128 characters long'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createModule } from 'graphql-modules';
|
||||
import { InMemoryRateLimiter, InMemoryRateLimitStore } from './providers/in-memory-rate-limiter';
|
||||
import { RateLimitProvider } from './providers/rate-limit.provider';
|
||||
import { resolvers } from './resolvers';
|
||||
import typeDefs from './module.graphql';
|
||||
|
|
@ -8,5 +9,5 @@ export const rateLimitModule = createModule({
|
|||
dirname: __dirname,
|
||||
typeDefs,
|
||||
resolvers,
|
||||
providers: [RateLimitProvider],
|
||||
providers: [RateLimitProvider, InMemoryRateLimitStore, InMemoryRateLimiter],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import LRU from 'lru-cache';
|
||||
import { HiveError } from '../../../shared/errors';
|
||||
import { AuthManager } from '../../auth/providers/auth-manager';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Singleton,
|
||||
})
|
||||
export class InMemoryRateLimitStore {
|
||||
limiters = new Map<
|
||||
string,
|
||||
{
|
||||
windowSize: number;
|
||||
maxActions: number;
|
||||
limiter: SlidingWindowRateLimiter;
|
||||
}
|
||||
>();
|
||||
|
||||
ensureLimiter(action: string, windowSize: number, maxActions: number) {
|
||||
const existing = this.limiters.get(action);
|
||||
if (existing) {
|
||||
if (existing.maxActions !== maxActions || existing.windowSize !== windowSize) {
|
||||
throw new Error(
|
||||
`Rate limiter for action "${action}" already exists with different window size or max actions.`,
|
||||
);
|
||||
}
|
||||
|
||||
return existing.limiter;
|
||||
}
|
||||
|
||||
const limiter = new SlidingWindowRateLimiter(windowSize, maxActions);
|
||||
this.limiters.set(action, {
|
||||
windowSize,
|
||||
maxActions,
|
||||
limiter,
|
||||
});
|
||||
|
||||
return limiter;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
global: true,
|
||||
scope: Scope.Operation,
|
||||
})
|
||||
export class InMemoryRateLimiter {
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
private store: InMemoryRateLimitStore,
|
||||
private authManager: AuthManager,
|
||||
) {
|
||||
this.logger = logger.child({ service: 'InMemoryRateLimiter' });
|
||||
}
|
||||
|
||||
async check(action: string, windowSizeInMs: number, maxActions: number, message: string) {
|
||||
this.logger.debug(
|
||||
'Checking rate limit (action:%s, windowsSize: %s, maxActions: %s)',
|
||||
action,
|
||||
windowSizeInMs,
|
||||
maxActions,
|
||||
);
|
||||
if (!this.authManager.isUser()) {
|
||||
throw new Error('Expected to be called for an authenticated user.');
|
||||
}
|
||||
|
||||
const user = await this.authManager.getCurrentUser();
|
||||
const limiter = this.store.ensureLimiter(action, windowSizeInMs, maxActions);
|
||||
|
||||
if (!limiter.isAllowed(user.id)) {
|
||||
throw new HiveError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SlidingWindowRateLimiter {
|
||||
private windowSize: number;
|
||||
private maxActions: number;
|
||||
private userActions: LRU<string, number[]>;
|
||||
|
||||
constructor(windowSize: number, maxActions: number) {
|
||||
this.windowSize = windowSize;
|
||||
this.maxActions = maxActions;
|
||||
this.userActions = new LRU({
|
||||
max: 500,
|
||||
maxAge: windowSize,
|
||||
});
|
||||
}
|
||||
|
||||
isAllowed(userId: string): boolean {
|
||||
const now = Date.now();
|
||||
const userTimestamps = this.userActions.get(userId) || [];
|
||||
|
||||
const recentTimestamps: number[] = [];
|
||||
|
||||
// Remove timestamps that are outside the sliding window
|
||||
for (let index = userTimestamps.length - 1; index >= 0; index--) {
|
||||
const timestamp = userTimestamps[index];
|
||||
|
||||
if (now - timestamp <= this.windowSize) {
|
||||
recentTimestamps.unshift(timestamp);
|
||||
} else {
|
||||
// Stop when we reach the first timestamp outside the window.
|
||||
// This is because the timestamps are ordered from most recent to oldest
|
||||
// (We iterate from the end of the array)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the current timestamp to the list
|
||||
recentTimestamps.push(now);
|
||||
|
||||
// Update the user's timestamp list
|
||||
this.userActions.set(userId, recentTimestamps);
|
||||
|
||||
// Check if the number of actions is within the allowed limit
|
||||
return recentTimestamps.length <= this.maxActions;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue