From de36aa7eab37ce84f08cc4ca3c0159f5299e1a3d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Thu, 5 Nov 2026 20:05:07 +0800 Subject: [PATCH] Add native Project and load-balancer metadata with bounded audit evidence --- .github/workflows/security-release-gate.yml | 40 + COMPATIBILITY.md | 13 + Dockerfile | 6 +- Dockerfile.dapper | 6 +- README.md | 6 +- code/framework/api-pub-sub-jetty/pom.xml | 2 +- code/framework/api-pub-sub/pom.xml | 2 +- code/framework/api/pom.xml | 2 +- .../api/auth/ClientToolAuditEvent.java | 10 +- code/framework/archaius/pom.xml | 2 +- code/framework/async/pom.xml | 2 +- code/framework/auditing/pom.xml | 2 +- .../iaas/api/auditing/AuditServiceImpl.java | 6 +- .../auditing/AiServiceUpgradeAuditTest.java | 62 + code/framework/db-loader/pom.xml | 2 +- code/framework/deferred/pom.xml | 2 +- code/framework/encryption/pom.xml | 2 +- code/framework/engine/pom.xml | 2 +- .../engine/process/ProcessAuthorization.java | 51 +- ...ProcessAuthorizationProjectUpdateTest.java | 50 + code/framework/eventing/pom.xml | 2 +- code/framework/events/pom.xml | 2 +- code/framework/extension-spring/pom.xml | 2 +- code/framework/extension/pom.xml | 2 +- code/framework/java-server/pom.xml | 2 +- code/framework/jmx/pom.xml | 2 +- code/framework/jooq/pom.xml | 2 +- code/framework/json/pom.xml | 2 +- code/framework/launcher/pom.xml | 2 +- code/framework/lock/pom.xml | 2 +- code/framework/logback/pom.xml | 2 +- code/framework/managed-context/pom.xml | 2 +- code/framework/metrics/pom.xml | 2 +- code/framework/module/pom.xml | 2 +- code/framework/object/pom.xml | 2 +- code/framework/pool/pom.xml | 2 +- code/framework/resource-monitor/pom.xml | 2 +- code/framework/schema/pom.xml | 2 +- code/framework/server/pom.xml | 2 +- code/framework/spring/pom.xml | 2 +- code/framework/system-task/pom.xml | 2 +- code/framework/token/pom.xml | 2 +- code/framework/utils/pom.xml | 2 +- code/iaas/agent-instance/pom.xml | 2 +- code/iaas/agent-server/pom.xml | 2 +- code/iaas/agent/pom.xml | 2 +- code/iaas/allocator/pom.xml | 2 +- code/iaas/api-logic/pom.xml | 2 +- code/iaas/archaius-management/pom.xml | 2 +- code/iaas/auth-logic/pom.xml | 2 +- .../api/auth/ai/AiToolCallEventService.java | 6 +- .../auth/ai/view/AiResourceViewService.java | 4 + .../apikey/ApiKeyProcessAuthorization.java | 14 +- .../auth/change/AiProjectMetadataChange.java | 54 + .../change/AiResourceChangeDescriptor.java | 2 + .../auth/change/AiResourceChangeDomain.java | 98 +- .../change/AiResourceChangePlanService.java | 10 +- .../change/AiResourceChangePlanStore.java | 36 + .../AiResourceChangeProcessObserver.java | 141 +- .../auth/projects/ProjectAccessService.java | 74 + .../auth/projects/ProjectResourceManager.java | 2 +- .../auth/ai/AiToolCallEventServiceTest.java | 47 +- .../ai/view/AiProjectCollectionOwnerTest.java | 229 ++ .../AiLoadBalancerMetadataChangeTest.java | 410 +++ .../change/AiProjectMetadataChangeTest.java | 371 +++ .../NativeProjectQueuedOwnerTest.java | 156 ++ code/iaas/bootstrap/pom.xml | 2 +- code/iaas/config-item/api/pom.xml | 2 +- code/iaas/config-item/common/pom.xml | 2 +- code/iaas/config-item/server/pom.xml | 2 +- code/iaas/engine-jooq/pom.xml | 2 +- code/iaas/events/pom.xml | 2 +- code/iaas/external-handler/pom.xml | 2 +- code/iaas/ha/pom.xml | 2 +- code/iaas/healthcheck/pom.xml | 2 +- code/iaas/labels/pom.xml | 2 +- code/iaas/logic-common/pom.xml | 2 +- code/iaas/logic/pom.xml | 2 +- code/iaas/metadata/pom.xml | 2 +- code/iaas/model/pom.xml | 2 +- code/iaas/resource-pool/pom.xml | 2 +- code/iaas/service-discovery/api/pom.xml | 2 +- code/iaas/service-discovery/server/pom.xml | 2 +- code/iaas/ssh-common/pom.xml | 2 +- code/iaas/storage-service/pom.xml | 2 +- code/iaas/task-jooq/pom.xml | 2 +- code/implementation/activity-log/pom.xml | 2 +- .../agent-instance-impl/pom.xml | 2 +- code/implementation/docker/api/pom.xml | 2 +- code/implementation/docker/common/pom.xml | 2 +- code/implementation/docker/compute/pom.xml | 2 +- code/implementation/docker/machine/pom.xml | 2 +- code/implementation/docker/storage/pom.xml | 6 +- code/implementation/extension-api/pom.xml | 2 +- code/implementation/hazelcast/common/pom.xml | 2 +- .../implementation/hazelcast/eventing/pom.xml | 2 +- code/implementation/hazelcast/lock/pom.xml | 2 +- code/implementation/host-api/pom.xml | 2 +- code/implementation/host-stats/pom.xml | 2 +- code/implementation/register/pom.xml | 2 +- code/implementation/sample-setup/pom.xml | 2 +- code/implementation/settings-api/pom.xml | 2 +- .../simulator/agent-connection/pom.xml | 2 +- code/implementation/simulator/storage/pom.xml | 2 +- code/implementation/system-stack/pom.xml | 2 +- code/implementation/vm/pom.xml | 2 +- code/meta-parent/pom.xml | 2 +- code/packaging/app-config/pom.xml | 2 +- .../io/cattle/platform/app/IaasApiConfig.java | 5 + code/packaging/app/pom.xml | 2 +- code/packaging/bundle/pom.xml | 2 +- code/packaging/dev/pom.xml | 2 +- code/packaging/meta/pom.xml | 2 +- code/parent/pom.xml | 2 +- .../orchestration-engine-0.183.372.md | 11 + pom.xml | 2 +- .../content/schema/admin/admin-auth.json | 2 + .../schema/base/aiResourceChangePlan.json | 2 +- .../content/schema/base/aiToolCallEvent.json | 4 +- .../content/schema/project/project-auth.json | 2 + resources/content/schema/user/user-auth.json | 2 + resources/pom.xml | 2 +- scripts/api-key-operation-contract.json | 2424 +++++++++-------- scripts/api-key-operation-inventory.json | 589 ++-- .../api-key-operation-review-0.183.372.json | 190 ++ scripts/build | 2 +- scripts/check-cattle-jdk25-standalone | 2 +- scripts/check-pasturestack-source | 4 +- scripts/check-release-artifact | 2 +- scripts/install-ubuntu-security-updates.sh | 2 +- scripts/test-apt-security-bootstrap.py | 59 +- scripts/test-install-snapshot-packages.py | 4 +- 132 files changed, 3912 insertions(+), 1480 deletions(-) create mode 100644 code/framework/engine/src/test/java/io/cattle/platform/engine/process/ProcessAuthorizationProjectUpdateTest.java create mode 100644 code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChange.java create mode 100644 code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/view/AiProjectCollectionOwnerTest.java create mode 100644 code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiLoadBalancerMetadataChangeTest.java create mode 100644 code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChangeTest.java create mode 100644 code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/projects/NativeProjectQueuedOwnerTest.java create mode 100644 docs/releases/orchestration-engine-0.183.372.md create mode 100644 scripts/api-key-operation-review-0.183.372.json diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index 36b31c0396..e7cffa5e62 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -313,6 +313,46 @@ jobs: 'versionRootRechecksEventProofOriginalOwnerSourceAndFrozenStackBeforeMetadata', 'versionRootRejectsUnknownPathsMethodsQueriesAndContradictoryRouteShapesWithSafe403', }, + 'io.cattle.platform.engine.process.ProcessAuthorizationProjectUpdateTest': { + 'capturedNativeProjectUpdateProofCannotBeForgedRetargetedOrGrowFields', + }, + 'io.cattle.platform.iaas.api.auth.projects.NativeProjectQueuedOwnerTest': { + 'ordinaryNativeOwnerFullLegacyCustomAndLiveDenialsUseManagerCaptureWithoutAi', + }, + 'io.cattle.platform.iaas.api.auth.change.AiProjectMetadataChangeTest': { + 'accountRootLockUsesTheActualAccountRowAndDetectsConcurrentMetadataChange', + 'customReadKeyDoesNotBorrowGlobalOwnerWriteAuthority', + 'durableAccountUpdateBindingUsesSelectedRootAndSeparateNativePrincipal', + 'metadataOwnerUsesNativeProjectOwnerAndGlobalSchemaWithoutGrantingObjects', + 'projectDescriptorIsMetadataOnlyAndKeepsNativeTextBounds', + 'selectedAccountIdentityAndScalarSnapshotNeverCopyConfigurationOrCredentials', + 'selectedProjectReadAndPreflightUseFreshGlobalNativeOwnerThenRestoreSelectedContext', + 'typedProjectProcessCannotFallbackWhenDurableBindingIsMissingOrMismatched', + 'unknownProjectExecutionKeepsOnlyItsOriginalDurableProcessWithoutReplayOrTerminalAuthority', + }, + 'io.cattle.platform.iaas.api.auditing.AiServiceUpgradeAuditTest': { + 'bindingReasonDetailSurvivesJsonOutboxReplayAndCanonicalProjectionAsClientEvidence', + 'privateProjectCaptureKeepsSelectedAuditScopeForPrimaryAndQueueOutcomes', + }, + 'io.cattle.platform.iaas.api.auth.ai.AiToolCallEventServiceTest': { + 'bindingDetailCapabilityKeepsLegacyConstructorAndDurableReadonlyAcksCompatible', + 'invalidReasonDetailsNeverReachDurableSink', + }, + 'io.cattle.platform.iaas.api.auth.ai.view.AiProjectCollectionOwnerTest': { + 'adminNativeFullAndLegacySeeNonMembershipProjectsWithoutWideningMemberOrReadonly', + 'allIsPrivateToTypedProjectCollectionAndNeverChangesSingleGetOrOtherTypesOrAcceptsModelAll', + 'exactProjectCustomDenyAndReadonlyKeysRetainExactSetsThroughNativeCommonCollectionFilter', + }, + 'io.cattle.platform.iaas.api.auth.change.AiLoadBalancerMetadataChangeTest': { + 'onlyLoadBalancerMetadataFieldsAndIntentAreAdmitted', + 'freshNativeStackAndKeyChecksProtectTheSparsePutAndPrivateParentRead', + 'scalarSnapshotsAndStackContextCasExcludeConfigurationAndRejectScopeOrMovement', + 'loadBalancerPlanKeepsHumanApprovalSnapshotCasRevocationAndOneClaim', + 'loadBalancerMetadataLocksActualServiceRowAndDetectsNativeScalarChanges', + 'queuedNativeServiceUpdateUsesExactLbKindUuidStackAndCapturedProcess', + 'queuedNativeOwnerRetainsFullLegacySchemaRevisionAndRevocationChecks', + 'ordinaryNativeV1V2FullAndLegacyCaptureNeverEnterTheAiMandatoryBranch', + }, } suites = [] observed = {} diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ae0dae62cc..ebeb85ab3d 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,5 +1,18 @@ # Compatibility Contract +## Engine 0.183.372 candidate — not published + +Engine 0.183.372 candidate; not published or runtime qualified. + +Selected Project name/description updates use the native Project queued owner, fresh role/schema/Key limits, normal human approval, revision/CAS and the original durable claim. Visible nonselected Projects remain queryable but require normal context selection and a fresh reference before editing. Ordinary Full, legacy and custom native Project updates share the native owner; no generic HTTP tool or membership/network/auth change is added. + +Typed Project discovery uses the native all=true option and the common per-row Key collection filter. The optional fixed client-reported binding detail uses the existing audit start ACK capability; canonical evidence remains client_reported with NOT_EVALUATED/REPORTED, never a backend authorization denial. Six fixed role-field metadata entries permit only that optional input and read-only capability. HTTP tuple and native route/action grants remain unchanged. + +Load-balancer metadata adds only name and description through the existing native Service owner, fresh target/Stack reads, Key ceilings, human approval and one durable process claim. Ports, lbConfig, certificates, scaling, lifecycle and children are unchanged; the eight new source controls are not native acceptance. + +All 173 prior required identities are preserved and 26 new exact methods are registered (199 total). Source composition checks are separate from normal full App/Spring/schema/security CI, original artifact qualification and native business acceptance, which remain pending. Existing security findings remain disclosed; workflow SLSA is not supplied. Matching Web/Bridge and Server integration are separate; no whole-243 completion is claimed. + + ## Engine 0.183.371 published Typed Host metadata visibility and receipt checks use the native Host model; diff --git a/Dockerfile b/Dockerfile index ff11d510ab..c46065d899 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,7 +82,7 @@ RUN set -eux; \ printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\nAcquire::http::Pipeline-Depth "0";\nAcquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt";\nAcquire::https::Verify-Peer "true";\nAcquire::https::Verify-Host "true";\nAcquire::AllowInsecureRepositories "false";\nAPT::Get::AllowUnauthenticated "false";\n' > /etc/apt/apt.conf.d/80pasturestack-retries; \ printf 'Types: deb\nURIs: https://snapshot.ubuntu.com/ubuntu/%s\nSuites: resolute-security\nComponents: main\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nSnapshot: no\n' \ "${UBUNTU_APT_SECURITY_SNAPSHOT}" > /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ set --; \ for package in libexpat1 libsqlite3-0 libssh2-1t64; do \ test "$(grep -c "^${package}=" /licenses/ubuntu-security-packages.lock)" -eq 1; \ @@ -100,7 +100,7 @@ RUN set -eux; \ test "$(dpkg-query -W -f='${Version}' "${package}")" = "${UBUNTU_APT_OPENSSL_VERSION}"; \ done; \ rm -f /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ set --; \ for package in libpython3.14-minimal libpython3.14-stdlib python3.14-minimal python3.14 python3.14-venv curl libcurl3t64-gnutls libcurl4t64 ca-certificates libbrotli1 libpcre2-8-0 libpcre2-posix3; do \ architecture=amd64; \ @@ -144,7 +144,7 @@ RUN set -eux; \ xz-utils="${UBUNTU_APT_XZ_UTILS_VERSION}"; \ printf 'Types: deb\nURIs: https://snapshot.ubuntu.com/ubuntu/%s\nSuites: resolute-security\nComponents: main\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nSnapshot: no\n' \ "${UBUNTU_APT_SECURITY_SNAPSHOT}" > /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ for package in libssl3t64 openssl openssl-provider-legacy; do \ test "$(dpkg-query -W -f='${Version}' "${package}")" = "${UBUNTU_APT_OPENSSL_VERSION}"; \ done; \ diff --git a/Dockerfile.dapper b/Dockerfile.dapper index 79a49a440a..ddaaf8ed94 100644 --- a/Dockerfile.dapper +++ b/Dockerfile.dapper @@ -99,7 +99,7 @@ RUN set -eux; \ printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\nAcquire::http::Pipeline-Depth "0";\nAcquire::https::CaInfo "/etc/ssl/certs/ca-certificates.crt";\nAcquire::https::Verify-Peer "true";\nAcquire::https::Verify-Host "true";\nAcquire::AllowInsecureRepositories "false";\nAPT::Get::AllowUnauthenticated "false";\n' > /etc/apt/apt.conf.d/80pasturestack-retries; \ printf 'Types: deb\nURIs: https://snapshot.ubuntu.com/ubuntu/%s\nSuites: resolute-security\nComponents: main\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nSnapshot: no\n' \ "${UBUNTU_APT_SECURITY_SNAPSHOT}" > /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ set --; \ for package in libexpat1 libsqlite3-0 libssh2-1t64; do \ test "$(grep -c "^${package}=" /licenses/ubuntu-security-packages.lock)" -eq 1; \ @@ -117,7 +117,7 @@ RUN set -eux; \ test "$(dpkg-query -W -f='${Version}' "${package}")" = "${UBUNTU_APT_OPENSSL_VERSION}"; \ done; \ rm -f /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ set --; \ for package in libpython3.14-minimal libpython3.14-stdlib python3.14-minimal python3.14 python3.14-venv curl libcurl3t64-gnutls libcurl4t64 ca-certificates libbrotli1 libpcre2-8-0 libpcre2-posix3; do \ architecture=amd64; \ @@ -161,7 +161,7 @@ RUN set -eux; \ xz-utils="${UBUNTU_APT_XZ_UTILS_VERSION}"; \ printf 'Types: deb\nURIs: https://snapshot.ubuntu.com/ubuntu/%s\nSuites: resolute-security\nComponents: main\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nSnapshot: no\n' \ "${UBUNTU_APT_SECURITY_SNAPSHOT}" > /etc/apt/sources.list.d/pasturestack-security.sources; \ - apt-get update; \ + apt-get update --error-on=any; \ for package in libssl3t64 openssl openssl-provider-legacy; do \ test "$(dpkg-query -W -f='${Version}' "${package}")" = "${UBUNTU_APT_OPENSSL_VERSION}"; \ done; \ diff --git a/README.md b/README.md index 9843009a6c..129ad2122e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Rancher 1.6 ecosystem. It is not affiliated with Rancher Labs or SUSE. This fork of [`rancher/cattle`](https://github.com/rancher/cattle) preserves upstream history, authorship, licenses, and dependency notices. +## Next candidate: Engine 0.183.372 + +Selected-Project and load-balancer name/description metadata, native queued-owner authorization and fixed client-reported audit detail are in [source and CI preparation](docs/releases/orchestration-engine-0.183.372.md). Publication and native acceptance are pending; use the published release below. + ## Latest published release: Engine 0.183.371 [Engine 0.183.371](https://github.com/PastureStack/orchestration-engine/releases/tag/v0.183.371) @@ -177,7 +181,7 @@ Before publishing an Engine artifact, run the complete package gate: ```sh bash scripts/check-cattle-jdk25-full-package -ENGINE_VERSION=0.183.371 bash scripts/build --release +ENGINE_VERSION=0.183.372 bash scripts/build --release bash scripts/check-release-artifact dist/artifacts/cattle.jar ``` diff --git a/code/framework/api-pub-sub-jetty/pom.xml b/code/framework/api-pub-sub-jetty/pom.xml index 12c737806a..b2f5e23535 100644 --- a/code/framework/api-pub-sub-jetty/pom.xml +++ b/code/framework/api-pub-sub-jetty/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/api-pub-sub/pom.xml b/code/framework/api-pub-sub/pom.xml index 7cd38434fd..b9f02dce7d 100644 --- a/code/framework/api-pub-sub/pom.xml +++ b/code/framework/api-pub-sub/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/api/pom.xml b/code/framework/api/pom.xml index 2ad250b951..dcbf693d3b 100644 --- a/code/framework/api/pom.xml +++ b/code/framework/api/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/api/src/main/java/io/cattle/platform/api/auth/ClientToolAuditEvent.java b/code/framework/api/src/main/java/io/cattle/platform/api/auth/ClientToolAuditEvent.java index e0c6133dd3..e2faffe9d7 100644 --- a/code/framework/api/src/main/java/io/cattle/platform/api/auth/ClientToolAuditEvent.java +++ b/code/framework/api/src/main/java/io/cattle/platform/api/auth/ClientToolAuditEvent.java @@ -4,7 +4,12 @@ /** Bounded client evidence, never proof of authorization or business completion. */ public record ClientToolAuditEvent(String requestId, String command, String phase, - String status, String reason, String correlationId) { + String status, String reason, String correlationId, String reasonDetail) { + public static final String BRIDGE_PROJECT_BINDING_REQUIRED = "BRIDGE_PROJECT_BINDING_REQUIRED"; + public ClientToolAuditEvent(String requestId, String command, String phase, + String status, String reason, String correlationId) { + this(requestId, command, phase, status, reason, correlationId, null); + } public static final Set COMMANDS = Set.of("find_stacks", "find_services", "get_service", "preview_service_upgrade", "apply_service_upgrade", "get_operation", "update_service_description", "list_capabilities", "find_resources", "get_resource", "preview_resource_change", "apply_resource_change", "unknown"); @@ -19,6 +24,9 @@ public static boolean validId(String value) { public boolean valid() { return validId(requestId) && command != null && COMMANDS.contains(command) && status != null && STATUSES.contains(status) && reason != null && REASONS.contains(reason) + && (reasonDetail == null || BRIDGE_PROJECT_BINDING_REQUIRED.equals(reasonDetail) + && "preview_resource_change".equals(command) && "result".equals(phase) + && "needs_user".equals(status) && "NEEDS_USER".equals(reason)) && (("start".equals(phase) && "pending".equals(status) && correlationId == null) || ("result".equals(phase) && !"pending".equals(status) && validId(correlationId))); } diff --git a/code/framework/archaius/pom.xml b/code/framework/archaius/pom.xml index eb44a73e08..e4e6384699 100644 --- a/code/framework/archaius/pom.xml +++ b/code/framework/archaius/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-meta-parent - 0.183.371 + 0.183.372 ../../meta-parent/pom.xml diff --git a/code/framework/async/pom.xml b/code/framework/async/pom.xml index 115146baeb..3a79ef8a42 100644 --- a/code/framework/async/pom.xml +++ b/code/framework/async/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/auditing/pom.xml b/code/framework/auditing/pom.xml index aa658c0ca5..4f7a81376a 100644 --- a/code/framework/auditing/pom.xml +++ b/code/framework/auditing/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/auditing/src/main/java/io/cattle/platform/iaas/api/auditing/AuditServiceImpl.java b/code/framework/auditing/src/main/java/io/cattle/platform/iaas/api/auditing/AuditServiceImpl.java index 8baf734b46..3ec8691176 100644 --- a/code/framework/auditing/src/main/java/io/cattle/platform/iaas/api/auditing/AuditServiceImpl.java +++ b/code/framework/auditing/src/main/java/io/cattle/platform/iaas/api/auditing/AuditServiceImpl.java @@ -58,7 +58,7 @@ public class AuditServiceImpl implements AuditService, ApiKeyAuditSink, ApiReque public static final Set API_KEY_AUDIT_FIELDS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( "eventId", "keyId", "decision", "outcome", "httpStatus", "responseCode", "requestId", "actor", "targetType", "targetId", "operation", "policyRevision", "reason", "phase", "preview", "processId", "processName", "hostUuid", "failureCode", - "clientReportedCommand", "clientReportedPhase", "clientReportedStatus", "clientReportedReason", + "clientReportedCommand", "clientReportedPhase", "clientReportedStatus", "clientReportedReason", "clientReportedReasonDetail", "clientCorrelationId", "evidenceSource", "channel"))); private static final Set SENSITIVE_AUDIT_FIELDS = Set.of( "secretvalue", "secretvalues", "password", "newsecret", "oldsecret", "adminaccountpassword", "serviceaccountpassword", @@ -374,7 +374,8 @@ private void recordServiceDecision(ApiRequest request, Policy policy, String act @Override public void recordResourceChangeDecision(ApiRequest request, Policy policy, String action, String receiptId, String resourceType, Long resourceId, boolean allowed, String reason) { - if (!Set.of("stack", "service", "container", "externalService", "dnsService", "certificate", "host", "aiResourceChangePlan") + if (!Set.of("stack", "service", "container", "externalService", "dnsService", "certificate", "host", "project", + "loadBalancerService", "aiResourceChangePlan") .contains(resourceType)) throw new IllegalArgumentException("Invalid resource-change audit target"); recordTypedResourceDecision(request, policy, action, receiptId, "aichange", resourceType, resourceId, @@ -434,6 +435,7 @@ public void recordClientToolEvent(ApiRequest request, Policy policy, ClientToolA data.put("evidenceSource", "client_reported"); data.put("channel", "webmcp-client-report"); data.put("clientReportedCommand", report.command()); data.put("clientReportedPhase", report.phase()); data.put("clientReportedStatus", report.status()); data.put("clientReportedReason", report.reason()); + if (report.reasonDetail() != null) data.put("clientReportedReasonDetail", report.reasonDetail()); if (report.correlationId() != null) data.put("clientCorrelationId", report.correlationId()); Map event = new HashMap<>(); event.put("resourceType", "aiToolCallEvent"); event.put("resourceId", null); diff --git a/code/framework/auditing/src/test/java/io/cattle/platform/iaas/api/auditing/AiServiceUpgradeAuditTest.java b/code/framework/auditing/src/test/java/io/cattle/platform/iaas/api/auditing/AiServiceUpgradeAuditTest.java index fb457757dc..5eb756d3b8 100644 --- a/code/framework/auditing/src/test/java/io/cattle/platform/iaas/api/auditing/AiServiceUpgradeAuditTest.java +++ b/code/framework/auditing/src/test/java/io/cattle/platform/iaas/api/auditing/AiServiceUpgradeAuditTest.java @@ -25,6 +25,68 @@ public class AiServiceUpgradeAuditTest { @Rule public TemporaryFolder temporary = new TemporaryFolder(); + @Test public void privateProjectCaptureKeepsSelectedAuditScopeForPrimaryAndQueueOutcomes() throws Exception { + // Auth-logic's durableAccountUpdateBinding test owns private capture and the native owner. + // This module owns canonical projection of that public audit/queue contract. + List> delivered = new ArrayList<>(); List envelopes = new ArrayList<>(); + AuditServiceImpl audit = service(delivered, envelopes); ApiRequest request = new ApiRequest(null, null); + request.setType("aiResourceChangePlan"); request.setMethod("POST"); + request.setRequestObject(Map.of("name", "after", "description", "PRIVATE-DESCRIPTION")); + request.setAttribute("apiKey.audit.keyId", "1c7"); request.setAttribute("apiKey.audit.policyRevision", 3L); + request.setAttribute("apiKey.audit.verifiedPrincipalAccountId", 42L); + String id = "aichange-11111111-1111-1111-1111-111111111111"; + audit.recordResourceChangeDecision(request, policy(), "apply", id, "project", 77L, true, "ResourceChangeAccepted"); + audit.recordResourceChangeDecision(request, policy(), "apply", id, "project", 77L, false, "OwnerPermissionDenied"); + var metadata = new HashMap(Map.of("accountId", 77L, "principalAccountId", 42L, + "keyId", "1c7", "policyRevision", 3L, "targetType", "project", "targetId", "77", + "requestId", id, "operation", "update")); + var execution = new io.cattle.platform.engine.process.LaunchConfiguration("account.update", "account", "77", null, 0, Map.of()); + audit.afterExecution(execution, metadata, io.cattle.platform.engine.process.ExitReason.DONE); + audit.afterExecution(execution, metadata, io.cattle.platform.engine.process.ExitReason.AUTHORIZATION_DENIED); + assertEquals(4, delivered.size()); assertEquals(4, envelopes.size()); + for (int i = 0; i < delivered.size(); i++) { + Map data = delivered.get(i); Object[] envelope = envelopes.get(i); + assertEquals("project", envelope[0]); assertEquals(77L, envelope[1]); + assertEquals(77L, envelope[4]); assertEquals(42L, envelope[5]); + assertEquals("1c7", data.get("keyId")); assertEquals("3", data.get("policyRevision").toString()); + assertEquals(i % 2 == 0 ? "ALLOW" : "DENY", data.get("decision")); + assertFalse(data.toString().contains("after")); assertFalse(data.toString().contains("PRIVATE")); + } + } + + @Test public void bindingReasonDetailSurvivesJsonOutboxReplayAndCanonicalProjectionAsClientEvidence() throws Exception { + List> delivered = new ArrayList<>(); List envelopes = new ArrayList<>(); + AuditServiceImpl service = service(delivered, envelopes); AuditLogDao working = service.auditLogDao; + service.auditLogDao = (AuditLogDao) Proxy.newProxyInstance(AuditLogDao.class.getClassLoader(), new Class[]{AuditLogDao.class}, + (proxy, method, args) -> { throw new IllegalStateException("temporary unavailable"); }); + String id = "aitool-11111111-1111-1111-1111-111111111111"; + ApiRequest request = new ApiRequest(null, null); request.setRequestObject(Map.of("password", "PRIVATE-PAYLOAD")); + service.recordClientToolEvent(request, policy(), new ClientToolAuditEvent(id, "preview_resource_change", "result", + "needs_user", "NEEDS_USER", id, ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED)); + assertTrue(delivered.isEmpty()); + try (var paths = Files.walk(temporary.getRoot().toPath(), 2)) { + var pending = paths.filter(path -> path.toString().endsWith(".json")).limit(3).toList(); assertEquals(1, pending.size()); + String json = Files.readString(pending.getFirst()); + assertTrue(json.contains("clientReportedReasonDetail")); assertTrue(json.contains(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED)); + assertFalse(json.contains("PRIVATE-PAYLOAD")); assertFalse(json.contains("requestObject")); + } + service.auditLogDao = working; + service.recordClientToolEvent(policy(), new ClientToolAuditEvent(id, "get_service", "start", "pending", "NONE", null)); + assertEquals(2, delivered.size()); Map detail = delivered.getFirst(); + assertEquals(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED, detail.get("clientReportedReasonDetail")); + assertEquals("client_reported", detail.get("evidenceSource")); assertEquals("NOT_EVALUATED", detail.get("decision")); + assertEquals("REPORTED", detail.get("outcome")); assertEquals("NEEDS_USER", detail.get("clientReportedReason")); + assertFalse(delivered.getLast().containsKey("clientReportedReasonDetail")); + assertEquals(77L, envelopes.getFirst()[4]); assertEquals(42L, envelopes.getFirst()[5]); + var row = new io.cattle.platform.core.model.tables.records.AuditLogRecord(); row.setResourceType("aiToolCallEvent"); + row.setData(Map.of("fields", detail)); + var output = new io.github.ibuildthecloud.gdapi.model.impl.ResourceImpl("1al1", "auditLog", + new HashMap<>(Map.of("requestObject", "PRIVATE", "responseObject", "PRIVATE"))); + new AuditLogOutPutFilter().filter(new ApiRequest(null, null), row, output); + assertEquals(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED, output.getFields().get("clientReportedReasonDetail")); + assertEquals("client_reported", output.getFields().get("evidenceSource")); assertFalse(output.getFields().containsKey("requestObject")); + assertFalse(output.getFields().containsKey("responseObject")); + } @Test public void cookieQueryCompletionProjectsCanonicalMetadataButNeverRawPayload() { var row = new io.cattle.platform.core.model.tables.records.AuditLogRecord(); row.setResourceType("aiAuditQuery"); diff --git a/code/framework/db-loader/pom.xml b/code/framework/db-loader/pom.xml index aafe0604e8..67e2c8e1d8 100644 --- a/code/framework/db-loader/pom.xml +++ b/code/framework/db-loader/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/deferred/pom.xml b/code/framework/deferred/pom.xml index fa49d2de04..72a1a7bf79 100644 --- a/code/framework/deferred/pom.xml +++ b/code/framework/deferred/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/encryption/pom.xml b/code/framework/encryption/pom.xml index a7dfdbe2e8..f33ef50e80 100644 --- a/code/framework/encryption/pom.xml +++ b/code/framework/encryption/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/engine/pom.xml b/code/framework/engine/pom.xml index 736a2130fa..0c3c718ea5 100644 --- a/code/framework/engine/pom.xml +++ b/code/framework/engine/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/engine/src/main/java/io/cattle/platform/engine/process/ProcessAuthorization.java b/code/framework/engine/src/main/java/io/cattle/platform/engine/process/ProcessAuthorization.java index 42456fee70..ee46f05b3c 100644 --- a/code/framework/engine/src/main/java/io/cattle/platform/engine/process/ProcessAuthorization.java +++ b/code/framework/engine/src/main/java/io/cattle/platform/engine/process/ProcessAuthorization.java @@ -19,7 +19,7 @@ public final class ProcessAuthorization { private static final Set FIELDS = Set.of("keyId", "policyRevision", "principalAccountId", "accountId", "requestId", "operation", "targetType", "targetId", "preview", "requestMethod", "requestAction", "requestLink", "requestCollection", "projectCreation", "aliasServiceUuid", "aliasMapUuid", "aliasConsumedServiceId", "aliasConsumedServiceUuid", - "aliasStackId", "aliasStackUuid", "aliasConsumedStackId", "aliasConsumedStackUuid"); + "aliasStackId", "aliasStackUuid", "aliasConsumedStackId", "aliasConsumedStackUuid", "projectUpdateUuid", "projectUpdateFields"); private ProcessAuthorization() { } @@ -28,6 +28,12 @@ public static Map sanitize(Map source) { if (source != null) { for (String name : FIELDS) { Object value = source.get(name); + if (name.equals("projectUpdateFields")) { + if (value instanceof List fields && !fields.isEmpty() && fields.size() <= 128 + && fields.stream().allMatch(field -> field instanceof String text && text.matches("[A-Za-z][A-Za-z0-9_]{0,127}")) + && Set.copyOf(fields).size() == fields.size()) result.put(name, List.copyOf(fields)); + continue; + } if ((name.equals("requestCollection") || name.equals("projectCreation")) && !(value instanceof Boolean)) { continue; } @@ -76,7 +82,8 @@ public static void prepare(LaunchConfiguration config, List binding = new HashMap<>(); @@ -129,7 +136,45 @@ private static boolean capturedExecution(LaunchConfiguration config) { || !proof.containsKey("accountId") || !sameAccount(proof.get("accountId"), config.getAccountId()) || !(proof.get("authorization") instanceof Map raw)) return false; Map metadata = metadata(config); - return !metadata.isEmpty() && metadata.equals(raw) && sanitize(raw).equals(raw); + return !metadata.isEmpty() && metadata.equals(raw) && sanitize(raw).equals(raw) + && (!projectUpdateCaptured(config, metadata) || projectUpdateExecution(config, metadata)); + } + + /** Select mandatory native proof without treating a missing or modified proof as an old generic job. */ + public static boolean projectUpdateCaptured(LaunchConfiguration config, Map metadata) { + if (metadata.containsKey("projectUpdateUuid") || metadata.containsKey("projectUpdateFields")) return true; + Object proof = config.getData() == null ? null : config.getData().get(CAPTURED_EXECUTION_KEY); + return proof instanceof Map binding && binding.get("authorization") instanceof Map authorization + && (authorization.containsKey("projectUpdateUuid") || authorization.containsKey("projectUpdateFields")); + } + + /** Native scheduling data only: actual changed field names, never values or authorization flags. */ + public static List projectUpdateFields(LaunchConfiguration config) { + if (config.getData() == null) return List.of(); + Set reserved = Set.of(DATA_KEY, COMPLETION_PENDING_KEY, VERIFIED_CHILD_KEY, CAPTURED_EXECUTION_KEY, "old", "type"); + java.util.ArrayList fields = new java.util.ArrayList<>(); + for (String field : config.getData().keySet()) { + if (reserved.contains(field)) continue; + if (!field.matches("[A-Za-z][A-Za-z0-9_]{0,127}")) return List.of(); + fields.add(field); + } + if (fields.isEmpty() || fields.size() > 128 || !(config.getData().get("old") instanceof Map old) + || !old.keySet().equals(Set.copyOf(fields))) return List.of(); + fields.sort(String::compareTo); return List.copyOf(fields); + } + + public static boolean projectUpdateExecution(LaunchConfiguration config, Map metadata) { + return "project".equals(metadata.get("targetType")) && "update".equals(metadata.get("operation")) + && "PUT".equals(metadata.get("requestMethod")) && Boolean.FALSE.equals(metadata.get("requestCollection")) + && Boolean.FALSE.equals(metadata.get("preview")) && !metadata.containsKey("requestAction") && !metadata.containsKey("requestLink") + && "account".equals(config.getResourceType()) && "account.update".equals(config.getProcessName()) && config.getAccountId() == null + && Objects.equals(config.getResourceId(), metadata.get("targetId")) + && metadata.get("accountId") instanceof Number project && project.longValue() > 0 + && Long.toString(project.longValue()).equals(config.getResourceId()) + && metadata.get("projectUpdateUuid") instanceof String uuid && !uuid.isBlank() + && metadata.get("projectUpdateFields") instanceof List fields && !fields.isEmpty() + && fields.equals(projectUpdateFields(config)) + && (config.getData().get("type") == null || "project".equals(config.getData().get("type"))); } private static boolean sameAccount(Object expected, Object actual) { diff --git a/code/framework/engine/src/test/java/io/cattle/platform/engine/process/ProcessAuthorizationProjectUpdateTest.java b/code/framework/engine/src/test/java/io/cattle/platform/engine/process/ProcessAuthorizationProjectUpdateTest.java new file mode 100644 index 0000000000..548f3a9796 --- /dev/null +++ b/code/framework/engine/src/test/java/io/cattle/platform/engine/process/ProcessAuthorizationProjectUpdateTest.java @@ -0,0 +1,50 @@ +package io.cattle.platform.engine.process; + +import static org.junit.Assert.*; +import io.cattle.platform.engine.manager.impl.ProcessRecord; +import java.util.*; +import org.junit.Test; + +public class ProcessAuthorizationProjectUpdateTest { + @Test public void capturedNativeProjectUpdateProofCannotBeForgedRetargetedOrGrowFields() { + Map metadata = new LinkedHashMap<>(Map.of("keyId", "1c7", "principalAccountId", 42L, "accountId", 77L, + "targetType", "project", "targetId", "77", "requestId", UUID.randomUUID().toString(), "requestMethod", "PUT", + "requestCollection", false, "operation", "update", "preview", false)); + var sourceFields = new ArrayList<>(List.of("name", "virtualMachine")); + metadata.put("projectUpdateUuid", "native-uuid-77"); metadata.put("projectUpdateFields", sourceFields); + Map payload = new HashMap<>(Map.of("name", "after", "virtualMachine", true, "old", Map.of("name", "before", "virtualMachine", false))); + payload.put(ProcessAuthorization.CAPTURED_EXECUTION_KEY, Map.of("version", 1, "authorization", metadata)); + payload.put(ProcessAuthorization.DATA_KEY, metadata); + LaunchConfiguration untrusted = new LaunchConfiguration("account.update", "account", "77", null, 0, payload); + ProcessAuthorization.prepare(untrusted, List.of()); assertTrue(ProcessAuthorization.metadata(untrusted).isEmpty()); + assertFalse(ProcessAuthorization.executionBound(new ProcessRecord(untrusted, 1L, "unit"))); + LaunchConfiguration config = new LaunchConfiguration("account.update", "account", "77", null, 0, payload); + ProcessAuthorization.prepare(config, List.of(new ProcessAuthorizationHook() { + @Override public Map capture(LaunchConfiguration ignored) { return metadata; } + })); + ProcessRecord actual = new ProcessRecord(config, 2L, "unit"); assertTrue(ProcessAuthorization.executionBound(actual)); + sourceFields.add("labels"); + assertEquals(List.of("name", "virtualMachine"), ProcessAuthorization.metadata(actual).get("projectUpdateFields")); + assertThrows(UnsupportedOperationException.class, + () -> ((List) ProcessAuthorization.metadata(actual).get("projectUpdateFields")).add("labels")); + for (String mutation : List.of("target", "type", "method", "fields", "uuid", "proof", "metadataFields")) { + var copy = new ProcessRecord(config, 3L, "unit"); copy.setData(new HashMap<>(copy.getData())); + switch (mutation) { + case "target" -> copy.setResourceId("88"); + case "type" -> copy.setResourceType("instance"); + case "method" -> copy.setProcessName("account.remove"); + case "fields" -> copy.getData().put("labels", Map.of("unexpected", "field")); + case "proof" -> copy.getData().remove(ProcessAuthorization.CAPTURED_EXECUTION_KEY); + default -> { + var altered = new HashMap<>(ProcessAuthorization.metadata(copy)); + if (mutation.equals("uuid")) altered.put("projectUpdateUuid", "different"); else altered.remove("projectUpdateFields"); + copy.getData().put(ProcessAuthorization.DATA_KEY, altered); + } + } + assertFalse(mutation, ProcessAuthorization.executionBound(copy)); + assertTrue(mutation, ProcessAuthorization.projectUpdateCaptured(copy, ProcessAuthorization.metadata(copy))); + } + assertFalse(ProcessAuthorization.sanitize(Map.of("projectUpdateFields", List.of("name", "name"))).containsKey("projectUpdateFields")); + assertFalse(ProcessAuthorization.sanitize(Map.of("projectUpdateFields", List.of("name", "a\nsecret"))).containsKey("projectUpdateFields")); + } +} diff --git a/code/framework/eventing/pom.xml b/code/framework/eventing/pom.xml index f9674ea542..b849e326fa 100644 --- a/code/framework/eventing/pom.xml +++ b/code/framework/eventing/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/events/pom.xml b/code/framework/events/pom.xml index 3dd958529c..5d1c44b8cf 100644 --- a/code/framework/events/pom.xml +++ b/code/framework/events/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/extension-spring/pom.xml b/code/framework/extension-spring/pom.xml index 1576cf2383..f51006d5e3 100644 --- a/code/framework/extension-spring/pom.xml +++ b/code/framework/extension-spring/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/extension/pom.xml b/code/framework/extension/pom.xml index 3a68107f6d..e24bbf09fb 100644 --- a/code/framework/extension/pom.xml +++ b/code/framework/extension/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/java-server/pom.xml b/code/framework/java-server/pom.xml index 1506387aa0..24f9f43663 100644 --- a/code/framework/java-server/pom.xml +++ b/code/framework/java-server/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/jmx/pom.xml b/code/framework/jmx/pom.xml index 2a2f2207e0..a533930790 100644 --- a/code/framework/jmx/pom.xml +++ b/code/framework/jmx/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/jooq/pom.xml b/code/framework/jooq/pom.xml index f513d9d1a5..93d77273b9 100644 --- a/code/framework/jooq/pom.xml +++ b/code/framework/jooq/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/json/pom.xml b/code/framework/json/pom.xml index ebede60ad6..4e0036724b 100644 --- a/code/framework/json/pom.xml +++ b/code/framework/json/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/launcher/pom.xml b/code/framework/launcher/pom.xml index e158c8fb20..160fc1eb5b 100644 --- a/code/framework/launcher/pom.xml +++ b/code/framework/launcher/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/lock/pom.xml b/code/framework/lock/pom.xml index b71a48cc3c..93ce8aafed 100644 --- a/code/framework/lock/pom.xml +++ b/code/framework/lock/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/logback/pom.xml b/code/framework/logback/pom.xml index 3c10540049..3207deca71 100644 --- a/code/framework/logback/pom.xml +++ b/code/framework/logback/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-meta-parent - 0.183.371 + 0.183.372 ../../meta-parent/pom.xml diff --git a/code/framework/managed-context/pom.xml b/code/framework/managed-context/pom.xml index 3db20e4ca4..f9ef4ba4bb 100644 --- a/code/framework/managed-context/pom.xml +++ b/code/framework/managed-context/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/metrics/pom.xml b/code/framework/metrics/pom.xml index ffb386932e..dfbc88a11a 100644 --- a/code/framework/metrics/pom.xml +++ b/code/framework/metrics/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/module/pom.xml b/code/framework/module/pom.xml index cf1a9d5fd7..a475271713 100644 --- a/code/framework/module/pom.xml +++ b/code/framework/module/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/object/pom.xml b/code/framework/object/pom.xml index 1f91d3f943..f2a96a7802 100644 --- a/code/framework/object/pom.xml +++ b/code/framework/object/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/pool/pom.xml b/code/framework/pool/pom.xml index e12c4ea688..58878e45ab 100644 --- a/code/framework/pool/pom.xml +++ b/code/framework/pool/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/resource-monitor/pom.xml b/code/framework/resource-monitor/pom.xml index 994a551d20..f07ca7e471 100644 --- a/code/framework/resource-monitor/pom.xml +++ b/code/framework/resource-monitor/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/schema/pom.xml b/code/framework/schema/pom.xml index f092c8cea0..e1508ed366 100644 --- a/code/framework/schema/pom.xml +++ b/code/framework/schema/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/server/pom.xml b/code/framework/server/pom.xml index 02957bdf74..423e3a19b1 100644 --- a/code/framework/server/pom.xml +++ b/code/framework/server/pom.xml @@ -4,7 +4,7 @@ cattle-meta-parent io.cattle - 0.183.371 + 0.183.372 ../../meta-parent/pom.xml diff --git a/code/framework/spring/pom.xml b/code/framework/spring/pom.xml index 83d2959f84..c6b882b618 100644 --- a/code/framework/spring/pom.xml +++ b/code/framework/spring/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/system-task/pom.xml b/code/framework/system-task/pom.xml index 70356d9ce2..36cfd604c4 100644 --- a/code/framework/system-task/pom.xml +++ b/code/framework/system-task/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/token/pom.xml b/code/framework/token/pom.xml index fd7d3f14d7..f71a11fd18 100644 --- a/code/framework/token/pom.xml +++ b/code/framework/token/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/framework/utils/pom.xml b/code/framework/utils/pom.xml index 05da0b40db..75d88e5593 100644 --- a/code/framework/utils/pom.xml +++ b/code/framework/utils/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/agent-instance/pom.xml b/code/iaas/agent-instance/pom.xml index b93fcb8bf2..39c07a718f 100644 --- a/code/iaas/agent-instance/pom.xml +++ b/code/iaas/agent-instance/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/agent-server/pom.xml b/code/iaas/agent-server/pom.xml index 5683195985..dc576e86db 100644 --- a/code/iaas/agent-server/pom.xml +++ b/code/iaas/agent-server/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/agent/pom.xml b/code/iaas/agent/pom.xml index a03839be8e..e4b7108d87 100644 --- a/code/iaas/agent/pom.xml +++ b/code/iaas/agent/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/allocator/pom.xml b/code/iaas/allocator/pom.xml index 695a3df19e..1dbb33f215 100644 --- a/code/iaas/allocator/pom.xml +++ b/code/iaas/allocator/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/api-logic/pom.xml b/code/iaas/api-logic/pom.xml index 95451062da..a92925a981 100644 --- a/code/iaas/api-logic/pom.xml +++ b/code/iaas/api-logic/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/archaius-management/pom.xml b/code/iaas/archaius-management/pom.xml index fd716d9292..712d9e0600 100644 --- a/code/iaas/archaius-management/pom.xml +++ b/code/iaas/archaius-management/pom.xml @@ -4,7 +4,7 @@ io.cattle cattle-parent - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/auth-logic/pom.xml b/code/iaas/auth-logic/pom.xml index 0051e99455..16130698b3 100644 --- a/code/iaas/auth-logic/pom.xml +++ b/code/iaas/auth-logic/pom.xml @@ -4,7 +4,7 @@ cattle-parent io.cattle - 0.183.371 + 0.183.372 ../../parent/pom.xml diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventService.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventService.java index 23dfe19ec9..17cc486ee6 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventService.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventService.java @@ -25,10 +25,11 @@ public class AiToolCallEventService { public Map report(ApiRequest request) { Policy actor = actor(request); Map input = CollectionUtils.toMap(request.getRequestObject()); - if (!Set.of("command", "phase", "status", "reason", "correlationId", "type").containsAll(input.keySet())) throw error(422, "AiToolEventInvalid"); + if (!Set.of("command", "phase", "status", "reason", "reasonDetail", "correlationId", "type").containsAll(input.keySet()) + || input.containsKey("reasonDetail") && !(input.get("reasonDetail") instanceof String)) throw error(422, "AiToolEventInvalid"); ClientToolAuditEvent event = new ClientToolAuditEvent("aitool-" + UUID.randomUUID(), text(input.get("command")), text(input.get("phase")), text(input.get("status")), - text(input.get("reason")), text(input.get("correlationId"))); + text(input.get("reason")), text(input.get("correlationId")), text(input.get("reasonDetail"))); if (!event.valid()) throw error(422, "AiToolEventInvalid"); if (auditSinks == null || auditSinks.isEmpty()) throw error(503, "AiToolAuditUnavailable"); try { @@ -40,6 +41,7 @@ public Map report(ApiRequest request) { Map response = new LinkedHashMap<>(); response.put("id", event.requestId()); response.put("requestId", event.requestId()); response.put("accepted", true); response.put("evidenceSource", "client_reported"); + if ("start".equals(event.phase())) response.put("supportedReasonDetails", List.of(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED)); return response; } diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/view/AiResourceViewService.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/view/AiResourceViewService.java index 759587cd9b..18d2f74de3 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/view/AiResourceViewService.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/ai/view/AiResourceViewService.java @@ -123,6 +123,10 @@ public Map read(ApiRequest request) { ? null : rawId((String) input.get("resourceId")).toString()); domain.setMethod("GET"); domain.setAction(null); domain.setLink(null); domain.setRequestObject(null); domain.setRequestParams(new LinkedHashMap<>()); domain.setConditions(new LinkedHashMap<>()); + // Ask the ordinary Project owner for its normal Admin view; native RBAC and + // the common Key collection filter still decide every returned Project. + if (source == null && "project".equals(type) && domain.getId() == null) + domain.getRequestParams().put("all", new String[] {"true"}); domain.setInclude(null); domain.setSort(new Sort("id", Sort.SortOrder.ASC, null)); domain.setPagination(new Pagination(MEMORY_COLLECTIONS.contains(type) ? 10001 : 51)); diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/apikey/ApiKeyProcessAuthorization.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/apikey/ApiKeyProcessAuthorization.java index 476801d8e8..36a047d5cb 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/apikey/ApiKeyProcessAuthorization.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/apikey/ApiKeyProcessAuthorization.java @@ -16,6 +16,7 @@ import io.cattle.platform.engine.process.ProcessAuthorizationDeniedException; import io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator; import io.cattle.platform.iaas.api.auth.projects.ProjectCreationScope; +import io.cattle.platform.iaas.api.auth.projects.ProjectAccessService; import io.cattle.platform.object.ObjectManager; import io.github.ibuildthecloud.gdapi.context.ApiContext; import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; @@ -33,6 +34,7 @@ public class ApiKeyProcessAuthorization implements ProcessAuthorizationHook { @Inject ObjectManager objectManager; @Inject ApiAuthenticator authenticator; @Inject ApiKeyTargetResolver targets; + @Inject ProjectAccessService projectAccess; Clock clock = Clock.systemUTC(); private final ApiKeyPolicyCodec codec = new ApiKeyPolicyCodec(); private final ApiKeyPolicyEvaluator evaluator = new ApiKeyPolicyEvaluator(); @@ -71,6 +73,7 @@ public class ApiKeyProcessAuthorization implements ProcessAuthorizationHook { metadata.put("targetId", createdProjectId.toString()); } metadata.put("preview", false); + if (projectAccess != null) metadata.putAll(projectAccess.captureUpdate(request, owner, config)); if (ProcessAuthorization.aliasLinkExecution(config, metadata)) metadata.putAll(targets.aliasSchedulingReferences(config, metadata, owner)); return metadata; } @@ -91,14 +94,19 @@ public class ApiKeyProcessAuthorization implements ProcessAuthorizationHook { ApiRequest request = new ApiRequest(null, objectManager.getSchemaFactory()); request.setSchemaVersion("v2-beta"); request.setType(type); - var current = authenticator.currentAuthorization(credential.getAccountId(), number(metadata, "accountId"), request); + boolean projectUpdate = ProcessAuthorization.projectUpdateCaptured(config, metadata); + var current = authenticator.currentAuthorization(credential.getAccountId(), + projectUpdate ? credential.getAccountId() : number(metadata, "accountId"), request); Object root = objectManager.loadResource(type, parseNumeric(type, text(metadata, "targetId"))); Object child = objectManager.loadResource(config.getResourceType(), config.getResourceId()); if (root == null || child == null || !schemaAllows(current.schemas().getSchema(type), metadata)) { denied("OwnerPermissionDenied"); } if (authorizedProjectCreation(config, metadata, policy, current.policy(), root, child)) return; - if (current.policy().authorizeObject(root) == null) denied("OwnerPermissionDenied"); + if (projectUpdate) { + if (projectAccess == null) denied("OwnerPermissionDenied"); + projectAccess.authorizeQueuedUpdate(root, child, current.policy(), current.schemas().getSchema("project"), config, metadata); + } else if (current.policy().authorizeObject(root) == null) denied("OwnerPermissionDenied"); // Image CREATE runs before the caller returns. Its actual instance // relation is persisted by storage first; that narrow dependency is // authorized through live owner access, never a blanket internal bypass. @@ -115,7 +123,7 @@ public class ApiKeyProcessAuthorization implements ProcessAuthorizationHook { && ("image.deactivate".equals(config.getProcessName()) || "image.remove".equals(config.getProcessName())) || child instanceof ImageStoragePoolMap && "imageStoragePoolMap".equalsIgnoreCase(config.getResourceType()) && ("imagestoragepoolmap.deactivate".equals(config.getProcessName()) || "imagestoragepoolmap.remove".equals(config.getProcessName()))); - if (current.policy().authorizeObject(child) == null) { + if (!projectUpdate && current.policy().authorizeObject(child) == null) { if (imageDependency) dependency = targets.resolveImageDependency(root, (Image) child, current.policy()); else if (imageEnsure) ensure = targets.resolveImageEnsureDependency(root, child, current.policy(), EngineContext.getEngineContext().currentVerifiedExecution(), metadata); diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChange.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChange.java new file mode 100644 index 0000000000..ae9741c582 --- /dev/null +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChange.java @@ -0,0 +1,54 @@ +package io.cattle.platform.iaas.api.auth.change; + +import io.cattle.platform.api.auth.Policy; +import io.cattle.platform.core.model.Account; +import io.cattle.platform.iaas.api.auth.projects.ProjectAccessService; +import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; +import io.github.ibuildthecloud.gdapi.model.Schema; +import jakarta.inject.Inject; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.List; +import java.util.Collection; + +/** The selected Account root's metadata only. Native project owner checks remain authoritative. */ +public class AiProjectMetadataChange { + @Inject ProjectAccessService projectAccess; + + public static Account selected(Object value, long selectedProject) { + if (!(value instanceof Account project) || project.getId() == null || project.getId() != selectedProject + || !"project".equals(project.getKind()) || project.getRemoved() != null || !"active".equals(project.getState()) + || project.getUuid() == null || project.getUuid().isBlank()) throw new ClientVisibleException(404, "NotFound"); + return project; + } + + public void authorize(Account project, Policy nativeOwner, Schema schema, boolean write, Collection fields) { + if (nativeOwner == null || schema == null || schema.getResourceMethods() == null + || !schema.getResourceMethods().contains("GET")) throw new ClientVisibleException(403, "OwnerPermissionDenied"); + if (write) { + if (fields == null || fields.isEmpty() || fields.stream().anyMatch(field -> !Set.of("name", "description").contains(field))) + throw new ClientVisibleException(403, "OwnerPermissionDenied"); + // Metadata is this adapter's subset. The native Project owner retains all actual schema/owner checks without a grant. + projectAccess.authorizeUpdate(project, nativeOwner, schema, fields); + } + } + + public static Map snapshot(Account project) { + Map result = new LinkedHashMap<>(); + result.put("id", project.getId()); result.put("uuid", project.getUuid()); result.put("kind", project.getKind()); + result.put("name", project.getName()); result.put("description", project.getDescription()); + result.put("state", project.getState()); result.put("removed", project.getRemoved()); + return result; + } + + public static boolean metadataBinding(Map binding) { + return binding != null && binding.get("metadataFields") instanceof List fields && !fields.isEmpty() && fields.size() <= 2 + && fields.stream().allMatch(field -> field instanceof String name && Set.of("name", "description").contains(name)) + && Set.copyOf(fields).size() == fields.size() && "project".equals(binding.get("resourceType")) + && "update_metadata".equals(binding.get("operation")) + && "PUT".equals(binding.get("method")) && binding.get("action") == null && "update".equals(binding.get("keyOperation")) + && binding.get("rootId") instanceof Number root && binding.get("accountId") instanceof Number selected + && root.longValue() > 0 && root.longValue() == selected.longValue(); + } +} diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java index 7975d7da1a..63990b5f4a 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDescriptor.java @@ -50,8 +50,10 @@ private static Map descriptors() { : "update_metadata".equals(operation) ? Set.of("name", "description") : "update_targets".equals(operation) ? Set.of("serviceIds") : Set.of()); } + add(result, "loadBalancerService", "update_metadata", "PUT", null, "update", Set.of("name", "description")); add(result, "certificate", "update_metadata", "PUT", null, "update", Set.of("name", "description")); add(result, "host", "update_metadata", "PUT", null, "update", Set.of("name", "description")); + add(result, "project", "update_metadata", "PUT", null, "update", Set.of("name", "description")); return Map.copyOf(result); } diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java index 529c66e959..c805b5986a 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeDomain.java @@ -2,6 +2,7 @@ import static io.cattle.platform.core.model.tables.InstanceTable.INSTANCE; import static io.cattle.platform.core.model.tables.HostTable.HOST; +import static io.cattle.platform.core.model.tables.AccountTable.ACCOUNT; import static io.cattle.platform.core.model.tables.CertificateTable.CERTIFICATE; import static io.cattle.platform.core.model.tables.ServiceTable.SERVICE; import static io.cattle.platform.core.model.tables.StackTable.STACK; @@ -56,12 +57,15 @@ public class AiResourceChangeDomain extends AbstractJooqDao { private static final String DOMAIN = "ai.resource.change.domain.descriptor"; private static final String PARENT_READ = "ai.resource.change.parent.read"; private static final String ALIAS_REFERENCE = "ai.resource.change.alias.reference"; + private static final String LB_METADATA_STACK = "ai.resource.change.lb.metadata.stack"; + private record MetadataStackRead(ApiRequest request, String publicId, String nativeId) { } private record AliasReference(String type, String publicId, String nativeId) { } @Inject ObjectManager objects; @Inject AiResourceChangeProcessObserver processObserver; @Inject ResourceManagerLocator managers; @Inject ApiAuthenticator authenticator; @Inject AiKeyAuthorization keys; + @Inject AiProjectMetadataChange projectMetadata; @Inject ApiKeyAuthorizationService keyAccess; @Inject ValidationHandler validation; @Inject JsonMapper json; @@ -80,6 +84,10 @@ public static boolean trustedDomain(ApiRequest request) { && request.getAttribute(ALIAS_REFERENCE) instanceof AliasReference reference && Objects.equals(reference.type(), request.getType()) && (Objects.equals(reference.publicId(), request.getId()) || Objects.equals(reference.nativeId(), request.getId()))) return true; + if (loadBalancerMetadata(descriptor) && request.getAttribute(LB_METADATA_STACK) instanceof MetadataStackRead scope + && scope.request() == request && "stack".equals(request.getType()) && "GET".equals(request.getMethod()) + && request.getAction() == null && (Objects.equals(scope.publicId(), request.getId()) + || Objects.equals(scope.nativeId(), request.getId()))) return true; if (!parent && !descriptor.resourceType().equals(request.getType())) return false; if ("GET".equals(request.getMethod()) && request.getAction() == null) return true; return !parent && descriptor.method().equals(request.getMethod()) && Objects.equals(descriptor.action(), request.getAction()) @@ -107,11 +115,15 @@ public Object parent(ApiRequest request, AiResourceChangeDescriptor descriptor, private Object read(ApiRequest original, AiResourceChangeDescriptor descriptor, String type, String publicId, boolean parent) { ApiRequest request = domain(original, descriptor, type, publicId, "GET", null, new LinkedHashMap<>()); - if (parent) request.setAttribute(PARENT_READ, true); + if (parent && loadBalancerMetadata(descriptor)) + request.setAttribute(LB_METADATA_STACK, new MetadataStackRead(request, publicId, Long.toString(internalId("stack", publicId)))); + else if (parent) request.setAttribute(PARENT_READ, true); if ("dnsService".equals(descriptor.resourceType()) && !"dnsService".equals(type)) request.setAttribute(ALIAS_REFERENCE, new AliasReference(type, publicId, Long.toString(internalId(type, publicId)))); + long selectedProject = "project".equals(type) ? actor(original).getAccountId() : -1; return authorized(original, request, () -> { Object row = manager(type).getById(type, request.getId(), new ListOptions(request)); + if ("project".equals(type)) return AiProjectMetadataChange.selected(row, selectedProject); if (row == null || ObjectUtils.getRemoved(row) != null || !matchesResourceType(type, row) || !Objects.equals(ObjectUtils.getAccountId(row), ApiUtils.getPolicy().getAccountId())) throw error(404, "NotFound"); return row; @@ -120,7 +132,8 @@ private Object read(ApiRequest original, AiResourceChangeDescriptor descriptor, private static boolean matchesResourceType(String type, Object target) { // A Host's kind identifies its native driver (for example docker), not the API resource type. - return "host".equals(type) ? target instanceof Host : Objects.equals(ObjectUtils.getKind(target), type); + return "host".equals(type) ? target instanceof Host : "project".equals(type) ? target instanceof Account account + && "project".equals(account.getKind()) : Objects.equals(ObjectUtils.getKind(target), type); } public Map prepare(ApiRequest original, AiResourceChangeDescriptor descriptor, @@ -135,6 +148,10 @@ public Map prepare(ApiRequest original, AiResourceChangeDescript descriptor.method(), descriptor.action(), body); return authorized(original, request, () -> { if (boundPlan != null) keys.authorizeBoundDomain(boundPlan, request); + if (loadBalancerMetadata(descriptor)) { + Map context = loadBalancerMetadataContext(original, descriptor, publicId); + if (boundPlan != null && !context.equals(boundPlan.get("referenceSnapshot"))) throw error(409, "ResourceChangePlanStale"); + } validateExternalHealthCheck(descriptor, parameters, request); if (descriptor.createsResource() && "service".equals(descriptor.resourceType())) { Map validated = copyMap(request.getRequestObject()); @@ -220,6 +237,7 @@ public Object dispatch(ApiRequest original, AiResourceChangeDescriptor descripto descriptor.method(), descriptor.action(), descriptor.body(parameters)); return authorized(original, request, () -> { validateExternalHealthCheck(descriptor, parameters, request); + if (loadBalancerMetadata(descriptor)) loadBalancerStack(original, descriptor, publicId); request.setAttribute("apiKey.audit.requestId", planId); return processObserver.dispatch(request, planId, () -> { ResourceManager manager = manager(descriptor.resourceType()); @@ -256,12 +274,19 @@ private ApiRequest domain(ApiRequest original, AiResourceChangeDescriptor descri private T authorized(ApiRequest original, ApiRequest request, Supplier operation) { Policy actor = actor(original); - var current = authenticator.currentAuthorization(actor.getAuthenticatedAsAccountId(), actor.getAccountId(), request); + boolean project = "project".equals(request.getType()) + && request.getAttribute(DOMAIN) instanceof AiResourceChangeDescriptor descriptor + && "project".equals(descriptor.resourceType()) && "update_metadata".equals(descriptor.operation()); + if (project && internalId("project", request.getId()) != actor.getAccountId()) throw error(404, "NotFound"); + var current = authenticator.currentAuthorization(actor.getAuthenticatedAsAccountId(), + project ? actor.getAuthenticatedAsAccountId() : actor.getAccountId(), request); request.setSchemaFactory(current.schemas()); Schema schema = current.schemas().getSchema(request.getType()); if (schema == null) throw error(403, "PermissionDenied"); + if (project) projectMetadata.authorize(AiProjectMetadataChange.selected(objects.loadResource(Account.class, actor.getAccountId()), + actor.getAccountId()), current.policy(), schema, "PUT".equals(request.getMethod()), copyMap(request.getRequestObject()).keySet()); if (request.getAttribute(DOMAIN) instanceof AiResourceChangeDescriptor descriptor - && Set.of("dnsService", "certificate", "host").contains(descriptor.resourceType())) { + && Set.of("dnsService", "certificate", "host", "project", "loadBalancerService").contains(descriptor.resourceType())) { if (request.getAction() != null) { var actions = request.getId() == null ? schema.getCollectionActions() : schema.getResourceActions(); if (actions == null || !actions.containsKey(request.getAction())) throw error(403, "PermissionDenied"); @@ -305,16 +330,53 @@ public Object lock(String type, long id) { }); return switch (type) { case "stack" -> create().selectFrom(STACK).where(STACK.ID.eq(id)).forUpdate().fetchOne(); - case "service", "externalService", "dnsService" -> create().selectFrom(SERVICE).where(SERVICE.ID.eq(id)).forUpdate().fetchOne(); + case "service", "externalService", "dnsService", "loadBalancerService" -> create().selectFrom(SERVICE).where(SERVICE.ID.eq(id)).forUpdate().fetchOne(); case "container" -> create().selectFrom(INSTANCE).where(INSTANCE.ID.eq(id)).forUpdate().fetchOne(); case "certificate" -> create().selectFrom(CERTIFICATE).where(CERTIFICATE.ID.eq(id)).forUpdate().fetchOne(); case "host" -> create().selectFrom(HOST).where(HOST.ID.eq(id)).forUpdate().fetchOne(); + case "project" -> create().selectFrom(ACCOUNT).where(ACCOUNT.ID.eq(id)).forUpdate().fetchOne(); default -> throw error(422, "ResourceChangeUnsupported"); }; } + static boolean loadBalancerMetadata(AiResourceChangeDescriptor descriptor) { + return "loadBalancerService".equals(descriptor.resourceType()) && "update_metadata".equals(descriptor.operation()); + } + + /** Only this new metadata plan uses a closed scalar snapshot; other Service/Stack snapshots stay unchanged. */ + public Map loadBalancerMetadataSnapshot(Object target) { + if (target == null) return new LinkedHashMap<>(); + if (!(target instanceof Service service) || !"loadBalancerService".equals(service.getKind())) throw error(404, "NotFound"); + Map result = new LinkedHashMap<>(); + for (String field : List.of("id", "uuid", "accountId", "kind", "name", "description", "state", "removed", "stackId")) + result.put(field, ObjectUtils.getPropertyIgnoreErrors(service, field)); + return copyMap(result); + } + + public Map loadBalancerMetadataContext(ApiRequest request, AiResourceChangeDescriptor descriptor, String publicId) { + if (!loadBalancerMetadata(descriptor)) throw error(422, "ResourceChangeUnsupported"); + Object stack = loadBalancerStack(request, descriptor, publicId); + Map result = new LinkedHashMap<>(); + for (String field : List.of("id", "uuid", "accountId", "kind", "name", "state", "removed")) + result.put(field, ObjectUtils.getPropertyIgnoreErrors(stack, field)); + return copyMap(result); + } + + private Object loadBalancerStack(ApiRequest request, AiResourceChangeDescriptor descriptor, String publicId) { + Object candidate = visible(request, descriptor, publicId); + if (!(candidate instanceof Service service) || service.getStackId() == null || service.getUuid() == null) throw error(404, "NotFound"); + Object parent = read(request, descriptor, "stack", publicId("stack", service.getStackId()), true); + if (!(parent instanceof io.cattle.platform.core.model.Stack stack) || stack.getUuid() == null) throw error(404, "NotFound"); + Service current = objects.loadResource(Service.class, service.getId()); + if (current == null || current.getRemoved() != null || !"loadBalancerService".equals(current.getKind()) + || !Objects.equals(current.getUuid(), service.getUuid()) || !Objects.equals(current.getAccountId(), service.getAccountId()) + || !Objects.equals(current.getStackId(), service.getStackId())) throw error(409, "ResourceChangePlanStale"); + return parent; + } + public Map snapshot(Object target) { if (target instanceof Host host) return copyMap(AiHostMetadataChange.snapshot(host)); + if (target instanceof Account project) return copyMap(AiProjectMetadataChange.snapshot(project)); Map result = new LinkedHashMap<>(); if (target != null) for (String field : List.of("id", "accountId", "kind", "name", "description", "state", "removed", "stackId", "startCount", "data")) { @@ -391,6 +453,11 @@ public String publicId(String type, Object id) { public String targetPath(ApiRequest request, Object target, Object parent, Map parameters) { if (target instanceof Service service && "dnsService".equals(service.getKind()) && parent == null && service.getStackId() != null) parent = read(request, AiResourceChangeDescriptor.require("dnsService", "update_targets"), "stack", publicId("stack", service.getStackId()), true); + if (target instanceof Service service && "loadBalancerService".equals(service.getKind()) && parent == null) + parent = loadBalancerStack(request, AiResourceChangeDescriptor.require("loadBalancerService", "update_metadata"), + publicId("loadBalancerService", service.getId())); + if (target instanceof Account selected) + return Objects.toString(AiProjectMetadataChange.selected(selected, actor(request).getAccountId()).getName(), ""); Policy actor = actor(request); Account project = objects.loadResource(Account.class, actor.getAccountId()); String name = target instanceof Host host ? AiHostMetadataChange.displayName(host) : target == null ? String.valueOf(parameters.get("name")) : String.valueOf(ObjectUtils.getPropertyIgnoreErrors(target, "name")); @@ -398,13 +465,17 @@ public String targetPath(ApiRequest request, Object target, Object parent, Map plan) { Object target = load(descriptor.resourceType(), number(plan.get("internalResourceId"))); + if ("project".equals(descriptor.resourceType())) { + AiProjectMetadataChange.selected(target, actor(request).getAccountId()); + return visible(request, descriptor, (String) plan.get("resourceId")); + } Policy actor = actor(request); ApiRequest domain = domain(request, descriptor, descriptor.resourceType(), (String) plan.get("resourceId"), "GET", null, new LinkedHashMap<>()); var current = authenticator.currentAuthorization(actor.getAuthenticatedAsAccountId(), actor.getAccountId(), domain); @@ -418,6 +489,21 @@ public Object receiptTarget(ApiRequest request, AiResourceChangeDescriptor descr || !(plan.get("createdResourceUuid") instanceof String uuid) || uuid.isBlank() || !uuid.equals(ObjectUtils.getPropertyIgnoreErrors(target, "uuid")))) throw error(403, "PermissionDenied"); if (ObjectUtils.getRemoved(target) == null) visible(request, descriptor, (String) plan.get("resourceId")); + if (loadBalancerMetadata(descriptor)) { + Map stack = loadBalancerMetadataContext(request, descriptor, (String) plan.get("resourceId")); + // Completed receipts are historical; pending receipts must still prove the approved identity. + if (!Set.of("succeeded", "failed", "cancelled").contains(Objects.toString(plan.get("status"), ""))) { + Map snapshot = copyMap(plan.get("snapshot")); + Map currentTarget = loadBalancerMetadataSnapshot(target); + for (String field : List.of("id", "uuid", "accountId", "kind", "stackId")) + if (!snapshot.containsKey(field) || !Objects.equals(snapshot.get(field), currentTarget.get(field))) + throw error(409, "ResourceChangePlanStale"); + Map reference = copyMap(plan.get("referenceSnapshot")); + for (String field : List.of("id", "uuid", "accountId", "kind")) + if (!reference.containsKey(field) || !Objects.equals(reference.get(field), stack.get(field))) + throw error(409, "ResourceChangePlanStale"); + } + } return target; } diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java index 0057915033..f148fbcbc9 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanService.java @@ -61,7 +61,9 @@ public Map preview(ApiRequest request) { plan.put("parameters", parameters); plan.put("body", body); if ("dnsService".equals(descriptor.resourceType()) && Set.of("update_targets", "remove").contains(descriptor.operation())) plan.put("referenceSnapshot", domain.aliasReferenceSnapshot(request, descriptor, resourceId, parameters)); - plan.put("snapshot", domain.snapshot(target)); plan.put("parentSnapshot", domain.snapshot(parent)); + if (AiResourceChangeDomain.loadBalancerMetadata(descriptor)) + plan.put("referenceSnapshot", domain.loadBalancerMetadataContext(request, descriptor, resourceId)); + plan.put("snapshot", targetSnapshot(descriptor, target)); plan.put("parentSnapshot", domain.snapshot(parent)); plan.put("targetPath", domain.targetPath(request, target, parent, parameters)); plan.put("changes", domain.changes(descriptor, target, parameters)); plan.put("expiresAt", clock.instant().plusSeconds(300).toString()); @@ -141,7 +143,7 @@ public Map action(String id, ApiRequest request) { } requireEnabled(); if (!clock.instant().isBefore(java.time.Instant.parse(text(plan.get("expiresAt"))))) throw error(409, "ResourceChangePlanExpired"); - if (!domain.snapshot(target).equals(plan.get("snapshot")) || !domain.snapshot(parent).equals(plan.get("parentSnapshot"))) + if (!targetSnapshot(descriptor, target).equals(plan.get("snapshot")) || !domain.snapshot(parent).equals(plan.get("parentSnapshot"))) throw error(409, "ResourceChangePlanStale"); if (!descriptor.createsResource()) visibleTarget(request, descriptor, text(plan.get("resourceId")), target); domain.parent(request, descriptor, parameters); domain.ready(descriptor, target, parent); @@ -187,6 +189,10 @@ public Map action(String id, ApiRequest request) { })); } + private Map targetSnapshot(AiResourceChangeDescriptor descriptor, Object target) { + return AiResourceChangeDomain.loadBalancerMetadata(descriptor) ? domain.loadBalancerMetadataSnapshot(target) : domain.snapshot(target); + } + private void visibleTarget(ApiRequest request, AiResourceChangeDescriptor descriptor, String id, Object lockedTarget) { Object visible = domain.visible(request, descriptor, id); // The final normal manager also uses the ambient RR snapshot. Reject an older view before claiming a JSON metadata write. diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanStore.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanStore.java index 53ca396b18..a243acf759 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanStore.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangePlanStore.java @@ -89,6 +89,35 @@ boolean attest(String id, Map before, Map after) .where(ID.eq(id).and(STATE.eq("dispatching")).and(EXECUTION.eq(encode(before)))).execute() == 1; } + boolean loadBalancerMetadataExecutionActive(String id, Map binding) { + return loadBalancerMetadata(binding) && create().fetchCount(TABLE, + ID.eq(id).and(STATE.in("dispatching", "accepted", "running", "unknown")).and(FAILURE.isNull()).and(EXECUTION.eq(encode(binding)))) == 1; + } + + boolean attestLoadBalancerMetadataProcess(String id, Map before, Map after) { + if (!loadBalancerMetadata(before) || before.containsKey("rootProcessId")) return false; + return create().update(TABLE).set(EXECUTION, encode(after)) + .where(ID.eq(id).and(STATE.in("dispatching", "accepted", "running", "unknown")).and(FAILURE.isNull()) + .and(EXECUTION.eq(encode(before)))).execute() == 1; + } + + private static boolean loadBalancerMetadata(Map binding) { + return binding != null && "loadBalancerService".equals(binding.get("resourceType")) + && "update_metadata".equals(binding.get("operation")) && "PUT".equals(binding.get("method")); + } + + boolean projectExecutionActive(String id, Map binding) { + return AiProjectMetadataChange.metadataBinding(binding) && create().fetchCount(TABLE, + ID.eq(id).and(STATE.in("dispatching", "accepted", "running", "unknown")).and(FAILURE.isNull()).and(EXECUTION.eq(encode(binding)))) == 1; + } + + boolean attestProjectProcess(String id, Map before, Map after) { + if (!AiProjectMetadataChange.metadataBinding(before) || before.containsKey("rootProcessId")) return false; + return create().update(TABLE).set(EXECUTION, encode(after)) + .where(ID.eq(id).and(STATE.in("dispatching", "accepted", "running", "unknown")).and(FAILURE.isNull()) + .and(EXECUTION.eq(encode(before)))).execute() == 1; + } + boolean recordAuthorizationDenied(String id, Map binding) { return create().update(TABLE).set(FAILURE, AUTHORIZATION_DENIED) .where(ID.eq(id).and(EXECUTION.eq(encode(binding))).and(STATE.notIn("preview", "cancelled")) @@ -122,6 +151,13 @@ private Map execution(Map plan) { binding.put("resourceType", descriptor.resourceType()); binding.put("operation", descriptor.operation()); binding.put("method", descriptor.method()); binding.put("action", descriptor.action()); binding.put("keyOperation", descriptor.keyOperation()); binding.put("rootId", plan.get("internalResourceId")); binding.put("initial", new java.util.ArrayList<>()); + if ("project".equals(descriptor.resourceType())) { + binding.put("metadataFields", descriptor.parameters(plan.get("parameters")).keySet().stream().sorted().toList()); + if (!AiProjectMetadataChange.metadataBinding(binding) || !(plan.get("snapshot") instanceof Map snapshot) + || !(snapshot.get("uuid") instanceof String uuid) || uuid.isBlank()) + throw new IllegalStateException("Resource-change execution binding unavailable"); + binding.put("rootUuid", uuid); + } java.util.ArrayList services = new java.util.ArrayList<>(); if (plan.get("snapshot") instanceof Map snapshot && snapshot.get("childServices") instanceof List children) { for (Object child : children) if (child instanceof Map row && row.get("id") instanceof Number) services.add(row.get("id")); diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeProcessObserver.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeProcessObserver.java index 3d88b6dc1e..306436197e 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeProcessObserver.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/change/AiResourceChangeProcessObserver.java @@ -8,6 +8,7 @@ import io.cattle.platform.engine.process.LaunchConfiguration; import io.cattle.platform.engine.process.ProcessAuthorization; import io.cattle.platform.engine.process.ProcessAuthorizationHook; +import io.cattle.platform.engine.process.ProcessAuthorizationDeniedException; import io.cattle.platform.iaas.api.auth.apikey.ApiKeyCredentialContext; import io.cattle.platform.iaas.api.auth.apikey.ApiKeyTargetResolver; import io.cattle.platform.object.ObjectManager; @@ -23,7 +24,7 @@ import java.util.Set; import java.util.function.Supplier; -/** Owner outcome projection only. This observer never authorizes a process or a receipt read. */ +/** Owner outcomes and exact metadata execution attestation; native authorization and receipt reads remain separate owners. */ public class AiResourceChangeProcessObserver implements ProcessAuthorizationHook { static final String ATTRIBUTE = "ai.resource.change.execution.owner"; @Inject AiResourceChangePlanStore store; @@ -41,10 +42,18 @@ public T dispatch(ApiRequest request, String id, Supplier operation) { if (binding == null || !id.equals(binding.get("id")) || !id.equals(request.getAttribute("apiKey.audit.requestId")) || !"ALLOW".equals(request.getAttribute("apiKey.audit.decision")) || number(binding.get("actorId")) != owner.getAuthenticatedAsAccountId() - || number(binding.get("accountId")) != owner.getAccountId() + || authorizationAccount(binding) != owner.getAccountId() || number(binding.get("credentialId")) != key.credentialId() || number(binding.get("credentialRevision")) != key.revision() || !Objects.equals(binding.get("credentialKind"), key.kind()) || !requestMatches(binding, request)) throw new IllegalStateException("Resource-change execution binding unavailable"); + if ("loadBalancerService".equals(binding.get("resourceType"))) { + Map plan = store.load(id); + if (plan == null || !(plan.get("parameters") instanceof Map approved) || approved.isEmpty() + || !Set.of("name", "description").containsAll(approved.keySet()) || !approved.equals(request.getRequestObject())) + throw new IllegalStateException("Resource-change metadata request unavailable"); + } + if (AiProjectMetadataChange.metadataBinding(binding) && !approvedProjectRequest(id, request)) + throw new IllegalStateException("Resource-change metadata request unavailable"); Object previous = request.getAttribute(ATTRIBUTE); request.setAttribute(ATTRIBUTE, new Dispatch(request, id)); try { return operation.get(); } @@ -66,18 +75,19 @@ public T dispatch(ApiRequest request, String id, Supplier operation) { } if (rootId == null) return Map.of(); Object root = objects.loadResource(nativeType, rootId); - if (!ownedRow(root, number(binding.get("accountId"))) || !resourceKind(binding, root) + if (!ownedRoot(binding, root) || !resourceKind(binding, root) || !Objects.equals(ObjectUtils.getId(root), rootId) - || number(config.getAccountId()) != number(binding.get("accountId")) + || !(AiProjectMetadataChange.metadataBinding(binding) ? config.getAccountId() == null + : number(config.getAccountId()) == number(binding.get("accountId"))) || "create".equals(binding.get("operation")) && !"requested".equals(ObjectUtils.getState(root))) return Map.of(); Object initial = objects.loadResource(config.getResourceType(), config.getResourceId()); - if (!ownedRow(initial, number(binding.get("accountId"))) || !Objects.equals(String.valueOf(ObjectUtils.getId(initial)), config.getResourceId()) + if (!ownedRoot(binding, initial) || !Objects.equals(String.valueOf(ObjectUtils.getId(initial)), config.getResourceId()) || !initialEdge(binding, rootId, config, initial)) return Map.of(); Map after = new LinkedHashMap<>(binding); if (binding.get("rootUuid") != null && !Objects.equals(binding.get("rootUuid"), uuid(root))) return Map.of(); after.put("rootId", rootId); after.put("rootUuid", uuid(root)); after.put("rootStackId", ObjectUtils.getPropertyIgnoreErrors(root, "stackId")); - if ("dnsService".equals(binding.get("resourceType"))) { + if (Set.of("dnsService", "loadBalancerService").contains(binding.get("resourceType"))) { var stack = objects.loadResource(io.cattle.platform.core.model.Stack.class, number(after.get("rootStackId"))); if (!ownedRow(stack, number(binding.get("accountId")))) return Map.of(); after.put("rootStackUuid", uuid(stack)); @@ -95,6 +105,15 @@ public T dispatch(ApiRequest request, String id, Supplier operation) { throw new IllegalStateException("Resource-change execution binding unavailable"); } + @Override public void beforeExecution(LaunchConfiguration config, Map metadata) { + if (metadata.get("requestId") instanceof String id && id.matches("aichange-[0-9a-f-]{36}") + && "loadBalancerService".equals(metadata.get("targetType")) && "PUT".equals(metadata.get("requestMethod")) + && "service".equals(config.getResourceType()) && "service.update".equals(config.getProcessName()) + && !attestsLoadBalancerMetadata(config, metadata)) throw new ProcessAuthorizationDeniedException("OwnerPermissionDenied"); + if (requiresProjectMetadataAttestation(config, metadata) && !attestsProjectMetadataExecution(config, metadata)) + throw new ProcessAuthorizationDeniedException("OwnerPermissionDenied"); + } + @Override public void afterExecution(LaunchConfiguration config, Map metadata, ExitReason reason) { if (reason != ExitReason.AUTHORIZATION_DENIED || !(config instanceof ProcessRecord record) || record.getId() == null || record.getId() <= 0 @@ -117,18 +136,18 @@ private boolean provenChild(Map binding, LaunchConfiguration con long accountId = number(binding.get("accountId")); Object root = objects.loadResource(nativeType((String) binding.get("resourceType")), number(binding.get("rootId"))); Object child = objects.loadResource(config.getResourceType(), config.getResourceId()); - if (!ownedRow(root, accountId) || !resourceKind(binding, root) + if (!ownedRoot(binding, root) || !resourceKind(binding, root) || number(binding.get("rootId")) != number(ObjectUtils.getId(root)) || child == null || !Objects.equals(String.valueOf(ObjectUtils.getId(child)), config.getResourceId()) || !Objects.equals(uuid(root), binding.get("rootUuid")) || !Objects.equals(ObjectUtils.getPropertyIgnoreErrors(root, "stackId"), binding.get("rootStackId") instanceof Number stack ? stack.longValue() : null)) return false; - if ("dnsService".equals(binding.get("resourceType"))) { + if (Set.of("dnsService", "loadBalancerService").contains(binding.get("resourceType"))) { var stack = objects.loadResource(io.cattle.platform.core.model.Stack.class, number(binding.get("rootStackId"))); if (!ownedRow(stack, accountId) || !Objects.equals(uuid(stack), binding.get("rootStackUuid"))) return false; } if (list(binding.get("initial")).contains(initialIdentity(config, child))) - return ownedRow(child, accountId) && initialEdge(binding, number(binding.get("rootId")), config, child) + return ownedRoot(binding, child) && initialEdge(binding, number(binding.get("rootId")), config, child) && (!(child instanceof io.cattle.platform.core.model.ServiceConsumeMap) || ProcessAuthorization.executionBound(config)); // StackRemove's direct ServiceRemove edge is scheduled by that exact // owner. Freeze the original child IDs and recheck their persisted parent. @@ -281,7 +300,7 @@ private static boolean endpointMap(io.cattle.platform.core.model.ServiceExposeMa } private static boolean resourceKind(Map binding, Object root) { - return !Set.of("externalService", "dnsService").contains(String.valueOf(binding.get("resourceType"))) + return !Set.of("externalService", "dnsService", "loadBalancerService").contains(String.valueOf(binding.get("resourceType"))) || root instanceof Service service && Objects.equals(binding.get("resourceType"), service.getKind()); } @@ -380,7 +399,107 @@ private static String initialProcess(Map binding) { } private static String nativeType(String type) { - return "container".equals(type) ? "instance" : Set.of("externalService", "dnsService").contains(type) ? "service" : type; + return "project".equals(type) ? "account" : "container".equals(type) ? "instance" + : Set.of("externalService", "dnsService", "loadBalancerService").contains(type) ? "service" : type; + } + private boolean attestsLoadBalancerMetadata(LaunchConfiguration config, Map metadata) { + if (!(metadata.get("requestId") instanceof String id) || !id.matches("aichange-[0-9a-f-]{36}") + || !(config instanceof ProcessRecord record) || record.getId() == null || record.getId() <= 0 + || !ProcessAuthorization.metadata(config).equals(metadata)) return false; + for (int attempt = 0; attempt < 2; attempt++) { + Map binding = store.execution(id); + Map plan = store.load(id); + if (binding == null || plan == null || !id.equals(plan.get("operationId")) + || !"loadBalancerService".equals(binding.get("resourceType")) || !"update_metadata".equals(binding.get("operation")) + || !"PUT".equals(binding.get("method")) || !metadataMatches(binding, metadata) || !provenChild(binding, config, metadata) + || !(plan.get("parameters") instanceof Map approved) || approved.isEmpty() + || !Set.of("name", "description").containsAll(approved.keySet()) || config.getData() == null) return false; + Set reserved = Set.of("old", ProcessAuthorization.DATA_KEY, ProcessAuthorization.CAPTURED_EXECUTION_KEY, + ProcessAuthorization.VERIFIED_CHILD_KEY, ProcessAuthorization.COMPLETION_PENDING_KEY); + for (var entry : config.getData().entrySet()) { + if (!reserved.contains(entry.getKey()) && (!approved.containsKey(entry.getKey()) + || !Objects.equals(approved.get(entry.getKey()), entry.getValue()))) return false; + } + var root = objects.loadResource("service", number(binding.get("rootId"))); + var stack = objects.loadResource(io.cattle.platform.core.model.Stack.class, number(binding.get("rootStackId"))); + if (root == null || ObjectUtils.getRemoved(root) != null || stack == null || stack.getRemoved() != null) return false; + var persisted = objects.loadResource(io.cattle.platform.core.model.ProcessInstance.class, record.getId()); + if (persisted == null || persisted.getEndTime() != null || !Objects.equals(persisted.getId(), record.getId()) + || !Objects.equals(persisted.getAccountId(), config.getAccountId()) + || !Objects.equals(persisted.getProcessName(), config.getProcessName()) + || !Objects.equals(persisted.getResourceType(), config.getResourceType()) + || !Objects.equals(persisted.getResourceId(), config.getResourceId()) || !Objects.equals(persisted.getData(), config.getData()) + || !(persisted.getData().get(ProcessAuthorization.DATA_KEY) instanceof Map raw) + || !metadata.equals(raw) || !metadata.equals(ProcessAuthorization.sanitize(raw))) return false; + if (binding.get("rootProcessId") instanceof Number prior) + return prior.longValue() == record.getId() && store.loadBalancerMetadataExecutionActive(id, binding); + Map after = new LinkedHashMap<>(binding); after.put("rootProcessId", record.getId()); + if (store.attestLoadBalancerMetadataProcess(id, binding, after)) return true; + } + return false; + } + + private boolean requiresProjectMetadataAttestation(LaunchConfiguration config, Map metadata) { + if (!(metadata.get("requestId") instanceof String id) || !id.matches("aichange-[0-9a-f-]{36}")) return false; + // Shape only selects mandatory attestation; it can never supply authority when the private binding is absent. + if ("project".equals(metadata.get("targetType")) + || "account".equals(config.getResourceType()) && "account.update".equals(config.getProcessName())) return true; + Map binding = store.execution(id); + return binding != null && "project".equals(binding.get("resourceType")); + } + + /** A durable, already claimed exact Account.update chain, never a client-provided AI flag. */ + public boolean attestsProjectMetadataExecution(LaunchConfiguration config, Map metadata) { + if (!(config instanceof ProcessRecord record) || record.getId() == null || record.getId() <= 0 + || !(metadata.get("requestId") instanceof String id) || !id.matches("aichange-[0-9a-f-]{36}")) return false; + for (int attempt = 0; attempt < 2; attempt++) { + Map binding = store.execution(id); + if (!AiProjectMetadataChange.metadataBinding(binding) || config.getAccountId() != null + || !"account".equals(config.getResourceType()) || !"account.update".equals(config.getProcessName()) + || !metadataMatches(binding, metadata) || !approvedProjectUpdate(id, binding, config, metadata) + || !provenChild(binding, config, metadata)) return false; + var persisted = objects.loadResource(io.cattle.platform.core.model.ProcessInstance.class, record.getId()); + if (persisted == null || persisted.getEndTime() != null || !Objects.equals(persisted.getId(), record.getId()) + || persisted.getAccountId() != null + || !Objects.equals(persisted.getProcessName(), config.getProcessName()) + || !Objects.equals(persisted.getResourceType(), config.getResourceType()) + || !Objects.equals(persisted.getResourceId(), config.getResourceId()) || persisted.getData() == null + || !(persisted.getData().get(ProcessAuthorization.DATA_KEY) instanceof Map raw) + || !metadata.equals(raw) || !metadata.equals(ProcessAuthorization.sanitize(raw))) return false; + if (binding.get("rootProcessId") instanceof Number prior) + return prior.longValue() == record.getId() && store.projectExecutionActive(id, binding); + Map after = new LinkedHashMap<>(binding); after.put("rootProcessId", record.getId()); + if (store.attestProjectProcess(id, binding, after)) return true; + } + return false; + } + private boolean approvedProjectRequest(String id, ApiRequest request) { + Map plan = store.load(id); + if (plan == null || !(plan.get("parameters") instanceof Map approved) + || !(request.getRequestObject() instanceof Map body)) return false; + Map actual = new LinkedHashMap<>(body); + if (actual.containsKey("type") && !"project".equals(actual.remove("type"))) return false; + return actual.equals(approved); + } + + private boolean approvedProjectUpdate(String id, Map binding, LaunchConfiguration config, Map metadata) { + if (!ProcessAuthorization.executionBound(config) || !ProcessAuthorization.projectUpdateExecution(config, metadata) + || !Objects.equals(binding.get("rootUuid"), metadata.get("projectUpdateUuid"))) return false; + List actual = (List) metadata.get("projectUpdateFields"); + if (!((List) binding.get("metadataFields")).containsAll(actual)) return false; + Map plan = store.load(id); + if (plan == null || !(plan.get("parameters") instanceof Map approved)) return false; + // Native scheduling omits unchanged parameters. Every actual change still equals its privately approved value. + return actual.stream().allMatch(field -> approved.containsKey(field) && Objects.equals(approved.get(field), config.getData().get(field))); + } + + private static long authorizationAccount(Map binding) { + return number(binding.get(AiProjectMetadataChange.metadataBinding(binding) ? "actorId" : "accountId")); + } + private static boolean ownedRoot(Map binding, Object row) { + if (!AiProjectMetadataChange.metadataBinding(binding)) return ownedRow(row, number(binding.get("accountId"))); + try { return AiProjectMetadataChange.selected(row, number(binding.get("accountId"))) != null; } + catch (io.github.ibuildthecloud.gdapi.exception.ClientVisibleException denied) { return false; } } private static boolean ownedRow(Object row, long account) { if (row == null || !Objects.equals(ObjectUtils.getAccountId(row), account) || uuid(row) == null) return false; diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectAccessService.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectAccessService.java index 839a68b3a9..1f22dfd655 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectAccessService.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectAccessService.java @@ -6,13 +6,26 @@ import io.cattle.platform.core.model.Account; import io.cattle.platform.iaas.api.auth.dao.AuthDao; import io.github.ibuildthecloud.gdapi.context.ApiContext; +import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; +import io.github.ibuildthecloud.gdapi.model.Schema; +import io.github.ibuildthecloud.gdapi.request.ApiRequest; +import io.cattle.platform.engine.process.LaunchConfiguration; +import io.cattle.platform.engine.process.ProcessAuthorization; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; /** Live Project RBAC shared by normal resource access and Key attenuation. */ public class ProjectAccessService { private final AuthDao authDao; private final AccountDao accountDao; + private static final ThreadLocal UPDATE = new ThreadLocal<>(); + private record UpdateScope(ApiRequest request, Policy owner, long projectId, String uuid, Set fields) { } public ProjectAccessService(AuthDao authDao, AccountDao accountDao) { this.authDao = Objects.requireNonNull(authDao); @@ -24,6 +37,67 @@ public Account checkAccess(Account project, Policy policy) { return evaluate(project, policy) == null ? null : project; } + /** The native manager's owner/schema check, without request-local object grants. */ + public void authorizeUpdate(Account project, Policy owner, Schema schema, Collection fields) { + if (project == null || project.getId() == null || !ProjectConstants.TYPE.equals(project.getKind()) + || project.getRemoved() != null || owner == null || schema == null || fields == null + || schema.getResourceMethods() == null || !schema.getResourceMethods().contains("PUT") + || !authDao.isProjectOwner(project.getId(), owner.getAccountId(), + owner.isOption(Policy.AUTHORIZED_FOR_ALL_ACCOUNTS), owner.getIdentities())) denied(); + for (String field : fields) { + if (schema.getResourceFields() == null || schema.getResourceFields().get(field) == null + || !schema.getResourceFields().get(field).isUpdate()) denied(); + } + } + + /** Only ProjectResourceManager can open this scope after its ordinary owner check. */ + T withUpdate(Account project, Policy owner, ApiRequest request, Supplier update) { + var context = ApiContext.getContext(); + if (context == null || context.getApiRequest() != request || context.getPolicy() != owner + || !"project".equals(request.getType()) || !"PUT".equals(request.getMethod()) + || request.getAction() != null || request.getLink() != null || project == null || project.getId() == null + || !project.getId().toString().equals(request.getId()) || project.getUuid() == null || project.getUuid().isBlank() + || !(request.getRequestObject() instanceof Map)) denied(); + Map body = (Map) request.getRequestObject(); + Set fields = body.keySet().stream().filter(k -> !"type".equals(k)).map(k -> { + if (!(k instanceof String field)) { denied(); return ""; } + return field; + }).collect(java.util.stream.Collectors.toUnmodifiableSet()); + authorizeUpdate(project, owner, request.getSchemaFactory().getSchema("project"), fields); + UpdateScope previous = UPDATE.get(); UPDATE.set(new UpdateScope(request, owner, project.getId(), project.getUuid(), fields)); + try { return update.get(); } + finally { if (previous == null) UPDATE.remove(); else UPDATE.set(previous); } + } + + /** Native Account identity and actual changed fields; never copied from client provenance flags. */ + public Map captureUpdate(ApiRequest request, Policy owner, LaunchConfiguration process) { + UpdateScope scope = UPDATE.get(); + if (scope == null) return Map.of(); + if (scope.request() != request || scope.owner() != owner) denied(); + if (!"account".equals(process.getResourceType()) || !"account.update".equals(process.getProcessName()) + || !Long.toString(scope.projectId()).equals(process.getResourceId()) || process.getAccountId() != null) return Map.of(); + List fields = ProcessAuthorization.projectUpdateFields(process); + if (fields.isEmpty() || !scope.fields().containsAll(fields)) denied(); + Map result = new LinkedHashMap<>(); + result.put("accountId", scope.projectId()); result.put("projectUpdateUuid", scope.uuid()); result.put("projectUpdateFields", fields); + return Map.copyOf(result); + } + + /** One captured native Account.update only. No authority is granted to arbitrary project children. */ + public void authorizeQueuedUpdate(Object root, Object child, Policy owner, Schema schema, + LaunchConfiguration process, Map metadata) { + if (!(root instanceof Account) || !(child instanceof Account) + || !ProcessAuthorization.executionBound(process) || !ProcessAuthorization.projectUpdateExecution(process, metadata)) denied(); + Account project = (Account) root; Account actual = (Account) child; + if (!Objects.equals(project.getId(), actual.getId()) || !Objects.equals(project.getUuid(), actual.getUuid()) + || !ProjectConstants.TYPE.equals(actual.getKind()) || actual.getRemoved() != null + || !Objects.equals(project.getUuid(), metadata.get("projectUpdateUuid")) + || !(metadata.get("accountId") instanceof Number account) || !Objects.equals(project.getId(), account.longValue())) denied(); + authorizeUpdate(project, owner, schema, ProcessAuthorization.projectUpdateFields(process)); + } + + private static void denied() { throw new ClientVisibleException(403, "OwnerPermissionDenied"); } + private record ProjectAccessRights(boolean owner, boolean setMembers) { } private ProjectAccessRights evaluate(Account project, Policy policy) { diff --git a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectResourceManager.java b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectResourceManager.java index b9e26071e8..7f79ab942f 100644 --- a/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectResourceManager.java +++ b/code/iaas/auth-logic/src/main/java/io/cattle/platform/iaas/api/auth/projects/ProjectResourceManager.java @@ -227,7 +227,7 @@ protected Object updateInternal(String type, String id, Object obj, ApiRequest a Account project = (Account) obj; if (authDao.isProjectOwner(project.getId(), policy.getAccountId(), policy.isOption(Policy.AUTHORIZED_FOR_ALL_ACCOUNTS), policy.getIdentities())) { - return super.updateInternal(type, id, obj, apiRequest); + return projectAccess.withUpdate(project, policy, apiRequest, () -> super.updateInternal(type, id, obj, apiRequest)); } else { throw new ClientVisibleException(ResponseCodes.FORBIDDEN, "Forbidden", "You must be a project owner to update the name or description.", null); } diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventServiceTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventServiceTest.java index fbefca2665..7e12c56362 100644 --- a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventServiceTest.java +++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/AiToolCallEventServiceTest.java @@ -10,6 +10,7 @@ import io.cattle.platform.core.model.tables.records.AccountRecord; import io.cattle.platform.iaas.api.auth.apikey.ApiKeyCredentialContext; import io.cattle.platform.object.ObjectManager; +import io.cattle.platform.util.type.CollectionUtils; import io.github.ibuildthecloud.gdapi.context.ApiContext; import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; import io.github.ibuildthecloud.gdapi.id.IdentityFormatter; @@ -24,6 +25,41 @@ public class AiToolCallEventServiceTest { @After public void cleanup() { ApiContext.remove(); } + @Test public void bindingDetailCapabilityKeepsLegacyConstructorAndDurableReadonlyAcksCompatible() throws Exception { + Fixture f = new Fixture(); + ClientToolAuditEvent.class.getConstructor(String.class, String.class, String.class, String.class, String.class, String.class); + assertEquals(13, ClientToolAuditEvent.REASONS.size()); + assertTrue(new ClientToolAuditEvent(id(), "get_service", "result", "succeeded", "NONE", id()).valid()); + Map start = f.service.report(f.request("preview_resource_change", "start", "pending", "NONE", null)); + assertEquals(List.of(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED), start.get("supportedReasonDetails")); + ApiRequest detail = f.request("preview_resource_change", "result", "needs_user", "NEEDS_USER", (String) start.get("requestId")); + CollectionUtils.toMap(detail.getRequestObject()).put("reasonDetail", ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED); + assertEquals(true, f.service.report(detail).get("accepted")); + assertEquals(ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED, f.events.getLast().reasonDetail()); + Map legacy = f.service.report(f.request("get_service", "result", "succeeded", "NONE", id())); + assertEquals(true, legacy.get("accepted")); assertFalse(legacy.containsKey("supportedReasonDetails")); + assertNull(f.events.getLast().reasonDetail()); assertEquals("readonly", f.policy.getRoles().iterator().next()); + verify(f.objects, never()).persist(any()); + } + + @Test public void invalidReasonDetailsNeverReachDurableSink() { + Fixture f = new Fixture(); + for (Object value : java.util.Arrays.asList(null, 1, Map.of("reason", "PRIVATE"), List.of("PRIVATE"), "", "PRIVATE", "x".repeat(20000))) { + ApiRequest request = f.request("preview_resource_change", "result", "needs_user", "NEEDS_USER", id()); + CollectionUtils.toMap(request.getRequestObject()).put("reasonDetail", value); + assertEquals("AiToolEventInvalid", rejected(() -> f.service.report(request))); + } + for (String[] tuple : List.of(new String[]{"get_service", "result", "needs_user", "NEEDS_USER"}, + new String[]{"preview_resource_change", "start", "pending", "NONE"}, + new String[]{"preview_resource_change", "result", "succeeded", "NEEDS_USER"}, + new String[]{"preview_resource_change", "result", "needs_user", "FORBIDDEN"})) { + ApiRequest request = f.request(tuple[0], tuple[1], tuple[2], tuple[3], "start".equals(tuple[1]) ? null : id()); + CollectionUtils.toMap(request.getRequestObject()).put("reasonDetail", ClientToolAuditEvent.BRIDGE_PROJECT_BINDING_REQUIRED); + assertEquals("AiToolEventInvalid", rejected(() -> f.service.report(request))); + } + assertTrue(f.events.isEmpty()); + } + @Test public void readonlyActorGetsDurableStartAndResultAcksForEveryCommand() { Fixture f = new Fixture(); for (String command : ClientToolAuditEvent.COMMANDS) { @@ -89,7 +125,9 @@ public class AiToolCallEventServiceTest { } static String id() { return "aitool-11111111-1111-1111-1111-111111111111"; } - static String rejected(Runnable run) { try { run.run(); fail("must reject"); return null; } catch (ClientVisibleException error) { return error.getCode(); } } + static String rejected(Runnable run) { + try { run.run(); fail("must reject"); return null; } catch (ClientVisibleException error) { return error.getCode(); } + } static class Fixture { final AiToolCallEventService service = new AiToolCallEventService(); final Policy policy = mock(Policy.class); final ObjectManager objects = mock(ObjectManager.class); @@ -97,10 +135,13 @@ static class Fixture { final AccountRecord actor = new AccountRecord(), project = new AccountRecord(); final List events = new ArrayList<>(); final List recordedActors = new ArrayList<>(); Fixture() { - when(policy.getAuthenticatedAsAccountId()).thenReturn(42L); when(policy.getAccountId()).thenReturn(77L); when(policy.getRoles()).thenReturn(java.util.Set.of("readonly")); + when(policy.getAuthenticatedAsAccountId()).thenReturn(42L); when(policy.getAccountId()).thenReturn(77L); + when(policy.getRoles()).thenReturn(java.util.Set.of("readonly")); actor.setId(42L); actor.setState("active"); project.setId(77L); project.setKind("project"); project.setState("active"); when(objects.loadResource(Account.class, 42L)).thenReturn(actor); when(objects.loadResource(Account.class, 77L)).thenReturn(project); - doAnswer(call -> { recordedActors.add(((Policy) call.getArgument(1)).getAuthenticatedAsAccountId()); events.add(call.getArgument(2)); return null; }).when(sink).recordClientToolEvent(any(), any(), any()); + doAnswer(call -> { + recordedActors.add(((Policy) call.getArgument(1)).getAuthenticatedAsAccountId()); events.add(call.getArgument(2)); return null; + }).when(sink).recordClientToolEvent(any(), any(), any()); service.keyAuthorization = mock(AiKeyAuthorization.class); when(service.keyAuthorization.actor(any())).thenReturn(policy); service.objects = objects; service.auditSinks = List.of(sink); ApiContext.newContext().setPolicy(policy); ApiContext.getContext().setIdFormatter(new IdentityFormatter()); diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/view/AiProjectCollectionOwnerTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/view/AiProjectCollectionOwnerTest.java new file mode 100644 index 0000000000..09cca53d37 --- /dev/null +++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/ai/view/AiProjectCollectionOwnerTest.java @@ -0,0 +1,229 @@ +package io.cattle.platform.iaas.api.auth.ai.view; + +import static io.cattle.platform.core.model.tables.AccountTable.ACCOUNT; +import static io.cattle.platform.core.model.tables.ProjectMemberTable.PROJECT_MEMBER; +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import io.cattle.platform.api.auth.ApiKeyAuditSink; +import io.cattle.platform.api.auth.Identity; +import io.cattle.platform.api.auth.Policy; +import io.cattle.platform.api.auth.impl.NoPolicyOptions; +import io.cattle.platform.archaius.util.ArchaiusUtil; +import io.cattle.platform.core.dao.AccountDao; +import io.cattle.platform.core.model.Account; +import io.cattle.platform.iaas.api.auth.ai.AiKeyAuthorization; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyAiBridgeGuard; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyAuthorizationService; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyCredentialContext; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyPolicy; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyTargetResolver; +import io.cattle.platform.iaas.api.auth.dao.impl.AuthDaoImpl; +import io.cattle.platform.iaas.api.auth.impl.AccountPolicy; +import io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator; +import io.cattle.platform.iaas.api.auth.projects.ProjectAccessService; +import io.cattle.platform.iaas.api.auth.projects.ProjectResourceManager; +import io.cattle.platform.object.ObjectManager; +import io.github.ibuildthecloud.gdapi.context.ApiContext; +import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; +import io.github.ibuildthecloud.gdapi.factory.SchemaFactory; +import io.github.ibuildthecloud.gdapi.id.IdFormatter; +import io.github.ibuildthecloud.gdapi.model.ListOptions; +import io.github.ibuildthecloud.gdapi.model.Resource; +import io.github.ibuildthecloud.gdapi.model.Schema; +import io.github.ibuildthecloud.gdapi.model.impl.ResourceImpl; +import io.github.ibuildthecloud.gdapi.request.ApiRequest; +import io.github.ibuildthecloud.gdapi.server.model.ApiServletContext; +import io.github.ibuildthecloud.gdapi.request.resource.ResourceManager; +import io.github.ibuildthecloud.gdapi.request.resource.ResourceManagerLocator; +import jakarta.servlet.http.HttpServletRequest; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.conf.RenderQuotedNames; +import org.jooq.conf.Settings; +import org.jooq.impl.DSL; +import org.junit.After; +import org.junit.Test; + +/** Real native list -> AuthDao H2 -> ProjectAccess -> common authorize/Key filter. */ +public class AiProjectCollectionOwnerTest { + @After public void clearContext() { ApiContext.remove(); } + + @Test public void adminNativeFullAndLegacySeeNonMembershipProjectsWithoutWideningMemberOrReadonly() throws Exception { + try (Fixture f = new Fixture()) { + assertEquals(List.of("1a5", "1a6", "1a7"), f.ids(f.read(42, true, full(), Map.of("resourceType", "project")))); + assertArrayEquals(new String[] {"true"}, f.manager.allValues.getLast()); + // Legacy is the original native GET contract, not a new Legacy AI-Bridge admission. + assertEquals(List.of(5L, 6L, 7L), f.nativeLegacyIds(42, true)); + assertEquals(List.of("1a5"), f.ids(f.read(42, false, full(), Map.of("resourceType", "project")))); + assertEquals(List.of("1a6"), f.ids(f.read(43, false, full(), Map.of("resourceType", "project")))); + assertEquals(2, f.sql.fetchCount(PROJECT_MEMBER)); // No Admin membership was added. + } + } + + @Test public void exactProjectCustomDenyAndReadonlyKeysRetainExactSetsThroughNativeCommonCollectionFilter() throws Exception { + try (Fixture f = new Fixture()) { + for (ApiKeyPolicy.Scope scope : List.of(ApiKeyPolicy.Scope.resource("project", "1a6"), ApiKeyPolicy.Scope.project("1a6"))) { + var only = custom(ApiKeyPolicy.Effect.DENY, ApiKeyPolicy.Effect.ALLOW, scope, "read"); + assertEquals(List.of("1a6"), f.ids(f.read(42, true, only, Map.of("resourceType", "project")))); + } + var deny = custom(ApiKeyPolicy.Effect.ALLOW, ApiKeyPolicy.Effect.DENY, ApiKeyPolicy.Scope.resource("project", "1a6"), "read"); + assertEquals(List.of("1a5", "1a7"), f.ids(f.read(42, true, deny, Map.of("resourceType", "project")))); + var readonly = custom(ApiKeyPolicy.Effect.DENY, ApiKeyPolicy.Effect.ALLOW, ApiKeyPolicy.Scope.global(), "read"); + assertEquals(List.of("1a5", "1a6", "1a7"), f.ids(f.read(42, true, readonly, Map.of("resourceType", "project")))); + assertEquals(List.of("1a5"), f.ids(f.read(42, false, readonly, Map.of("resourceType", "project")))); + var updateOnly = custom(ApiKeyPolicy.Effect.DENY, ApiKeyPolicy.Effect.ALLOW, ApiKeyPolicy.Scope.project("1a6"), "update"); + assertEquals(List.of(), f.ids(f.read(42, true, updateOnly, Map.of("resourceType", "project")))); + var memberOnly = custom(ApiKeyPolicy.Effect.DENY, ApiKeyPolicy.Effect.ALLOW, ApiKeyPolicy.Scope.project("1a5"), "read"); + f.manager.afterNativeList = () -> f.sql.deleteFrom(PROJECT_MEMBER).where(PROJECT_MEMBER.EXTERNAL_ID.eq("42")).execute(); + Map removed = f.read(42, false, memberOnly, Map.of("resourceType", "project")); + assertEquals(List.of(), f.ids(removed)); assertNull(removed.get("nextMarker")); + assertFalse(removed.containsKey("total")); assertFalse(removed.toString().contains("environment-five")); + } + } + + @Test public void allIsPrivateToTypedProjectCollectionAndNeverChangesSingleGetOrOtherTypesOrAcceptsModelAll() throws Exception { + try (Fixture f = new Fixture()) { + assertEquals(List.of("1a6"), f.ids(f.read(42, true, full(), Map.of("resourceType", "project", "resourceId", "1a6")))); + assertNull(f.manager.allValues.getLast()); + ResourceManager setting = mock(ResourceManager.class); + when(setting.list(eq("setting"), any(ApiRequest.class))).thenAnswer(call -> { + assertFalse(((ApiRequest) call.getArgument(1)).getRequestParams().containsKey("all")); + return List.of(new ResourceImpl("safe", "setting", Map.of("name", "ordinary"))); + }); + when(setting.convertResponse(any(Object.class), any())).thenAnswer(call -> call.getArgument(0)); + when(f.managers.getResourceManagerByType("setting")).thenReturn(setting); + assertEquals(1, ((List) f.read(42, true, full(), Map.of("resourceType", "setting")).get("data")).size()); + int count = f.manager.allValues.size(); + ClientVisibleException rejected = assertThrows(ClientVisibleException.class, + () -> f.read(42, true, full(), Map.of("resourceType", "project", "all", true))); + assertEquals("ResourceViewInputInvalid", rejected.getCode()); assertEquals(count, f.manager.allValues.size()); + assertFalse(f.lastInput.getRequestParams().containsKey("all")); + } + } + + private static ApiKeyPolicy full() { + return new ApiKeyPolicy(ApiKeyPolicy.Mode.FULL, ApiKeyPolicy.Effect.ALLOW, null, List.of(), ApiKeyPolicy.Purpose.AI_BRIDGE); + } + private static ApiKeyPolicy custom(ApiKeyPolicy.Effect fallback, ApiKeyPolicy.Effect effect, ApiKeyPolicy.Scope scope, String operation) { + return new ApiKeyPolicy(ApiKeyPolicy.Mode.CUSTOM, fallback, null, + List.of(new ApiKeyPolicy.Rule("focused-project-read", effect, scope, Set.of(operation))), ApiKeyPolicy.Purpose.AI_BRIDGE); + } + private static void inject(Object target, String name, Object value) throws Exception { + for (Class type = target.getClass(); type != null; type = type.getSuperclass()) { + try { var field = type.getDeclaredField(name); field.setAccessible(true); field.set(target, value); return; } + catch (NoSuchFieldException next) { /* native superclass fields are intentional */ } + } + throw new NoSuchFieldException(name); + } + + static class NativeProjectManager extends ProjectResourceManager { + final List allValues = new ArrayList<>(); + Runnable afterNativeList; + @Override public Object listInternal(SchemaFactory schemas, String type, Map criteria, ListOptions options) { + allValues.add((String[]) ApiContext.getContext().getApiRequest().getRequestParams().get("all")); + Object rows = super.listInternal(schemas, type, criteria, options); + if (afterNativeList != null) { Runnable hook = afterNativeList; afterNativeList = null; hook.run(); } + return rows; + } + @Override public Resource convertResponse(Object value, ApiRequest request) { + Account row = (Account) value; + return new ResourceImpl("1a" + row.getId(), "project", Map.of("name", row.getName(), "state", row.getState())); + } + } + + static class Fixture implements AutoCloseable { + final Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + java.util.UUID.randomUUID() + ";NON_KEYWORDS=ROLE"); + final DSLContext sql = DSL.using(connection, SQLDialect.H2, new Settings().withRenderQuotedNames(RenderQuotedNames.NEVER)); + final AuthDaoImpl dao = new AuthDaoImpl(); + final NativeProjectManager manager = new NativeProjectManager(); + final AiResourceViewService view = new AiResourceViewService(); + final ApiKeyAuthorizationService keyAccess = new ApiKeyAuthorizationService(); + final ResourceManagerLocator managers = mock(ResourceManagerLocator.class); + final SchemaFactory schemas = mock(SchemaFactory.class); + final Schema schema = mock(Schema.class); + final Object previousEnabled = ArchaiusUtil.getConfiguration().getProperty("api.ai.bridge.enabled"); + ApiRequest lastInput; + Fixture() throws Exception { + sql.createSchema("cattle").execute(); sql.createTable(ACCOUNT).columns(ACCOUNT.fields()).execute(); + sql.createTable(PROJECT_MEMBER).columns(PROJECT_MEMBER.fields()).execute(); dao.setConfiguration(sql.configuration()); + sql.insertInto(ACCOUNT, ACCOUNT.ID, ACCOUNT.KIND, ACCOUNT.STATE, ACCOUNT.UUID, ACCOUNT.NAME) + .values(42L, "user", "active", "user-42", "actor42").values(43L, "user", "active", "user-43", "actor43") + .values(5L, "project", "active", "project-five", "environment-five") + .values(6L, "project", "active", "project-six", "environment-six") + .values(7L, "project", "active", "project-seven", "environment-seven").execute(); + sql.insertInto(PROJECT_MEMBER, PROJECT_MEMBER.ID, PROJECT_MEMBER.PROJECT_ID, PROJECT_MEMBER.EXTERNAL_ID_TYPE, + PROJECT_MEMBER.EXTERNAL_ID, PROJECT_MEMBER.ROLE, PROJECT_MEMBER.STATE, PROJECT_MEMBER.KIND, PROJECT_MEMBER.UUID) + .values(1L, 5L, "rancher_id", "42", "member", "active", "projectMember", "member-one") + .values(2L, 6L, "rancher_id", "43", "readonly", "active", "projectMember", "member-two").execute(); + // Generated DDL represents the MySQL NULL default as a CLOB literal on H2. + sql.execute("update cattle.account set data='{}'"); sql.execute("update cattle.project_member set data='{}'"); + AccountDao accounts = mock(AccountDao.class); + when(accounts.isActiveAccount(any(Account.class))).thenAnswer(call -> { + Account row = call.getArgument(0); return "active".equals(row.getState()) && row.getRemoved() == null; + }); + var access = new ProjectAccessService(dao, accounts); var objects = mock(ObjectManager.class); + when(objects.getType(any(Object.class))).thenReturn("account"); + when(objects.loadResource(eq(Account.class), anyString())).thenAnswer(call -> dao.getAccountById(Long.valueOf(call.getArgument(1).toString()))); + when(objects.loadResource(eq(Account.class), anyLong())).thenAnswer(call -> dao.getAccountById(call.getArgument(1))); + when(objects.loadResource(eq("project"), anyString())).thenAnswer(call -> dao.getAccountById(Long.valueOf(call.getArgument(1).toString()))); + when(objects.loadResource(eq("project"), anyLong())).thenAnswer(call -> dao.getAccountById(call.getArgument(1))); + IdFormatter formatter = mock(IdFormatter.class); + when(formatter.parseId(anyString())).thenAnswer(call -> call.getArgument(0).replaceFirst("^1a", "")); + when(formatter.formatId(anyString(), any())).thenAnswer(call -> "1a" + call.getArgument(1)); + var resolver = new ApiKeyTargetResolver(); inject(resolver, "objectManager", objects); + inject(resolver, "projectAccess", access); inject(resolver, "idFormatter", formatter); + inject(keyAccess, "targets", resolver); inject(keyAccess, "objectManager", objects); inject(keyAccess, "resourceManagers", managers); + inject(keyAccess, "auditSinks", List.of(mock(ApiKeyAuditSink.class))); + manager.setObjectManager(objects); inject(manager, "authDao", dao); + inject(manager, "projectAccess", access); inject(manager, "resourceAccess", List.of(keyAccess)); + when(managers.getResourceManagerByType("project")).thenReturn(manager); + when(schemas.getSchema(anyString())).thenReturn(schema); when(schema.getCollectionMethods()).thenReturn(List.of("GET")); + when(schema.getResourceMethods()).thenReturn(List.of("GET")); when(schema.getResourceFields()).thenReturn(Map.of()); + view.managers = managers; view.keyAccess = keyAccess; + view.authenticator = mock(ApiAuthenticator.class); view.keyAuthorization = mock(AiKeyAuthorization.class); + ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", true); + ApiContext.newContext().setIdFormatter(formatter); + } + Policy policy(long actor, boolean admin) { + Account principal = dao.getAccountById(actor); + return new AccountPolicy(principal, principal, Set.of(new Identity("rancher_id", Long.toString(actor))), new NoPolicyOptions() { + @Override public boolean isOption(String name) { return admin && Policy.AUTHORIZED_FOR_ALL_ACCOUNTS.equals(name); } + }); + } + Map read(long actor, boolean admin, ApiKeyPolicy key, Map body) { + Policy owner = policy(actor, admin); when(view.keyAuthorization.actor(any())).thenReturn(owner); + when(view.authenticator.currentAuthorization(eq(actor), eq(actor), any())).thenReturn(new ApiAuthenticator.CurrentAuthorization(owner, schemas)); + ApiRequest request = new ApiRequest(null, schemas); request.setType("aiResourceView"); request.setMethod("POST"); request.setRequestObject(body); + request.setRequestParams(new LinkedHashMap<>()); HttpServletRequest http = mock(HttpServletRequest.class); + when(http.getHeader(ApiKeyAiBridgeGuard.EXPECTED_REVISION_HEADER)).thenReturn("3"); + request.setRequestServletContext(new ApiServletContext(http, null, null)); + ApiKeyCredentialContext.attach(request, new ApiKeyCredentialContext(99, actor, "apiKeyRestricted", 3, key)); + ApiContext.getContext().setApiRequest(request); ApiContext.getContext().setPolicy(owner); lastInput = request; + return view.read(request); + } + List nativeLegacyIds(long actor, boolean admin) { + var request = new ApiRequest(null, schemas); request.setType("project"); request.setMethod("GET"); + request.setRequestParams(Map.of("all", new String[] {"true"})); request.setConditions(new LinkedHashMap<>()); + ApiKeyCredentialContext.attach(request, new ApiKeyCredentialContext(100, actor, "apiKey", 0, null)); + Policy owner = policy(actor, admin); ApiContext.getContext().setApiRequest(request); ApiContext.getContext().setPolicy(owner); + keyAccess.authorize(request, owner); + return ((List) manager.list("project", request)).stream().map(x -> ((Account) x).getId()).sorted().toList(); + } + List ids(Map value) { + return ((List) value.get("data")).stream().map(x -> (String) ((Map) x).get("id")).sorted().toList(); + } + @Override public void close() throws Exception { + if (previousEnabled == null) ArchaiusUtil.getConfiguration().clearProperty("api.ai.bridge.enabled"); + else ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", previousEnabled); + connection.close(); ApiContext.remove(); + } + } +} diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiLoadBalancerMetadataChangeTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiLoadBalancerMetadataChangeTest.java new file mode 100644 index 0000000000..c585fe3dd5 --- /dev/null +++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiLoadBalancerMetadataChangeTest.java @@ -0,0 +1,410 @@ +package io.cattle.platform.iaas.api.auth.change; + +import static io.cattle.platform.core.model.tables.ServiceTable.SERVICE; +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import io.cattle.platform.api.auth.ApiKeyAuditSink; +import io.cattle.platform.api.auth.Policy; +import io.cattle.platform.api.formatter.DefaultIdFormatter; +import io.cattle.platform.archaius.util.ArchaiusUtil; +import io.cattle.platform.core.model.Service; +import io.cattle.platform.core.model.tables.records.ServiceRecord; +import io.cattle.platform.core.model.tables.records.StackRecord; +import io.cattle.platform.engine.manager.impl.ProcessRecord; +import io.cattle.platform.engine.process.LaunchConfiguration; +import io.cattle.platform.engine.process.ProcessAuthorization; +import io.cattle.platform.engine.process.ProcessAuthorizationDeniedException; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyAuthorizationService; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyCredentialContext; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyPolicy; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyPolicyEvaluator; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyProcessAuthorization; +import io.cattle.platform.iaas.api.auth.apikey.ApiKeyTargetResolver; +import io.cattle.platform.json.JacksonJsonMapper; +import io.cattle.platform.object.ObjectManager; +import io.github.ibuildthecloud.gdapi.context.ApiContext; +import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; +import io.github.ibuildthecloud.gdapi.factory.SchemaFactory; +import io.github.ibuildthecloud.gdapi.model.impl.FieldImpl; +import io.github.ibuildthecloud.gdapi.request.ApiRequest; +import io.github.ibuildthecloud.gdapi.request.resource.ResourceManager; +import io.github.ibuildthecloud.gdapi.request.resource.ResourceManagerLocator; +import java.sql.DriverManager; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; +import org.jooq.impl.DefaultConfiguration; +import org.junit.After; +import org.junit.Test; + +/** This new subset keeps the existing native owner, Key authorization and durable plan owners. */ +public class AiLoadBalancerMetadataChangeTest { + @After public void clear() { ApiContext.remove(); } + + @Test public void onlyLoadBalancerMetadataFieldsAndIntentAreAdmitted() { + var descriptor = descriptor(); + assertEquals("PUT", descriptor.method()); assertEquals("update", descriptor.keyOperation()); assertNull(descriptor.action()); + var input = new LinkedHashMap(); input.put("name", "safe-name"); input.put("description", null); + assertEquals(input, descriptor.body(descriptor.parameters(input))); + for (String field : List.of("lbConfig", "portRules", "certificateIds", "image", "scale", "action", "data", "launchConfig", "stackId")) + assertEquals("ResourceChangeInputInvalid", assertThrows(ClientVisibleException.class, + () -> descriptor.parameters(Map.of(field, "PRIVATE-CANARY"))).getCode()); + for (String operation : List.of("create", "start", "stop", "restart", "scale", "remove", "archive", "restore")) + assertThrows(ClientVisibleException.class, () -> AiResourceChangeDescriptor.require("loadBalancerService", operation)); + assertThrows(ClientVisibleException.class, () -> descriptor.parameters(Map.of())); + assertThrows(ClientVisibleException.class, () -> descriptor.parameters(Map.of("name", " "))); + } + + @Test public void freshNativeStackAndKeyChecksProtectTheSparsePutAndPrivateParentRead() throws Exception { + enabled(() -> { + var f = new DomainFixture(); realKey(f, full()); + when(f.stackManager.getById(eq("stack"), eq("1st3"), any())).thenAnswer(call -> { + ApiRequest request = ApiContext.getContext().getApiRequest(); + assertTrue(AiResourceChangeDomain.trustedDomain(request)); + var copied = new ApiRequest(request); assertFalse(AiResourceChangeDomain.trustedDomain(copied)); + request.setMethod("PUT"); assertFalse(AiResourceChangeDomain.trustedDomain(request)); request.setMethod("GET"); + assertEquals(Map.of(), request.getRequestObject()); return f.stack; + }); + when(f.manager.update(eq("loadBalancerService"), eq("1s7"), any())).thenAnswer(call -> { + ApiRequest actual = call.getArgument(2); assertTrue(AiResourceChangeDomain.trustedDomain(actual)); + assertEquals(Map.of("name", "after"), actual.getRequestObject()); return f.row; + }); + assertEquals(Map.of("name", "after"), f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "after"))); + assertSame(f.row, f.domain.dispatch(f.request, descriptor(), "1s7", Map.of("name", "after"), "own-plan")); + assertEquals("Environment / Blue / shared", f.domain.targetPath(f.request, f.row, null, Map.of())); + f.stack.setName("Green"); assertEquals("Environment / Green / shared", f.domain.targetPath(f.request, f.row, null, Map.of())); + realKey(f, new ApiKeyPolicy(ApiKeyPolicy.Mode.CUSTOM, ApiKeyPolicy.Effect.DENY, null, + List.of(new ApiKeyPolicy.Rule("lb-only", ApiKeyPolicy.Effect.ALLOW, + ApiKeyPolicy.Scope.resource("service", "1s7"), Set.of("read", "update"))), ApiKeyPolicy.Purpose.AI_BRIDGE)); + assertEquals("KeyScopeDenied", assertThrows(ClientVisibleException.class, + () -> f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "never"))).getCode()); + realKey(f, new ApiKeyPolicy(ApiKeyPolicy.Mode.CUSTOM, ApiKeyPolicy.Effect.DENY, null, + List.of(new ApiKeyPolicy.Rule("lb-service", ApiKeyPolicy.Effect.ALLOW, + ApiKeyPolicy.Scope.resource("service", "1s7"), Set.of("read", "update")), + new ApiKeyPolicy.Rule("read-stack", ApiKeyPolicy.Effect.ALLOW, + ApiKeyPolicy.Scope.resource("stack", "1st3"), Set.of("read"))), ApiKeyPolicy.Purpose.AI_BRIDGE)); + assertEquals(Map.of("name", "after"), f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "after"))); + when(f.http.getHeader(io.cattle.platform.iaas.api.auth.apikey.ApiKeyAiBridgeGuard.EXPECTED_REVISION_HEADER)).thenReturn("2"); + assertEquals("ApiKeyPolicyChanged", assertThrows(ClientVisibleException.class, + () -> f.domain.dispatch(f.request, descriptor(), "1s7", Map.of("name", "never"), "own-plan")).getCode()); + verify(f.manager, times(1)).update(anyString(), anyString(), any()); + assertSame(f.request, ApiContext.getContext().getApiRequest()); assertSame(f.policy, ApiContext.getContext().getPolicy()); + }); + } + + @Test public void scalarSnapshotsAndStackContextCasExcludeConfigurationAndRejectScopeOrMovement() throws Exception { + enabled(() -> { + var f = new DomainFixture(); realKey(f, full()); + f.row.setData(Map.of("fields", Map.of("lbConfig", Map.of("secret", "PRIVATE-CANARY")))); + var snapshot = f.domain.loadBalancerMetadataSnapshot(f.row); + assertEquals("lb-uuid", snapshot.get("uuid")); assertFalse(snapshot.containsKey("data")); + assertFalse(snapshot.toString().contains("PRIVATE-CANARY")); + assertTrue(f.domain.snapshot(f.row).containsKey("data")); // Existing Service/Stack CAS semantics remain intact. + var context = f.domain.loadBalancerMetadataContext(f.request, descriptor(), "1s7"); + var receipt = new LinkedHashMap(); + receipt.put("internalResourceId", 7L); receipt.put("resourceId", "1s7"); receipt.put("status", "accepted"); + receipt.put("snapshot", snapshot); receipt.put("referenceSnapshot", context); + when(f.domain.objects.loadResource("loadBalancerService", 7L)).thenReturn(f.row); + when(f.policy.authorizeObject(f.row)).thenReturn(f.row); + f.row.setName("after"); + assertTrue(f.domain.complete(descriptor(), f.row, Map.of("name", "after"), snapshot)); + assertSame(f.row, f.domain.receiptTarget(f.request, descriptor(), receipt)); + f.row.setUuid("replacement"); + assertTrue("Matching metadata alone cannot prove the original resource identity", + f.domain.complete(descriptor(), f.row, Map.of("name", "after"), snapshot)); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, + () -> f.domain.receiptTarget(f.request, descriptor(), receipt)).getCode()); + f.row.setUuid("lb-uuid"); f.stack.setUuid("replacement"); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, + () -> f.domain.receiptTarget(f.request, descriptor(), receipt)).getCode()); + f.stack.setUuid("stack-uuid"); f.row.setStackId(4L); + var otherStack = stack(); otherStack.setId(4L); otherStack.setUuid("other-stack-uuid"); + when(f.stackManager.getById(eq("stack"), eq("1st4"), any())).thenReturn(otherStack); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, + () -> f.domain.receiptTarget(f.request, descriptor(), receipt)).getCode()); + f.row.setStackId(3L); f.row.setState("updating"); f.stack.setName("new-readable-name"); + assertSame("Normal mutable state and labels do not change receipt identity", f.row, + f.domain.receiptTarget(f.request, descriptor(), receipt)); + receipt.put("status", "succeeded"); receipt.put("verifiedResult", true); + receipt.remove("snapshot"); receipt.remove("referenceSnapshot"); + assertSame("An already verified receipt remains historical after snapshots are erased", f.row, + f.domain.receiptTarget(f.request, descriptor(), receipt)); + f.stack.setName("renamed"); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, + () -> f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "after"), Map.of("referenceSnapshot", context))).getCode()); + f.stack.setAccountId(88L); + assertEquals("NotFound", assertThrows(ClientVisibleException.class, + () -> f.domain.targetPath(f.request, f.row, null, Map.of())).getCode()); + f.stack.setAccountId(77L); when(f.stackManager.getById(eq("stack"), eq("1st3"), any())).thenAnswer(call -> { + var moved = row(); moved.setStackId(4L); when(f.domain.objects.loadResource(Service.class, 7L)).thenReturn(moved); return f.stack; + }); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, + () -> f.domain.loadBalancerMetadataContext(f.request, descriptor(), "1s7")).getCode()); + when(f.schema.getResourceMethods()).thenReturn(List.of("GET")); + assertEquals("PermissionDenied", assertThrows(ClientVisibleException.class, + () -> f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "after"))).getCode()); + when(f.schema.getResourceMethods()).thenReturn(List.of("GET", "PUT")); f.name.setUpdate(false); + assertEquals("PermissionDenied", assertThrows(ClientVisibleException.class, + () -> f.domain.prepare(f.request, descriptor(), "1s7", Map.of("name", "after"))).getCode()); + verify(f.manager, never()).update(anyString(), anyString(), any()); + }); + } + + @Test public void loadBalancerPlanKeepsHumanApprovalSnapshotCasRevocationAndOneClaim() { + var f = new AiResourceChangePlanServiceTest.Fixture(); f.target.setKind("loadBalancerService"); f.target.setUuid("lb-uuid"); + var owner = new AiResourceChangeDomain(); owner.json = f.json; + when(f.domain.loadBalancerMetadataSnapshot(any())).thenAnswer(call -> owner.loadBalancerMetadataSnapshot(call.getArgument(0))); + when(f.domain.loadBalancerMetadataContext(any(), any(), anyString())).thenReturn(Map.of("uuid", "stack-uuid", "name", "Blue")); + when(f.domain.receiptTarget(any(), any(), anyMap())).thenAnswer(call -> { + Map plan = call.getArgument(2); + f.domain.loadBalancerMetadataContext(call.getArgument(0), call.getArgument(1), (String) plan.get("resourceId")); + return f.target; + }); + ApiRequest request = f.request(null, true); request.setRequestObject(Map.of("resourceType", "loadBalancerService", "operation", "update_metadata", + "resourceId", "1s7", "parameters", Map.of("description", "new private description"))); + f.id = (String) f.service.preview(request).get("id"); + assertFalse(((Map) f.documents.get(f.id).get("snapshot")).containsKey("data")); + assertEquals("ResourceChangeNeedsConfirmation", assertThrows(ClientVisibleException.class, () -> f.apply(true)).getCode()); + f.target.setUuid("replacement"); + assertEquals("ResourceChangePlanStale", assertThrows(ClientVisibleException.class, f::confirm).getCode()); f.target.setUuid("lb-uuid"); + f.writeAllowed = false; assertEquals("PermissionDenied", assertThrows(ClientVisibleException.class, f::confirm).getCode()); + f.writeAllowed = true; f.confirm(); + assertEquals("ResourceChangeBoundKeyRequired", assertThrows(ClientVisibleException.class, () -> f.apply(false)).getCode()); + assertEquals("accepted", f.apply(true).get("status")); f.apply(true); + assertEquals(1, f.dispatched); assertEquals(1, f.claims); f.complete = true; + var done = f.service.get(f.id, f.request(null, true)); assertEquals(true, done.get("verifiedResult")); + assertFalse(done.containsKey("snapshot")); assertFalse(done.containsKey("referenceSnapshot")); + when(f.domain.loadBalancerMetadataContext(any(), any(), anyString())).thenThrow(new ClientVisibleException(403, "KeyScopeDenied")); + assertEquals("KeyScopeDenied", assertThrows(ClientVisibleException.class, () -> f.service.get(f.id, f.request(null, true))).getCode()); + } + + @Test public void loadBalancerMetadataLocksActualServiceRowAndDetectsNativeScalarChanges() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:lb-lock-" + UUID.randomUUID() + ";DATABASE_TO_LOWER=TRUE")) { + var config = new DefaultConfiguration().set(connection).set(SQLDialect.H2); var sql = DSL.using(config); + sql.createSchemaIfNotExists("cattle").execute(); + sql.createTable(SERVICE).columns(java.util.Arrays.stream(SERVICE.fields()).map(field -> DSL.field(field.getUnqualifiedName(), + field.getDataType().getSQLDataType().nullable(true))).toList()).execute(); + sql.insertInto(SERVICE).set(SERVICE.ID, 7L).set(SERVICE.ACCOUNT_ID, 77L).set(SERVICE.UUID, "lb-uuid") + .set(SERVICE.KIND, "loadBalancerService").set(SERVICE.NAME, "shared").set(SERVICE.STATE, "active").set(SERVICE.SYSTEM, false) + .set(SERVICE.REMOVED, (java.util.Date) null).set(SERVICE.DATA, Map.of()).execute(); + var domain = new AiResourceChangeDomain(); domain.json = new JacksonJsonMapper(); domain.setConfiguration(config); + assertEquals("ResourceChangeTransactionRequired", assertThrows(ClientVisibleException.class, + () -> domain.lock("loadBalancerService", 7)).getCode()); + connection.setAutoCommit(false); var before = domain.loadBalancerMetadataSnapshot(domain.lock("loadBalancerService", 7)); + sql.update(SERVICE).set(SERVICE.DESCRIPTION, "human-update").where(SERVICE.ID.eq(7L)).execute(); + assertNotEquals(before, domain.loadBalancerMetadataSnapshot(domain.lock("loadBalancerService", 7))); connection.rollback(); + } + } + + @Test public void queuedNativeServiceUpdateUsesExactLbKindUuidStackAndCapturedProcess() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:lb-observer-" + UUID.randomUUID() + ";DATABASE_TO_LOWER=TRUE")) { + connection.createStatement().execute("CREATE TABLE ai_resource_change_plan(id VARCHAR(64) PRIMARY KEY,state VARCHAR(32),document CLOB," + + "actor_id BIGINT,expires_at BIGINT,execution_binding CLOB,failure_code VARCHAR(64))"); + var store = new AiResourceChangePlanStore(); store.json = new JacksonJsonMapper(); + store.setConfiguration(new DefaultConfiguration().set(connection).set(SQLDialect.H2)); + var observer = new AiResourceChangeProcessObserver(); observer.store = store; observer.objects = mock(ObjectManager.class); + observer.targets = new ApiKeyTargetResolver(); var schema = mock(SchemaFactory.class); + var formatter = new DefaultIdFormatter(); formatter.setSchemaFactory(schema); formatter.setTypeMappings(Map.of("credential", "c")); + inject(observer.targets, "idFormatter", formatter); + var policy = mock(Policy.class); when(policy.getAccountId()).thenReturn(77L); when(policy.getAuthenticatedAsAccountId()).thenReturn(42L); + ApiContext.newContext().setPolicy(policy); ApiContext.getContext().setIdFormatter(formatter); + var row = row(); var stack = stack(); + when(observer.objects.loadResource("service", 7L)).thenReturn(row); when(observer.objects.loadResource("service", "7")).thenReturn(row); + when(observer.objects.loadResource(io.cattle.platform.core.model.Stack.class, 3L)).thenReturn(stack); + String id = "aichange-" + UUID.randomUUID(); var plan = new LinkedHashMap(); + plan.putAll(Map.of("id", id, "resourceType", "loadBalancerService", "operation", "update_metadata", "actorId", 42L, "accountId", 77L, + "credentialId", 7L, "credentialRevision", 3L, "credentialKind", "apiKeyRestricted", "internalResourceId", 7L, "resourceId", "1s7")); + plan.put("status", "preview"); plan.put("parameters", Map.of("name", "after")); plan.put("expiresAt", "2030-01-01T00:00:00Z"); + store.insert(plan); plan.put("status", "dispatching"); plan.put("operationId", id); assertTrue(store.claim(plan)); + var request = new ApiRequest(null, schema); request.setType("loadBalancerService"); request.setMethod("PUT"); request.setId("7"); + ApiKeyCredentialContext.attach(request, new ApiKeyCredentialContext(7, 42, "apiKeyRestricted", 3, full())); + request.setAttribute("apiKey.audit.requestId", id); request.setAttribute("apiKey.audit.decision", "ALLOW"); + request.setAttribute("apiKey.audit.keyId", "1c7"); + request.setAttribute("apiKey.audit.operation", "update"); + request.setRequestObject(Map.of("name", "after")); + ApiContext.getContext().setApiRequest(request); + var nativeOwner = new ApiKeyProcessAuthorization(); + var config = new LaunchConfiguration("service.update", "service", "7", 77L, 0, Map.of("name", "after", "old", Map.of("name", "shared"))); + request.setRequestObject(Map.of("name", "after", "lbConfig", Map.of("unsafe", true))); + assertThrows(IllegalStateException.class, () -> observer.dispatch(request, id, () -> { fail("unapproved business data"); return null; })); + assertNull(request.getAttribute(AiResourceChangeProcessObserver.ATTRIBUTE)); request.setRequestObject(Map.of("name", "after")); + observer.dispatch(request, id, () -> { ProcessAuthorization.prepare(config, List.of(nativeOwner, observer)); return null; }); + assertNull(request.getAttribute(AiResourceChangeProcessObserver.ATTRIBUTE)); + var record = new ProcessRecord(config, 901L, "test"); record.setData(store.json.readValue(store.json.writeValueAsString(record.getData()))); + var metadata = ProcessAuthorization.metadata(record); + assertFalse("ordinary service.update does not use the Project-only captured execution capability", ProcessAuthorization.executionBound(record)); + + assertEquals("stack-uuid", store.execution(id).get("rootStackUuid")); + var check = AiResourceChangeProcessObserver.class.getDeclaredMethod("metadataMatches", Map.class, Map.class); + check.setAccessible(true); assertEquals("metadataMatches", true, check.invoke(observer, store.execution(id), metadata)); + check = AiResourceChangeProcessObserver.class.getDeclaredMethod("provenChild", Map.class, LaunchConfiguration.class, Map.class); + check.setAccessible(true); assertEquals("provenChild", true, check.invoke(observer, store.execution(id), record, metadata)); + var sql = DSL.using(new DefaultConfiguration().set(connection).set(SQLDialect.H2)); + var processTable = io.cattle.platform.core.model.tables.ProcessInstanceTable.PROCESS_INSTANCE; + sql.createSchemaIfNotExists("cattle").execute(); + sql.createTable(processTable).columns(java.util.Arrays.stream(processTable.fields()).map(field -> DSL.field(field.getUnqualifiedName(), + field.getDataType().getSQLDataType().nullable(true))).toList()).execute(); + sql.insertInto(processTable).set(processTable.ID, 901L).set(processTable.ACCOUNT_ID, 77L).set(processTable.PROCESS_NAME, "service.update") + .set(processTable.RESOURCE_TYPE, "service").set(processTable.RESOURCE_ID, "7").set(processTable.DATA, record.getData()).execute(); + when(observer.objects.loadResource(eq(io.cattle.platform.core.model.ProcessInstance.class), anyLong())).thenAnswer(call -> + sql.selectFrom(processTable).where(processTable.ID.eq((Long) call.getArgument(1))).fetchOne()); + observer.beforeExecution(record, metadata); assertEquals(901L, ((Number) store.execution(id).get("rootProcessId")).longValue()); + assertEquals(7L, ((Number) store.execution(id).get("rootId")).longValue()); + row.setKind("service"); assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); + row.setKind("loadBalancerService"); row.setUuid("replacement"); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); row.setUuid("lb-uuid"); + stack.setUuid("replacement"); assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); + stack.setUuid("stack-uuid"); row.setAccountId(88L); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); row.setAccountId(77L); + var wrong = new ProcessRecord(new LaunchConfiguration("service.update", "service", "8", 77L, 0, record.getData()), 902L, "test"); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(wrong, metadata)); + var duplicate = new ProcessRecord(config, 902L, "test"); duplicate.setData(record.getData()); + sql.insertInto(processTable).set(processTable.ID, 902L).set(processTable.ACCOUNT_ID, 77L).set(processTable.PROCESS_NAME, "service.update") + .set(processTable.RESOURCE_TYPE, "service").set(processTable.RESOURCE_ID, "7").set(processTable.DATA, record.getData()).execute(); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(duplicate, metadata)); + record.getData().put("lbConfig", Map.of("unsafe", true)); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); record.getData().remove("lbConfig"); + observer.beforeExecution(record, metadata); assertEquals(1, ((List) store.execution(id).get("initial")).size()); + row.setRemoved(new java.util.Date()); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); + row.setRemoved(null); + stack.setRemoved(new java.util.Date()); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); + stack.setRemoved(null); + plan.put("status", "unknown"); store.save(plan); observer.beforeExecution(record, metadata); + assertFalse(Boolean.TRUE.equals(store.load(id).get("verifiedResult"))); assertEquals("unknown", store.load(id).get("status")); + sql.deleteFrom(processTable).where(processTable.ID.eq(901L)).execute(); + assertThrows(ProcessAuthorizationDeniedException.class, () -> observer.beforeExecution(record, metadata)); + } + } + + @Test public void queuedNativeOwnerRetainsFullLegacySchemaRevisionAndRevocationChecks() throws Exception { + var owner = mock(Policy.class); when(owner.authorizeObject(any())).thenAnswer(call -> call.getArgument(0)); + var schemas = mock(SchemaFactory.class); var schema = mock(io.github.ibuildthecloud.gdapi.model.Schema.class); + when(schemas.getSchema("loadBalancerService")).thenReturn(schema); when(schema.getResourceMethods()).thenReturn(List.of("PUT")); + var objects = mock(ObjectManager.class); when(objects.getSchemaFactory()).thenReturn(schemas); + var credential = mock(io.cattle.platform.core.model.Credential.class); when(credential.getId()).thenReturn(7L); + when(credential.getAccountId()).thenReturn(42L); when(credential.getKind()).thenReturn("apiKey"); when(credential.getState()).thenReturn("active"); + var row = row(); when(objects.loadResource(io.cattle.platform.core.model.Credential.class, 7L)).thenReturn(credential); + when(objects.loadResource("loadBalancerService", 7L)).thenReturn(row); when(objects.loadResource("service", "7")).thenReturn(row); + var authenticator = mock(io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator.class); + when(authenticator.currentAuthorization(eq(42L), eq(77L), any())).thenReturn( + new io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator.CurrentAuthorization(owner, schemas)); + var targets = mock(ApiKeyTargetResolver.class); when(targets.parseScopeId("credential", "1c7")).thenReturn(7L); + when(targets.resolveObject(anyString(), eq(row), eq(owner))).thenReturn( + ApiKeyPolicyEvaluator.Target.stackResource("service", "1s7", "1a77", "1st3")); + var hook = new ApiKeyProcessAuthorization(); + inject(hook, "objectManager", objects); + inject(hook, "authenticator", authenticator); + inject(hook, "targets", targets); + var metadata = new LinkedHashMap(Map.of("keyId", "1c7", "policyRevision", 0L, "principalAccountId", 42L, + "accountId", 77L, "operation", "update", "targetType", "loadBalancerService", "targetId", "7", + "requestMethod", "PUT", "requestCollection", false)); + var config = new LaunchConfiguration("service.update", "service", "7", 77L, 0, Map.of("name", "after", "old", Map.of("name", "shared"))); + metadata.put("requestId", UUID.randomUUID().toString()); + hook.beforeExecution(config, metadata); // Native legacy Key, not a mocked AI grant. + new AiResourceChangeProcessObserver().beforeExecution(config, metadata); + var codec = new io.cattle.platform.iaas.api.auth.apikey.ApiKeyPolicyCodec(); + var stored = codec.store(credential, generalFull(), 3); when(credential.getData()).thenReturn(stored); metadata.put("policyRevision", 3L); + hook.beforeExecution(config, metadata); + when(owner.authorizeObject(row)).thenReturn(null); + assertEquals("OwnerPermissionDenied", assertThrows(ProcessAuthorizationDeniedException.class, () -> hook.beforeExecution(config, metadata)).getCode()); + when(owner.authorizeObject(row)).thenReturn(row); when(schema.getResourceMethods()).thenReturn(List.of("GET")); + assertEquals("OwnerPermissionDenied", assertThrows(ProcessAuthorizationDeniedException.class, () -> hook.beforeExecution(config, metadata)).getCode()); + when(schema.getResourceMethods()).thenReturn(List.of("PUT")); metadata.put("policyRevision", 2L); + assertEquals("ApiKeyPolicyChanged", assertThrows(ProcessAuthorizationDeniedException.class, () -> hook.beforeExecution(config, metadata)).getCode()); + metadata.put("policyRevision", 3L); when(credential.getState()).thenReturn("removed"); + assertEquals("ApiKeyRevoked", assertThrows(ProcessAuthorizationDeniedException.class, () -> hook.beforeExecution(config, metadata)).getCode()); + verify(authenticator, atLeast(4)).currentAuthorization(eq(42L), eq(77L), any()); + } + + @Test public void ordinaryNativeV1V2FullAndLegacyCaptureNeverEnterTheAiMandatoryBranch() { + for (String version : List.of("v1", "v2-beta")) for (boolean restrictedFull : List.of(false, true)) { + var owner = mock(Policy.class); when(owner.getAccountId()).thenReturn(77L); when(owner.getAuthenticatedAsAccountId()).thenReturn(42L); + var request = new ApiRequest(null, null); request.setSchemaVersion(version); request.setType("loadBalancerService"); + request.setId("7"); request.setMethod("PUT"); request.setRequestObject(Map.of("name", "native-owner-change")); + ApiContext.newContext().setPolicy(owner); + var formatter = new DefaultIdFormatter(); formatter.setTypeMappings(Map.of("credential", "c")); + formatter.setSchemaFactory(mock(SchemaFactory.class)); ApiContext.getContext().setIdFormatter(formatter); + ApiKeyCredentialContext.attach(request, new ApiKeyCredentialContext(7, 42, restrictedFull ? "apiKeyRestricted" : "apiKey", + restrictedFull ? 3 : 0, restrictedFull ? generalFull() : null)); + request.setAttribute("apiKey.audit.requestId", UUID.randomUUID().toString()); request.setAttribute("apiKey.audit.decision", "ALLOW"); + request.setAttribute("apiKey.audit.keyId", "1c7"); request.setAttribute("apiKey.audit.operation", "update"); + ApiContext.getContext().setApiRequest(request); + var observer = new AiResourceChangeProcessObserver(); observer.store = mock(AiResourceChangePlanStore.class); + var config = new LaunchConfiguration("service.update", "service", "7", 77L, 0, + Map.of("name", "native-owner-change", "old", Map.of("name", "shared"))); + ProcessAuthorization.prepare(config, List.of(new ApiKeyProcessAuthorization(), observer)); + var metadata = ProcessAuthorization.metadata(config); + assertEquals("loadBalancerService", metadata.get("targetType")); assertEquals("PUT", metadata.get("requestMethod")); + assertEquals(77L, ((Number) metadata.get("accountId")).longValue()); assertEquals(42L, ((Number) metadata.get("principalAccountId")).longValue()); + observer.beforeExecution(config, metadata); verifyNoInteractions(observer.store); + assertEquals(version, request.getSchemaVersion()); assertSame(request, ApiContext.getContext().getApiRequest()); + } + } + + private static AiResourceChangeDescriptor descriptor() { return AiResourceChangeDescriptor.require("loadBalancerService", "update_metadata"); } + private static ApiKeyPolicy full() { + return new ApiKeyPolicy(ApiKeyPolicy.Mode.FULL, ApiKeyPolicy.Effect.ALLOW, null, List.of(), ApiKeyPolicy.Purpose.AI_BRIDGE); + } + private static ApiKeyPolicy generalFull() { + return new ApiKeyPolicy(ApiKeyPolicy.Mode.FULL, ApiKeyPolicy.Effect.ALLOW, null, List.of(), ApiKeyPolicy.Purpose.GENERAL); + } + private static ServiceRecord row() { + var row = new ServiceRecord(); row.setId(7L); row.setAccountId(77L); row.setUuid("lb-uuid"); row.setKind("loadBalancerService"); + row.setStackId(3L); row.setName("shared"); row.setState("active"); return row; + } + private static StackRecord stack() { + var stack = new StackRecord(); stack.setId(3L); stack.setAccountId(77L); stack.setUuid("stack-uuid"); stack.setKind("stack"); + stack.setName("Blue"); stack.setState("active"); return stack; + } + private static class DomainFixture extends AiResourceChangeDomainTest.Fixture { + final ServiceRecord row = row(); final StackRecord stack = stack(); + final ResourceManager manager = mock(ResourceManager.class), stackManager = mock(ResourceManager.class); + DomainFixture() { + when(schemas.getSchema("loadBalancerService")).thenReturn(schema); when(schemas.getSchema("stack")).thenReturn(schema); + when(schema.getResourceMethods()).thenReturn(List.of("GET", "PUT")); var description = new FieldImpl(); description.setUpdate(true); + when(schema.getResourceFields()).thenReturn(Map.of("name", name, "description", description)); + domain.managers = mock(ResourceManagerLocator.class); when(domain.managers.getResourceManagerByType("loadBalancerService")).thenReturn(manager); + when(domain.managers.getResourceManagerByType("stack")).thenReturn(stackManager); + when(manager.getById(eq("loadBalancerService"), eq("1s7"), any())).thenReturn(row); + when(stackManager.getById(eq("stack"), eq("1st3"), any())).thenReturn(stack); + when(domain.objects.loadResource(Service.class, 7L)).thenReturn(row); + domain.targets = mock(ApiKeyTargetResolver.class); when(domain.targets.parseScopeId(anyString(), anyString())).thenAnswer(call -> + Long.parseLong(((String) call.getArgument(1)).replaceFirst("^1(?:st|s)", ""))); + var formatter = new DefaultIdFormatter(); + formatter.setSchemaFactory(schemas); + when(schemas.getBaseType("loadBalancerService")).thenReturn("service"); + formatter.setTypeMappings(Map.of("service", "s", "stack", "st")); ApiContext.getContext().setIdFormatter(formatter); + ((io.cattle.platform.core.model.Account) domain.objects.loadResource(io.cattle.platform.core.model.Account.class, 77L)).setName("Environment"); + } + } + private static void realKey(DomainFixture f, ApiKeyPolicy policy) throws Exception { + var service = new ApiKeyAuthorizationService(); var targets = mock(ApiKeyTargetResolver.class); + when(targets.resolve(any(), any())).thenAnswer(call -> { + ApiRequest request = call.getArgument(0); + return List.of(ApiKeyPolicyEvaluator.Target.projectResource("loadBalancerService".equals(request.getType()) ? "service" : request.getType(), + request.getId(), "1a77")); + }); + inject(service, "targets", targets); inject(service, "auditSinks", List.of(mock(ApiKeyAuditSink.class))); f.domain.keyAccess = service; + ApiKeyCredentialContext.attach(f.request, new ApiKeyCredentialContext(7, 42, "apiKeyRestricted", 3, policy)); + } + private static void inject(Object target, String name, Object value) throws Exception { + var field = target.getClass().getDeclaredField(name); field.setAccessible(true); field.set(target, value); + } + private static void enabled(Checked operation) throws Exception { + Object previous = ArchaiusUtil.getConfiguration().getProperty("api.ai.bridge.enabled"); + try { ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", true); operation.run(); } + finally { + if (previous == null) ArchaiusUtil.getConfiguration().clearProperty("api.ai.bridge.enabled"); + else ArchaiusUtil.getConfiguration().setProperty("api.ai.bridge.enabled", previous); + } + } + private interface Checked { void run() throws Exception; } +} diff --git a/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChangeTest.java b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChangeTest.java new file mode 100644 index 0000000000..099bd3a07f --- /dev/null +++ b/code/iaas/auth-logic/src/test/java/io/cattle/platform/iaas/api/auth/change/AiProjectMetadataChangeTest.java @@ -0,0 +1,371 @@ +package io.cattle.platform.iaas.api.auth.change; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import io.cattle.platform.api.auth.Policy; +import io.cattle.platform.api.auth.impl.NoPolicyOptions; +import io.cattle.platform.core.model.Account; +import io.cattle.platform.core.model.tables.records.AccountRecord; +import io.cattle.platform.engine.process.LaunchConfiguration; +import io.cattle.platform.engine.process.ProcessAuthorization; +import io.cattle.platform.iaas.api.auth.ai.AiKeyAuthorization; +import io.cattle.platform.iaas.api.auth.apikey.*; +import io.cattle.platform.iaas.api.auth.dao.AuthDao; +import io.cattle.platform.iaas.api.auth.impl.AccountPolicy; +import io.cattle.platform.iaas.api.auth.impl.ApiAuthenticator; +import io.cattle.platform.json.JacksonJsonMapper; +import io.cattle.platform.object.ObjectManager; +import io.github.ibuildthecloud.gdapi.context.ApiContext; +import io.github.ibuildthecloud.gdapi.exception.ClientVisibleException; +import io.github.ibuildthecloud.gdapi.factory.SchemaFactory; +import io.github.ibuildthecloud.gdapi.model.Schema; +import io.github.ibuildthecloud.gdapi.model.impl.FieldImpl; +import io.github.ibuildthecloud.gdapi.request.ApiRequest; +import io.github.ibuildthecloud.gdapi.request.resource.ResourceManager; +import java.sql.DriverManager; +import java.util.*; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; +import org.junit.After; +import org.junit.Test; + +public class AiProjectMetadataChangeTest { + @After public void clear() { ApiContext.remove(); } + + @Test public void projectDescriptorIsMetadataOnlyAndKeepsNativeTextBounds() { + var d = descriptor(); assertEquals("PUT", d.method()); assertNull(d.action()); assertEquals("update", d.keyOperation()); + assertEquals(Map.of("name", "x".repeat(255)), d.parameters(Map.of("name", "x".repeat(255)))); + var nullable = new LinkedHashMap(); nullable.put("description", null); + assertTrue(d.parameters(nullable).containsKey("description")); assertNull(d.parameters(nullable).get("description")); + for (Object input : List.of(Map.of(), Map.of("name", ""), Map.of("name", "x".repeat(256)), + Map.of("description", "x".repeat(1025)), Map.of("name", 1), Map.of("description", false), + Map.of("members", List.of()), Map.of("labels", Map.of()), Map.of("networkId", "7"), Map.of("state", "active"))) + assertEquals("ResourceChangeInputInvalid", assertThrows(ClientVisibleException.class, () -> d.parameters(input)).getCode()); + for (String op : List.of("create", "remove", "start", "stop", "restart", "archive", "restore", "update_targets")) + assertEquals("ResourceChangeUnsupported", assertThrows(ClientVisibleException.class, + () -> AiResourceChangeDescriptor.require("project", op)).getCode()); + } + + @Test public void selectedAccountIdentityAndScalarSnapshotNeverCopyConfigurationOrCredentials() { + AccountRecord project = project(77); project.setDescription(null); + project.setData(Map.of("members", "PRIVATE", "network", "PRIVATE", "authConfig", "PRIVATE")); + assertSame(project, AiProjectMetadataChange.selected(project, 77)); + Map before = AiProjectMetadataChange.snapshot(project); + assertEquals(Set.of("id", "uuid", "kind", "name", "description", "state", "removed"), before.keySet()); + assertNull(before.get("description")); assertFalse(before.toString().contains("PRIVATE")); + project.setName("literal