diff --git a/charts/skillhub/templates/server-deployment.yaml b/charts/skillhub/templates/server-deployment.yaml index c3443dcfd..1e3ae4306 100644 --- a/charts/skillhub/templates/server-deployment.yaml +++ b/charts/skillhub/templates/server-deployment.yaml @@ -79,6 +79,8 @@ spec: value: {{ $profiles | quote }} - name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED value: {{ .Values.server.suiteReviewWritesEnabled | quote }} + - name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE + value: {{ .Values.server.actuatorExposureInclude | default "health,info" | quote }} {{- $businessLogs := .Values.server.businessLogs | default dict }} - name: SKILLHUB_BUSINESS_LOGS_ENABLED value: {{ $businessLogs.enabled | default false | quote }} diff --git a/charts/skillhub/tests/configuration-contracts.sh b/charts/skillhub/tests/configuration-contracts.sh index db3ed740c..c23a04ee2 100755 --- a/charts/skillhub/tests/configuration-contracts.sh +++ b/charts/skillhub/tests/configuration-contracts.sh @@ -633,3 +633,25 @@ print('Helm application Secret render/rotation contracts passed') PY_SECRET_RENDER echo "Helm configuration contract tests passed" + +# Business Logs and metrics remain opt-in; credentials reference an existing Secret. +render business-default "$CHART_DIR" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-default.yaml" +grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "health,info"' +grep -A1 -F 'name: SKILLHUB_BUSINESS_LOGS_ENABLED' "$TMP_DIR/business-default.yaml" | grep -Fq 'value: "false"' +assert_rejected business-no-endpoint --set server.businessLogs.enabled=true +assert_rejected actuator-wildcard --set-string 'server.actuatorExposureInclude=*' +cat >"$TMP_DIR/business-values.yaml" <<'BUSINESS_VALUES' +server: + actuatorExposureInclude: health,info,metrics,prometheus + businessLogs: + enabled: true + endpoint: https://receiver.example.com/v1/logs + headersSecretRef: + name: business-logs-credentials + key: headers +BUSINESS_VALUES +render business-enabled "$CHART_DIR" -f "$TMP_DIR/business-values.yaml" --show-only templates/server-deployment.yaml >"$TMP_DIR/business-enabled.yaml" +grep -A1 -F 'name: SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'value: "health,info,metrics,prometheus"' +grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'name: "business-logs-credentials"' +grep -A6 -F 'name: SKILLHUB_BUSINESS_LOGS_HEADERS' "$TMP_DIR/business-enabled.yaml" | grep -Fq 'optional: false' +echo 'Helm business Logs / metrics contracts passed' diff --git a/charts/skillhub/values.schema.json b/charts/skillhub/values.schema.json index af42bc723..62ba1bbfc 100644 --- a/charts/skillhub/values.schema.json +++ b/charts/skillhub/values.schema.json @@ -452,6 +452,7 @@ "enabled": { "type": "boolean" }, "replicaCount": { "type": "integer", "minimum": 1 }, "suiteReviewWritesEnabled": { "type": "boolean" }, + "actuatorExposureInclude": { "enum": ["health,info", "health,info,metrics,prometheus"] }, "businessLogs": { "type": "object", "additionalProperties": false, diff --git a/charts/skillhub/values.yaml b/charts/skillhub/values.yaml index 9dfd6b47a..3bb76f2c3 100644 --- a/charts/skillhub/values.yaml +++ b/charts/skillhub/values.yaml @@ -312,6 +312,9 @@ server: # Server instance supports typed review subjects; fresh non-rolling installs may enable it directly. suiteReviewWritesEnabled: false + # Opt in with health,info,metrics,prometheus. No wildcard exposure. + actuatorExposureInclude: health,info + # Independent of tracing; optional OTLP Logs receiver. Other budgets use server.extraEnv. businessLogs: enabled: false diff --git a/compose.release.yml b/compose.release.yml index c450a7657..848695ecd 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -117,6 +117,7 @@ services: SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:-PT16M} SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true} SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false} + SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info} SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none} SKILLHUB_BUSINESS_LOGS_ENABLED: ${SKILLHUB_BUSINESS_LOGS_ENABLED:-false} SKILLHUB_BUSINESS_LOGS_ENDPOINT: ${SKILLHUB_BUSINESS_LOGS_ENDPOINT:-} diff --git a/scripts/validate-release-config.sh b/scripts/validate-release-config.sh index 3cf5d3909..256a67480 100755 --- a/scripts/validate-release-config.sh +++ b/scripts/validate-release-config.sh @@ -348,6 +348,10 @@ validate_boolean BOOTSTRAP_ADMIN_ENABLED validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED validate_boolean SKILLHUB_BUSINESS_LOGS_ENABLED +case "${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:-health,info}" in + health,info|health,info,metrics,prometheus) ;; + *) error "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE must be health,info or health,info,metrics,prometheus" ;; +esac # Do not echo the endpoint or authentication headers. Full URL/budget validation is at startup. if [ "${SKILLHUB_BUSINESS_LOGS_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_BUSINESS_LOGS_ENDPOINT diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index ac4ce8a6e..2b7383ac3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -357,7 +357,7 @@ management: endpoints: web: exposure: - include: health,info + include: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:health,info} endpoint: health: show-details: when-authorized diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java new file mode 100644 index 000000000..559a9931c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/BusinessMetricsSecurityTest.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.metrics; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability +@SpringBootTest(properties = "SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE=health,info,metrics,prometheus") +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class BusinessMetricsSecurityTest { + @Autowired MockMvc mockMvc; + @Autowired MeterRegistry registry; + @MockBean NamespaceMemberRepository namespaceMemberRepository; + @MockBean DeviceAuthService deviceAuthService; + + static RequestPostProcessor session(String... roles) { + var principal = new PlatformPrincipal("synthetic-metrics-user", "Metrics test", null, null, "local", Set.of(roles)); + var authorities = java.util.Arrays.stream(roles).map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList(); + return authentication(new UsernamePasswordAuthenticationToken(principal, null, authorities)); + } + + @Test + void optInExposesOnlyMetricsToAuthorizedSessions() throws Exception { + registry.counter("skillhub.business.logs.enqueued").increment(); + for (String path : List.of("/actuator/metrics", "/actuator/metrics/skillhub.business.logs.enqueued", "/actuator/prometheus")) { + mockMvc.perform(get(path).with(session("SUPER_ADMIN"))).andExpect(status().isOk()); + mockMvc.perform(get(path).with(session())).andExpect(status().isForbidden()); + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + mockMvc.perform(get("/actuator/env").with(session("SUPER_ADMIN"))).andExpect(status().isNotFound()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java index 74ed95609..972dda370 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusSecurityTest.java @@ -30,6 +30,14 @@ class PrometheusSecurityTest { @MockBean private DeviceAuthService deviceAuthService; + @Test + void metricsRemainUnexposedByDefaultEvenForAdmin() throws Exception { + for (String path : java.util.List.of("/actuator/metrics", "/actuator/prometheus")) { + mockMvc.perform(get(path).with(BusinessMetricsSecurityTest.session("SUPER_ADMIN"))) + .andExpect(status().isNotFound()); + } + } + @Test void prometheusEndpointShouldNotBeAnonymous() throws Exception { mockMvc.perform(get("/actuator/prometheus")) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 115454ecb..97772f6e1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -36,6 +36,8 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.permitAll(null, "/swagger-ui/**"), RouteAuthorizationPolicy.permitAll(null, "/.well-known/**"), RouteAuthorizationPolicy.roles(null, "/actuator/prometheus", "SUPER_ADMIN", "AUDITOR"), + RouteAuthorizationPolicy.roles(null, "/actuator/metrics", "SUPER_ADMIN"), + RouteAuthorizationPolicy.roles(null, "/actuator/metrics/**", "SUPER_ADMIN"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/star"),