Files
stack/apps/api/src/common/guards/api-key.guard.ts
Jason Woltje 7101864a15 fix(#189): add composite database index for job_events table
Add composite index [jobId, timestamp] to improve query performance
for the most common job_events access patterns.

Changes:
- Add @@index([jobId, timestamp]) to JobEvent model in schema.prisma
- Create migration 20260202122655_add_job_events_composite_index
- Add performance tests to validate index effectiveness
- Document index design rationale in scratchpad
- Fix lint errors in api-key.guard, herald.service, runner-jobs.service

Rationale:
The composite index [jobId, timestamp] optimizes the dominant query
pattern used across all services:
- JobEventsService.getEventsByJobId (WHERE jobId, ORDER BY timestamp)
- RunnerJobsService.streamEvents (WHERE jobId + timestamp range)
- RunnerJobsService.findOne (implicit jobId filter + timestamp order)

This index provides:
- Fast filtering by jobId (highly selective)
- Efficient timestamp-based ordering
- Optimal support for timestamp range queries
- Backward compatibility with jobId-only queries

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:30:19 -06:00

82 lines
2.5 KiB
TypeScript

import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { timingSafeEqual } from "crypto";
/**
* ApiKeyGuard - Authentication guard for service-to-service communication
*
* Validates the X-API-Key header against the COORDINATOR_API_KEY environment variable.
* Uses constant-time comparison to prevent timing attacks.
*
* Usage:
* @UseGuards(ApiKeyGuard)
* @Controller('coordinator')
* export class CoordinatorIntegrationController { ... }
*/
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string> }>();
const providedKey = this.extractApiKeyFromHeader(request);
if (!providedKey) {
throw new UnauthorizedException("No API key provided");
}
const configuredKey = this.configService.get<string>("COORDINATOR_API_KEY");
if (!configuredKey) {
throw new UnauthorizedException("API key authentication not configured");
}
if (!this.isValidApiKey(providedKey, configuredKey)) {
throw new UnauthorizedException("Invalid API key");
}
return true;
}
/**
* Extract API key from X-API-Key header (case-insensitive)
*/
private extractApiKeyFromHeader(request: {
headers: Record<string, string>;
}): string | undefined {
const headers = request.headers;
// Check common variations (lowercase, uppercase, mixed case)
const apiKey =
headers["x-api-key"] ?? headers["X-API-Key"] ?? headers["X-Api-Key"] ?? headers["x-api-key"];
// Return undefined if key is empty string
if (typeof apiKey === "string" && apiKey.trim() === "") {
return undefined;
}
return apiKey;
}
/**
* Validate API key using constant-time comparison to prevent timing attacks
*/
private isValidApiKey(providedKey: string, configuredKey: string): boolean {
try {
// Convert strings to buffers for constant-time comparison
const providedBuffer = Buffer.from(providedKey, "utf8");
const configuredBuffer = Buffer.from(configuredKey, "utf8");
// Keys must be same length for timingSafeEqual
if (providedBuffer.length !== configuredBuffer.length) {
return false;
}
return timingSafeEqual(providedBuffer, configuredBuffer);
} catch {
// If comparison fails for any reason, reject
return false;
}
}
}