Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/openapi-guard.ts
T

176 lines
6.0 KiB
JavaScript

#!/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<string, unknown>;
};
paths?: Record<string, Record<string, unknown>>;
};
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<string, unknown> {
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 : "<missing 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();
}