Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openespi-authserver/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Health/liveness/readiness for orchestrators; other endpoints need an admin token. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- #205: passkey (WebAuthn) second factor for human admins. Spring Security 7 ships
WebAuthn as its own module (version managed by the Spring Security BOM); it pulls in
webauthn4j-core for the ceremony verification. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -473,6 +475,35 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h
return http.build();
}

/**
* Stateless chain for the Spring Boot Actuator endpoints.
*
* <p>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).</p>
*/
@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).
*
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,4 @@ management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: when_authorized
include: health,info,metrics
10 changes: 3 additions & 7 deletions openespi-authserver/src/main/resources/application-espi40.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 3 additions & 10 deletions openespi-authserver/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 15 additions & 5 deletions openespi-authserver/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 2 additions & 9 deletions openespi-authserver/src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# Management endpoints: inherited from application.yml, so the tests exercise the real
# exposure and access rules.
Original file line number Diff line number Diff line change
Expand Up @@ -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
# Management endpoints: inherited from application.yml, so the tests exercise the real
# exposure and access rules (AdminBearerTokenIntegrationTest).
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 0 additions & 8 deletions openespi-common/src/main/resources/application-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 0 additions & 8 deletions openespi-common/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 0 additions & 8 deletions openespi-common/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 5 additions & 6 deletions openespi-common/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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).</p>
*
* <p>Ordered just after the back-channel chain so actuator requests never reach the ESPI
* resource-server chain or the customer-login chain.</p>
*/
@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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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
Loading
Loading