From 09ce7d3760f7d3b8729db08e8ed5670b2130f3f7 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Wed, 24 Jun 2026 17:17:31 +0100 Subject: [PATCH] feat(rate-limiting): add IP whitelist and admin bypass to quota guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RATE_LIMIT_TRUSTED_IPS env var support (comma-separated IPs that bypass quota checks entirely — intended for internal load balancers and health-check agents) - Add INTERNAL_SERVICE_KEY env var + X-Internal-Service-Key header for service-to-service calls that should not consume user quotas - Add automatic admin role bypass — users with role 'admin' skip quota enforcement; supports both flat role string and roles array (Auth0 namespaced claim and local roles[] array) - Extract resolveRawIp() helper to keep IP parsing DRY - getTrustedIps() parsed once at guard construction to avoid per-request env reads closes #510 --- src/rate-limiting/guards/quota.guard.ts | 57 ++++++++++++++++---- src/rate-limiting/rate-limiting.constants.ts | 23 ++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/rate-limiting/guards/quota.guard.ts b/src/rate-limiting/guards/quota.guard.ts index 81628831..f5f14e9e 100644 --- a/src/rate-limiting/guards/quota.guard.ts +++ b/src/rate-limiting/guards/quota.guard.ts @@ -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. @@ -13,12 +18,20 @@ 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 = getTrustedIps(); + private readonly internalKey: string | undefined = process.env.INTERNAL_SERVICE_KEY; constructor( private readonly reflector: Reflector, @@ -26,7 +39,6 @@ export class QuotaGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - // Check for @SkipQuota or @UseQuota({ skip: true }) const options = this.reflector.getAllAndOverride(QUOTA_KEY, [ context.getHandler(), context.getClass(), @@ -36,13 +48,14 @@ export class QuotaGuard implements CanActivate { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); - // 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); @@ -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'}`; } } diff --git a/src/rate-limiting/rate-limiting.constants.ts b/src/rate-limiting/rate-limiting.constants.ts index 3397a4cc..9c2cb7ab 100644 --- a/src/rate-limiting/rate-limiting.constants.ts +++ b/src/rate-limiting/rate-limiting.constants.ts @@ -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 { + 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']);