Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,175 @@
|
||||
#!/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();
|
||||
}
|
||||
Reference in new issue
Block a user