From c380c66803d4090eff9c2d3f2712958e171f5e9d Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 11:38:32 -0500 Subject: [PATCH] feat: actuator health on all three apps; other endpoints need an admin token - openespi-common no longer ships management.* or spring.application.name in its jar's application.properties / profile ymls. Every dependent app loaded them: management.endpoints.enabled-by-default=false (deprecated, still honored by Boot 4.1.2) disabled all endpoints, so DC's /actuator/health returned 404, and the .properties name overrode each app's own name in metric tags. - Each app: endpoint access is opt-in (access.default: none) and read-only. Exposed: health, info, metrics (+ prometheus on DC/TP). health shows status only and enables the liveness/readiness probe groups. AS no longer exposes env. - Each app gets an actuator SecurityFilterChain (EndpointRequest): health is anonymous; everything else requires an admin access token. - DC: opaque token with DataCustodian_Admin_Access (existing introspector). - AS: authserver.admin bearer token, resolved in-process (as for #204). - TP: opaque token introspected at the AS with the TP's client credentials, scope espi.actuator.admin-scope (default ThirdParty_Admin_Access). - AS adds spring-boot-starter-actuator; TP adds the resource-server starter. - AS profiles drop enabled-by-default / endpoint.*.enabled, which Boot rejects next to the access keys. - DC/TP local profiles use the secrets the AS seeds (dc-secret, tp-secret); with "secret" every introspection got a 401 from the AS. Closes #236 Co-Authored-By: Claude Opus 5.5 --- openespi-authserver/pom.xml | 5 ++ .../config/AuthorizationServerConfig.java | 33 +++++++- .../resources/application-dev-postgresql.yml | 5 +- .../src/main/resources/application-espi40.yml | 10 +-- .../src/main/resources/application-prod.yml | 13 +-- .../src/main/resources/application.yml | 20 +++-- .../AdminBearerTokenIntegrationTest.java | 26 ++++++ .../src/test/resources/application-test.yml | 11 +-- .../resources/application-testcontainers.yml | 11 +-- .../resources/application-aws-sandbox.yml | 8 -- .../src/main/resources/application-docker.yml | 8 -- .../src/main/resources/application-local.yml | 8 -- .../src/main/resources/application-prod.yml | 8 -- .../src/main/resources/application.properties | 11 ++- .../config/ActuatorSecurityConfiguration.java | 61 ++++++++++++++ .../config/SecurityConfiguration.java | 4 +- .../src/main/resources/application-local.yml | 4 +- .../src/main/resources/application.yml | 15 +++- .../ActuatorSecurityConfigurationTest.java | 77 ++++++++++++++++++ openespi-thirdparty/pom.xml | 5 ++ .../config/ActuatorSecurityConfiguration.java | 80 +++++++++++++++++++ .../src/main/resources/application-local.yml | 4 +- .../src/main/resources/application.yml | 26 ++++-- 23 files changed, 359 insertions(+), 94 deletions(-) create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfiguration.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfigurationTest.java create mode 100644 openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/ActuatorSecurityConfiguration.java diff --git a/openespi-authserver/pom.xml b/openespi-authserver/pom.xml index c298238e..573e4fa6 100644 --- a/openespi-authserver/pom.xml +++ b/openespi-authserver/pom.xml @@ -84,6 +84,11 @@ org.springframework.boot spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java index 1581a63e..cd227cc4 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java @@ -23,6 +23,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; +import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.MediaType; import org.springframework.security.config.Customizer; @@ -473,6 +475,35 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h return http.build(); } + /** + * Stateless chain for the Spring Boot Actuator endpoints. + * + *

Only {@code health} (status only, including the {@code liveness} and {@code readiness} probe + * groups) is anonymous, so load balancers and orchestrators can check that the server is + * operating normally. Every other exposed endpoint ({@code info}, {@code metrics} and the links + * page) requires an {@code authserver.admin} bearer token, resolved in-process exactly as on + * {@link #adminApiBearerSecurityFilterChain}. Endpoint access is read-only + * ({@code management.endpoint.*.access} in application.yml).

+ */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain actuatorSecurityFilterChain( + HttpSecurity http, OAuth2AuthorizationService authorizationService) throws Exception { + OpaqueTokenIntrospector introspector = new AdminScopeOpaqueTokenIntrospector( + new AuthorizationServiceOpaqueTokenIntrospector(authorizationService)); + + http + .securityMatcher(EndpointRequest.toAnyEndpoint()) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(EndpointRequest.to("health")).permitAll() + .anyRequest().hasRole("ADMIN")) + .oauth2ResourceServer(resourceServer -> resourceServer + .opaqueToken(opaque -> opaque.introspector(introspector))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } + /** * Stateless admin-API chain for machine clients (issue #204). * @@ -559,7 +590,7 @@ public SecurityFilterChain defaultSecurityFilterChain( "/assets/**", "/webjars/**", "/login", "/error", "/.well-known/**", - "/actuator/health", "/actuator/info", + // (actuator endpoints are on actuatorSecurityFilterChain) // PR C3 — AS↔DC delegation handoff endpoints. Both must be reachable // without prior authentication: /authorize/delegate is hit BEFORE // the customer authenticates anywhere (its job is to delegate auth diff --git a/openespi-authserver/src/main/resources/application-dev-postgresql.yml b/openespi-authserver/src/main/resources/application-dev-postgresql.yml index 2f7c59d2..6c67783c 100644 --- a/openespi-authserver/src/main/resources/application-dev-postgresql.yml +++ b/openespi-authserver/src/main/resources/application-dev-postgresql.yml @@ -68,7 +68,4 @@ management: endpoints: web: exposure: - include: health,info,metrics - endpoint: - health: - show-details: when_authorized \ No newline at end of file + include: health,info,metrics \ No newline at end of file diff --git a/openespi-authserver/src/main/resources/application-espi40.yml b/openespi-authserver/src/main/resources/application-espi40.yml index ff3db126..3ec67610 100644 --- a/openespi-authserver/src/main/resources/application-espi40.yml +++ b/openespi-authserver/src/main/resources/application-espi40.yml @@ -135,13 +135,9 @@ management: exposure: include: health,info base-path: /actuator - enabled-by-default: false - endpoint: - health: - enabled: true - show-details: never # Security hardening - info: - enabled: true + # Endpoint access and health detail settings come from application.yml (opt-in, read-only, + # show-details: never). Do not use the removed enabled-by-default / endpoint.*.enabled keys: + # Spring Boot rejects them alongside the access keys. server: port: ${MANAGEMENT_PORT:8444} ssl: diff --git a/openespi-authserver/src/main/resources/application-prod.yml b/openespi-authserver/src/main/resources/application-prod.yml index 44c474f7..dbebf8d7 100644 --- a/openespi-authserver/src/main/resources/application-prod.yml +++ b/openespi-authserver/src/main/resources/application-prod.yml @@ -121,16 +121,9 @@ management: exposure: include: health,info,metrics base-path: /actuator - enabled-by-default: false - endpoint: - health: - enabled: true - show-details: when_authorized - show-components: when_authorized - info: - enabled: true - metrics: - enabled: true + # Endpoint access and health detail settings come from application.yml (opt-in, read-only, + # show-details: never). Do not use the removed enabled-by-default / endpoint.*.enabled keys: + # Spring Boot rejects them alongside the access keys. server: port: ${MANAGEMENT_PORT:8444} ssl: diff --git a/openespi-authserver/src/main/resources/application.yml b/openespi-authserver/src/main/resources/application.yml index 77034f5e..34ad8382 100644 --- a/openespi-authserver/src/main/resources/application.yml +++ b/openespi-authserver/src/main/resources/application.yml @@ -82,17 +82,27 @@ logging: file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" # Management Endpoints +# Endpoint access is opt-in and read-only. Only health (status only, plus the liveness/readiness +# probe groups) is anonymous; every other endpoint needs an authserver.admin bearer token +# (AuthorizationServerConfig#actuatorSecurityFilterChain). env is deliberately not exposed. management: endpoints: + access: + default: none web: exposure: - include: health,info,metrics,env + include: health,info,metrics endpoint: health: - show-details: when_authorized - info: - env: - enabled: true + access: read-only + show-details: never + show-components: never + probes: + enabled: true + info: + access: read-only + metrics: + access: read-only # ESPI Configuration espi: diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminBearerTokenIntegrationTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminBearerTokenIntegrationTest.java index c163090e..d2e062b5 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminBearerTokenIntegrationTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/integration/AdminBearerTokenIntegrationTest.java @@ -208,6 +208,32 @@ void adminTokenIntrospects() throws Exception { .andExpect(jsonPath("$.scope").value(ADMIN_SCOPE)); } + @Test + @DisplayName("actuator: health and its probe groups are anonymous") + void actuatorHealthIsAnonymous() throws Exception { + for (String path : new String[] {"/actuator/health", "/actuator/health/liveness", "/actuator/health/readiness"}) { + mockMvc.perform(get(path)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + } + + @Test + @DisplayName("actuator: info and metrics -> 401 without a token, 403 without authserver.admin, 200 with it") + void actuatorNonHealthEndpointsRequireAdminToken() throws Exception { + String dcToken = obtainToken("data_custodian_admin", "dc-secret", "DataCustodian_Admin_Access"); + String adminToken = adminToken(); + + for (String path : new String[] {"/actuator", "/actuator/info", "/actuator/metrics"}) { + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()); + mockMvc.perform(get(path).header(HttpHeaders.AUTHORIZATION, "Bearer " + dcToken)) + .andExpect(status().isForbidden()); + mockMvc.perform(get(path).header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)) + .andExpect(status().isOk()); + } + } + private String adminToken() throws Exception { ensureAdminClientSeeded(); return obtainToken(ADMIN_CLIENT, ADMIN_SECRET, ADMIN_SCOPE); diff --git a/openespi-authserver/src/test/resources/application-test.yml b/openespi-authserver/src/test/resources/application-test.yml index f04ed9c3..8e1a3d97 100644 --- a/openespi-authserver/src/test/resources/application-test.yml +++ b/openespi-authserver/src/test/resources/application-test.yml @@ -62,12 +62,5 @@ logging: pattern: console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" -# Management Endpoints for Tests -management: - endpoints: - web: - exposure: - include: health,info - endpoint: - health: - show-details: always \ No newline at end of file +# Management endpoints: inherited from application.yml, so the tests exercise the real +# exposure and access rules. diff --git a/openespi-authserver/src/test/resources/application-testcontainers.yml b/openespi-authserver/src/test/resources/application-testcontainers.yml index a9a1f496..a39dcbc5 100644 --- a/openespi-authserver/src/test/resources/application-testcontainers.yml +++ b/openespi-authserver/src/test/resources/application-testcontainers.yml @@ -62,12 +62,5 @@ logging: pattern: console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" -# Management Endpoints for Tests -management: - endpoints: - web: - exposure: - include: health,info - endpoint: - health: - show-details: always \ No newline at end of file +# Management endpoints: inherited from application.yml, so the tests exercise the real +# exposure and access rules (AdminBearerTokenIntegrationTest). diff --git a/openespi-common/src/main/resources/application-aws-sandbox.yml b/openespi-common/src/main/resources/application-aws-sandbox.yml index 00aac670..ec0500ac 100644 --- a/openespi-common/src/main/resources/application-aws-sandbox.yml +++ b/openespi-common/src/main/resources/application-aws-sandbox.yml @@ -90,14 +90,6 @@ logging: file: name: ${LOG_FILE_PATH:/var/log/openespi/aws-sandbox.log} -# Management - Basic configuration (applications should configure endpoints) -management: - endpoints: - enabled-by-default: false - info: - env: - enabled: true - # ESPI Configuration for AWS Sandbox espi: database: diff --git a/openespi-common/src/main/resources/application-docker.yml b/openespi-common/src/main/resources/application-docker.yml index 66b52c13..3e31c458 100644 --- a/openespi-common/src/main/resources/application-docker.yml +++ b/openespi-common/src/main/resources/application-docker.yml @@ -77,14 +77,6 @@ logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" -# Management - Basic configuration (applications should configure endpoints) -management: - endpoints: - enabled-by-default: false - info: - env: - enabled: true - # ESPI Configuration for Docker espi: database: diff --git a/openespi-common/src/main/resources/application-local.yml b/openespi-common/src/main/resources/application-local.yml index bc6f7a07..9974b7a5 100644 --- a/openespi-common/src/main/resources/application-local.yml +++ b/openespi-common/src/main/resources/application-local.yml @@ -78,14 +78,6 @@ logging: pattern: console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" -# Management - Basic configuration (applications should extend) -management: - endpoints: - enabled-by-default: false - info: - env: - enabled: true - # ESPI Configuration for Local Development espi: database: diff --git a/openespi-common/src/main/resources/application-prod.yml b/openespi-common/src/main/resources/application-prod.yml index f150db0f..53a7f7fe 100644 --- a/openespi-common/src/main/resources/application-prod.yml +++ b/openespi-common/src/main/resources/application-prod.yml @@ -91,14 +91,6 @@ logging: max-size: 100MB max-history: 30 -# Management - Basic configuration (applications should configure endpoints) -management: - endpoints: - enabled-by-default: false - info: - env: - enabled: false - # ESPI Configuration for Production espi: database: diff --git a/openespi-common/src/main/resources/application.properties b/openespi-common/src/main/resources/application.properties index 56d884b3..86864c00 100644 --- a/openespi-common/src/main/resources/application.properties +++ b/openespi-common/src/main/resources/application.properties @@ -24,9 +24,9 @@ # Default Profile Configuration spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev-mysql} -# Application Information -spring.application.name=OpenESPI-Common -spring.application.version=@project.version@ +# No spring.application.name here: this file ships inside the jar and, being .properties, would +# override the name each application sets in its own application.yml (it tagged every DC/TP metric +# application="OpenESPI-Common"). # Default Database Configuration (overridden by profiles) spring.datasource.username=${DB_USERNAME:root} @@ -40,9 +40,8 @@ spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.Ph spring.jackson.default-property-inclusion=non_null spring.jackson.datatype.datetime.write-dates-as-timestamps=false -## Todo - no longer in Spring Boot 4+ -# Default Management Configuration (applications should configure endpoints) -management.endpoints.enabled-by-default=false +# No management.* settings here: this file ships inside the OpenESPI-Common jar and is loaded by +# every application that depends on it. Each application owns its actuator configuration. # ESPI Default Configuration espi.version=1.0 diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfiguration.java new file mode 100644 index 00000000..d016ca14 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.config; + +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security filter chain for the Spring Boot Actuator endpoints. + * + *

Only {@code health} (status only, including the {@code liveness} and {@code readiness} probe + * groups) is anonymous, so load balancers and orchestrators can check that the application is + * operating normally. Every other exposed endpoint ({@code info}, {@code metrics}, + * {@code prometheus} and the links page) requires an opaque access token carrying the + * {@code DataCustodian_Admin_Access} scope, validated by the same introspector as the ESPI API. + * Endpoint access is read-only ({@code management.endpoint.*.access} in application.yml).

+ * + *

Ordered just after the back-channel chain so actuator requests never reach the ESPI + * resource-server chain or the customer-login chain.

+ */ +@Configuration +public class ActuatorSecurityConfiguration { + + static final String ADMIN_AUTHORITY = "SCOPE_DataCustodian_Admin_Access"; + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http, + OpaqueTokenIntrospector introspector) throws Exception { + return http + .securityMatcher(EndpointRequest.toAnyEndpoint()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers(EndpointRequest.to("health")).permitAll() + .anyRequest().hasAuthority(ADMIN_AUTHORITY)) + .oauth2ResourceServer(oauth2 -> oauth2 + .opaqueToken(opaque -> opaque.introspector(introspector))) + .build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java index b70a76fa..e2d0df6a 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/SecurityConfiguration.java @@ -153,11 +153,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, // Configure authorization rules. ESPI customer FB scopes are READ-ONLY; every write is // admin-gated (see the blanket write rule below). .authorizeHttpRequests(authz -> authz - // Public endpoints + // Public endpoints (actuator endpoints are on ActuatorSecurityConfiguration's chain) .requestMatchers( "/error", - "/actuator/health", - "/actuator/info", "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", diff --git a/openespi-datacustodian/src/main/resources/application-local.yml b/openespi-datacustodian/src/main/resources/application-local.yml index 9a68b37d..48a96077 100644 --- a/openespi-datacustodian/src/main/resources/application-local.yml +++ b/openespi-datacustodian/src/main/resources/application-local.yml @@ -50,4 +50,6 @@ espi: issuer-uri: http://localhost:9999 introspection-endpoint: http://localhost:9999/oauth2/introspect client-id: data_custodian_admin - client-secret: secret \ No newline at end of file + # Must match the AS's seeded dev client (AuthorizationServerConfig: {noop}dc-secret), or every + # token introspection - ESPI API and actuator - fails with 401 from the AS. + client-secret: dc-secret \ No newline at end of file diff --git a/openespi-datacustodian/src/main/resources/application.yml b/openespi-datacustodian/src/main/resources/application.yml index 2f81ce85..fd741fb3 100644 --- a/openespi-datacustodian/src/main/resources/application.yml +++ b/openespi-datacustodian/src/main/resources/application.yml @@ -107,17 +107,30 @@ server: mime-types: application/json,application/xml,text/html,text/xml,text/plain,application/atom+xml # Actuator Configuration +# Endpoint access is opt-in and read-only. Only health (status only, plus the liveness/readiness +# probe groups) is anonymous; every other endpoint needs an access token with the +# DataCustodian_Admin_Access scope (ActuatorSecurityConfiguration). management: endpoints: + access: + default: none web: exposure: include: health,info,metrics,prometheus base-path: /actuator endpoint: health: - show-details: when-authorized + access: read-only + show-details: never + show-components: never + probes: + enabled: true + info: + access: read-only metrics: access: read-only + prometheus: + access: read-only metrics: tags: application: ${spring.application.name} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfigurationTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfigurationTest.java new file mode 100644 index 00000000..92162674 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/ActuatorSecurityConfigurationTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.config; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Actuator access: health (and its probe groups) is anonymous and reports status only; every other + * exposed endpoint needs a {@code DataCustodian_Admin_Access} token. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ActuatorSecurityConfigurationTest { + + @Autowired + private MockMvc mockMvc; + + @ParameterizedTest + @ValueSource(strings = {"/actuator/health", "/actuator/health/liveness", "/actuator/health/readiness"}) + void health_is_anonymous_and_shows_status_only(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.components").doesNotExist()); + } + + @ParameterizedTest + @ValueSource(strings = {"/actuator", "/actuator/info", "/actuator/metrics"}) + void other_endpoints_reject_anonymous_requests(String path) throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isUnauthorized()); + } + + // /actuator/prometheus is not listed below: Spring Boot tests disable metric exporters by default, + // so the endpoint does not exist here. Its rule is the same anyRequest() admin rule. + @ParameterizedTest + @ValueSource(strings = {"/actuator", "/actuator/info", "/actuator/metrics"}) + void other_endpoints_reject_non_admin_tokens(String path) throws Exception { + mockMvc.perform(get(path).with(opaqueToken().authorities(new SimpleGrantedAuthority("FB_4")))) + .andExpect(status().isForbidden()); + } + + @ParameterizedTest + @ValueSource(strings = {"/actuator", "/actuator/info", "/actuator/metrics"}) + void other_endpoints_accept_admin_tokens(String path) throws Exception { + mockMvc.perform(get(path).with(opaqueToken() + .authorities(new SimpleGrantedAuthority(ActuatorSecurityConfiguration.ADMIN_AUTHORITY)))) + .andExpect(status().isOk()); + } +} diff --git a/openespi-thirdparty/pom.xml b/openespi-thirdparty/pom.xml index c55402f7..dccdc9a0 100644 --- a/openespi-thirdparty/pom.xml +++ b/openespi-thirdparty/pom.xml @@ -156,6 +156,11 @@ org.springframework.boot spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + org.springframework.boot spring-boot-starter-validation diff --git a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/ActuatorSecurityConfiguration.java b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/ActuatorSecurityConfiguration.java new file mode 100644 index 00000000..a9dd38e0 --- /dev/null +++ b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/config/ActuatorSecurityConfiguration.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.thirdparty.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; +import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security filter chain for the Spring Boot Actuator endpoints. + * + *

Only {@code health} (status only, including the {@code liveness} and {@code readiness} probe + * groups) is anonymous, so load balancers and orchestrators can check that the application is + * operating normally. Every other exposed endpoint ({@code info}, {@code metrics}, + * {@code prometheus} and the links page) requires an opaque admin access token. The Third Party is + * otherwise only an OAuth client, so the token is introspected at the Authorization Server with the + * Third Party's client credentials and must carry {@code espi.actuator.admin-scope} + * ({@code ThirdParty_Admin_Access} by default). Endpoint access is read-only + * ({@code management.endpoint.*.access} in application.yml).

+ * + *

Ordered first so actuator requests never reach the {@code oauth2Login} chain in + * {@link SecurityConfiguration}, which would redirect them to the login page.

+ */ +@Configuration +public class ActuatorSecurityConfiguration { + + @Value("${espi.authorization-server.introspection-endpoint:http://localhost:9999/oauth2/introspect}") + private String introspectionUri; + + @Value("${espi.authorization-server.client-id:third_party}") + private String clientId; + + @Value("${espi.authorization-server.client-secret:secret}") + private String clientSecret; + + @Value("${espi.actuator.admin-scope:ThirdParty_Admin_Access}") + private String adminScope; + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception { + OpaqueTokenIntrospector introspector = SpringOpaqueTokenIntrospector + .withIntrospectionUri(introspectionUri) + .clientId(clientId) + .clientSecret(clientSecret) + .build(); + + return http + .securityMatcher(EndpointRequest.toAnyEndpoint()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers(EndpointRequest.to("health")).permitAll() + .anyRequest().hasAuthority("SCOPE_" + adminScope)) + .oauth2ResourceServer(oauth2 -> oauth2 + .opaqueToken(opaque -> opaque.introspector(introspector))) + .build(); + } +} diff --git a/openespi-thirdparty/src/main/resources/application-local.yml b/openespi-thirdparty/src/main/resources/application-local.yml index 2eead5b8..aa109e6d 100644 --- a/openespi-thirdparty/src/main/resources/application-local.yml +++ b/openespi-thirdparty/src/main/resources/application-local.yml @@ -63,4 +63,6 @@ espi: authorization-endpoint: http://localhost:9999/oauth2/authorize token-endpoint: http://localhost:9999/oauth2/token client-id: third_party - client-secret: secret \ No newline at end of file + # Must match the AS's seeded dev client (AuthorizationServerConfig: {noop}tp-secret); used to + # introspect actuator admin tokens. + client-secret: tp-secret \ No newline at end of file diff --git a/openespi-thirdparty/src/main/resources/application.yml b/openespi-thirdparty/src/main/resources/application.yml index 0f30cb20..49a4e806 100644 --- a/openespi-thirdparty/src/main/resources/application.yml +++ b/openespi-thirdparty/src/main/resources/application.yml @@ -141,21 +141,31 @@ server: include-message: always # Actuator Configuration +# Endpoint access is opt-in and read-only. Only health (status only, plus the liveness/readiness +# probe groups) is anonymous; every other endpoint needs an admin access token, validated by +# introspection at the Authorization Server (ActuatorSecurityConfiguration, espi.actuator). management: endpoints: + access: + default: none web: exposure: include: health,info,metrics,prometheus base-path: /actuator endpoint: health: - show-details: when-authorized + access: read-only + show-details: never + show-components: never + probes: + enabled: true + info: + access: read-only metrics: - enabled: true + access: read-only + prometheus: + access: read-only metrics: - export: - prometheus: - enabled: true tags: application: ${spring.application.name} @@ -179,6 +189,12 @@ espi: token-endpoint: ${AUTHORIZATION_SERVER_TOKEN_ENDPOINT:http://localhost:9999/oauth2/token} client-id: ${AUTHORIZATION_SERVER_CLIENT_ID:third_party} client-secret: ${AUTHORIZATION_SERVER_CLIENT_SECRET:secret} + introspection-endpoint: ${AUTHORIZATION_SERVER_INTROSPECTION_ENDPOINT:http://localhost:9999/oauth2/introspect} + + # Admin-only actuator endpoints (everything except health): bearer tokens are introspected at + # the Authorization Server with the client credentials above and must carry this scope. + actuator: + admin-scope: ${ESPI_THIRDPARTY_ACTUATOR_ADMIN_SCOPE:ThirdParty_Admin_Access} # ESPI Resource Configuration resources: