From b74fc976f2fef519965e3cd2fe637d7432fd0da8 Mon Sep 17 00:00:00 2001 From: Ilyas Salikhov Date: Mon, 31 Aug 2026 22:21:48 +0300 Subject: [PATCH] fix(reference): restore public OpenAPI security --- reference/manifest.json | 4 +- reference/openapi/control-plane.yml | 65 +++++++++++++++++++++++++++++ reference/openapi/envd.yml | 21 ++++++++++ scripts/generate-reference.py | 41 ++++++++++++++++++ scripts/test-reference-contract.py | 39 +++++++++++++---- 5 files changed, 161 insertions(+), 9 deletions(-) diff --git a/reference/manifest.json b/reference/manifest.json index f718a483..7fae5cb3 100644 --- a/reference/manifest.json +++ b/reference/manifest.json @@ -2,8 +2,8 @@ "files": { "connect/filesystem.md": "1cc1e81386810d3c5c999458e02340e3becb3da8138d943558584094750e5aae", "connect/process.md": "037574455e9e8c5159c391d8c007e7272560f62c4e5ee681f8aec29f9cd24539", - "openapi/control-plane.yml": "95df3951c30223827e4933f1dab8c6e2c75a2a8d81269646f807427d11078548", - "openapi/envd.yml": "30a0fba3b410c77db6b50b674f0ef9d110bf626ca2558820b5a6a05e4640cf19", + "openapi/control-plane.yml": "f3009ea28f824b61092142caf527a062f59c791bc760a7810a4fb9dec8226a29", + "openapi/envd.yml": "83bc85bf7b4084105ac9ffeec708d04d0e14ab275026d616c30de31caea62589", "openapi/markdown/assignTemplateTags.md": "3ec3dc705cbda5780d0a9e78234c7777f014784b2ec193821b6f5bb2ea26ec57", "openapi/markdown/composeFiles.md": "3e5c37b40268689b6e5287a906c75b80f71fefafe94205c6327c976ca0594d5c", "openapi/markdown/connectSandbox.md": "9106d09a6f525aa0bcbd1eed06de62a4d015d1f6fa63496ccb6aebbebb3ad332", diff --git a/reference/openapi/control-plane.yml b/reference/openapi/control-plane.yml index c8b11a18..f31a0adb 100644 --- a/reference/openapi/control-plane.yml +++ b/reference/openapi/control-plane.yml @@ -1684,6 +1684,11 @@ components: maskedValueSuffix: type: string description: Suffix used in masked version of the token or key + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key tags: - name: sandboxes - name: snapshots @@ -1733,6 +1738,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTeamMetrics + security: + - ApiKeyAuth: [] /teams/{teamID}/metrics/max: get: summary: Maximum team metrics @@ -1782,6 +1789,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTeamMetricsMax + security: + - ApiKeyAuth: [] /sandboxes: post: summary: Create sandbox @@ -1808,6 +1817,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: createSandbox + security: + - ApiKeyAuth: [] /v2/sandboxes: get: summary: List sandboxes (v2) @@ -1855,6 +1866,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: listSandboxes + security: + - ApiKeyAuth: [] /sandboxes/metrics: get: summary: List sandbox metrics @@ -1887,6 +1900,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: listSandboxMetrics + security: + - ApiKeyAuth: [] /v2/sandboxes/{sandboxID}/logs: get: summary: Sandbox logs (v2) @@ -1948,6 +1963,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getSandboxLogs + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}: get: summary: Sandbox @@ -1970,6 +1987,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getSandbox + security: + - ApiKeyAuth: [] delete: summary: Kill sandbox description: Kill a sandbox @@ -1987,6 +2006,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: killSandbox + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/metrics: get: summary: Sandbox metrics @@ -2029,6 +2050,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getSandboxMetrics + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/pause: post: summary: Pause sandbox @@ -2055,6 +2078,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: pauseSandbox + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/fork: post: summary: Fork sandbox @@ -2093,6 +2118,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: forkSandbox + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/connect: post: summary: Connect sandbox @@ -2130,6 +2157,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: connectSandbox + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/timeout: post: summary: Set sandbox timeout @@ -2156,6 +2185,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: setSandboxTimeout + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/network: put: summary: Update sandbox network @@ -2184,6 +2215,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: updateSandboxNetwork + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/refreshes: post: summary: Refresh sandbox @@ -2205,6 +2238,8 @@ paths: '404': $ref: '#/components/responses/404' operationId: refreshSandbox + security: + - ApiKeyAuth: [] /sandboxes/{sandboxID}/snapshots: post: summary: Create snapshot @@ -2237,6 +2272,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: createSnapshot + security: + - ApiKeyAuth: [] /snapshots: get: summary: List snapshots @@ -2276,6 +2313,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: listSnapshots + security: + - ApiKeyAuth: [] /v3/templates: post: summary: Create template (v3) @@ -2304,6 +2343,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: createTemplate + security: + - ApiKeyAuth: [] /v2/templates: get: summary: List templates (v2) @@ -2340,6 +2381,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: listTemplates + security: + - ApiKeyAuth: [] /templates/{templateID}/files/{hash}: get: summary: Template build file upload URL @@ -2370,6 +2413,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTemplateUploadUrl + security: + - ApiKeyAuth: [] /templates/{templateID}: get: summary: List template builds @@ -2395,6 +2440,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTemplate + security: + - ApiKeyAuth: [] delete: summary: Delete template description: Delete a template @@ -2410,6 +2457,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: deleteTemplate + security: + - ApiKeyAuth: [] /v2/templates/{templateID}/builds/{buildID}: post: summary: Start template build (v2) @@ -2433,6 +2482,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: startTemplateBuild + security: + - ApiKeyAuth: [] /v2/templates/{templateID}: patch: summary: Update template (v2) @@ -2461,6 +2512,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: updateTemplate + security: + - ApiKeyAuth: [] /templates/{templateID}/builds/{buildID}/status: get: summary: Template build status @@ -2506,6 +2559,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTemplateBuildStatus + security: + - ApiKeyAuth: [] /templates/{templateID}/builds/{buildID}/logs: get: summary: Template build logs @@ -2565,6 +2620,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTemplateBuildLogs + security: + - ApiKeyAuth: [] /templates/tags: post: summary: Assign template tags @@ -2593,6 +2650,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: assignTemplateTags + security: + - ApiKeyAuth: [] delete: summary: Delete template tags description: Delete multiple tags from templates @@ -2616,6 +2675,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: deleteTemplateTags + security: + - ApiKeyAuth: [] /templates/{templateID}/tags: get: summary: List template tags @@ -2642,6 +2703,8 @@ paths: '500': $ref: '#/components/responses/500' operationId: listTemplateTags + security: + - ApiKeyAuth: [] /templates/aliases/{alias}: get: summary: Check template alias @@ -2671,3 +2734,5 @@ paths: '500': $ref: '#/components/responses/500' operationId: getTemplateByAlias + security: + - ApiKeyAuth: [] diff --git a/reference/openapi/envd.yml b/reference/openapi/envd.yml index c2eedb01..ff1ae074 100644 --- a/reference/openapi/envd.yml +++ b/reference/openapi/envd.yml @@ -16,6 +16,7 @@ paths: operationId: getEnvironmentHealth tags: - environment + security: [] /metrics: get: summary: Service stats @@ -29,6 +30,9 @@ paths: operationId: getEnvironmentMetrics tags: - environment + security: + - {} + - AccessTokenAuth: [] /envs: get: summary: Environment variables @@ -42,6 +46,9 @@ paths: operationId: getEnvironmentVariables tags: - environment + security: + - {} + - AccessTokenAuth: [] /files: get: summary: Download a file @@ -66,6 +73,9 @@ paths: '500': $ref: '#/components/responses/InternalServerError' operationId: downloadFile + security: + - {} + - AccessTokenAuth: [] post: summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. @@ -130,6 +140,9 @@ paths: '507': $ref: '#/components/responses/NotEnoughDiskSpace' operationId: uploadFile + security: + - {} + - AccessTokenAuth: [] /files/compose: post: summary: Compose multiple files into a single file using zero-copy concatenation. @@ -160,6 +173,9 @@ paths: '507': $ref: '#/components/responses/NotEnoughDiskSpace' operationId: composeFiles + security: + - {} + - AccessTokenAuth: [] components: parameters: FilePath: @@ -417,3 +433,8 @@ components: required: - nfs_target - path + securitySchemes: + AccessTokenAuth: + type: apiKey + in: header + name: X-Access-Token diff --git a/scripts/generate-reference.py b/scripts/generate-reference.py index 178519c5..0e5575f7 100644 --- a/scripts/generate-reference.py +++ b/scripts/generate-reference.py @@ -233,6 +233,46 @@ def normalize_operation_auth( } +def apply_public_operation_security( + source: dict, + public: dict, + records: list[dict], + public_scheme_names: set[str], +) -> None: + """Restore only the normalized public auth contract after filtering.""" + source_schemes = source.get("components", {}).get("securitySchemes", {}) + published_schemes = {} + scheme_by_header = {} + for name in sorted(public_scheme_names): + scheme = source_schemes.get(name) + if not isinstance(scheme, dict): + raise SystemExit(f"Public auth scheme is missing: {name}") + if scheme.get("type") != "apiKey" or scheme.get("in") != "header": + raise SystemExit(f"Public auth scheme {name} must be an apiKey header") + header = scheme.get("name") + if not isinstance(header, str) or not header: + raise SystemExit(f"Public auth scheme {name} has no header name") + if header in scheme_by_header: + raise SystemExit(f"Multiple public auth schemes use header {header}") + published_schemes[name] = strip_extension(scheme) + scheme_by_header[header] = name + + public.setdefault("components", {})["securitySchemes"] = published_schemes + for record in records: + operation = public["paths"][record["path"]][record["method"].lower()] + auth = record["auth"] + if auth is None: + operation["security"] = [] + continue + scheme_name = scheme_by_header.get(auth["header"]) + if scheme_name is None: + raise SystemExit( + f"No public auth scheme for {record['operationId']} header {auth['header']}" + ) + requirement = {scheme_name: []} + operation["security"] = [requirement] if auth["required"] else [{}, requirement] + + def build_openapi(name: str, config: dict) -> list[dict]: source = ROOT / config["source"] document = yaml.safe_load(source.read_text()) @@ -327,6 +367,7 @@ def build_openapi(name: str, config: dict) -> list[dict]: {"name": group} for group in sorted({record["group"] for record in records}) ] filter_public_openapi(public, for_reference=True) + apply_public_operation_security(document, public, records, public_auth_schemes) destination = ( OUT / "openapi" diff --git a/scripts/test-reference-contract.py b/scripts/test-reference-contract.py index 41a5096e..e675b60d 100644 --- a/scripts/test-reference-contract.py +++ b/scripts/test-reference-contract.py @@ -25,7 +25,6 @@ "volumeMounts", } FORBIDDEN_SECURITY_SCHEMES = { - "AccessTokenAuth", "AdminApiKeyAuth", "AdminTeamAuth", "AuthProviderBearerAuth", @@ -175,15 +174,41 @@ def main() -> None: public_documents = {} for name in ("openapi/control-plane.yml", "openapi/envd.yml"): document = yaml.safe_load((REFERENCE / name).read_text()) - public_documents[name.removeprefix("openapi/").removesuffix(".yml")] = document - assert "securitySchemes" not in document.get("components", {}) - for path_item in document["paths"].values(): - for method, operation in path_item.items(): - if method.lower() in {"get", "post", "put", "patch", "delete"}: - assert "security" not in operation + spec_name = name.removeprefix("openapi/").removesuffix(".yml") + public_documents[spec_name] = document + expected_scheme = ( + "ApiKeyAuth" if spec_name == "control-plane" else "AccessTokenAuth" + ) + expected_header = ( + "X-API-Key" if spec_name == "control-plane" else "X-Access-Token" + ) + assert set(document.get("components", {}).get("securitySchemes", {})) == { + expected_scheme + } + assert document["components"]["securitySchemes"][expected_scheme] == { + "type": "apiKey", + "in": "header", + "name": expected_header, + } assert_public_schema(document) assert_local_refs_resolve(document) + for operation in operations: + document = public_documents[operation["spec"]] + rendered_operation = document["paths"][operation["path"]][ + operation["method"].lower() + ] + auth = operation["auth"] + if auth is None: + assert rendered_operation["security"] == [] + continue + scheme = ( + "ApiKeyAuth" if operation["spec"] == "control-plane" else "AccessTokenAuth" + ) + requirement = {scheme: []} + expected = [requirement] if auth["required"] else [{}, requirement] + assert rendered_operation["security"] == expected + rendered_markdown = "\n".join( path.read_text() for path in (REFERENCE / "openapi/markdown").glob("*.md") )