diff --git a/.github/workflows/haproxy.yaml b/.github/workflows/haproxy.yaml index b75a151..34ade8e 100644 --- a/.github/workflows/haproxy.yaml +++ b/.github/workflows/haproxy.yaml @@ -10,6 +10,9 @@ on: - 'systemd/configs/haproxy.cfg' - 'tests/**' - 'Makefile' + - 'scripts/openchami-validate-endpoints' + - 'validation/**' + - 'openchami.spec' - '.github/workflows/haproxy.yaml' push: branches: [main] @@ -17,6 +20,9 @@ on: - 'systemd/configs/haproxy.cfg' - 'tests/**' - 'Makefile' + - 'scripts/openchami-validate-endpoints' + - 'validation/**' + - 'openchami.spec' - '.github/workflows/haproxy.yaml' permissions: diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..881425e --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 OpenCHAMI a Series of LF Projects, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index 3cad74e..491d08b 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -.PHONY: all clean test-haproxy +.PHONY: all clean test-haproxy test-validator GIT ?= $(shell command -v git 2>/dev/null) TAG ?= $(shell $(GIT) describe --tags --always --abbrev=0) @@ -17,6 +17,9 @@ all: openchami-$(VERSION)-$(RELEASE).noarch.rpm test-haproxy: python3 -m unittest discover -s tests -p 'test_*.py' -v +test-validator: + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_endpoint_validation.py' -v + $(HOME)/rpmbuild: rpmdev-setuptree diff --git a/README.md b/README.md index a346e2c..9953ecf 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ Before serving production cloud-init data, apply the HAProxy trust boundary, privileged WireGuard bootstrap lifecycle, and checks operators must perform; installing the RPM alone does not establish those protections. +The RPM also includes the read-only `openchami-validate-endpoints` command and +versioned endpoint/JWT expectations. Use the [endpoint validation and upgrade guide](docs/endpoint-validation.md) +to compare observed access before and after an update. Observation-only or untested +rows remain incomplete; the tool does not activate authentication or alter services. + ## Building Locally Requirements: diff --git a/docs/endpoint-validation.md b/docs/endpoint-validation.md new file mode 100644 index 0000000..48f0b44 --- /dev/null +++ b/docs/endpoint-validation.md @@ -0,0 +1,196 @@ + + + +# Endpoint validation and upgrade comparison + +The RPM installs `openchami-validate-endpoints` and versioned JSON policies under +`/usr/share/openchami/validation/policies/`. The tool reports expected JWT behavior, +observed HTTP access and mismatches. It does not change services, firewall rules, +configuration, tokens or data. Run it explicitly; installation and upgrades never +invoke it automatically. Python 3 is the only additional runtime dependency; no pip +packages or new service dependencies are required. + +## Choose a policy and vantage point + +`release-v1.json` describes a selected read-only set for the packaged topology: +SMD v2.20.5, boot-service v0.3.1 and metadata-service v0.2.1 behind HAProxy. It checks +public health and existing public SMD component-list access, plus SMD's already +protected ComponentEndpoints collection with the shipped JWKS configuration. +Generated boot/metadata collection reads are **observations**, not an assertion that +their `enable_auth` setting enforces inbound JWTs. Assert JWT requirements only in a +reviewed policy qualified against the deployed service configuration. + +The route classifications come from the pinned service sources: +[SMD v2.20.5](https://github.com/OpenCHAMI/smd/blob/v2.20.5/cmd/smd/routers.go), +[boot-service v0.3.1](https://github.com/OpenCHAMI/boot-service/blob/v0.3.1/cmd/server/main.go) +and [metadata-service v0.2.1](https://github.com/OpenCHAMI/metadata-service/blob/v0.2.1/cmd/server/main.go). +Requalify these expectations when service versions or configuration change. + +The boot collection probe uses `/boot-service/nodes` without a trailing slash. +HAProxy strips the service prefix and boot-service v0.3.1 redirects `/nodes/` to +`/nodes` before the collection handler. Using the canonical path records collection +access rather than a redirect. The validator continues to report redirects without +following them or forwarding credentials. + +`legacy-bss-v1.json` is separate for sites running legacy BSS. Dumpstate, +endpoint-history and hosts GET reflect the existing public read behavior. Bootparameters +is observed because its existing authentication depends on the site's JWKS setting. +The current RPM deploys boot-service rather than legacy BSS; do not test one as the other. + +These are selected probes, not a complete API inventory. No mutation, enrollment, +phone-home, token issuance or dynamic bootstrap action is tested. Read-only GETs +can still update service access histories, metrics and logs or consume resources. +Use disposable canary data and normal maintenance windows where appropriate. + +Run from each relevant access location: the head-node VM, an approved management +client and a representative provisioning network. Label the saved report with its +vantage point. A VM-local success does not establish external availability or isolation. +For direct backend comparisons, use a reviewed copy of the policy with a separate +target and the backend's unprefixed paths. Do not publish backend ports for the test. + +## Run against HAProxy + +```bash +openchami-validate-endpoints \ + --policy /usr/share/openchami/validation/policies/release-v1.json \ + --target smd=https://head.example:8443 \ + --target boot=https://head.example:8443 \ + --target metadata=https://head.example:8443 \ + --cacert /path/to/site-root-ca.pem \ + --json > endpoints-before.json +``` + +Obtain an existing short-lived token with permission for the selected SMD read using +the site's normal identity workflow. Do not paste it into command arguments. For an +interactive shell, an example that avoids shell-history token contents is: + +```bash +read -r -s -p 'SMD validation token: ' OPENCHAMI_VALIDATION_TOKEN +echo +export OPENCHAMI_VALIDATION_TOKEN +openchami-validate-endpoints \ + --policy /usr/share/openchami/validation/policies/release-v1.json \ + --target smd=https://head.example:8443 \ + --target boot=https://head.example:8443 \ + --target metadata=https://head.example:8443 \ + --token-env smd=OPENCHAMI_VALIDATION_TOKEN \ + --cacert /path/to/site-root-ca.pem --json > endpoints-after.json +validation_status=$? +unset OPENCHAMI_VALIDATION_TOKEN +echo "Validator exit: $validation_status" +``` + +Credentials must have compatible issuer, audience, expiry and permissions. The tool +does not mint or validate JWTs locally; the service decides. The shipped release +policy uses separate logical `smd`, `boot` and `metadata` targets +even when all three use the same HAProxy address. Map an SMD token only to `smd`; +credentials are never implicitly shared between these service targets. Optionally +provide `--denied-token-env smd=NON_ADMIN_TOKEN` for a valid identity known to lack +the required authority; a `required` row then additionally expects 403 for that token. +Do not use this optional check on deployments that do not enforce authorization. + +TLS certificate and hostname verification stay enabled. Use the site's CA bundle; +there is no insecure TLS option. Actual credentials require HTTPS unless the operator +explicitly selects `--allow-http-token` for a trusted internal HTTP path. Environment +HTTP proxies are ignored, and redirects are reported without following them or +forwarding credentials. Each probe has a socket timeout (default 5 seconds), with no +retries; run serially to keep load bounded. DNS resolution may have system-level timeouts. + +The report contains status codes, access classes and matching expectations, never +response bodies, request credentials or response headers. It omits target URLs too. +Keep the target-to-URL and vantage-point mapping separately with the change record. +Treat reports as operational inventory. The services can still log incoming requests; +this tool does not alter server-side logging. + +## Interpret results + +| Policy expectation | Evidence needed to pass | +| --- | --- | +| `public` | Anonymous success from `success_statuses`; authorized success too when an authorized token is supplied. Invalid-token behavior is recorded without imposing a policy. | +| `required` | Anonymous 401, deliberately invalid JWT 401, and supplied authorized JWT success. Missing authorized credentials makes the result incomplete. Optional valid but unauthorized token must return 403. | +| `observe` | Record responses without asserting a settled JWT policy. Always incomplete rather than silently green. | +| `absent` | Anonymous HTTP 404 or 410. An outage, connection refusal, redirect or 403 is not proof the route is absent. | + +HTTP 401 is authentication rejection; 403 is forbidden; 404/410 is not found; +3xx is a redirect; connection/TLS errors have no HTTP response. None is interchangeable +with successful authorized access. A gateway returning 403 to every caller fails a +JWT-required row. A login redirect fails rather than following it to an apparent 200. +These checks establish listed status behavior, not which layer enforced it or complete +JWT correctness. A 200 alone does not verify the returned resource identity or contents. + +Exit codes: **0** all listed expectations passed; **1** at least one failed; +**2** incomplete coverage/policy, missing credentials/targets or invalid input. Failure +takes precedence over incomplete rows. Machine reports include separate counts and +per-probe evidence. A missing target never silently disappears. The shipped profiles +contain intentional observations, so their fully successful run still exits 2 until +those rows receive reviewed site/version-specific expectations. Do not ignore that +status or describe it as a full security pass. + +## Extend policy without changing deployments + +Copy the relevant shipped policy into a version-controlled site policy. Give it a new +`policy_id`; retain the original so before/after comparisons use the same expectations. +Policies under `/usr/share` are versioned package data, not site configuration. Future +RPMs should add a new policy filename/version when expectations change. An unsupported +`schema_version`, unknown field, duplicate ID or mutation method is an input error. + +Schema version 1 requires exactly `schema_version`, `policy_id` and `endpoints` at the +top level. Each endpoint requires `id`, `target`, `method`, `path`, `jwt`, +`success_statuses` and `reason`. IDs/targets use letters, digits, dots, underscores and +hyphens. Only literal GET paths without query strings, fragments or traversal are +accepted. `success_statuses` is a nonempty array of 2xx status codes. + +Example for a future or site-approved JWT requirement, **not current release policy**: + +```json +{ + "schema_version": 1, + "policy_id": "site-admin-reads-v2", + "endpoints": [ + { + "id": "metadata-groups", + "target": "metadata", + "method": "GET", + "path": "/groups/", + "jwt": "required", + "success_statuses": [200], + "reason": "Site-approved administrative read policy after auth migration" + } + ] +} +``` + +The matching target could be `metadata=https://head.example:8443/metadata-service`. +Choosing a stricter validation expectation does not implement it on the service. +Keep `observe` rows visible until the site policy specifies the expected access +behavior. Test mutations with service regression tests and disposable integration +fixtures; schema v1 rejects them rather than attempting invalid-body or fake-ID +requests on a live deployment. + +## Upgrade checklist + +1. Record RPM/service image versions, effective configuration, chosen policy hash, + target mapping and vantage point. Capture a before report with existing settings. + Resolve unexpected anonymous access, unavailable public routes and token failures + rather than changing expectations merely to get a green report. +2. Compare active HAProxy configuration with any `.rpmnew` file: `%config(noreplace)` + can retain old identity-header rules. Preserve the current routes and forwarding + sanitization. Use the configuration checks in [metadata-security.md](metadata-security.md) + before an operator performs a controlled restart. Inspect effective Quadlet + overrides and published ports; an HTTP probe does not inventory network exposure. +3. After the reviewed update, repeat with the **same policy, targets, vantage point + and equivalent token privileges**, saving a separate report. Compare `id`, `jwt`, + `expected`, probe status/access and verdict; timestamps naturally differ. JSON reports + include the exact policy file's SHA-256 hash. Record + policy changes separately from behavior changes. +4. Qualify bootstrap and actual CLI workflows independently. A read-only validator + cannot establish WireGuard ownership, node-specific payload isolation, mutation + authorization, forwarding-header safety or database upgrade correctness. Keep + the loopback HAProxy tests and service-specific security tests as release gates. +5. If results regress, keep the previous report, active config and known deployed + image digests available for a reviewed rollback/forward fix. The validator and + its policies require no service-data migration. Do not automatically restore an + unsafe identity-header configuration or weaken auth based on a single failed probe. + +Nothing runs automatically during RPM scriptlets. Deployment changes, policy approval, +token provisioning and release publication remain explicit operator actions. diff --git a/docs/metadata-security.md b/docs/metadata-security.md index dde78e3..9395fc8 100644 --- a/docs/metadata-security.md +++ b/docs/metadata-security.md @@ -84,6 +84,11 @@ the previous header trust behavior is not a safe rollback. ## Verification +Use the packaged [endpoint validator and upgrade checklist](endpoint-validation.md) +to record expected versus observed anonymous/JWT access from each relevant network +location before and after updates. Its selected GET probes complement the tests below; +they do not prove bootstrap isolation or sanitize headers on behalf of a service. + With HAProxy, OpenSSL, Python 3, and make installed, run `make test-haproxy`. The test uses the packaged configuration with temporary loopback listeners, an ephemeral TLS certificate, and an inert backend. It checks HTTP and HTTPS, diff --git a/openchami.spec b/openchami.spec index 3d51348..8464e08 100644 --- a/openchami.spec +++ b/openchami.spec @@ -12,6 +12,7 @@ BuildArch: noarch Requires: podman Requires: jq Requires: curl +Requires: python3 Requires(post): coreutils Requires(post): openssl Requires(post): hostname @@ -37,6 +38,8 @@ mkdir -p %{buildroot}/etc/openchami/configs \ %{buildroot}/etc/profile.d \ %{buildroot}/usr/libexec/openchami +mkdir -p %{buildroot}/usr/share/openchami/validation/policies + cp -r systemd/configs/* %{buildroot}/etc/openchami/configs/ cp -r systemd/containers/* %{buildroot}/usr/share/containers/systemd/ cp -r systemd/volumes/* %{buildroot}/usr/share/containers/systemd/ @@ -49,6 +52,8 @@ cp scripts/openchami_profile.sh %{buildroot}/etc/profile.d/openchami cp scripts/multi-psql-db.sh %{buildroot}/etc/openchami/pg-init/multi-psql-db.sh cp scripts/ohpc-nodes.sh %{buildroot}/usr/libexec/openchami/ cp scripts/tokensmith_bootstrap_token %{buildroot}/usr/sbin/ +install -m 0755 scripts/openchami-validate-endpoints %{buildroot}/usr/bin/ +install -m 0644 validation/policies/* %{buildroot}/usr/share/openchami/validation/policies/ chmod +x %{buildroot}/usr/libexec/openchami/bootstrap_openchami.sh chmod +x %{buildroot}/usr/libexec/openchami/ohpc-nodes.sh @@ -61,7 +66,8 @@ chmod 600 %{buildroot}/etc/openchami/configs/openchami.env chmod 644 %{buildroot}/etc/openchami/configs/* %files -%license LICENSE +%license LICENSE LICENSES/MIT.txt +%doc docs/endpoint-validation.md %config(noreplace) /etc/openchami/configs/* /usr/share/containers/systemd/* /usr/lib/systemd/system/openchami.target @@ -74,6 +80,8 @@ chmod 644 %{buildroot}/etc/openchami/configs/* /etc/openchami/pg-init/multi-psql-db.sh /usr/bin/openchami-certificate-update /usr/sbin/tokensmith_bootstrap_token +/usr/bin/openchami-validate-endpoints +/usr/share/openchami/validation/policies/* %pre # Any pre-existing OpenCHAMI quadlets in /etc/containers/systemd could diff --git a/scripts/openchami-validate-endpoints b/scripts/openchami-validate-endpoints new file mode 100755 index 0000000..180dac2 --- /dev/null +++ b/scripts/openchami-validate-endpoints @@ -0,0 +1,281 @@ +#!/usr/bin/python3 +# SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +# SPDX-License-Identifier: MIT + +"""Compare read-only endpoint probes to an explicitly selected versioned policy.""" + +import argparse +import datetime +import http.client +import hashlib +import json +import math +import os +from pathlib import Path +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request + + +IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}\Z") +INVALID_JWT = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiJvcGVuY2hhbWktdmFsaWRhdG9yIiwiZXhwIjo0MTAyNDQ0ODAwfQ." + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +) + + +def validate_policy(policy): + if not isinstance(policy, dict) or set(policy) != { + "schema_version", "policy_id", "endpoints" + }: + raise ValueError("policy requires schema_version, policy_id and endpoints") + if type(policy["schema_version"]) is not int or policy["schema_version"] != 1: + raise ValueError("unsupported policy schema_version") + if not isinstance(policy["policy_id"], str) or not IDENTIFIER.fullmatch(policy["policy_id"]): + raise ValueError("invalid policy_id") + entries = policy["endpoints"] + if not isinstance(entries, list) or not 1 <= len(entries) <= 500: + raise ValueError("policy requires between 1 and 500 endpoints") + seen = set() + fields = {"id", "target", "method", "path", "jwt", "reason", "success_statuses"} + for entry in entries: + if not isinstance(entry, dict) or set(entry) != fields: + raise ValueError("each endpoint must contain exactly the documented fields") + for field in ("id", "target"): + if not isinstance(entry[field], str) or not IDENTIFIER.fullmatch(entry[field]): + raise ValueError("invalid endpoint id or target") + if entry["id"] in seen: + raise ValueError("duplicate endpoint id") + seen.add(entry["id"]) + path = entry["path"] + if (not isinstance(path, str) or not re.fullmatch(r"/[A-Za-z0-9_./~-]*", path) + or path.startswith("//") or ".." in path.split("/")): + raise ValueError("paths must be literal absolute paths without queries or traversal") + if entry["method"] != "GET": + raise ValueError("only GET probes are supported; mutations are never probed") + if entry["jwt"] not in ("public", "required", "observe", "absent"): + raise ValueError("unsupported jwt expectation") + if not isinstance(entry["reason"], str) or not entry["reason"].strip(): + raise ValueError("endpoint requires a policy rationale") + statuses = entry["success_statuses"] + if (not isinstance(statuses, list) or not statuses + or any(type(s) is not int or not 200 <= s < 300 for s in statuses)): + raise ValueError("success_statuses must contain only successful HTTP status codes") + return policy + + +def validate_target(address): + try: + parsed = urllib.parse.urlsplit(address) + port = parsed.port + if (parsed.scheme not in ("http", "https") or not parsed.hostname + or parsed.username is not None or parsed.password is not None + or parsed.query or parsed.fragment or "?" in address or "#" in address + or any(ord(c) <= 32 or ord(c) >= 127 for c in address) + or "\\" in address or (port is not None and port == 0)): + raise ValueError() + except (ValueError, TypeError): + raise ValueError("targets must be HTTP(S) URLs without credentials, queries or fragments") from None + return address.rstrip("/") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def probe_url(url, bearer, timeout, cafile): + context = ssl.create_default_context(cafile=cafile) + # Never enable TLS key logging through an inherited SSLKEYLOGFILE setting. + if hasattr(context, "keylog_filename"): + context.keylog_filename = None + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), + urllib.request.HTTPSHandler(context=context), + ) + headers = {"Accept": "application/json", "User-Agent": "openchami-endpoint-validator/1"} + if bearer is not None: + headers["Authorization"] = "Bearer " + bearer + request = urllib.request.Request(url, headers=headers, method="GET") + try: + with opener.open(request, timeout=timeout) as response: + return {"status": response.status, "error": None} + except urllib.error.HTTPError as error: + status = error.code + error.close() + return {"status": status, "error": None} + except urllib.error.URLError as error: + kind = "tls-error" if isinstance(error.reason, ssl.SSLError) else "connection-error" + return {"status": None, "error": kind} + except ssl.SSLError: + return {"status": None, "error": "tls-error"} + except (OSError, http.client.HTTPException): + return {"status": None, "error": "connection-error"} + + +def access_class(status): + if status is None: + return "no-http-response" + if 200 <= status < 300: + return "accessible" + if status == 401: + return "authentication-rejected" + if status == 403: + return "forbidden" + if status in (404, 410): + return "not-found" + if 300 <= status < 400: + return "redirect" + if status >= 500: + return "server-error" + return "other-http-response" + + +def check_endpoint(entry, targets, tokens, denied_tokens, timeout, cafile, probe=probe_url): + required = entry["jwt"] == "required" + expected = {"anonymous": [401] if required else entry["success_statuses"]} + if entry["jwt"] == "observe": + expected = {"anonymous": "observe"} + if entry["jwt"] == "absent": + expected = {"anonymous": [404, 410]} + if entry["jwt"] != "absent": + expected["invalid"] = [401] if required else "observe" + expected["authorized"] = "observe" if entry["jwt"] == "observe" else entry["success_statuses"] + if required and entry["target"] in denied_tokens: + expected["unauthorized"] = [403] + row = {"id": entry["id"], "target": entry["target"], "method": "GET", + "path": entry["path"], "jwt": entry["jwt"], "expected": expected, + "probes": {}, "verdict": "incomplete", "reason": "target-not-configured"} + target = entry["target"] + if target not in targets: + return row + credentials = {"anonymous": None} + if entry["jwt"] != "absent": + credentials["invalid"] = INVALID_JWT + if target in tokens: + credentials["authorized"] = tokens[target] + if required and target in denied_tokens: + credentials["unauthorized"] = denied_tokens[target] + failed = False + for name, credential in credentials.items(): + observed = probe(targets[target] + entry["path"], credential, timeout, cafile) + observed["access"] = access_class(observed["status"]) + match = expected[name] + observed["matches"] = None if match == "observe" else observed["status"] in match + if observed["status"] is None or observed["matches"] is False: + failed = True + row["probes"][name] = observed + if failed: + row.update(verdict="fail", reason="unexpected-access-or-http-status") + elif entry["jwt"] == "observe": + row.update(reason="policy-undecided") + elif required and target not in tokens: + row.update(reason="authorized-credential-not-supplied") + else: + row.update(verdict="pass", reason="listed-expectations-met") + return row + + +def assignments(values): + result = {} + for value in values: + name, separator, item = value.partition("=") + if not separator or not IDENTIFIER.fullmatch(name) or not item or name in result: + raise ValueError("expected unique TARGET=VALUE assignments") + result[name] = item + return result + + +def credential_values(names): + result = {} + for target, name in names.items(): + value = os.environ.get(name) + if not value or len(value) > 16384 or any(ord(c) <= 32 or ord(c) >= 127 for c in value): + raise ValueError("a credential environment variable is missing or invalid") + result[target] = value + return result + + +def unique_fields(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON field") + result[key] = value + return result + + +def run(args): + source = args.policy.read_bytes() + policy = validate_policy(json.loads(source, object_pairs_hook=unique_fields)) + targets = {name: validate_target(value) for name, value in assignments(args.target).items()} + if not targets: + raise ValueError("at least one --target is required") + known = {entry["target"] for entry in policy["endpoints"]} + tokens = credential_values(assignments(args.token_env)) + denied = credential_values(assignments(args.denied_token_env)) + if set(targets) - known or (set(tokens) | set(denied)) - set(targets): + raise ValueError("target and credential names must match policy targets") + if not math.isfinite(args.timeout) or not 0 < args.timeout <= 60: + raise ValueError("timeout must be greater than zero and at most 60 seconds") + if not args.allow_http_token: + for name in set(tokens) | set(denied): + if urllib.parse.urlsplit(targets[name]).scheme != "https": + raise ValueError("real tokens require HTTPS; trusted internal HTTP needs --allow-http-token") + if args.cacert: + ssl.create_default_context(cafile=args.cacert) + rows = [check_endpoint(entry, targets, tokens, denied, args.timeout, args.cacert) + for entry in policy["endpoints"]] + counts = {verdict: sum(row["verdict"] == verdict for row in rows) + for verdict in ("pass", "fail", "incomplete")} + exit_code = 1 if counts["fail"] else 2 if counts["incomplete"] else 0 + report = {"report_schema_version": 1, "policy_id": policy["policy_id"], + "policy_sha256": hashlib.sha256(source).hexdigest(), + "observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "summary": counts, "exit_code": exit_code, "endpoints": rows} + if args.json: + print(json.dumps(report, indent=2)) + else: + print("Policy: " + policy["policy_id"]) + for row in rows: + observations = ", ".join( + "{}={}:{}".format(name, probe["status"], probe["access"]) + for name, probe in row["probes"].items() + ) or "not-probed" + print("{} {}/{} GET {} jwt={} expected={} observed={} ({})".format( + row["verdict"].upper(), row["target"], row["id"], row["path"], + row["jwt"], json.dumps(row["expected"], separators=(",", ":")), + observations, row["reason"], + )) + print("PASS={pass} FAIL={fail} INCOMPLETE={incomplete}".format(**counts)) + return exit_code + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--policy", required=True, type=Path, help="versioned JSON endpoint policy") + parser.add_argument("--target", action="append", default=[], metavar="NAME=URL") + parser.add_argument("--token-env", action="append", default=[], metavar="NAME=ENV_VAR", + help="authorized JWT environment variable for a target; never pass the token itself") + parser.add_argument("--denied-token-env", action="append", default=[], metavar="NAME=ENV_VAR", + help="optional valid JWT lacking authority, expected to receive 403") + parser.add_argument("--cacert", help="trusted PEM CA bundle (certificate verification stays enabled)") + parser.add_argument("--timeout", type=float, default=5, help="per-probe socket timeout, maximum 60 seconds") + parser.add_argument("--allow-http-token", action="store_true", + help="permit real credentials over explicitly selected trusted HTTP targets") + parser.add_argument("--json", action="store_true", help="emit machine-readable status report") + args = parser.parse_args() + try: + return run(args) + except (ValueError, OSError, ssl.SSLError): + # Do not copy exception text: it may contain a URL, credential or file data. + print("Invalid input or unreadable policy/CA file; check the documented schema and arguments.", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_endpoint_validation.py b/tests/test_endpoint_validation.py new file mode 100644 index 0000000..9b696a6 --- /dev/null +++ b/tests/test_endpoint_validation.py @@ -0,0 +1,330 @@ +# SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +# SPDX-License-Identifier: MIT + +"""Behavioral contract for the RPM-shipped, read-only endpoint validator.""" + +import importlib.machinery +import importlib.util +import json +import http.server +import os +from pathlib import Path +import socket +import ssl +import stat +import subprocess +import sys +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +LOADER = importlib.machinery.SourceFileLoader( + "endpoint_validator", str(ROOT / "scripts/openchami-validate-endpoints") +) + + +class EndpointValidationTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_loader(LOADER.name, LOADER) + cls.tool = importlib.util.module_from_spec(spec) + LOADER.exec_module(cls.tool) + + def endpoint(self, jwt="required"): + return { + "id": "inventory", "target": "ingress", "method": "GET", + "path": "/inventory", "jwt": jwt, "success_statuses": [200], + "reason": "test policy", + } + + def result(self, jwt, statuses, token=True): + calls = [] + + def probe(url, bearer, timeout, cafile): + calls.append(bearer) + return {"status": statuses[len(calls) - 1], "error": None} + + row = self.tool.check_endpoint( + self.endpoint(jwt), {"ingress": "https://example.invalid"}, + {"ingress": "fixture-secret-token"} if token else {}, {}, + 2, None, probe, + ) + return row, calls + + def test_required_needs_anonymous_invalid_and_valid_evidence(self): + row, calls = self.result("required", [401, 401, 200]) + self.assertEqual(row["verdict"], "pass") + self.assertEqual(calls[0], None) + self.assertEqual(calls[-1], "fixture-secret-token") + self.assertNotIn("fixture-secret-token", json.dumps(row)) + + def test_gateway_denial_missing_routes_and_bad_token_are_not_auth_success(self): + for statuses in ([403, 403, 403], [404, 404, 404], [401, 200, 200], + [401, 401, 403], [401, 401, 302], [200, 401, 200]): + with self.subTest(statuses=statuses): + row, _ = self.result("required", statuses) + self.assertEqual(row["verdict"], "fail") + + def test_missing_credential_or_target_is_incomplete(self): + row, _ = self.result("required", [401, 401], token=False) + self.assertEqual(row["verdict"], "incomplete") + row = self.tool.check_endpoint(self.endpoint(), {}, {}, {}, 2, None) + self.assertEqual(row["verdict"], "incomplete") + self.assertEqual(row["probes"], {}) + + def test_public_and_unresolved_policy_are_distinct(self): + row, _ = self.result("public", [200, 401, 200]) + self.assertEqual(row["verdict"], "pass") + row, _ = self.result("observe", [200, 200, 200]) + self.assertEqual(row["verdict"], "incomplete") + self.assertEqual(row["probes"]["anonymous"]["access"], "accessible") + + def test_network_failure_is_not_a_pass(self): + row, _ = self.result("public", [None, None, None]) + self.assertEqual(row["verdict"], "fail") + + def test_policy_rejects_mutations_and_ambiguous_input_before_requests(self): + for patch in ({"method": "DELETE"}, {"path": "//evil.invalid/"}, + {"path": "/nodes?token=secret"}, {"jwt": "optional"}, + {"success_statuses": [403]}, {"typo": True}): + with self.subTest(patch=patch): + endpoint = dict(self.endpoint(), **patch) + with self.assertRaises(ValueError): + self.tool.validate_policy({"schema_version": 1, + "policy_id": "test-v1", + "endpoints": [endpoint]}) + + def test_duplicate_ids_and_unknown_schema_are_rejected(self): + for version, endpoints in ((2, [self.endpoint()]), + (1, [self.endpoint(), self.endpoint()])): + with self.assertRaises(ValueError): + self.tool.validate_policy({"schema_version": version, + "policy_id": "test-v1", + "endpoints": endpoints}) + + def test_duplicate_json_fields_are_rejected(self): + with self.assertRaises(ValueError): + json.loads('{"jwt":"required","jwt":"public"}', + object_pairs_hook=self.tool.unique_fields) + + def test_credential_is_sent_only_to_its_logical_target(self): + policy = json.loads((ROOT / "validation/policies/release-v1.json").read_text()) + calls = [] + + def probe(url, token, *_): + calls.append((url, token)) + return {"status": 401 if token is None or token == self.tool.INVALID_JWT else 200, + "error": None} + + for entry in policy["endpoints"]: + self.tool.check_endpoint(entry, + {name: "https://example.invalid" for name in ("smd", "boot", "metadata")}, + {"smd": "fixture-smd-only"}, {}, 2, None, probe) + self.assertTrue(any(token == "fixture-smd-only" for _, token in calls)) + for url, token in calls: + if "/boot-service/" in url or "/metadata-service/" in url: + self.assertNotEqual(token, "fixture-smd-only") + + def test_targets_reject_credentials_queries_and_non_http(self): + for address in ("https://user:secret@host", "https://host/?token=x", + "file:///etc/passwd", "https://host/#secret"): + with self.subTest(address=address), self.assertRaises(ValueError): + self.tool.validate_target(address) + + def test_packaged_policies_are_valid_and_documented(self): + self.assertEqual({source.name for source in (ROOT / "validation/policies").glob("*.json")}, + {"release-v1.json", "legacy-bss-v1.json"}) + for source in (ROOT / "validation/policies").glob("*.json"): + with self.subTest(source=source.name): + policy = self.tool.validate_policy(json.loads(source.read_text())) + self.assertEqual(policy["policy_id"], source.stem) + self.assertIn(source.name, (ROOT / "docs/endpoint-validation.md").read_text()) + + def test_optional_non_admin_jwt_requires_403(self): + for denied_status, verdict in ((403, "pass"), (401, "fail"), (200, "fail")): + answers = iter((401, 401, 200, denied_status)) + row = self.tool.check_endpoint( + self.endpoint(), {"ingress": "https://example.invalid"}, + {"ingress": "authorized"}, {"ingress": "non-admin"}, 2, None, + lambda *args: {"status": next(answers), "error": None}, + ) + self.assertEqual(row["verdict"], verdict) + + def test_absent_means_http_not_found_not_forbidden_or_unreachable(self): + for status, verdict in ((404, "pass"), (410, "pass"), (403, "fail"), + (200, "fail"), (None, "fail")): + row, calls = self.result("absent", [status]) + self.assertEqual(row["verdict"], verdict) + self.assertEqual(calls, [None]) + + +class EndpointCLITest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + auth = self.headers.get("Authorization") + cls.requests.append((self.path, auth)) + if self.path == "/redirect": + status = 302 + elif self.path == "/private": + status = 200 if auth == "Bearer fixture-authorized-secret" else 403 if auth == "Bearer fixture-nonadmin-secret" else 401 + elif self.path == "/failure": + status = 500 + elif self.path == "/missing": + status = 404 + else: + status = 200 + self.send_response(status) + if status == 302: + self.send_header("Location", cls.address + "/leaked-token") + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_): + pass + + cls.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + cls.address = "http://127.0.0.1:{}".format(cls.server.server_port) + cls.addClassCleanup(cls.server.server_close) + thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + thread.start() + cls.addClassCleanup(cls.server.shutdown) + + def call(self, path="/private", jwt="required", token=True, extra=()): + with tempfile.TemporaryDirectory() as directory: + policy = { + "schema_version": 1, "policy_id": "cli-test-v1", "endpoints": [{ + "id": "test", "target": "ingress", "method": "GET", "path": path, + "jwt": jwt, "success_statuses": [200], "reason": "fixture", + }], + } + source = Path(directory) / "policy.json" + source.write_text(json.dumps(policy)) + args = [sys.executable, str(ROOT / "scripts/openchami-validate-endpoints"), + "--policy", str(source), "--target", "ingress=" + self.address, "--json"] + if token: + args += ["--token-env", "ingress=VALIDATION_TEST_TOKEN"] + env = dict(os.environ, VALIDATION_TEST_TOKEN="fixture-authorized-secret", + VALIDATION_DENIED_TOKEN="fixture-nonadmin-secret", + http_proxy="http://127.0.0.1:1", HTTP_PROXY="http://127.0.0.1:1") + result = subprocess.run(args + list(extra), env=env, capture_output=True, + text=True, timeout=10) + for secret in ("fixture-authorized-secret", "fixture-nonadmin-secret"): + self.assertNotIn(secret, result.stdout + result.stderr) + return result + + def test_real_http_auth_matrix_and_status_only_report(self): + result = self.call(extra=("--allow-http-token", "--denied-token-env", "ingress=VALIDATION_DENIED_TOKEN")) + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertEqual(len(report["policy_sha256"]), 64) + self.assertEqual(report["summary"], {"pass": 1, "fail": 0, "incomplete": 0}) + self.assertEqual(report["endpoints"][0]["probes"]["unauthorized"]["status"], 403) + self.assertNotIn("headers", result.stdout) + self.assertNotIn("body", result.stdout) + + def test_no_redirect_following_or_credential_forwarding(self): + before = len(self.requests) + result = self.call(path="/redirect", jwt="public", extra=("--allow-http-token",)) + self.assertEqual(result.returncode, 1) + self.assertTrue(all(path == "/redirect" for path, _ in self.requests[before:])) + self.assertEqual(json.loads(result.stdout)["endpoints"][0]["probes"]["authorized"]["status"], 302) + + def test_http_credentials_require_explicit_opt_in_before_requests(self): + before = len(self.requests) + result = self.call() + self.assertEqual(result.returncode, 2) + self.assertEqual(len(self.requests), before) + + def test_incomplete_and_fail_exit_codes(self): + self.assertEqual(self.call(token=False).returncode, 2) + self.assertEqual(self.call(path="/public", jwt="observe", token=False).returncode, 2) + self.assertEqual(self.call(path="/failure", jwt="public", token=False).returncode, 1) + self.assertEqual(self.call(path="/missing", jwt="public", token=False).returncode, 1) + + def test_connection_failure_is_explicit(self): + from urllib.parse import urlsplit + spec = importlib.util.spec_from_loader(LOADER.name, LOADER) + tool = importlib.util.module_from_spec(spec) + LOADER.exec_module(tool) + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + # Reserved but not listening: no other process can claim the test port. + self.addCleanup(sock.close) + address = "http://127.0.0.1:{}".format(sock.getsockname()[1]) + self.assertEqual(urlsplit(address).hostname, "127.0.0.1") + probe = tool.probe_url(address, None, 0.2, None) + self.assertIsNone(probe["status"]) + self.assertEqual(probe["error"], "connection-error") + + def test_https_rejects_untrusted_certificate_and_accepts_selected_ca(self): + spec = importlib.util.spec_from_loader(LOADER.name, LOADER) + tool = importlib.util.module_from_spec(spec) + LOADER.exec_module(tool) + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_): + pass + + with tempfile.TemporaryDirectory() as directory: + cert, key = Path(directory) / "cert.pem", Path(directory) / "key.pem" + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", str(key), "-out", str(cert), "-days", "1", + "-subj", "/CN=validator.test", "-addext", "subjectAltName=IP:127.0.0.1", + ], check=True, capture_output=True) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + server.socket = context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + address = "https://127.0.0.1:{}".format(server.server_port) + result = tool.probe_url(address, None, 2, None) + self.assertEqual(result["error"], "tls-error") + self.assertIsNone(result["status"]) + result = tool.probe_url(address, "fixture-authorized-secret", 2, str(cert)) + self.assertEqual(result, {"status": 200, "error": None}) + finally: + server.shutdown() + server.server_close() + + +class PackageInstallTest(unittest.TestCase): + def test_real_install_section_in_disposable_buildroot(self): + spec = (ROOT / "openchami.spec").read_text() + self.assertIn("Requires: python3\n", spec) + self.assertIn("%doc docs/endpoint-validation.md", spec) + self.assertNotIn("validate-endpoints", spec.split("%post\n", 1)[1]) + with tempfile.TemporaryDirectory() as directory: + script = spec.split("%install\n", 1)[1].split("\n%files", 1)[0] + script = script.replace("%{buildroot}", directory) + self.assertNotIn("%{", script) + subprocess.run(["sh", "-eu", "-c", script], cwd=ROOT, check=True, + stdout=subprocess.DEVNULL) + binary = Path(directory) / "usr/bin/openchami-validate-endpoints" + self.assertEqual(stat.S_IMODE(binary.stat().st_mode), 0o755) + subprocess.run([sys.executable, str(binary), "--help"], check=True, + stdout=subprocess.DEVNULL) + installed = Path(directory) / "usr/share/openchami/validation/policies" + self.assertEqual({source.name for source in installed.glob("*.json")}, + {"release-v1.json", "legacy-bss-v1.json"}) + for source in installed.glob("*.json"): + self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o644) + self.assertIn("schema_version", json.loads(source.read_text())) + self.assertTrue(source.with_name(source.name + ".license").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_haproxy.py b/tests/test_haproxy.py index 49aedb2..aa4c0d0 100644 --- a/tests/test_haproxy.py +++ b/tests/test_haproxy.py @@ -6,6 +6,8 @@ import http.client import http.server +import importlib.machinery +import importlib.util import json import os from pathlib import Path @@ -25,9 +27,26 @@ class EchoRequest(http.server.BaseHTTPRequestHandler): def do_GET(self): + # Model boot-service v0.3.1's RedirectSlashes before collection access. + # Authentication here is synthetic; the shipped policy only observes it. + if self.path in ("/nodes", "/nodes/"): + if self.path.endswith("/"): + status = 301 + else: + status = 200 if self.headers.get("Authorization") == "Bearer fixture-node-reader" else 401 + self.send_response(status) + if status == 301: + self.send_header("Location", "/nodes") + self.send_header("Content-Length", "0") + self.end_headers() + return + request_body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) body = json.dumps( { "path": self.path, + "method": self.command, + "body": request_body.decode(), + "authorization": self.headers.get_all("Authorization", []), "headers": { name: self.headers.get_all(name, []) for name in IDENTITY_HEADERS }, @@ -39,6 +58,11 @@ def do_GET(self): self.end_headers() self.wfile.write(body) + do_POST = do_GET + do_PUT = do_GET + do_PATCH = do_GET + do_DELETE = do_GET + def log_message(self, *_args): pass @@ -171,7 +195,7 @@ def stop(): raise RuntimeError(f"HAProxy failed to start: {log.read()}") time.sleep(0.02) - def request(self, frontend, headers=(), path="/metadata-service/user-data"): + def request(self, frontend, headers=(), path="/metadata-service/user-data", method="GET", body=""): host, port, tls = frontend sock = socket.create_connection((host, port), timeout=3) try: @@ -181,9 +205,11 @@ def request(self, frontend, headers=(), path="/metadata-service/user-data"): context.check_hostname = False context.verify_mode = ssl.CERT_NONE sock = context.wrap_socket(sock, server_hostname="metadata.test") - lines = [f"GET {path} HTTP/1.1", "Host: metadata.test", "Connection: close"] + encoded_body = body.encode() + lines = [f"{method} {path} HTTP/1.1", "Host: metadata.test", "Connection: close", + f"Content-Length: {len(encoded_body)}"] lines.extend(f"{name}: {value}" for name, value in headers) - sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode()) + sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode() + encoded_body) response = http.client.HTTPResponse(sock) response.begin() body = response.read() @@ -239,6 +265,47 @@ def test_existing_routes(self): ) self.assertEqual(result["path"], rewritten) + def test_packaged_boot_collection_probe_reaches_handler_without_redirect(self): + root = Path(__file__).resolve().parents[1] + loader = importlib.machinery.SourceFileLoader( + "proxy_endpoint_validator", str(root / "scripts/openchami-validate-endpoints") + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + tool = importlib.util.module_from_spec(spec) + loader.exec_module(tool) + policy_file = root / "validation/policies/release-v1.json" + policy = json.loads(policy_file.read_text()) + endpoint, = (entry for entry in policy["endpoints"] if entry["id"] == "boot-nodes") + host, port, _ = self.frontends[0] + target = f"http://{host}:{port}" + row = tool.check_endpoint(endpoint, {"boot": target}, + {"boot": "fixture-node-reader"}, {}, 2, None) + self.assertEqual(row["verdict"], "incomplete") + self.assertEqual({name: probe["status"] for name, probe in row["probes"].items()}, + {"anonymous": 401, "invalid": 401, "authorized": 200}) + # A trailing slash still records the redirect, rather than following it + # or forwarding a credential to its destination. + redirect = tool.probe_url(target + "/boot-service/nodes/", None, 2, None) + self.assertEqual(redirect, {"status": 301, "error": None}) + + def test_admin_methods_and_bootstrap_queries_are_preserved(self): + # Inert echo backend: these are contract checks, never live mutations. + for frontend in self.frontends: + for method in ("GET", "POST", "PUT", "PATCH", "DELETE"): + with self.subTest(frontend=frontend, method=method): + payload = '{"fixture":"request-body"}' if method != "GET" else "" + result = self.request( + frontend, (("Authorization", "Bearer fixture-operator-token"),), + "/metadata-service/groups/", method, payload, + ) + self.assertEqual(result["method"], method) + self.assertEqual(result["body"], payload) + self.assertEqual(result["authorization"], ["Bearer fixture-operator-token"]) + self.assertEqual(result["path"], "/groups/") + query = "/boot-service/boot/v1/bootscript?mac=02%3A00%3A00%3A00%3A00%3A01&nid=1&name=x0c0s0b0n0" + result = self.request(frontend, path=query) + self.assertEqual(result["path"], query.removeprefix("/boot-service")) + if __name__ == "__main__": unittest.main() diff --git a/validation/policies/legacy-bss-v1.json b/validation/policies/legacy-bss-v1.json new file mode 100644 index 0000000..a36617b --- /dev/null +++ b/validation/policies/legacy-bss-v1.json @@ -0,0 +1,10 @@ +{ + "schema_version": 1, + "policy_id": "legacy-bss-v1", + "endpoints": [ + {"id": "bss-dumpstate", "target": "bss", "method": "GET", "path": "/boot/v1/dumpstate", "jwt": "public", "success_statuses": [200], "reason": "Existing public BSS read; response data is never retained by the validator."}, + {"id": "bss-history", "target": "bss", "method": "GET", "path": "/boot/v1/endpoint-history", "jwt": "public", "success_statuses": [200], "reason": "Existing public BSS read."}, + {"id": "bss-hosts", "target": "bss", "method": "GET", "path": "/boot/v1/hosts", "jwt": "public", "success_statuses": [200], "reason": "Existing public BSS read."}, + {"id": "bss-bootparameters", "target": "bss", "method": "GET", "path": "/boot/v1/bootparameters", "jwt": "observe", "success_statuses": [200], "reason": "Existing optional JWKS configuration controls auth. Select required or public in a reviewed site policy to assert that site's setting."} + ] +} diff --git a/validation/policies/legacy-bss-v1.json.license b/validation/policies/legacy-bss-v1.json.license new file mode 100644 index 0000000..75852cd --- /dev/null +++ b/validation/policies/legacy-bss-v1.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +SPDX-License-Identifier: MIT diff --git a/validation/policies/release-v1.json b/validation/policies/release-v1.json new file mode 100644 index 0000000..56e11cf --- /dev/null +++ b/validation/policies/release-v1.json @@ -0,0 +1,94 @@ +{ + "schema_version": 1, + "policy_id": "release-v1", + "endpoints": [ + { + "id": "smd-liveness", + "target": "smd", + "method": "GET", + "path": "/hsm/v2/service/liveness", + "jwt": "public", + "success_statuses": [ + 204 + ], + "reason": "Public liveness route in packaged SMD v2.20.5." + }, + { + "id": "smd-ready", + "target": "smd", + "method": "GET", + "path": "/hsm/v2/service/ready", + "jwt": "public", + "success_statuses": [ + 200 + ], + "reason": "Public readiness route in packaged SMD v2.20.5; unavailable readiness remains a failure." + }, + { + "id": "smd-component-endpoints", + "target": "smd", + "method": "GET", + "path": "/hsm/v2/Inventory/ComponentEndpoints", + "jwt": "required", + "success_statuses": [ + 200 + ], + "reason": "Protected collection GET in SMD v2.20.5 with the RPM's SMD_JWKS_URL configured." + }, + { + "id": "smd-components", + "target": "smd", + "method": "GET", + "path": "/hsm/v2/State/Components", + "jwt": "public", + "success_statuses": [ + 200 + ], + "reason": "Existing public collection GET in packaged SMD v2.20.5." + }, + { + "id": "boot-health", + "target": "boot", + "method": "GET", + "path": "/boot-service/health", + "jwt": "public", + "success_statuses": [ + 200 + ], + "reason": "Public health endpoint in packaged boot-service v0.3.1." + }, + { + "id": "metadata-health", + "target": "metadata", + "method": "GET", + "path": "/metadata-service/health", + "jwt": "public", + "success_statuses": [ + 200 + ], + "reason": "Public health endpoint in packaged metadata-service v0.2.1." + }, + { + "id": "boot-nodes", + "target": "boot", + "method": "GET", + "path": "/boot-service/nodes", + "jwt": "observe", + "success_statuses": [ + 200 + ], + "reason": "Record generated resource access for upgrade comparison; inbound auth policy and implementation require separate qualification." + }, + { + "id": "metadata-groups", + "target": "metadata", + "method": "GET", + "path": "/metadata-service/groups/", + "jwt": "observe", + "success_statuses": [ + 200 + ], + "reason": "Record generated CRUD read exposure without claiming enable_auth proves inbound JWT enforcement." + } + ] +} diff --git a/validation/policies/release-v1.json.license b/validation/policies/release-v1.json.license new file mode 100644 index 0000000..75852cd --- /dev/null +++ b/validation/policies/release-v1.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +SPDX-License-Identifier: MIT