Skip to content
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
57 changes: 47 additions & 10 deletions src/rate-limiting/guards/quota.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Reflector } from '@nestjs/core';
import { Request, Response } from 'express';
import { QuotaTrackingService } from '../services/quota-tracking.service';
import { QUOTA_KEY, QuotaOptions } from '../decorators/quota.decorator';
import { UserTier } from '../rate-limiting.constants';
import {
UserTier,
INTERNAL_SERVICE_HEADER,
getTrustedIps,
ADMIN_ROLES,
} from '../rate-limiting.constants';

/**
* Global quota guard — checks per-user consumption before each request.
Expand All @@ -13,20 +18,27 @@ import { UserTier } from '../rate-limiting.constants';
* 1. request.user (JWT-populated by AuthGuard)
* 2. Falls back to IP-based tracking with FREE tier if unauthenticated
*
* Bypass order (checked before quota is enforced):
* 1. @SkipQuota() / @UseQuota({ skip: true }) decorator
* 2. Request IP is in RATE_LIMIT_TRUSTED_IPS env var
* 3. Valid X-Internal-Service-Key header matches INTERNAL_SERVICE_KEY env var
* 4. Authenticated user carries an admin role
*
* Injects standard rate-limit headers on every response so clients can
* observe their remaining quota without hitting 429s.
*/
@Injectable()
export class QuotaGuard implements CanActivate {
private readonly logger = new Logger(QuotaGuard.name);
private readonly trustedIps: Set<string> = getTrustedIps();
private readonly internalKey: string | undefined = process.env.INTERNAL_SERVICE_KEY;

constructor(
private readonly reflector: Reflector,
private readonly tracking: QuotaTrackingService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
// Check for @SkipQuota or @UseQuota({ skip: true })
const options = this.reflector.getAllAndOverride<QuotaOptions>(QUOTA_KEY, [
context.getHandler(),
context.getClass(),
Expand All @@ -36,13 +48,14 @@ export class QuotaGuard implements CanActivate {
const req = context.switchToHttp().getRequest<Request & { user?: any }>();
const res = context.switchToHttp().getResponse<Response>();

// Resolve identity — authenticated user or fall back to IP
if (this.isWhitelisted(req)) return true;
if (this.isAdminUser(req.user)) return true;

const userId: string = req.user?.id ?? req.user?.sub ?? this.resolveIp(req);
const tier: UserTier = options?.tier ?? this.resolveTier(req.user);

const result = await this.tracking.checkAndIncrement(userId, tier);

// Always inject quota headers
res.setHeader('X-RateLimit-Limit-Minute', result.limit.minute);
res.setHeader('X-RateLimit-Limit-Hour', result.limit.hour);
res.setHeader('X-RateLimit-Limit-Day', result.limit.day);
Expand All @@ -52,27 +65,51 @@ export class QuotaGuard implements CanActivate {

if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter ?? 60);

this.logger.warn(
`Quota exceeded userId=${userId} tier=${tier} retryAfter=${result.retryAfter}s`,
);

throw new RateLimitExceededException(result.retryAfter);
}

return true;
}

private isWhitelisted(req: Request): boolean {
const ip = this.resolveRawIp(req);
if (ip && this.trustedIps.has(ip)) return true;

if (this.internalKey) {
const header = req.headers[INTERNAL_SERVICE_HEADER];
if (header === this.internalKey) return true;
}

return false;
}

private isAdminUser(user?: any): boolean {
if (!user) return false;
const role: string = (user.role ?? user['https://teachlink.io/role'] ?? '').toString();
if (ADMIN_ROLES.has(role)) return true;
const roles: string[] = Array.isArray(user.roles) ? user.roles : [];
return roles.some((r) => {
const name = typeof r === 'string' ? r : (r as { name?: string })?.name ?? '';
return ADMIN_ROLES.has(name);
});
}

private resolveTier(user?: any): UserTier {
if (!user) return UserTier.FREE;
// Map user.tier or user.plan field; default to FREE
const raw = (user.tier ?? user.plan ?? 'FREE').toString().toUpperCase();
return UserTier[raw as keyof typeof UserTier] ?? UserTier.FREE;
}

private resolveIp(req: Request): string {
private resolveRawIp(req: Request): string | undefined {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') return `ip:${forwarded.split(',')[0].trim()}`;
return `ip:${req.ip ?? req.socket?.remoteAddress ?? 'unknown'}`;
if (typeof forwarded === 'string') return forwarded.split(',')[0].trim();
return req.ip ?? req.socket?.remoteAddress;
}

private resolveIp(req: Request): string {
return `ip:${this.resolveRawIp(req) ?? 'unknown'}`;
}
}
23 changes: 23 additions & 0 deletions src/rate-limiting/rate-limiting.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,26 @@ export type QuotaResetPeriod = keyof typeof QUOTA_RESET_PERIODS;

/** DI token for the quota storage strategy */
export const QUOTA_STORAGE = Symbol('QUOTA_STORAGE');

/**
* Header that internal services must send alongside INTERNAL_SERVICE_KEY.
* Requests carrying a valid key bypass all quota checks.
*/
export const INTERNAL_SERVICE_HEADER = 'x-internal-service-key';

/**
* Comma-separated list of trusted IP addresses that bypass quota checks.
* Read from RATE_LIMIT_TRUSTED_IPS at startup.
*/
export function getTrustedIps(): Set<string> {
const raw = process.env.RATE_LIMIT_TRUSTED_IPS ?? '';
return new Set(
raw
.split(',')
.map((ip) => ip.trim())
.filter(Boolean),
);
}

/** Roles that are unconditionally exempt from quota enforcement. */
export const ADMIN_ROLES = new Set(['admin', 'ADMIN']);
Loading