From 9307d76fcb363efa73a8d1e6997939801eaeab25 Mon Sep 17 00:00:00 2001 From: chen21019 Date: Fri, 30 Oct 2026 09:58:59 +0800 Subject: [PATCH] Add scoped AI domain workflows and transaction-safe audit component pins --- COMPATIBILITY.md | 14 + docs/releases/server-1.6.560.md | 69 +++++ scripts/check-server-api-explorer-patch.sh | 56 ++-- .../test-current-server-packaging-contract.py | 80 +++--- server/Dockerfile.web-compose-release | 38 +-- server/build-api-explorer-patch-image.sh | 22 +- server/build-component-candidate.sh | 6 +- server/console-broker/ai_audit.go | 218 ++++++++++++++ .../ai_audit_cookie_owner_test.go | 52 ++++ server/console-broker/ai_audit_owner.go | 271 ++++++++++++++++++ server/console-broker/ai_audit_owner_test.go | 224 +++++++++++++++ server/console-broker/ai_audit_test.go | 236 +++++++++++++++ server/console-broker/audit.go | 14 +- server/console-broker/audit_test.go | 10 +- server/console-broker/broker.go | 5 + server/console-broker/keyaudit.go | 20 ++ server/console-broker/keyaudit_test.go | 4 +- server/console-broker/main.go | 3 + server/security/openvex.json | 6 +- server/security/vendor-pending.json | 2 +- 20 files changed, 1239 insertions(+), 111 deletions(-) create mode 100644 docs/releases/server-1.6.560.md create mode 100644 server/console-broker/ai_audit.go create mode 100644 server/console-broker/ai_audit_cookie_owner_test.go create mode 100644 server/console-broker/ai_audit_owner.go create mode 100644 server/console-broker/ai_audit_owner_test.go create mode 100644 server/console-broker/ai_audit_test.go diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 131e1b2dc5f..08201810288 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,5 +1,19 @@ # Compatibility Contract +## Server v1.6.560 source candidate + +Not yet published. Install examples remain on verified published 559. +The candidate consumes published Web Console 1.6.201 / optional bridge 0.2.3 +and Engine 0.183.360 original CI artifacts. It adds External Service, DNS Alias +and metadata-only audit flows, corrects the Catalog-validator collection +admission and retains audit events across a rejected typed-write transaction. +Native SQL/Key/role checks and unknown-filter denials are preserved. +Catalog 0.20.17 / Compose 0.14.40 and official Brotli package proof are unchanged. +Combined runtime, role, cross-ID, layout, weak-model and durable-DENY acceptance +remain pending. This does not complete the 243-group frontend inventory. +See the [candidate note](docs/releases/server-1.6.560.md) for identities and scope. + + ## Server v1.6.559 published diff --git a/docs/releases/server-1.6.560.md b/docs/releases/server-1.6.560.md new file mode 100644 index 00000000000..b2e862d104b --- /dev/null +++ b/docs/releases/server-1.6.560.md @@ -0,0 +1,69 @@ +# Server v1.6.560 candidate + +Not published or runtime accepted. Install examples stay on published 559. +Both components passed required CI, same-tree merge and immutable publication. +All original public asset bytes were read back anonymously. Server assembly +and designated live acceptance remain separate pending gates. + +## Behavior and owner boundaries + +The matching Web Console 1.6.201 / bridge 0.2.3 and Engine 0.183.360 add bounded +External Service, DNS Alias and audit metadata workflows. External Service +creation is stopped; hostname and IP modes are mutually exclusive. DNS Alias +creation, local target binding and activation are separate reviewed changes. +Advanced links and incoming-consumer cleanup remain unavailable. + +Fix project-scoped native and typed collection admission through the final +Catalog response validator. The actual SQL authorization guard attests only +this known query-preserving validator; unknown/synthetic filters and altered +query/pagination implementations still fail closed. RBAC and Key policy are +unchanged, including explicit denials and cross-project/parent isolation. + +Audit queries use the installed broker's existing authorized sources via a +fixed bounded CLI and private stdin. Public tools receive safe metadata, never +cookies, Key secrets, arbitrary URLs/SQL or raw audit payloads. Engine records +the owner's actual query result; payload inspection/export remain human-only. +Catalog/Archive behavior and four independent default-off opt-ins are retained. + +Audit events created in a Core write transaction retain their forced durable +spool until the existing independent replayer can commit delivery. A rejected +typed read must remain auditable after its outer write rolls back. The fix +keeps current metadata, capacity and failure behavior without nested account +locks. Source/transaction tests passed; designated live durability is pending. + +## Required acceptance: pending + +The previous 558 Service-stop failure, 559 post-create collection failure and +their original reports remain recorded. No partial result is promoted to a +whole workflow PASS. The designated 8080 complete Core lifecycle, Catalog +launch/Archive, new domains, affected Key/role/cross-ID, layouts and weak-model +cases remain pending. Retain only unaffected immutable evidence. + +The baseline remains 243 business groups and 3,089 frontend inventory rows; +this candidate does not complete that inventory. Browser SBOM coverage retains +its recorded limits. KVM HOLD and explicit human ceremonies are not API grants. + +## Upgrade and rollback + +Deploy only the new immutable published image after necessary checks. Preserve +existing persistent volumes, origin/session/MFA controls, operator/JVM settings, +restart policy and AppArmor. Take normal database/volume backups and retain the +previous container/image. Never replay uncertain writes on upgrade/rollback; +settle or cancel plans and revoke temporary Keys. A matching database backup +may be required for rollback. Do not delete volumes or plan receipts. + +Production, HAProxy, firewall and production data are outside this QA change. +Official Brotli package proof and all other package gates remain exact. No VEX +scope expansion or weakened security threshold is introduced. + +## Qualified component coordinates + +- Web 1.6.201 / bridge 0.2.3 producer: `24715414f143e2c58cf53084bd84cbea0922798f`; tag source `18acf40d85061533762e8d0d1b9c1f7be69afe9c`. +- Web archive SHA256: `5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8`. +- Runtime SBOM SHA256: `8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503`; lock `d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f`. +- Bridge archive SHA256: `aad5cadfd3806b0f2c7339a1585a6bd19f3e61585af91dd4da119514775e7e93`; 13 published assets verified. +- Engine v0.183.360 producer: `72230c8b471312377bae968c0c17d63e8ad97ffb`; tag source `d6845eedd1df5ae8e69cc075d80b166bc8d26731`. +- Engine JAR SHA256: `0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21`; runtime SBOM `088ce2b94ea1c30a6d1e52e600d2ef45258aeb26cdded784407a91020a19f474`. +- Original Engine CI: 408 suites / 3822 tests / zero failure or error / one existing skip; 12 published assets verified. +- Engine provenance is original GitHub artifact/source/tree evidence; its workflow supplies no native SLSA attestation. +- Catalog 0.20.17 and Compose 0.14.40 retain the qualified original pins from Server559; other component/package inputs are unchanged. diff --git a/scripts/check-server-api-explorer-patch.sh b/scripts/check-server-api-explorer-patch.sh index 0ed1ad823ee..09881409c27 100755 --- a/scripts/check-server-api-explorer-patch.sh +++ b/scripts/check-server-api-explorer-patch.sh @@ -533,7 +533,7 @@ require_marker "$release_dockerfile" \ SERVER_INCREMENTAL_RELEASE_BASE_MISSING for web_sbom_release_marker in \ 'COPY --chmod=0755 artifacts/verify-web-console-sbom.py /usr/local/bin/verify-web-console-sbom' \ - 'ARG WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT=web-console-1.6.200-runtime.cdx.json' \ + 'ARG WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT=web-console-1.6.201-runtime.cdx.json' \ '"${WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT}" "${WEB_CONSOLE_RUNTIME_SBOM_SHA256}" "${web_sbom}";' \ '--lock-sha256 "${WEB_CONSOLE_LOCK_SHA256}" --archive "${web_archive}"' \ '--webmcp-ceiling enabled;' \ @@ -571,7 +571,7 @@ python3 scripts/test-web-console-sbom.py python3 server/security/test_web_console_sbom_scan.py require_marker "$release_dockerfile" \ - 'ARG SERVER_RELEASE_TAG=v1.6.559' \ + 'ARG SERVER_RELEASE_TAG=v1.6.560' \ SERVER_INCREMENTAL_RELEASE_DEFAULT_MISSING require_marker "$release_dockerfile" \ 'org.opencontainers.image.version="${SERVER_RELEASE_TAG}"' \ @@ -629,22 +629,22 @@ require_marker "$publish_workflow" \ 'bash source/server/artifacts/verify-host-api-package.sh' \ SERVER_HOST_API_PRODUCER_RELEASE_CHECK_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.200' \ + 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.201' \ SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.200.tar.gz' \ + 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.201.tar.gz' \ SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_ARTIFACT_SHA256=97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0' \ + 'ARG WEB_CONSOLE_ARTIFACT_SHA256=5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8' \ SERVER_INCREMENTAL_WEB_CONSOLE_HASH_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_COMMIT=03e0d7b25b8dfcbc45baecf6d448834016aac649' \ + 'ARG WEB_CONSOLE_COMMIT=24715414f143e2c58cf53084bd84cbea0922798f' \ SERVER_INCREMENTAL_WEB_CONSOLE_COMMIT_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_LOCK_SHA256=1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd' \ + 'ARG WEB_CONSOLE_LOCK_SHA256=d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f' \ SERVER_INCREMENTAL_WEB_CONSOLE_LOCK_HASH_MISSING require_marker "$release_dockerfile" \ - 'ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c' \ + 'ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503' \ SERVER_INCREMENTAL_WEB_CONSOLE_SBOM_HASH_MISSING require_marker "$release_dockerfile" \ "grep -aF 'hostsPage.permissionDenied'" \ @@ -653,22 +653,22 @@ require_marker "$release_dockerfile" \ '"hostsPage.permissionDenied":"您沒有權限在此環境中新增主機。"' \ SERVER_INCREMENTAL_WEB_CONSOLE_ZH_TW_PERMISSION_MESSAGE_MISSING require_marker "$build_script" \ - 'web_console_commit=${WEB_CONSOLE_COMMIT:-03e0d7b25b8dfcbc45baecf6d448834016aac649}' \ + 'web_console_commit=${WEB_CONSOLE_COMMIT:-24715414f143e2c58cf53084bd84cbea0922798f}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_COMMIT_MISSING require_marker "$build_script" \ - 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.200}' \ + 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.201}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING require_marker "$build_script" \ - 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.200.tar.gz}' \ + 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.201.tar.gz}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING require_marker "$build_script" \ - 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0}' \ + 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_HASH_MISSING require_marker "$build_script" \ - 'web_console_lock_sha256=${WEB_CONSOLE_LOCK_SHA256:-1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd}' \ + 'web_console_lock_sha256=${WEB_CONSOLE_LOCK_SHA256:-d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_LOCK_HASH_MISSING require_marker "$build_script" \ - 'web_console_runtime_sbom_sha256=${WEB_CONSOLE_RUNTIME_SBOM_SHA256:-0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c}' \ + 'web_console_runtime_sbom_sha256=${WEB_CONSOLE_RUNTIME_SBOM_SHA256:-8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503}' \ SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_SBOM_HASH_MISSING require_marker "$release_dockerfile" \ 'ARG CATALOG_SERVICE_VERSION=0.20.17' \ @@ -1139,21 +1139,21 @@ require_marker "$build_script" \ '--file server/Dockerfile.web-compose-release' \ SERVER_INCREMENTAL_RELEASE_BUILD_PATH_MISSING require_marker "$build_script" \ - 'image=${IMAGE:-pasturestack-validation/server:v1.6.559}' \ + 'image=${IMAGE:-pasturestack-validation/server:v1.6.560}' \ SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING require_marker "$build_script" \ - 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.559}' \ + 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.560}' \ SERVER_INCREMENTAL_RELEASE_BUILD_DEFAULT_MISSING require_marker "$build_script" \ 'CATTLE_RANCHER_SERVER_VERSION="${server_release_tag}"' \ SERVER_INCREMENTAL_RELEASE_BUILD_RUNTIME_VERSION_MISSING for release_engine_marker in \ - 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.359' \ + 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.360' \ 'ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar' \ - 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90' \ - 'ARG ORCHESTRATION_ENGINE_COMMIT=eac3fde5fc7779b6107e2702785120a9c591927b' \ + 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21' \ + 'ARG ORCHESTRATION_ENGINE_COMMIT=72230c8b471312377bae968c0c17d63e8ad97ffb' \ 'COPY --from=release_artifacts /out/orchestration-engine.jar /tmp/orchestration-engine.jar' \ - 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.359' \ + 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.360' \ 'sh /usr/share/cattle/verify-engine-archive.sh "${archive}" "${ORCHESTRATION_ENGINE_VERSION}" "${ORCHESTRATION_ENGINE_ARTIFACT_SHA256}"' \ 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar' \ 'cattle-app-config-${ORCHESTRATION_ENGINE_VERSION}.jar' \ @@ -1172,10 +1172,10 @@ for release_engine_marker in \ SERVER_INCREMENTAL_ENGINE_REPLACEMENT_MISSING done for release_engine_build_marker in \ - 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.359}' \ + 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.360}' \ 'orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar}' \ - 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90}' \ - 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-eac3fde5fc7779b6107e2702785120a9c591927b}' \ + 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21}' \ + 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-72230c8b471312377bae968c0c17d63e8ad97ffb}' \ 'CATTLE_CATTLE_VERSION="${orchestration_engine_release_tag}"' \ 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar' \ '< "${server_dir}/artifacts/verify-engine-archive.sh"'; do @@ -1694,10 +1694,10 @@ fi if grep -Fxq '## Current source candidate' README.md; then for candidate_readme_marker in \ '' \ - 'Server `v1.6.559` is a prepared source candidate, not a published image.' \ + 'Server `v1.6.560` is a prepared source candidate, not a published image.' \ 'run them only after its official release' \ 'This candidate is not yet available' \ - 'ghcr.io/pasturestack/server:v1.6.559'; do + 'ghcr.io/pasturestack/server:v1.6.560'; do require_marker README.md "$candidate_readme_marker" \ SERVER_CURRENT_CANDIDATE_README_MISSING done @@ -2303,7 +2303,7 @@ done jq -e ' ["CVE-2026-107928","CVE-2026-107929","CVE-2026-86409","CVE-2026-86410","CVE-2026-86412","CVE-2026-86414","CVE-2026-87862","CVE-2026-87863","CVE-2026-87864","CVE-2026-87865","CVE-2026-92386","CVE-2026-92387","CVE-2026-92388","CVE-2026-92389","CVE-2026-92390","CVE-2026-92391","CVE-2026-92392","CVE-2026-94266"] as $curl_fixed_cves | .["@context"] == "https://openvex.dev/ns/v0.2.0" - and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.559" + and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.560" and (.statements | length) == 56 and ([.statements[].vulnerability.name] | length == (unique | length)) and ([.statements[] | select(.status == "fixed") | .vulnerability.name] | sort) @@ -2341,7 +2341,7 @@ jq -r ' | @tsv ' "$runtime_vendor_pending" | LC_ALL=C sort -u >"$vendor_pending_fixture" bash "$vendor_pending_validator" "$runtime_vendor_pending" \ - "$vendor_pending_fixture" v1.6.559 >/dev/null + "$vendor_pending_fixture" v1.6.560 >/dev/null bash scripts/test-vendor-pending-findings.sh >/dev/null rm -f "$vendor_pending_fixture" trap - EXIT @@ -2430,4 +2430,4 @@ for release_readback_contract in \ fi done -printf 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.559 base=v1.6.460 engine=0.183.359 web_console=1.6.200 catalog_service=0.20.17 webhook_automation_service=0.10.5 authentication_service=0.4.46 curl=8.18.0-1ubuntu2.8 freemarker=2.3.35 artifact_scan=required vendor_pending=exact-set role_matrix=qa-required locale_layout=qa-required\n' +printf 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.560 base=v1.6.460 engine=0.183.360 web_console=1.6.201 catalog_service=0.20.17 webhook_automation_service=0.10.5 authentication_service=0.4.46 curl=8.18.0-1ubuntu2.8 freemarker=2.3.35 artifact_scan=required vendor_pending=exact-set role_matrix=qa-required locale_layout=qa-required\n' diff --git a/scripts/test-current-server-packaging-contract.py b/scripts/test-current-server-packaging-contract.py index f139ed7b704..f1c07f8a78e 100644 --- a/scripts/test-current-server-packaging-contract.py +++ b/scripts/test-current-server-packaging-contract.py @@ -34,16 +34,16 @@ PUBLISHED_508 = 'Server `v1.6.508` 已正式發布,封裝 Web Console `1.6.171`。' STALE_508_CANDIDATE = 'Server `v1.6.508` candidate packages Web Console `1.6.171`' COMPATIBILITY_CODE = 'SERVER_CREATE_RESPONSE_ORDER_COMPATIBILITY_MISSING' -WEB_SHA = '97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0' -WEB_SOURCE = '03e0d7b25b8dfcbc45baecf6d448834016aac649' -WEB_SBOM_SHA = '0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c' -WEB_LOCK_SHA = '1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd' +WEB_SHA = '5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8' +WEB_SOURCE = '24715414f143e2c58cf53084bd84cbea0922798f' +WEB_SBOM_SHA = '8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503' +WEB_LOCK_SHA = 'd255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f' CATALOG_COMMIT = '00eec89e5fdeb9728c1c41babccad8b7dfc8b297' -ENGINE_SHA = '07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90' -ENGINE_SOURCE = 'eac3fde5fc7779b6107e2702785120a9c591927b' +ENGINE_SHA = '0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21' +ENGINE_SOURCE = '72230c8b471312377bae968c0c17d63e8ad97ffb' OLD_COORDINATES = { - 'v1.6.559': 'v1.6.521', - '1.6.200': '1.6.181', + 'v1.6.560': 'v1.6.521', + '1.6.201': '1.6.181', WEB_SHA: '9199681f2c4311f3fbe8727fcf0f6fa136c9bafe9fbe9d13878c55df442a7a03', WEB_SOURCE: '7f2bdad0c90a3241693c2ddd4475202ababba550', CATALOG_COMMIT: '7670ffd81d5f0b5570197fb03c7e55b46da45bf3', @@ -60,29 +60,29 @@ ('LICENSE_SHA256', 'license_sha256', '0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594'), ) LEGACY_ENGINE_COORDINATES = { - '0.183.359': '0.183.337', + '0.183.360': '0.183.337', ENGINE_SHA: '7e45189f2b57097efc7929c04ddf7767aebbe027d1b9f90023aa6711522c5c80', ENGINE_SOURCE: 'ba041fe312dfe97fc1e5144af576ddcf1b573d8e', } EXPECTED = { COMPATIBILITY_CODE: (COMPATIBILITY, PUBLISHED_508), - 'SERVER_INCREMENTAL_RELEASE_DEFAULT_MISSING': (DOCKER, 'ARG SERVER_RELEASE_TAG=v1.6.559'), + 'SERVER_INCREMENTAL_RELEASE_DEFAULT_MISSING': (DOCKER, 'ARG SERVER_RELEASE_TAG=v1.6.560'), 'SERVER_INCREMENTAL_RELEASE_VERSION_MISSING': (DOCKER, 'org.opencontainers.image.version="${SERVER_RELEASE_TAG}"'), 'SERVER_INCREMENTAL_RELEASE_RUNTIME_VERSION_MISSING': (DOCKER, 'ENV CATTLE_RANCHER_SERVER_VERSION=${SERVER_RELEASE_TAG}'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING': (DOCKER, 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.200'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.200.tar.gz'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_VERSION_MISSING': (DOCKER, 'ARG WEB_CONSOLE_RELEASE_TAG=1.6.201'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_ARTIFACT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.201.tar.gz'), 'SERVER_INCREMENTAL_WEB_CONSOLE_HASH_MISSING': (DOCKER, 'ARG WEB_CONSOLE_ARTIFACT_SHA256=' + WEB_SHA), 'SERVER_INCREMENTAL_WEB_CONSOLE_COMMIT_MISSING': (DOCKER, 'ARG WEB_CONSOLE_COMMIT=' + WEB_SOURCE), 'SERVER_INCREMENTAL_WEB_CONSOLE_LOCK_HASH_MISSING': (DOCKER, 'ARG WEB_CONSOLE_LOCK_SHA256=' + WEB_LOCK_SHA), 'SERVER_INCREMENTAL_WEB_CONSOLE_SBOM_HASH_MISSING': (DOCKER, 'ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=' + WEB_SBOM_SHA), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_COMMIT_MISSING': (BUILD, 'web_console_commit=${WEB_CONSOLE_COMMIT:-' + WEB_SOURCE + '}'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING': (BUILD, 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.200}'), - 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING': (BUILD, 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.200.tar.gz}'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_VERSION_MISSING': (BUILD, 'web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.201}'), + 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_ARTIFACT_MISSING': (BUILD, 'web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.201.tar.gz}'), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_HASH_MISSING': (BUILD, 'web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-' + WEB_SHA + '}'), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_LOCK_HASH_MISSING': (BUILD, 'web_console_lock_sha256=${WEB_CONSOLE_LOCK_SHA256:-' + WEB_LOCK_SHA + '}'), 'SERVER_INCREMENTAL_WEB_CONSOLE_BUILD_SBOM_HASH_MISSING': (BUILD, 'web_console_runtime_sbom_sha256=${WEB_CONSOLE_RUNTIME_SBOM_SHA256:-' + WEB_SBOM_SHA + '}'), - 'SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING': (BUILD, 'image=${IMAGE:-pasturestack-validation/server:v1.6.559}'), - 'SERVER_INCREMENTAL_RELEASE_BUILD_DEFAULT_MISSING': (BUILD, 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.559}'), + 'SERVER_INCREMENTAL_RELEASE_BUILD_VERSION_MISSING': (BUILD, 'image=${IMAGE:-pasturestack-validation/server:v1.6.560}'), + 'SERVER_INCREMENTAL_RELEASE_BUILD_DEFAULT_MISSING': (BUILD, 'server_release_tag=${SERVER_RELEASE_TAG:-v1.6.560}'), 'SERVER_INCREMENTAL_RELEASE_BUILD_RUNTIME_VERSION_MISSING': (BUILD, 'CATTLE_RANCHER_SERVER_VERSION="${server_release_tag}"'), 'SERVER_WEB_CONSOLE_RUNTIME_VERSION_GATE_MISSING': (BUILD, 'test "$(cat "${web_root}/VERSION.txt")" = "${WEB_CONSOLE_RELEASE_TAG}"'), 'SERVER_VOLUME_NATIVE_READ_ONLY_BUILD_GATE_MISSING': (DOCKER, 'grep -F \'"volume.isNative" : "r"\' >/dev/null'), @@ -103,18 +103,18 @@ CREATE_RESPONSE_CODES = ['SERVER_WEB_CREATE_IDENTITY_BUILD_GATE_MISSING','SERVER_WEB_CANONICAL_RECORD_BUILD_GATE_MISSING','SERVER_WEB_CREATE_IDENTITY_RUNTIME_GATE_MISSING','SERVER_WEB_CANONICAL_RECORD_RUNTIME_GATE_MISSING'] ENGINE_EXPECTED = { 'SERVER_INCREMENTAL_ENGINE_REPLACEMENT_MISSING': (DOCKER, 'release_engine_marker', ( - 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.359', + 'ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.360', 'ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar', 'ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=' + ENGINE_SHA, 'ARG ORCHESTRATION_ENGINE_COMMIT=' + ENGINE_SOURCE, - 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.359', + 'ARG ORCHESTRATION_ENGINE_VERSION=0.183.360', 'sh /usr/share/cattle/verify-engine-archive.sh "${archive}" "${ORCHESTRATION_ENGINE_VERSION}" "${ORCHESTRATION_ENGINE_ARTIFACT_SHA256}"', 'cattle-resources-${ORCHESTRATION_ENGINE_VERSION}.jar', 'cattle-app-config-${ORCHESTRATION_ENGINE_VERSION}.jar', 'ENV CATTLE_CATTLE_VERSION=${ORCHESTRATION_ENGINE_RELEASE_TAG}', )), 'SERVER_INCREMENTAL_ENGINE_BUILD_COORDINATE_MISSING': (BUILD, 'release_engine_build_marker', ( - 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.359}', + 'orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.360}', 'orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar}', 'orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-' + ENGINE_SHA + '}', 'orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-' + ENGINE_SOURCE + '}', @@ -608,15 +608,15 @@ def verify(files, gate=GATE): pipeline = r'(?:^|[;\n])\s*' + re.escape(marker) + r'(?=\s*(?:;|\n|$))' if len(re.findall(pipeline, source)) != 1: raise AssertionError('CURRENT_CATALOG_HASH_BYPASS') - if 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.559"' not in gate: + if 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.560"' not in gate: raise AssertionError('CURRENT_VEX_GATE_PIN_MISMATCH') - if '"$vendor_pending_fixture" v1.6.559 >/dev/null' not in gate: + if '"$vendor_pending_fixture" v1.6.560 >/dev/null' not in gate: raise AssertionError('CURRENT_VENDOR_GATE_PIN_MISMATCH') - if json.loads(files[VEX]).get('@id') != 'https://github.com/PastureStack/server/security/openvex/v1.6.559': + if json.loads(files[VEX]).get('@id') != 'https://github.com/PastureStack/server/security/openvex/v1.6.560': raise AssertionError('CURRENT_VEX_RELEASE_MISMATCH') - if json.loads(files[VENDOR]).get('release') != 'v1.6.559': + if json.loads(files[VENDOR]).get('release') != 'v1.6.560': raise AssertionError('CURRENT_VENDOR_RELEASE_MISMATCH') - if 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.559 base=v1.6.460 engine=0.183.359 web_console=1.6.200 catalog_service=0.20.17 ' not in gate: + if 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.560 base=v1.6.460 engine=0.183.360 web_console=1.6.201 catalog_service=0.20.17 ' not in gate: raise AssertionError('CURRENT_SUMMARY_MISMATCH') verify_incremental_contract(files, gate) verify_load_balancer_contract(files, gate) @@ -631,11 +631,11 @@ def previous_gate(gate=GATE): if stale(marker) != marker: replacements["'" + marker + "' "] = "'" + stale(marker) + "' " replacements.update({ - 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.559"': + 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.560"': 'and .["@id"] == "https://github.com/PastureStack/server/security/openvex/v1.6.521"', - '"$vendor_pending_fixture" v1.6.559 >/dev/null': + '"$vendor_pending_fixture" v1.6.560 >/dev/null': '"$vendor_pending_fixture" v1.6.521 >/dev/null', - 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.559 base=v1.6.460 engine=0.183.359 web_console=1.6.200 catalog_service=0.20.17 ': + 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.560 base=v1.6.460 engine=0.183.360 web_console=1.6.201 catalog_service=0.20.17 ': 'SERVER_API_EXPLORER_PATCH_OK release=v1.6.521 base=v1.6.460 engine=0.183.340 web_console=1.6.181 catalog_service=0.20.12 ', }) for marker, stale_marker in replacements.items(): @@ -1299,7 +1299,7 @@ def test_each_old_engine_coordinate_rejected(self): self.assertGreater(checked, 0) def test_historical_engine340_cannot_be_packaged(self): - old = {'0.183.359': '0.183.340', + old = {'0.183.360': '0.183.340', ENGINE_SHA: '5e5a1b908b43bf3824fc3bb2108bd661cd5199ce9bcfd187e0b18b5608a38cea', ENGINE_SOURCE: 'ff0a41e7676211043eb4f755a306a9e2a19abee1'} checked = 0 @@ -1320,13 +1320,13 @@ def test_historical_engine340_cannot_be_packaged(self): def test_engine352_actual_war_producer_and_previous351_350_boundary(self): candidate = (REPO / 'server/build-component-candidate.sh').read_text(encoding='utf-8') - self.assertIn('export SERVER_RELEASE_TAG=${SERVER_RELEASE_TAG:-v1.6.559}', candidate) - self.assertIn('export ORCHESTRATION_ENGINE_RELEASE_TAG=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.359}', candidate) + self.assertIn('export SERVER_RELEASE_TAG=${SERVER_RELEASE_TAG:-v1.6.560}', candidate) + self.assertIn('export ORCHESTRATION_ENGINE_RELEASE_TAG=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.360}', candidate) for old in ( - {'0.183.359': '0.183.351', + {'0.183.360': '0.183.351', ENGINE_SHA: 'efd0e826364c551f782b281f081d53cadb48faa7d5c37c2f101e6f9c7d7f9ea6', ENGINE_SOURCE: '1befd83278618f9a5b7cdaf96522004dd89918ba'}, - {'0.183.359': '0.183.350', + {'0.183.360': '0.183.350', ENGINE_SHA: '637ddaa42cd93c019b4d0813d7053b71dc42797a7f3ac2aa4b10fcc7e304cb31', ENGINE_SOURCE: '9c884710e5ea3d4136d80a870d9de9485674f3c3'}, {ENGINE_SOURCE: '109f2623f00ca3a3a9079414a0f0f8bd23cd1eb2'}, @@ -1349,7 +1349,7 @@ def test_engine352_actual_war_producer_and_previous351_350_boundary(self): def test_immediately_previous_engine341_cannot_be_packaged(self): - old = {'0.183.359': '0.183.341', + old = {'0.183.360': '0.183.341', ENGINE_SHA: '3ede1ad28b7a7e0754c4c953bd486162f7ffe0b2f76ce384ec485632af6b8e6d', ENGINE_SOURCE: 'b245cf778479445ded0786d3898b03fea153212e'} checked = 0 @@ -1370,7 +1370,7 @@ def test_immediately_previous_engine341_cannot_be_packaged(self): def test_immediately_previous_engine342_cannot_be_packaged(self): - old = {'0.183.359': '0.183.342', + old = {'0.183.360': '0.183.342', ENGINE_SHA: '3533f247392795329c92516a7966510aafbe81a52e3f922e5ec657a2071363b5', ENGINE_SOURCE: 'ddae4bb8e12806f70816f5782cd999eccd0f2ce6'} checked = 0 @@ -1391,7 +1391,7 @@ def test_immediately_previous_engine342_cannot_be_packaged(self): def test_immediately_previous_engine343_cannot_be_packaged(self): - old = {'0.183.359': '0.183.343', + old = {'0.183.360': '0.183.343', ENGINE_SHA: 'b29c0057f96dbae38bfe2c663c63bbe4c8b5c755b3090f39414aa51f9813025f', ENGINE_SOURCE: '5aa9d8978c75a7fe63330453c399a92716d88568'} checked = 0 @@ -1412,7 +1412,7 @@ def test_immediately_previous_engine343_cannot_be_packaged(self): def test_immediately_previous_engine344_cannot_be_packaged(self): - old = {'0.183.359': '0.183.344', + old = {'0.183.360': '0.183.344', ENGINE_SHA: 'ffafa4700156b74ec11bcae1b830738e49943f1a3b8b0565895ed6d7726eebf1', ENGINE_SOURCE: '72c42e6f2000301fe03ffd2442fb9ee89aee120c'} checked = 0 @@ -1432,7 +1432,7 @@ def test_immediately_previous_engine344_cannot_be_packaged(self): self.assertGreater(checked, 0) def test_immediately_previous_engine345_cannot_be_packaged(self): - old = {'0.183.359': '0.183.345', + old = {'0.183.360': '0.183.345', ENGINE_SHA: '780263c4a884527fa80a28b998ad4bda88b9ad6002a687f9f074602ecad46e62', ENGINE_SOURCE: 'd27d4ac73fa05ae43b9ae0c32875780e8a3765b1'} checked = 0 @@ -1452,7 +1452,7 @@ def test_immediately_previous_engine345_cannot_be_packaged(self): self.assertGreater(checked, 0) def test_immediately_previous_engine346_cannot_be_packaged(self): - old = {'0.183.359': '0.183.346', + old = {'0.183.360': '0.183.346', ENGINE_SHA: '9deeda5758b7f04135441a203092dd0308d90b65d25037b62ef4f9361d3cf806', ENGINE_SOURCE: 'e250e4a17958216c30c1f5d0d31f7b6418a968d5'} checked = 0 @@ -1628,7 +1628,7 @@ def test_machine_actual_binary_package_and_retired_boundary_are_verified(self): def test_current_readme_status_specific_prose_does_not_block_publication_transition(self): common = GATE.split('for current_readme_marker in ', 1)[1].split('; do', 1)[0] self.assertNotIn('candidate', common) - self.assertNotIn('v1.6.559', common) + self.assertNotIn('v1.6.560', common) self.assertIn("grep -Fqi 'keep all three named volumes' README.md", GATE) status = GATE.split("if grep -Fxq '## Current source candidate' README.md; then", 1)[1] candidate, published = status.split("elif grep -Fxq '## Current release' README.md; then", 1) @@ -1637,7 +1637,7 @@ def test_current_readme_status_specific_prose_does_not_block_publication_transit self.assertIn('This candidate is not yet available', candidate) self.assertIn('SERVER_CURRENT_PUBLISHED_README_MISSING', published) self.assertNotIn('candidate', published) - self.assertNotIn('v1.6.559', published) + self.assertNotIn('v1.6.560', published) if __name__ == '__main__': diff --git a/server/Dockerfile.web-compose-release b/server/Dockerfile.web-compose-release index 5eb8bfb2c54..4c38c4550d5 100644 --- a/server/Dockerfile.web-compose-release +++ b/server/Dockerfile.web-compose-release @@ -90,13 +90,13 @@ ARG PER_HOST_SUBNET_STARTUP_SHA256=f22b41f18d1733c037babed4d2e0474efeedc6017a968 ARG PER_HOST_SUBNET_PROVENANCE_SHA256=afa2db0e0755c6bd3ee28e111937c521a6d84e6d71d563e99202cf4693000eaf ARG WEB_CONSOLE_RELEASE_BASE_URL=https://github.com/PastureStack/web-console/releases/download -ARG WEB_CONSOLE_RELEASE_TAG=1.6.200 -ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.200.tar.gz -ARG WEB_CONSOLE_ARTIFACT_SHA256=97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0 -ARG WEB_CONSOLE_COMMIT=03e0d7b25b8dfcbc45baecf6d448834016aac649 -ARG WEB_CONSOLE_LOCK_SHA256=1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd -ARG WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT=web-console-1.6.200-runtime.cdx.json -ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c +ARG WEB_CONSOLE_RELEASE_TAG=1.6.201 +ARG WEB_CONSOLE_ARTIFACT=web-console-1.6.201.tar.gz +ARG WEB_CONSOLE_ARTIFACT_SHA256=5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8 +ARG WEB_CONSOLE_COMMIT=24715414f143e2c58cf53084bd84cbea0922798f +ARG WEB_CONSOLE_LOCK_SHA256=d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f +ARG WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT=web-console-1.6.201-runtime.cdx.json +ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503 ARG CATALOG_SERVICE_RELEASE_BASE_URL=https://github.com/PastureStack/catalog-service/releases/download ARG CATALOG_SERVICE_VERSION=0.20.17 ARG CATALOG_SERVICE_COMMIT=2f70559e340865eb6243662ed33d8cbc7b1efa90 @@ -118,9 +118,9 @@ ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.5 ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=f2df8874d5a00a2d5713594609063256164ed624d29a733149d00cb271f0f66b ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=b98e55db2739ab45119eacc94c8f65cb44371480ebcebec95e5470fd67c72a4c ARG ORCHESTRATION_ENGINE_RELEASE_BASE_URL=https://github.com/PastureStack/orchestration-engine/releases/download -ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.359 +ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.360 ARG ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar -ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90 +ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21 ARG COMPOSE_EXECUTOR_RELEASE_BASE_URL=https://github.com/PastureStack/compose-cli/releases/download ARG COMPOSE_EXECUTOR_VERSION=0.14.40 ARG COMPOSE_EXECUTOR_ARCHIVE_SHA256=fa7a4127793ef4e8e6ef18d926d74b50046a80dd2097b08878ef576ddfce6732 @@ -709,12 +709,12 @@ ARG PER_HOST_SUBNET_VERSION=0.2.5 ARG PER_HOST_SUBNET_PRODUCER_COMMIT=e102e0bb38d84b0a9cbf87e5458cf1bc74d01904 ARG PER_HOST_SUBNET_ARCHIVE_SHA256=7237e83e96a64e7f3de64eb86bff9721c4e879c35cda9e9de1e4cb1ba225f298 ARG PER_HOST_SUBNET_PROVENANCE_SHA256=afa2db0e0755c6bd3ee28e111937c521a6d84e6d71d563e99202cf4693000eaf -ARG SERVER_RELEASE_TAG=v1.6.559 +ARG SERVER_RELEASE_TAG=v1.6.560 ARG API_EXPLORER_RELEASE_TAG=v1.1.20 ARG API_EXPLORER_ARTIFACT_SHA256=c989ee12a518f6d9d2b23b1f17f48d781aacf6ec31fb65291dd7de9b1661badb ARG API_EXPLORER_COMMIT=a586f0dc0aa50df579049a8d4bf26f04400fdf75 -ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.359 -ARG ORCHESTRATION_ENGINE_VERSION=0.183.359 +ARG ORCHESTRATION_ENGINE_RELEASE_TAG=v0.183.360 +ARG ORCHESTRATION_ENGINE_VERSION=0.183.360 ARG ENGINE_READONLY_SCHEMA_SHA256=7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233 ARG ENGINE_RESTRICTED_SCHEMA_SHA256=f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51 ARG HOST_API_VERSION=0.38.8 @@ -736,11 +736,11 @@ ARG GLIBC_PACKAGE_VERSION=2.43-2ubuntu2.4 ARG GLIBC_RUNTIME_PACKAGE_VERSION=2.43-2ubuntu2.4+pasturestack.1 ARG PERL_PACKAGE_VERSION=5.40.1-7ubuntu0.3 ARG PCRE2_PACKAGE_VERSION=10.46-1build1+pasturestack.1 -ARG WEB_CONSOLE_RELEASE_TAG=1.6.200 -ARG WEB_CONSOLE_ARTIFACT_SHA256=97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0 -ARG WEB_CONSOLE_COMMIT=03e0d7b25b8dfcbc45baecf6d448834016aac649 -ARG WEB_CONSOLE_LOCK_SHA256=1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd -ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c +ARG WEB_CONSOLE_RELEASE_TAG=1.6.201 +ARG WEB_CONSOLE_ARTIFACT_SHA256=5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8 +ARG WEB_CONSOLE_COMMIT=24715414f143e2c58cf53084bd84cbea0922798f +ARG WEB_CONSOLE_LOCK_SHA256=d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f +ARG WEB_CONSOLE_RUNTIME_SBOM_SHA256=8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503 ARG CATALOG_SERVICE_VERSION=0.20.17 ARG CATALOG_SERVICE_COMMIT=2f70559e340865eb6243662ed33d8cbc7b1efa90 ARG CATALOG_SERVICE_ARCHIVE_SHA256=4f83f89763ce325262baf8984782da2c21fd81e3cc22c0a1c76aaa50b2a50f20 @@ -759,8 +759,8 @@ ARG WEBHOOK_AUTOMATION_SERVICE_VERSION=0.10.5 ARG WEBHOOK_AUTOMATION_SERVICE_COMMIT=4da77ccac464bc7f7cd2020e0659a666a5d1ff7e ARG WEBHOOK_AUTOMATION_SERVICE_ARCHIVE_SHA256=f2df8874d5a00a2d5713594609063256164ed624d29a733149d00cb271f0f66b ARG WEBHOOK_AUTOMATION_SERVICE_BINARY_SHA256=b98e55db2739ab45119eacc94c8f65cb44371480ebcebec95e5470fd67c72a4c -ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90 -ARG ORCHESTRATION_ENGINE_COMMIT=eac3fde5fc7779b6107e2702785120a9c591927b +ARG ORCHESTRATION_ENGINE_ARTIFACT_SHA256=0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21 +ARG ORCHESTRATION_ENGINE_COMMIT=72230c8b471312377bae968c0c17d63e8ad97ffb ARG HOST_PROVISIONER_VERSION=0.39.10 ARG HOST_PROVISIONER_COMMIT=d5f6d5c9f4f0599f4bf1bdc9d5d0cca2e2e0c1ba ARG HOST_PROVISIONER_ARCHIVE_SHA256=fd8a814c5810746091ba3192a9fd31699e6865a2598654b9bbb55eae90bef661 diff --git a/server/build-api-explorer-patch-image.sh b/server/build-api-explorer-patch-image.sh index 65683b8bf5f..ac51a8cd810 100755 --- a/server/build-api-explorer-patch-image.sh +++ b/server/build-api-explorer-patch-image.sh @@ -14,14 +14,14 @@ if [[ -n "$(git status --porcelain --untracked-files=normal)" ]]; then fi revision=${PASTURESTACK_SERVER_REVISION:-$(git rev-parse HEAD)} -server_release_tag=${SERVER_RELEASE_TAG:-v1.6.559} +server_release_tag=${SERVER_RELEASE_TAG:-v1.6.560} source_date_epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)} base_image=${BASE_IMAGE:-ghcr.io/pasturestack/server:v1.6.460@sha256:c855af8aea232dacc5bb6df68e2271d482c68b53c43ab0c108ec19118f5ab403} orchestration_engine_release_base_url=${ORCHESTRATION_ENGINE_RELEASE_BASE_URL:-https://github.com/PastureStack/orchestration-engine/releases/download} -orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.359} +orchestration_engine_release_tag=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.360} orchestration_engine_artifact=${ORCHESTRATION_ENGINE_ARTIFACT:-cattle.jar} -orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-07e7de2548bd98d5209bf3594d69fe5b9a5d7ddba36eed4b6b6e4406022c3f90} -orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-eac3fde5fc7779b6107e2702785120a9c591927b} +orchestration_engine_artifact_sha256=${ORCHESTRATION_ENGINE_ARTIFACT_SHA256:-0ebbd517ee43e113f29cbc14fe542aa7c7900ae28d65d63a12c348b7f0ac9a21} +orchestration_engine_commit=${ORCHESTRATION_ENGINE_COMMIT:-72230c8b471312377bae968c0c17d63e8ad97ffb} orchestration_engine_version=${orchestration_engine_release_tag#v} engine_readonly_schema_sha256=${ENGINE_READONLY_SCHEMA_SHA256:-7f274219e8dd9c6d750a408a6edec1448b564f4b16ba2204c425c5b45cec2233} engine_restricted_schema_sha256=${ENGINE_RESTRICTED_SCHEMA_SHA256:-f854ba99260f29e324ab7446a8592996946038920ac9bcf17689d98d1ebc5e51} @@ -63,13 +63,13 @@ api_explorer_artifact=${API_EXPLORER_ARTIFACT:-api-explorer-1.1.20.tar.gz} api_explorer_artifact_sha256=${API_EXPLORER_ARTIFACT_SHA256:-c989ee12a518f6d9d2b23b1f17f48d781aacf6ec31fb65291dd7de9b1661badb} api_explorer_commit=${API_EXPLORER_COMMIT:-a586f0dc0aa50df579049a8d4bf26f04400fdf75} web_console_release_base_url=${WEB_CONSOLE_RELEASE_BASE_URL:-https://github.com/PastureStack/web-console/releases/download} -web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.200} -web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.200.tar.gz} -web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-97fe46c426cb83263fcfc7f5884c15ca90f7ec3ebec71d9ca023d8b7ad2665c0} -web_console_commit=${WEB_CONSOLE_COMMIT:-03e0d7b25b8dfcbc45baecf6d448834016aac649} -web_console_lock_sha256=${WEB_CONSOLE_LOCK_SHA256:-1797dd27bb7b1c6b86b8689435e916b03523a9fdf45c5178ec7c2ea05706d4bd} +web_console_release_tag=${WEB_CONSOLE_RELEASE_TAG:-1.6.201} +web_console_artifact=${WEB_CONSOLE_ARTIFACT:-web-console-1.6.201.tar.gz} +web_console_artifact_sha256=${WEB_CONSOLE_ARTIFACT_SHA256:-5bc8142d9322657c5cbae782756c3aa5a45b6537074de8749066492fa8947eb8} +web_console_commit=${WEB_CONSOLE_COMMIT:-24715414f143e2c58cf53084bd84cbea0922798f} +web_console_lock_sha256=${WEB_CONSOLE_LOCK_SHA256:-d255b74a835ab7c0224d12613baacdf021d8b30d46204a71c22d57f35725666f} web_console_runtime_sbom_artifact=${WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT:-web-console-${web_console_release_tag}-runtime.cdx.json} -web_console_runtime_sbom_sha256=${WEB_CONSOLE_RUNTIME_SBOM_SHA256:-0ce1f6b99e1e449c545d3d7df65302fdb58d1974fecea6477b20262c2148921c} +web_console_runtime_sbom_sha256=${WEB_CONSOLE_RUNTIME_SBOM_SHA256:-8b837eb330b9815df48b4256d3bf0c4e215864b996e2be40dd707ec532872503} catalog_service_release_base_url=${CATALOG_SERVICE_RELEASE_BASE_URL:-https://github.com/PastureStack/catalog-service/releases/download} catalog_service_version=${CATALOG_SERVICE_VERSION:-0.20.17} catalog_service_commit=${CATALOG_SERVICE_COMMIT:-2f70559e340865eb6243662ed33d8cbc7b1efa90} @@ -130,7 +130,7 @@ usage_telemetry_agent_archive_sha256=${USAGE_TELEMETRY_AGENT_ARCHIVE_SHA256:-6d4 usage_telemetry_agent_binary_sha256=${USAGE_TELEMETRY_AGENT_BINARY_SHA256:-90ed65f689c91801abd0cf482211c96a01e7c63ef695e4faecdf4713eff730be} supported_docker_range='~v1.12.3 || ~v1.13.0 || ~v17.03.0 || ~v17.06.0 || ~v17.09.0 || ~v17.12.0 || ~v18.03.0 || ~v18.06.0 || ~v18.09.0 || ~v19.03.2 || v24.0.9 || >=v29.4.1 <=v29.7.2 || v29.8.0' newest_docker_version=v29.8.0 -image=${IMAGE:-pasturestack-validation/server:v1.6.559} +image=${IMAGE:-pasturestack-validation/server:v1.6.560} build_options=() [[ "$revision" =~ ^[0-9a-f]{40}$ ]] diff --git a/server/build-component-candidate.sh b/server/build-component-candidate.sh index 609f482195c..0677de05d5e 100755 --- a/server/build-component-candidate.sh +++ b/server/build-component-candidate.sh @@ -18,10 +18,10 @@ for name in "${required[@]}"; do case "$name" in *_COMMIT) [[ ${!name} =~ ^[0-9a-f]{40}$ ]] ;; *) [[ ${!name} =~ ^[0-9a-f]{64}$ ]] ;; esac export "$name" done -export SERVER_RELEASE_TAG=${SERVER_RELEASE_TAG:-v1.6.559} -export ORCHESTRATION_ENGINE_RELEASE_TAG=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.359} +export SERVER_RELEASE_TAG=${SERVER_RELEASE_TAG:-v1.6.560} +export ORCHESTRATION_ENGINE_RELEASE_TAG=${ORCHESTRATION_ENGINE_RELEASE_TAG:-v0.183.360} export ORCHESTRATION_ENGINE_ARTIFACT=cattle.jar -export WEB_CONSOLE_RELEASE_TAG=${WEB_CONSOLE_RELEASE_TAG:-1.6.200} +export WEB_CONSOLE_RELEASE_TAG=${WEB_CONSOLE_RELEASE_TAG:-1.6.201} export WEB_CONSOLE_ARTIFACT="web-console-${WEB_CONSOLE_RELEASE_TAG}.tar.gz" export WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT=${WEB_CONSOLE_RUNTIME_SBOM_ARTIFACT:-web-console-${WEB_CONSOLE_RELEASE_TAG}-runtime.cdx.json} export WEBSOCKET_PROXY_VERSION=${WEBSOCKET_PROXY_VERSION:-0.23.18} diff --git a/server/console-broker/ai_audit.go b/server/console-broker/ai_audit.go new file mode 100644 index 00000000000..16b5a97d110 --- /dev/null +++ b/server/console-broker/ai_audit.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "time" +) + +const aiAuditQueryPath = "/v2-beta/pasturestack/ai-audit-queries" + +var aiAuditToken = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9_.:-]{0,159}$") + +type aiAuditReadContext struct{} + +func isAIAuditRead(ctx context.Context) bool { + return ctx.Value(aiAuditReadContext{}) == true +} + +// Fixed read workflow. Locators remain subject to the original audit owner; +// neither tool references nor caller-selected identity headers grant access. +type aiAuditInput struct { + KeyID string `json:"keyId,omitempty"` + EnvironmentID string `json:"environmentId,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + LastMinutes *int `json:"lastMinutes,omitempty"` + Decision string `json:"decision,omitempty"` + Outcome string `json:"outcome,omitempty"` + Operation string `json:"operation,omitempty"` + TargetType string `json:"targetType,omitempty"` + RequestID string `json:"requestId,omitempty"` + HTTPStatus *int `json:"httpStatus,omitempty"` + Limit *int `json:"limit,omitempty"` + Offset *int `json:"offset,omitempty"` + EventID string `json:"eventId,omitempty"` +} + +func aiAuditInvalid() error { + return &auditHTTPError{Status: http.StatusBadRequest, Code: "invalid_ai_audit_query", Message: "Use typed audit filters and a bounded time range"} +} + +func (input aiAuditInput) query(now time.Time) (auditQuery, error) { + values := url.Values{"sort": {"created"}, "order": {"desc"}, "limit": {"50"}} + for name, value := range map[string]string{ + "keyId": input.KeyID, "accountId": input.EnvironmentID, "eventId": input.EventID, + "decision": input.Decision, "outcome": input.Outcome, "operation": input.Operation, + "targetType": input.TargetType, "requestId": input.RequestID, + } { + if value != "" { + if !aiAuditToken.MatchString(value) { + return auditQuery{}, aiAuditInvalid() + } + values.Set(name, value) + } + } + if input.Decision != "" && input.Decision != "ALLOW" && input.Decision != "DENY" && input.Decision != "NOT_EVALUATED" { + return auditQuery{}, aiAuditInvalid() + } + if input.LastMinutes != nil { + if *input.LastMinutes < 1 || *input.LastMinutes > 366*24*60 || input.From != "" || input.To != "" { + return auditQuery{}, aiAuditInvalid() + } + input.From = now.Add(-time.Duration(*input.LastMinutes) * time.Minute).Format(time.RFC3339Nano) + input.To = now.Format(time.RFC3339Nano) + } + if input.From != "" || input.To != "" { + values.Set("timeScope", "range") + values.Set("created_gte", input.From) + values.Set("created_lte", input.To) + } + if input.Limit != nil { + if *input.Limit < 1 || *input.Limit > 50 { + return auditQuery{}, aiAuditInvalid() + } + values.Set("limit", strconv.Itoa(*input.Limit)) + } + if input.Offset != nil { + values.Set("offset", strconv.Itoa(*input.Offset)) + } + if input.HTTPStatus != nil { + if *input.HTTPStatus < 100 || *input.HTTPStatus > 599 { + return auditQuery{}, aiAuditInvalid() + } + values.Set("httpStatus", strconv.Itoa(*input.HTTPStatus)) + } + return parseAuditQuery(values, now) +} + +func (b *broker) serveAIAudit(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writer.Header().Set("Allow", "POST") + writeJSONError(writer, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") + return + } + // Presence restricts this first adapter to native Cookie transport. Only + // the existing Engine/native owner below authenticates that cookie. + cookie := request.Header.Get("Cookie") + if auditOwner(request.Context()) == nil && (cookie == "" || len(cookie) > 32768 || request.Header.Get("Authorization") != "") { + writeJSONError(writer, http.StatusForbidden, "ai_audit_cookie_required", "This audit workflow requires the current native browser session") + return + } + var input *aiAuditInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 8192)) + decoder.DisallowUnknownFields() + if request.URL.RawQuery != "" || decoder.Decode(&input) != nil || input == nil || decoder.Decode(&struct{}{}) != io.EOF { + writeAuditFailure(writer, aiAuditInvalid()) + return + } + query, err := input.query(time.Now().UTC()) + if err != nil { + writeAuditFailure(writer, err) + return + } + // No caller actor/project/forwarded-identity header is forwarded as a grant. + native := request.Clone(context.WithValue(request.Context(), aiAuditReadContext{}, true)) + native.Header = make(http.Header) + native.Header.Set("Cookie", cookie) + if auditOwner(request.Context()) != nil && request.Header.Get("Authorization") != "" { + native.Header.Set("Authorization", request.Header.Get("Authorization")) + } + if input.KeyID != "" { + owner, viewer, err := b.fetchKeyAuditAuthority(native.Context(), native, input.KeyID) + if err != nil { + writeAuditFailure(writer, err) + return + } + query.KeyID, query.KeyOwnerAccountID, query.ViewerAccountID = input.KeyID, owner, viewer + } + result, err := b.runAuditQuery(native.Context(), native, query) + if err != nil { + writeAuditFailure(writer, err) + return + } + if input.EventID != "" { + found := make([]map[string]any, 0, 1) + for _, row := range result.Records { + if auditString(row, "id") == input.EventID { + found = append(found, row) + } + } + if len(found) != 1 { + writeJSONError(writer, http.StatusNotFound, "ai_audit_event_unavailable", "The event is unavailable under this query and current permissions") + return + } + result.Records, result.Total, query.Offset = found, 1, 0 + } + start := query.Offset + if start > result.Total { + start = result.Total + } + end := start + query.Limit + if end > result.Total { + end = result.Total + } + rows := make([]map[string]any, 0, end-start) + for _, row := range result.Records[start:end] { + projected := safeAIAuditRecord(row, input.KeyID) + if !aiAuditToken.MatchString(auditString(projected, "id")) { + writeJSONError(writer, http.StatusBadGateway, "invalid_audit_response", "The audit owner returned an invalid event identity") + return + } + rows = append(rows, projected) + } + var next any + if end < result.Total { + next = end + } + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("X-Content-Type-Options", "nosniff") + _ = json.NewEncoder(writer).Encode(map[string]any{ + "resourceType": "auditLog", "data": rows, "total": result.Total, "nextOffset": next, + "range": map[string]string{"from": query.From.Format(time.RFC3339Nano), "to": query.To.Format(time.RFC3339Nano)}, + "payloadPolicy": "HUMAN_ONLY_NATIVE_AUDIT_UI", + }) +} + +func safeAIAuditRecord(record map[string]any, keyID string) map[string]any { + metadata := safeKeyAuditRecord(record, keyID) + result := map[string]any{"type": "auditLog", "payloadsIncluded": false} + for _, field := range []string{ + "id", "accountId", "authenticatedAsAccountId", "keyId", "eventType", "resourceType", "resourceId", + "authType", "decision", "outcome", "responseCode", "requestId", "targetType", "targetId", + "operation", "phase", "processId", "processName", "failureCode", + } { + if value := auditString(metadata, field); aiAuditToken.MatchString(value) { + result[field] = value + } + } + if created, valid := auditTimestamp(record); valid { + result["created"] = created.UTC().Format(time.RFC3339Nano) + } + for _, field := range []string{"httpStatus", "policyRevision"} { + value, err := strconv.ParseInt(auditString(metadata, field), 10, 64) + if err == nil && value >= 0 && value <= 9007199254740991 { + result[field] = value + } + } + if value, ok := metadata["preview"].(bool); ok { + result["preview"] = value + } + for _, field := range []string{"request", "response"} { + state := "not_projected" + if value, present := record[field+"Object"]; present && value != nil { + state = "present" + } + result[field+"PayloadPresence"] = state + } + if value := auditInteractionChannel(record); validAuditChannel(value) { + result["interactionChannel"] = value + } + return result +} diff --git a/server/console-broker/ai_audit_cookie_owner_test.go b/server/console-broker/ai_audit_cookie_owner_test.go new file mode 100644 index 00000000000..eda1ad26182 --- /dev/null +++ b/server/console-broker/ai_audit_cookie_owner_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "net/http" + "testing" +) + +func TestAIAuditCookieOwnerUsesSameSafeSourcesWithoutBorrowingAnyKey(t *testing.T) { + foreignKey := false + b := ownerTestBroker(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v2-beta/aiResourceViews" || r.Header.Get("Cookie") != "R_SESS=PRIVATE" || + r.Header.Get("Authorization") != "" || r.Header.Get("X-PastureStack-Key-Expected-Revision") != "" { + t.Error("cookie authority changed") + w.WriteHeader(500) + return + } + var input map[string]any + _ = json.NewDecoder(r.Body).Decode(&input) + category := auditString(input, "resourceType") + page := input["sourcePage"].(map[string]any) + project := auditString(page, "projectId") + authority := map[string]any{"actorId": "1a1", "accountId": firstNonEmpty(project, "1a1")} + if foreignKey { + authority["credentialId"] = "1c99" + } + rows := []map[string]any{} + if category == "project" { + rows = append(rows, map[string]any{"id": "1a5", "type": "project"}) + } + if category == "auditLog" && project != "" { + rows = append(rows, map[string]any{"id": "1al10", "accountId": "1a5", "created": "2026-10-01T12:00:00Z"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"resourceType": category, "data": rows, "nextMarker": nil, + "sourcePageResult": true, "nativeAuthority": authority}) + })) + input := ownerInput() + input.Authorization = "" + input.Cookie = "R_SESS=PRIVATE" + input.CredentialID = "" + input.CredentialKind = "" + input.Revision = 0 + status, body, _ := ownerFrame(t, b, input) + if status != 200 || body["total"] != float64(1) { + t.Fatal("cookie metadata workflow failed") + } + foreignKey = true + status, body, _ = ownerFrame(t, b, input) + if status == 200 || body["data"] != nil { + t.Fatal("cookie query borrowed another credential") + } +} diff --git a/server/console-broker/ai_audit_owner.go b/server/console-broker/ai_audit_owner.go new file mode 100644 index 00000000000..86b815a031f --- /dev/null +++ b/server/console-broker/ai_audit_owner.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// This envelope is accepted only by the fixed local CLI, never by an HTTP route. +// Credentials arrive through private stdin, not argv, environment or logs. +type aiAuditOwnerInput struct { + Query *aiAuditInput `json:"query"` + ActorID string `json:"actorId"` + Cookie string `json:"cookie,omitempty"` + Authorization string `json:"authorization,omitempty"` + CredentialID string `json:"credentialId,omitempty"` + CredentialKind string `json:"credentialKind,omitempty"` + Revision int64 `json:"revision,omitempty"` +} + +type aiAuditOwnerContext struct{} +type aiAuditOwnerState struct { + input aiAuditOwnerInput +} + +func auditOwner(ctx context.Context) *aiAuditOwnerState { + state, _ := ctx.Value(aiAuditOwnerContext{}).(*aiAuditOwnerState) + return state +} + +type boundedAuditResponse struct { + header http.Header + status int + body bytes.Buffer +} + +func (w *boundedAuditResponse) Header() http.Header { return w.header } +func (w *boundedAuditResponse) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } +} +func (w *boundedAuditResponse) Write(data []byte) (int, error) { + if w.status == 0 { + w.status = 200 + } + if w.body.Len()+len(data) > 512*1024 { + return 0, errors.New("audit output bound") + } + return w.body.Write(data) +} + +func runAIAuditOwnerCLI(stdin io.Reader, stdout io.Writer) int { + var input *aiAuditOwnerInput + decoder := json.NewDecoder(io.LimitReader(stdin, 65537)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || input == nil || decoder.Decode(&struct{}{}) != io.EOF || + input.Query == nil || !keyAuditIDPattern.MatchString(input.ActorID) || + (input.Cookie == "") == (input.Authorization == "") || len(input.Cookie)+len(input.Authorization) > 32768 || + input.Authorization != "" && (!strings.HasPrefix(input.Authorization, "Basic ") || + input.Revision < 1 || !keyAuditIDPattern.MatchString(input.CredentialID) || + input.CredentialKind != "apiKey" && input.CredentialKind != "apiKeyRestricted") { + return 2 // No private input or diagnostic is printed. + } + cfg := brokerConfig{UpstreamURL: "http://127.0.0.1:8082", SessionDialURL: "http://127.0.0.1:8080", + MaxSessions: 1, ReplayBytes: 65536, ActiveTTL: time.Hour, HistoryTTL: time.Hour, CleanupInterval: time.Hour} + b, err := newBroker(cfg, log.New(io.Discard, "", 0)) + if err != nil { + return 2 + } + defer b.close() + return executeAIAuditOwner(b, *input, stdout) +} + +func executeAIAuditOwner(b *broker, input aiAuditOwnerInput, stdout io.Writer) int { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + ctx = context.WithValue(ctx, aiAuditOwnerContext{}, &aiAuditOwnerState{input: input}) + payload, err := json.Marshal(input.Query) + if err != nil { + return 2 + } + request, _ := http.NewRequestWithContext(ctx, "POST", "http://127.0.0.1"+aiAuditQueryPath, bytes.NewReader(payload)) + if input.Cookie != "" { + request.Header.Set("Cookie", input.Cookie) + } + if input.Authorization != "" { + request.Header.Set("Authorization", input.Authorization) + } + response := &boundedAuditResponse{header: make(http.Header)} + b.serveAIAudit(response, request) + var body map[string]any + if json.Unmarshal(response.body.Bytes(), &body) != nil { + return 2 + } + if json.NewEncoder(stdout).Encode(map[string]any{"status": response.status, "body": body}) != nil { + return 2 + } + return 0 +} + +// Every source is still the native manager's current role/Key read. This adapter +// has no SQL, service credential or raw-payload fallback. +func (b *broker) fetchAIAuditSource(ctx context.Context, incoming *http.Request, category, resourceID, projectID, keyKind string, values url.Values) (auditUpstreamCollection, error) { + state := auditOwner(ctx) + if state == nil { + return auditUpstreamCollection{}, aiAuditInvalid() + } + page := map[string]any{"limit": 1000} + for native, typed := range map[string]string{"marker": "marker", "projectId": "projectId", "accountId": "accountId", "created_gte": "from", "created_lte": "to"} { + if value := values.Get(native); value != "" { + page[typed] = value + } + } + if projectID != "" { + page["projectId"] = projectID + } + if keyKind != "" { + page["keyKind"] = keyKind + } + body := map[string]any{"resourceType": category, "sourcePage": page} + if resourceID != "" { + body["resourceId"] = resourceID + } + payload, err := json.Marshal(body) + if err != nil { + return auditUpstreamCollection{}, aiAuditInvalid() + } + target := *b.upstreamURL + target.Path = "/v2-beta/aiResourceViews" + target.RawPath = "" + target.RawQuery = "" + target.Fragment = "" + request, err := http.NewRequestWithContext(ctx, "POST", target.String(), bytes.NewReader(payload)) + if err != nil { + return auditUpstreamCollection{}, aiAuditInvalid() + } + request.Header.Set("Content-Type", "application/json") + if state.input.Cookie != "" { + request.Header.Set("Cookie", state.input.Cookie) + } + if state.input.Authorization != "" { + request.Header.Set("Authorization", state.input.Authorization) + request.Header.Set("X-PastureStack-Key-Expected-Revision", strconv.FormatInt(state.input.Revision, 10)) + } + client := &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("audit source redirect denied") }} + response, err := client.Do(request) + if err != nil { + return auditUpstreamCollection{}, &auditHTTPError{Status: 502, Code: "audit_upstream_unavailable", Message: "Audit source unavailable"} + } + defer response.Body.Close() + if response.StatusCode != 200 && response.StatusCode != 201 { + status := response.StatusCode + if status != 401 && status != 403 && status != 404 && status != 422 { + status = 502 + } + return auditUpstreamCollection{}, &auditHTTPError{Status: status, Code: "audit_upstream_denied", Message: "Audit source denied"} + } + var result struct { + ResourceType string `json:"resourceType"` + Data []map[string]any `json:"data"` + NextMarker *string `json:"nextMarker"` + SourcePageResult bool `json:"sourcePageResult"` + Authority struct { + ActorID string `json:"actorId"` + AccountID string `json:"accountId"` + CredentialID string `json:"credentialId"` + Revision int64 `json:"credentialRevision"` + Kind string `json:"credentialKind"` + } `json:"nativeAuthority"` + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 4*1024*1024+1)) + decoder.UseNumber() + if decoder.Decode(&result) != nil || decoder.Decode(&struct{}{}) != io.EOF || !result.SourcePageResult || len(result.Data) > 1000 || + result.ResourceType != firstNonEmpty(keyKind, category) || result.Authority.ActorID != state.input.ActorID || + !keyAuditIDPattern.MatchString(result.Authority.AccountID) || + result.Authority.AccountID != firstNonEmpty(projectID, values.Get("projectId"), state.input.ActorID) || + state.input.Authorization != "" && (result.Authority.CredentialID != state.input.CredentialID || + result.Authority.Revision != state.input.Revision || result.Authority.Kind != state.input.CredentialKind) || + state.input.Authorization == "" && (result.Authority.CredentialID != "" || result.Authority.Revision != 0 || result.Authority.Kind != "") { + return auditUpstreamCollection{}, &auditHTTPError{Status: 502, Code: "invalid_key_authority", Message: "Audit source authority changed"} + } + collection := auditUpstreamCollection{Data: result.Data, Pagination: map[string]any{}, viewerAccountID: result.Authority.ActorID} + if result.NextMarker != nil { + if len(result.Data) == 0 || !keyAuditIDPattern.MatchString(*result.NextMarker) || + *result.NextMarker != auditString(result.Data[len(result.Data)-1], "id") || *result.NextMarker == values.Get("marker") { + return auditUpstreamCollection{}, &auditHTTPError{Status: 502, Code: "invalid_audit_response", Message: "Audit source pagination rejected"} + } + next := url.Values{} + for name, items := range values { + next[name] = append([]string(nil), items...) + } + next.Set("marker", *result.NextMarker) + collection.Pagination["next"] = "/v2-beta/auditlogs?" + next.Encode() + } + return collection, nil +} + +// Public route forwards one fixed typed Engine request. The Engine then invokes +// the private CLI and records the actual whole-query outcome in its durable sink. +func (b *broker) serveAIAuditGateway(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + writeJSONError(w, 405, "method_not_allowed", "Method not allowed") + return + } + if r.URL.RawQuery != "" { + writeAuditFailure(w, aiAuditInvalid()) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 8193)) + if err != nil || len(body) > 8192 { + writeAuditFailure(w, aiAuditInvalid()) + return + } + target := *b.upstreamURL + target.Path = "/v2-beta/aiAuditQueries" + target.RawPath = "" + target.RawQuery = "" + target.Fragment = "" + request, err := http.NewRequestWithContext(r.Context(), "POST", target.String(), bytes.NewReader(body)) + if err != nil { + writeJSONError(w, 502, "audit_query_failed", "Audit query unavailable") + return + } + for _, header := range []string{"Cookie", "Authorization", "X-PastureStack-Key-Expected-Revision"} { + if value := r.Header.Get(header); value != "" { + request.Header.Set(header, value) + } + } + request.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 50 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("audit gateway redirect denied") }} + response, err := client.Do(request) + if err != nil { + writeJSONError(w, 502, "audit_query_failed", "Audit query unavailable") + return + } + defer response.Body.Close() + result, err := io.ReadAll(io.LimitReader(response.Body, 512*1024+1)) + if err != nil || len(result) > 512*1024 { + writeJSONError(w, 502, "audit_query_failed", "Audit query unavailable") + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + for _, header := range []string{"X-PastureStack-Key-Purpose", "X-PastureStack-Key-Revision"} { + if value := response.Header.Get(header); value != "" { + w.Header().Set(header, value) + } + } + w.WriteHeader(response.StatusCode) + _, _ = w.Write(result) +} + +func aiAuditCLI() bool { + if len(os.Args) == 2 && os.Args[1] == "--ai-audit-query" { + os.Exit(runAIAuditOwnerCLI(os.Stdin, os.Stdout)) + return true + } + return false +} diff --git a/server/console-broker/ai_audit_owner_test.go b/server/console-broker/ai_audit_owner_test.go new file mode 100644 index 00000000000..6d8f9ee25a5 --- /dev/null +++ b/server/console-broker/ai_audit_owner_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func ownerTestBroker(t *testing.T, handler http.Handler) *broker { + t.Helper() + upstream := httptest.NewServer(handler) + t.Cleanup(upstream.Close) + b, err := newBroker(brokerConfig{UpstreamURL: upstream.URL, SessionDialURL: upstream.URL, MaxSessions: 1, ReplayBytes: 65536, + ActiveTTL: time.Hour, HistoryTTL: time.Hour, CleanupInterval: time.Hour}, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(b.close) + return b +} + +func ownerInput() aiAuditOwnerInput { + return aiAuditOwnerInput{Query: &aiAuditInput{From: "2026-10-01T00:00:00Z", To: "2026-10-02T00:00:00Z", EnvironmentID: "1a5"}, + ActorID: "1a1", Authorization: "Basic PRIVATE_VERIFIED_CREDENTIAL", CredentialID: "1c8", CredentialKind: "apiKey", Revision: 3} +} + +func ownerFrame(t *testing.T, b *broker, input aiAuditOwnerInput) (int, map[string]any, string) { + t.Helper() + var output bytes.Buffer + if executeAIAuditOwner(b, input, &output) != 0 { + t.Fatal("owner produced no complete frame") + } + var result struct { + Status int + Body map[string]any + } + if json.Unmarshal(output.Bytes(), &result) != nil { + t.Fatal("owner frame invalid") + } + return result.Status, result.Body, output.String() +} + +func sourceFixture(t *testing.T, mutate func(string, map[string]any, map[string]any) int) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v2-beta/aiResourceViews" || r.URL.RawQuery != "" || + r.Header.Get("Authorization") != "Basic PRIVATE_VERIFIED_CREDENTIAL" || + r.Header.Get("X-PastureStack-Key-Expected-Revision") != "3" || + r.Header.Get("Cookie") != "" || r.Header.Get("X-Forwarded-User") != "" { + t.Error("source escaped exact private caller binding") + w.WriteHeader(500) + return + } + var input map[string]any + if json.NewDecoder(r.Body).Decode(&input) != nil { + t.Fatal("source input invalid") + } + category := auditString(input, "resourceType") + page := input["sourcePage"].(map[string]any) + project := auditString(page, "projectId") + authority := map[string]any{"actorId": "1a1", "accountId": firstNonEmpty(project, "1a1"), "credentialId": "1c8", "credentialKind": "apiKey", "credentialRevision": 3} + rows := []map[string]any{} + switch category { + case "project": + rows = append(rows, map[string]any{"id": "1a5", "type": "project", "name": "Visible"}) + case "account": + rows = append(rows, map[string]any{"id": "1a1", "type": "account", "name": "Viewer"}) + case "apiKey": + if project == "" { + w.WriteHeader(403) + return + } + rows = append(rows, map[string]any{"id": "1c9", "type": page["keyKind"], "accountId": "1a5"}) + case "auditLog": + if project != "" { + rows = append(rows, map[string]any{"id": "1al10", "type": "auditLog", "accountId": "1a5", "authenticatedAsAccountId": "1a1", + "keyId": "1c9", "created": "2026-10-01T12:00:00Z", "eventType": "api.service.update", "decision": "ALLOW", "outcome": "SUCCEEDED"}) + } + default: + t.Error("unexpected native source") + w.WriteHeader(500) + return + } + result := map[string]any{"resourceType": firstNonEmpty(auditString(page, "keyKind"), category), "data": rows, "nextMarker": nil, + "nativeAuthority": authority, "sourcePageResult": true} + if mutate != nil { + if status := mutate(category, page, result); status != 0 { + w.WriteHeader(status) + return + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) + }) +} + +func TestAIAuditPrivateOwnerUsesFourNativeSourcesAndOriginalKeyAuthority(t *testing.T) { + seen := map[string]int{} + b := ownerTestBroker(t, sourceFixture(t, func(category string, page, result map[string]any) int { seen[category]++; return 0 })) + input := ownerInput() + input.Query.KeyID = "1c9" + status, body, raw := ownerFrame(t, b, input) + if status != 200 || body["total"] != float64(1) || strings.Contains(raw, "PRIVATE") || body["payloadPolicy"] != "HUMAN_ONLY_NATIVE_AUDIT_UI" { + t.Fatal("bounded original aggregation or private boundary failed") + } + for _, category := range []string{"project", "account", "apiKey", "auditLog"} { + if seen[category] == 0 { + t.Fatal("source not checked:", category) + } + } + row := body["data"].([]any)[0].(map[string]any) + if row["keyId"] != "1c9" || row["payloadsIncluded"] != false || row["requestPayloadPresence"] != "not_projected" { + t.Fatal("key query projection changed") + } +} + +func TestAIAuditPrivateSourcesFailClosedOnEveryAuthorityDriftOrDeniedPage(t *testing.T) { + for _, category := range []string{"project", "account", "apiKey", "auditLog"} { + for _, failure := range []string{"deny", "actor", "revision", "credential", "crossProject"} { + t.Run(category+"/"+failure, func(t *testing.T) { + b := ownerTestBroker(t, sourceFixture(t, func(actual string, page, result map[string]any) int { + if actual != category { + return 0 + } + authority := result["nativeAuthority"].(map[string]any) + switch failure { + case "deny": + return 403 + case "actor": + authority["actorId"] = "1a2" + case "revision": + authority["credentialRevision"] = 4 + case "credential": + authority["credentialId"] = "1c90" + case "crossProject": + authority["accountId"] = "1a99" + } + return 0 + })) + input := ownerInput() + input.Query.KeyID = "1c9" + status, body, raw := ownerFrame(t, b, input) + // Account names are optional in the original owner, but an + // identity/revision mismatch is never an optional 403. + if category == "account" && failure == "deny" { + if status != 200 { + t.Fatal("original optional account-name behavior changed") + } + return + } + if status == 200 || body["data"] != nil || strings.Contains(raw, "PRIVATE") { + t.Fatal("partial aggregate escaped source failure") + } + }) + } + } +} + +func TestAIAuditPrivatePaginationRechecksRevisionAndNeverReturnsPartialRows(t *testing.T) { + calls := 0 + b := ownerTestBroker(t, sourceFixture(t, func(category string, page, result map[string]any) int { + if category != "auditLog" || page["projectId"] == nil { + return 0 + } + calls++ + if calls == 1 { + result["nextMarker"] = "1al10" + } else { + result["nativeAuthority"].(map[string]any)["credentialRevision"] = 4 + } + return 0 + })) + status, body, _ := ownerFrame(t, b, ownerInput()) + if status == 200 || body["data"] != nil || calls != 2 { + t.Fatal("later revision returned partial success") + } +} + +func TestAIAuditPublicGatewayIsFixedTypedAndForwardsOnlyVerifiedResponseMetadata(t *testing.T) { + calls := 0 + b := ownerTestBroker(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != "POST" || r.URL.Path != "/v2-beta/aiAuditQueries" || r.URL.RawQuery != "" || + r.Header.Get("Authorization") != "Basic caller" || r.Header.Get("X-PastureStack-Key-Expected-Revision") != "3" || + r.Header.Get("X-Forwarded-User") != "" || r.Header.Get("X-API-Project-ID") != "" { + t.Error("gateway escaped fixed route/authority") + } + w.Header().Set("X-PastureStack-Key-Purpose", "ai-bridge") + w.Header().Set("X-PastureStack-Key-Revision", "3") + _, _ = w.Write([]byte(`{"resourceType":"auditLog","data":[]}`)) + })) + r := httptest.NewRequest("POST", aiAuditQueryPath, strings.NewReader("{}")) + r.Header.Set("Authorization", "Basic caller") + r.Header.Set("X-PastureStack-Key-Expected-Revision", "3") + r.Header.Set("X-Forwarded-User", "forged") + r.Header.Set("X-API-Project-ID", "foreign") + w := httptest.NewRecorder() + b.ServeHTTP(w, r) + if w.Code != 200 || calls != 1 || w.Header().Get("X-PastureStack-Key-Revision") != "3" { + t.Fatal("fixed typed gateway failed") + } + r = httptest.NewRequest("POST", aiAuditQueryPath+"?url=http://foreign/", strings.NewReader("{}")) + w = httptest.NewRecorder() + b.ServeHTTP(w, r) + if w.Code != 400 || calls != 1 { + t.Fatal("arbitrary query forwarded") + } +} + +func TestAIAuditCLIRejectsUnverifiedUnknownOrTruncatedPrivateInputWithoutOutput(t *testing.T) { + for _, input := range []string{`{}`, `{"query":{},"actorId":"1a1","url":"http://foreign/","cookie":"secret"}`, + `{"query":{},"actorId":"1a1","cookie":"a","authorization":"b"}`, `{"query":{}`, strings.Repeat("x", 65538)} { + var output bytes.Buffer + if runAIAuditOwnerCLI(strings.NewReader(input), &output) == 0 || output.Len() != 0 { + t.Fatal("invalid private input was accepted or disclosed") + } + } +} diff --git a/server/console-broker/ai_audit_test.go b/server/console-broker/ai_audit_test.go new file mode 100644 index 00000000000..90c4ac91def --- /dev/null +++ b/server/console-broker/ai_audit_test.go @@ -0,0 +1,236 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const aiAuditRange = `"from":"2026-10-01T00:00:00Z","to":"2026-10-02T00:00:00Z"` + +// These tests call the isolated query owner. Public fixed forwarding has a separate gateway test. +func postAIAudit(t *testing.T, server *httptest.Server, payload, cookie, authorization string) (int, map[string]any, string) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, server.URL+aiAuditQueryPath, strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if cookie != "" { + request.Header.Set("Cookie", cookie) + } + if authorization != "" { + request.Header.Set("Authorization", authorization) + } + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, 65536)) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal("typed response was not JSON") + } + return response.StatusCode, body, string(raw) +} + +func TestAIAuditNativeOwnerQueryPaginationAndSafeDetail(t *testing.T) { + upstream := newAuditUpstreamFixture(t) + for _, id := range []string{"audit1", "audit2"} { + row := auditTestRecord(id, "2026-10-01T12:00:00Z", "1p1", "1a1", "api.service.update", "TokenAuth", "PRIVATE_DESCRIPTION") + row["decision"], row["outcome"], row["httpStatus"] = "DENY", "FAILED", 403 + row["responseObject"] = map[string]any{"jwt": "PRIVATE_RESPONSE"} + upstream.auditRecords = append(upstream.auditRecords, row) + } + upstream.auditRecords = append(upstream.auditRecords, + auditTestRecord("foreign", "2026-10-01T12:00:00Z", "foreign-project", "1a1", "event", "TokenAuth", "PRIVATE_FOREIGN")) + server := newAuditTestBroker(t, upstream, true) + payload := `{` + aiAuditRange + `,"environmentId":"1p1","decision":"DENY","limit":1}` + status, body, raw := postAIAudit(t, server, payload, "R_SESS=authorized", "") + if status != 200 || body["total"] != float64(2) || body["nextOffset"] != float64(1) { + t.Fatal("native authorized query/page did not match") + } + if strings.Contains(raw, "PRIVATE_") || strings.Contains(raw, "must-not-export") || strings.Contains(raw, "requestObject") { + t.Fatal("private native payload reached typed metadata") + } + first := body["data"].([]any)[0].(map[string]any) + if first["payloadsIncluded"] != false || first["responsePayloadPresence"] != "present" || first["httpStatus"] != float64(403) { + t.Fatal("safe payload policy or HTTP metadata missing") + } + status, second, _ := postAIAudit(t, server, `{`+aiAuditRange+`,"environmentId":"1p1","decision":"DENY","limit":1,"offset":1}`, "R_SESS=authorized", "") + if status != 200 || second["nextOffset"] != nil || second["data"].([]any)[0].(map[string]any)["id"] == first["id"] { + t.Fatal("native second page was not distinct and terminal") + } + status, detail, _ := postAIAudit(t, server, `{`+aiAuditRange+`,"environmentId":"1p1","eventId":"`+first["id"].(string)+`"}`, "R_SESS=authorized", "") + if status != 200 || len(detail["data"].([]any)) != 1 { + t.Fatal("exact authorized event detail failed") + } + status, _, _ = postAIAudit(t, server, `{`+aiAuditRange+`,"environmentId":"1p1","eventId":"foreign"}`, "R_SESS=authorized", "") + if status != 404 { + t.Fatal("foreign event escaped original owner query scope") + } +} + +func TestAIAuditKeyFilterReusesLiveAuthorityAndCannotReadAnotherKey(t *testing.T) { + fixture, server := newKeyAuditFixture(t, true) + fixture.keyID = "1c99" + fixture.records = []map[string]any{ + keyAuditRecord("own-event", "1c99", "1p1", "2026-10-01T12:00:00Z"), + keyAuditRecord("other-event", "1c100", "1p1", "2026-10-01T12:00:00Z"), + } + payload := `{` + aiAuditRange + `,"keyId":"1c99","environmentId":"1p1","decision":"ALLOW"}` + status, body, raw := postAIAudit(t, server, payload, "R_SESS=authorized", "") + if status != 200 || len(body["data"].([]any)) != 1 || len(fixture.keyQueries) == 0 || strings.Contains(raw, "secret") { + t.Fatal("exact Key audit owner/projection failed") + } + status, _, _ = postAIAudit(t, server, `{`+aiAuditRange+`,"keyId":"1c99","eventId":"other-event"}`, "R_SESS=authorized", "") + if status != 404 { + t.Fatal("cross-Key detail was not refused") + } + fixture.keyStatus = http.StatusForbidden + status, _, _ = postAIAudit(t, server, payload, "R_SESS=authorized", "") + if status != 403 { + t.Fatal("Key visibility loss was not rechecked") + } +} + +func TestAIAuditStrictInputsAndNativeCookieBoundary(t *testing.T) { + upstream := newAuditUpstreamFixture(t) + server := newAuditTestBroker(t, upstream, true) + for _, payload := range []string{ + `null`, `{"url":"https://outside.invalid"}`, `{"actorId":"1a1"}`, + `{"limit":51}`, `{"offset":20000}`, `{"lastMinutes":0}`, + `{"lastMinutes":1,"from":"2026-10-01T00:00:00Z"}`, + `{"from":"2026-10-01T00:00:00"}`, `{"decision":"ALLOW OR DENY"}`, + `{} {}`, `{"requestId":"` + strings.Repeat("x", 9000) + `"}`, + } { + status, _, _ := postAIAudit(t, server, payload, "R_SESS=authorized", "") + if status != 400 && status != 422 { + t.Fatal("invalid typed input was accepted") + } + } + for _, credentials := range [][2]string{{"", ""}, {"", "Basic not-a-cookie"}, {"R_SESS=authorized", "Basic not-a-cookie"}} { + status, body, _ := postAIAudit(t, server, "{}", credentials[0], credentials[1]) + if status != 403 || body["code"] != "ai_audit_cookie_required" { + t.Fatal("unsupported transport did not fail closed") + } + } + if len(upstream.auditQueries) != 0 { + t.Fatal("rejected inputs/transport reached audit data") + } +} + +func TestAIAuditSessionAndEnvironmentLossNeverReturnPartialRows(t *testing.T) { + upstream := newAuditUpstreamFixture(t) + server := newAuditTestBroker(t, upstream, true) + for _, test := range []struct { + cookie, payload string + status int + }{ + {"R_SESS=expired", "{}", 401}, + {"R_SESS=authorized", `{"environmentId":"unavailable"}`, 403}, + } { + status, body, _ := postAIAudit(t, server, test.payload, test.cookie, "") + if status != test.status || body["data"] != nil { + t.Fatal("current authorization rejection returned success/partial data") + } + } + if len(upstream.auditQueries) != 0 { + t.Fatal("failed authority read reached audit data") + } +} + +func TestAIAuditRejectsRedirectBeforeAnyForeignSourceCall(t *testing.T) { + hits := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + w.WriteHeader(http.StatusOK) + })) + defer foreign.Close() + upstream := &auditUpstreamFixture{server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, foreign.URL, http.StatusFound) + }))} + defer upstream.server.Close() + server := newAuditTestBroker(t, upstream, true) + status, _, _ := postAIAudit(t, server, "{}", "R_SESS=authorized", "") + if status != 502 || hits != 0 { + t.Fatal("typed native audit followed an authority redirect") + } +} + +func TestAIAuditLegacyHumanAuditPayloadContractIsUnchanged(t *testing.T) { + upstream := newAuditUpstreamFixture(t) + upstream.auditRecords = []map[string]any{ + auditTestRecord("legacy", "2026-10-01T12:00:00Z", "1p1", "1a1", "event", "TokenAuth", "human description"), + } + server := newAuditTestBroker(t, upstream, true) + response := performAuditRequest(t, server, auditQueryPath+"?createdFrom=2026-10-01T00:00:00Z&createdTo=2026-10-02T00:00:00Z") + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if response.StatusCode != 200 || !strings.Contains(string(body), "requestObject") { + t.Fatal("ordinary human audit detail was changed by the typed adapter") + } +} + +func TestAIAuditOriginalOwnerScanLimitReturnsNoPartialProjection(t *testing.T) { + upstream := newAuditUpstreamFixture(t) + record := map[string]any{"id": "bounded", "accountId": "1p1", "created": "2026-10-01T12:00:00Z"} + upstream.auditRecords = make([]map[string]any, auditMaximumScanRows+1) + for index := range upstream.auditRecords { + upstream.auditRecords[index] = record + } + server := newAuditTestBroker(t, upstream, true) + status, body, _ := postAIAudit(t, server, `{`+aiAuditRange+`}`, "R_SESS=authorized", "") + if status != 422 || body["code"] != "result_set_too_large" || body["data"] != nil { + t.Fatal("original owner's scan bound was bypassed or returned partial success") + } +} + +func TestAIAuditCallerHeadersCannotSupplyOwnerIdentityOrProject(t *testing.T) { + calls := 0 + upstream := &auditUpstreamFixture{server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Cookie") != "R_SESS=authorized" || r.Header.Get("X-Forwarded-User") != "" || + r.Header.Get("X-Api-Auth-Header") != "" || r.Header.Get("X-API-Project-ID") != "" { + t.Error("new route forwarded caller-selected authority") + w.WriteHeader(500) + return + } + w.Header().Set("X-API-USER-ID", "1a1") + switch r.URL.Path { + case "/v2-beta/projects": + writeFixtureCollection(w, []map[string]any{{"id": "1p1", "type": "project"}}, "") + case "/v2-beta/accounts": + writeFixtureCollection(w, nil, "") + case "/v2-beta/auditlogs": + writeFixtureCollection(w, []map[string]any{ + auditTestRecord("event", "2026-10-01T12:00:00Z", "1p1", "1a1", "event", "TokenAuth", "private"), + }, "") + default: + t.Error("unexpected native owner route") + w.WriteHeader(500) + } + }))} + defer upstream.server.Close() + server := newAuditTestBroker(t, upstream, true) + request, _ := http.NewRequest("POST", server.URL+aiAuditQueryPath, strings.NewReader(`{`+aiAuditRange+`,"environmentId":"1p1"}`)) + request.Header.Set("Cookie", "R_SESS=authorized") + request.Header.Set("X-Forwarded-User", "forged-actor") + request.Header.Set("X-Api-Auth-Header", "forged-identity") + request.Header.Set("X-API-Project-ID", "foreign-project") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != 200 || calls != 3 { + t.Fatal("native owner identity was not kept independent of caller headers") + } +} diff --git a/server/console-broker/audit.go b/server/console-broker/audit.go index cf90cf0f0fe..683009dedf8 100644 --- a/server/console-broker/audit.go +++ b/server/console-broker/audit.go @@ -265,7 +265,7 @@ func (b *broker) fetchAuditProjects(ctx context.Context, incoming *http.Request, if err != nil { return nil, "", err } - if keyQuery { + if keyQuery || auditOwner(ctx) != nil { if len(projectPage.Data) > auditUpstreamPageSize { return nil, "", &auditHTTPError{Status: http.StatusUnprocessableEntity, Code: "result_set_too_large", Message: "Current environment permissions exceeded the bounded authority query"} } @@ -366,6 +366,9 @@ func (b *broker) runAuditQuery(ctx context.Context, incoming *http.Request, quer // memberships at once. The Key view must query each live authorized // environment explicitly rather than silently showing personal rows only. records, err = b.fetchKeyAuditLogs(ctx, incoming, query, allowedProjects, upstreamValues) + } else if auditOwner(ctx) != nil { + query.ViewerAccountID = viewer + records, err = b.fetchKeyAuditLogs(ctx, incoming, query, allowedProjects, upstreamValues) } else { records, err = b.fetchAllAuditLogs(ctx, incoming, upstreamValues) } @@ -494,6 +497,13 @@ func (b *broker) fetchAuditPage(ctx context.Context, incoming *http.Request, pat } func (b *broker) fetchAuditPagePolicy(ctx context.Context, incoming *http.Request, path string, values url.Values, keyQuery bool) (auditUpstreamCollection, error) { + if auditOwner(ctx) != nil { + category := map[string]string{"/v2-beta/projects": "project", "/v2-beta/accounts": "account", "/v2-beta/auditlogs": "auditLog"}[path] + if category == "" { + return auditUpstreamCollection{}, aiAuditInvalid() + } + return b.fetchAIAuditSource(ctx, incoming, category, "", "", "", values) + } target := *b.upstreamURL target.Path = path target.RawPath = "" @@ -508,7 +518,7 @@ func (b *broker) fetchAuditPagePolicy(ctx context.Context, incoming *http.Reques request.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 20 * time.Second} - if keyQuery { + if keyQuery || isAIAuditRead(ctx) { client.CheckRedirect = func(*http.Request, []*http.Request) error { return errors.New("API key audit redirects are not allowed") } diff --git a/server/console-broker/audit_test.go b/server/console-broker/audit_test.go index ad74d5bd1cf..4fa0ad4cda5 100644 --- a/server/console-broker/audit_test.go +++ b/server/console-broker/audit_test.go @@ -126,7 +126,7 @@ func writeFixtureCollection(writer http.ResponseWriter, records []map[string]any }) } -func newAuditTestBroker(t *testing.T, fixture *auditUpstreamFixture) *httptest.Server { +func newAuditTestBroker(t *testing.T, fixture *auditUpstreamFixture, ownerOnly ...bool) *httptest.Server { t.Helper() cfg := brokerConfig{ ListenAddress: ":0", UpstreamURL: fixture.server.URL, SessionDialURL: fixture.server.URL, @@ -136,7 +136,13 @@ func newAuditTestBroker(t *testing.T, fixture *auditUpstreamFixture) *httptest.S if err != nil { t.Fatal(err) } - server := httptest.NewServer(instance) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(ownerOnly) > 0 && ownerOnly[0] && r.URL.Path == aiAuditQueryPath { + instance.serveAIAudit(w, r) + return + } + instance.ServeHTTP(w, r) + })) t.Cleanup(func() { server.Close() instance.close() diff --git a/server/console-broker/broker.go b/server/console-broker/broker.go index 2d1a2a59af7..20c3e4df8b0 100644 --- a/server/console-broker/broker.go +++ b/server/console-broker/broker.go @@ -254,6 +254,11 @@ func (b *broker) ServeHTTP(writer http.ResponseWriter, request *http.Request) { return } + if request.URL.Path == aiAuditQueryPath { + b.serveAIAuditGateway(writer, request) + return + } + if isKeyAuditPath(request.URL.Path) { b.serveKeyAudit(writer, request) return diff --git a/server/console-broker/keyaudit.go b/server/console-broker/keyaudit.go index 27e78e3c90c..3fa29bf1989 100644 --- a/server/console-broker/keyaudit.go +++ b/server/console-broker/keyaudit.go @@ -191,6 +191,26 @@ func (b *broker) fetchKeyAuditAuthorityInProject(ctx context.Context, incoming * return errors.New("API key authority redirects are not allowed") }} for index, kind := range []string{"apiKey", "apiKeyRestricted"} { + if auditOwner(ctx) != nil { + collection, err := b.fetchAIAuditSource(ctx, incoming, "apiKey", keyID, projectID, kind, url.Values{}) + if err != nil { + var denied *auditHTTPError + if errors.As(err, &denied) && denied.Status == 404 && index == 0 { + continue + } + if errors.As(err, &denied) && (denied.Status == 403 || denied.Status == 404) { + return "", "", &auditHTTPError{Status: 403, Code: "key_audit_access_lost", Message: "API key unavailable"} + } + return "", "", err + } + if len(collection.Data) != 1 || auditString(collection.Data[0], "id") != keyID || + !strings.EqualFold(auditString(collection.Data[0], "type"), kind) || + !keyAuditIDPattern.MatchString(auditString(collection.Data[0], "accountId")) || + projectID != "" && (auditString(collection.Data[0], "accountId") != projectID || collection.viewerAccountID != principal) { + return "", "", &auditHTTPError{Status: 502, Code: "invalid_key_authority", Message: "API key authority invalid"} + } + return auditString(collection.Data[0], "accountId"), collection.viewerAccountID, nil + } target.Path, target.RawPath, target.RawQuery, target.Fragment = "/v2-beta/"+strings.ToLower(kind)+"/"+keyID, "", "", "" if projectID != "" { target.RawQuery = url.Values{"projectId": {projectID}}.Encode() diff --git a/server/console-broker/keyaudit_test.go b/server/console-broker/keyaudit_test.go index 723564818df..c089393ff15 100644 --- a/server/console-broker/keyaudit_test.go +++ b/server/console-broker/keyaudit_test.go @@ -42,7 +42,7 @@ type keyAuditFixture struct { redirectHits int } -func newKeyAuditFixture(t *testing.T) (*keyAuditFixture, *httptest.Server) { +func newKeyAuditFixture(t *testing.T, ownerOnly ...bool) (*keyAuditFixture, *httptest.Server) { t.Helper() fixture := &keyAuditFixture{ keyID: "1a99", owner: "1a1", viewer: "1a1", keyStatus: http.StatusOK, @@ -169,7 +169,7 @@ func newKeyAuditFixture(t *testing.T) (*keyAuditFixture, *httptest.Server) { } })) t.Cleanup(upstream.server.Close) - return fixture, newAuditTestBroker(t, upstream) + return fixture, newAuditTestBroker(t, upstream, ownerOnly...) } func keyAuditRecord(id, keyID, project, created string) map[string]any { diff --git a/server/console-broker/main.go b/server/console-broker/main.go index 44b4599aa81..ab7801790f6 100644 --- a/server/console-broker/main.go +++ b/server/console-broker/main.go @@ -13,6 +13,9 @@ import ( ) func main() { + if aiAuditCLI() { + return + } cfg, err := configFromEnvironment() if err != nil { log.Fatalf("invalid console broker configuration: %s", safeLogValue(err)) diff --git a/server/security/openvex.json b/server/security/openvex.json index 47e85a8895c..f9660d8d1f2 100644 --- a/server/security/openvex.json +++ b/server/security/openvex.json @@ -1,9 +1,9 @@ { "@context": "https://openvex.dev/ns/v0.2.0", - "@id": "https://github.com/PastureStack/server/security/openvex/v1.6.559", + "@id": "https://github.com/PastureStack/server/security/openvex/v1.6.560", "author": "PastureStack Security", - "timestamp": "2026-10-29T23:21:31Z", - "version": 72, + "timestamp": "2026-10-30T01:53:41Z", + "version": 73, "statements": [ { "vulnerability": { diff --git a/server/security/vendor-pending.json b/server/security/vendor-pending.json index 3c5d6e368cc..1b9cfb27cd9 100644 --- a/server/security/vendor-pending.json +++ b/server/security/vendor-pending.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "release": "v1.6.559", + "release": "v1.6.560", "generatedAt": "2026-10-29T09:57:33.809116+00:00", "policy": { "allowedSeverities": [