feat: opt in to protected business metrics in Compose and Helm

Signed-off-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
This commit is contained in:
千乘妍 (Xiaoyaner) 2026-11-03 15:41:08 +08:00
parent 294410d324
commit 07d3c31482
10 changed files with 97 additions and 1 deletions

View file

@ -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 }}

View file

@ -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'

View file

@ -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,

View file

@ -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

View file

@ -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:-}

View file

@ -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

View file

@ -357,7 +357,7 @@ management:
endpoints:
web:
exposure:
include: health,info
include: ${SKILLHUB_ACTUATOR_EXPOSURE_INCLUDE:health,info}
endpoint:
health:
show-details: when-authorized

View file

@ -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());
}
}

View file

@ -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"))

View file

@ -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"),