#!/usr/bin/env node /* eslint-disable no-console */ import { fileURLToPath } from "node:url"; import { openApiDocument } from "../apps/api/src/openapi.js"; const httpMethods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); const publicOperationAllowlist = new Set([ "GET /healthz", "GET /v1/health", "GET /readyz", "GET /status", "GET /v1/docs", "GET /v1/openapi.json", "POST /v1/auth/login", ]); const optionalAnonymousAllowlist = new Set(["POST /v1/api-keys/validate"]); export type OpenApiDocument = { components?: { securitySchemes?: Record; }; paths?: Record>; }; export type GuardResult = { ok: boolean; violations: string[]; }; type OperationObject = { operationId?: unknown; security?: unknown; "x-api-key-permissions"?: unknown; }; type OperationEntry = { method: string; path: string; operation: OperationObject; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function operationContext({ method, path, operation }: OperationEntry) { const operationId = typeof operation.operationId === "string" && operation.operationId.length > 0 ? operation.operationId : ""; return `${method.toUpperCase()} ${path} ${operationId}`; } function listOperations(document: OpenApiDocument): OperationEntry[] { const operations: OperationEntry[] = []; const paths = isRecord(document.paths) ? document.paths : {}; for (const [path, pathItem] of Object.entries(paths)) { if (!isRecord(pathItem)) continue; for (const [method, operation] of Object.entries(pathItem)) { const normalizedMethod = method.toLowerCase(); if (!httpMethods.has(normalizedMethod) || !isRecord(operation)) continue; operations.push({ method: normalizedMethod, path, operation: operation as OperationObject }); } } return operations; } function securityRequirementSchemeNames(security: unknown): string[] { if (!Array.isArray(security)) return []; return security.flatMap((requirement) => { if (!isRecord(requirement)) return []; return Object.keys(requirement); }); } function hasEmptySecurityRequirement(security: unknown) { return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0); } function hasNonEmptySecurityRequirement(security: unknown) { return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length > 0); } function usesScheme(operation: OperationObject, schemeName: string) { return securityRequirementSchemeNames(operation.security).includes(schemeName); } function isScopedApiKeyPath(path: string) { return /^\/v1\/entities\/\{entity\}\/rows(?:\/\{rowId\})?$/.test(path) || /^\/v1\/relationships(?:\/\{id\})?$/.test(path); } function isServiceTokenOnlyMetadataPath(path: string) { return /^\/v1\/entities\/\{entity\}$/.test(path); } export function validateOpenApiSecurity(document: OpenApiDocument = openApiDocument): GuardResult { const declaredSchemes = new Set(Object.keys(document.components?.securitySchemes ?? {})); const violations: string[] = []; const operations = listOperations(document); if (!isRecord(document.paths) || Object.keys(document.paths).length === 0) { violations.push("OpenAPI document must define at least one path"); } if (operations.length === 0) { violations.push("OpenAPI document must define at least one operation"); } for (const schemeName of ["bearerAuth", "apiKeyHeader"]) { if (!declaredSchemes.has(schemeName)) { violations.push(`OpenAPI document must declare security scheme '${schemeName}'`); } } for (const entry of operations) { const context = operationContext(entry); const allowlistKey = `${entry.method.toUpperCase()} ${entry.path}`; const { operation } = entry; const schemeNames = securityRequirementSchemeNames(operation.security); for (const schemeName of schemeNames) { if (!declaredSchemes.has(schemeName)) { violations.push(`${context}: references undeclared security scheme '${schemeName}'`); } } if (publicOperationAllowlist.has(allowlistKey)) { if (operation.security !== undefined && (!Array.isArray(operation.security) || operation.security.length !== 0)) { violations.push(`${context}: public allowlisted operation must omit security or declare security: []`); } } else { if (!hasNonEmptySecurityRequirement(operation.security)) { violations.push(`${context}: protected operation must declare non-empty security`); } if (hasEmptySecurityRequirement(operation.security) && !optionalAnonymousAllowlist.has(allowlistKey)) { violations.push(`${context}: anonymous security requirement is only allowed for optional-auth allowlisted operations`); } } if (isScopedApiKeyPath(entry.path) && usesScheme(operation, "apiKeyHeader") && operation["x-api-key-permissions"] === undefined) { violations.push(`${context}: scoped apiKeyHeader operation must include x-api-key-permissions`); } if (isServiceTokenOnlyMetadataPath(entry.path) && operation["x-api-key-permissions"] !== undefined) { violations.push(`${context}: service-token metadata operation must not include x-api-key-permissions`); } } return { ok: violations.length === 0, violations }; } export function renderOpenApiSecurityGuardResult(result: GuardResult) { if (result.ok) return "PASS openapi-security: all operation security contracts are valid"; return ["FAIL openapi-security:", ...result.violations.map((violation) => `- ${violation}`)].join("\n"); } function main() { const result = validateOpenApiSecurity(openApiDocument); const output = renderOpenApiSecurityGuardResult(result); if (result.ok) { console.log(output); return; } console.error(output); process.exitCode = 1; } if (process.argv[1] === fileURLToPath(import.meta.url)) { main(); }