diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospector.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospector.java
new file mode 100644
index 00000000..2db381a8
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospector.java
@@ -0,0 +1,101 @@
+/*
+ *
+ * Copyright (c) 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.greenbuttonalliance.espi.common.scope.EspiScope;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Wraps the stock {@link OpaqueTokenIntrospector} and translates the introspected token's ESPI
+ * scope into ESPI Function-Block authorities the Data Custodian enforces on its resource endpoints.
+ *
+ *
The Authorization Server issues the ESPI 4.0 scope grammar
+ * ({@code FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13}). Spring's stock
+ * introspector would map that whole string to a single opaque authority
+ * ({@code SCOPE_FB=4_5_15;...}), which matches none of the DC's endpoint rules — so a real customer
+ * token would 403 on every resource (issue #157). This introspector instead parses the scope via
+ * {@link EspiScope} and emits one {@code FB_} authority per granted function block (e.g.
+ * {@code FB_4}, {@code FB_5}, {@code FB_15}).
+ *
+ * Non-FB scopes — the admin/OIDC scopes such as {@code DataCustodian_Admin_Access},
+ * {@code ThirdParty_Admin_Access}, {@code openid}, {@code profile} — are passed through with the
+ * conventional {@code SCOPE_} prefix so existing admin endpoint rules keep working.
+ *
+ * This replaces the contractor's non-ESPI {@code SCOPE_FB__READ/WRITE_3rd_party} vocabulary,
+ * which had no relationship to the scopes the AS actually issues.
+ */
+public final class EspiScopeOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
+
+ private final OpaqueTokenIntrospector delegate;
+
+ public EspiScopeOpaqueTokenIntrospector(OpaqueTokenIntrospector delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public OAuth2AuthenticatedPrincipal introspect(String token) {
+ OAuth2AuthenticatedPrincipal principal = delegate.introspect(token);
+ Set authorities = new LinkedHashSet<>();
+ for (String scope : scopesOf(principal)) {
+ EspiScope parsed = EspiScope.parse(scope);
+ if (parsed.functionBlocks().isEmpty()) {
+ // admin / OIDC / any non-ESPI scope — keep the stock SCOPE_ authority.
+ authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope));
+ } else {
+ // ESPI FB scope — one authority per granted function block.
+ for (Integer fb : parsed.functionBlocks()) {
+ authorities.add(new SimpleGrantedAuthority("FB_" + fb));
+ }
+ }
+ }
+ return new DefaultOAuth2AuthenticatedPrincipal(principal.getName(), principal.getAttributes(), authorities);
+ }
+
+ /**
+ * The introspected {@code scope} claim. Spring's stock introspector stores it as a
+ * {@code List} split on spaces; tolerate a raw {@code String} as well.
+ */
+ private static Collection scopesOf(OAuth2AuthenticatedPrincipal principal) {
+ Object scope = principal.getAttribute("scope");
+ if (scope instanceof Collection> collection) {
+ List out = new ArrayList<>(collection.size());
+ for (Object o : collection) {
+ if (o != null) {
+ out.add(o.toString());
+ }
+ }
+ return out;
+ }
+ if (scope instanceof String s && !s.isBlank()) {
+ return Arrays.asList(s.trim().split("\\s+"));
+ }
+ return List.of();
+ }
+}
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 b67eaaa1..f8e8efc3 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
@@ -19,11 +19,16 @@
package org.greenbuttonalliance.espi.datacustodian.config;
+import org.greenbuttonalliance.espi.common.scope.FunctionBlock;
+import org.greenbuttonalliance.espi.common.scope.FunctionBlockCategory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
+import org.springframework.security.authorization.AuthorityAuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagers;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -33,12 +38,14 @@
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
+import java.util.stream.Stream;
/**
* Security configuration for the OpenESPI Data Custodian Resource Server.
@@ -75,20 +82,61 @@ public class SecurityConfiguration {
*/
@Bean
@Order(1)
- public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ public SecurityFilterChain securityFilterChain(HttpSecurity http,
+ OpaqueTokenIntrospector introspector) throws Exception {
+ // ESPI 4.0 Function-Block authorization managers. Authorities are minted by
+ // EspiScopeOpaqueTokenIntrospector: FB_ per granted function block, plus SCOPE_
+ // for admin/OIDC scopes. (Replaces the contractor's non-ESPI SCOPE_FB_n_READ/WRITE_3rd_party.)
+ AuthorizationManager admin =
+ AuthorityAuthorizationManager.hasAuthority("SCOPE_DataCustodian_Admin_Access");
+ AuthorizationManager adminOrTpAdmin =
+ AuthorityAuthorizationManager.hasAnyAuthority(
+ "SCOPE_DataCustodian_Admin_Access", "SCOPE_ThirdParty_Admin_Access");
+
+ // Usage data (UsagePoint + its interval data: MeterReading, ReadingType, IntervalBlock,
+ // LocalTimeParameters, Batch). ESPI: base FB_4 (Interval Metering) AND at least one
+ // commodity FB (electricity/gas/water/temperature) — or DataCustodian admin.
+ AuthorizationManager fb4 =
+ AuthorityAuthorizationManager.hasAuthority("FB_4");
+ AuthorizationManager commodity =
+ AuthorityAuthorizationManager.hasAnyAuthority(commodityAuthorities());
+ AuthorizationManager usageData =
+ AuthorizationManagers.anyOf(admin, AuthorizationManagers.allOf(fb4, commodity));
+
+ // Usage summaries (data-shape FBs) and power-quality summary (FB_17).
+ AuthorizationManager usageSummary =
+ AuthorizationManagers.anyOf(admin,
+ AuthorityAuthorizationManager.hasAnyAuthority("FB_15", "FB_16", "FB_27", "FB_28"));
+ AuthorizationManager powerQuality =
+ AuthorizationManagers.anyOf(admin, AuthorityAuthorizationManager.hasAuthority("FB_17"));
+
+ // Customer / PII resources — least-privilege per the ESPI Customer-PII catalog: the base
+ // Connect-My-Data customer FB (FB_53) AND the resource-specific FB, or admin. FB→resource
+ // mapping is authoritative from the DMD-validator conformance XSLTs (FB_NN.xsl). Note: FB_55
+ // (Address) gates a field embedded in Customer, not an endpoint — deferred to response-shaping.
+ AuthorizationManager customerBase = piiGate(admin, "FB_54"); // Customer
+ AuthorizationManager customerAccount = piiGate(admin, "FB_56"); // billing
+ AuthorizationManager customerAgreement = piiGate(admin, "FB_57");
+ AuthorizationManager serviceLocation = piiGate(admin, "FB_58");
+ AuthorizationManager serviceSupplier = piiGate(admin, "FB_59");
+ AuthorizationManager meter = piiGate(admin, "FB_60");
+ AuthorizationManager endDevice = piiGate(admin, "FB_61");
+ AuthorizationManager programMapping = piiGate(admin, "FB_62");
+
return http
// Disable CSRF for API endpoints
.csrf(AbstractHttpConfigurer::disable)
-
+
// Enable CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
-
+
// Configure session management (stateless for OAuth2)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
-
- // Configure authorization rules
+
+ // 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
.requestMatchers(
@@ -99,105 +147,81 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/swagger-ui.html",
"/h2-console/**"
).permitAll()
-
- // ESPI root resource endpoints (require authentication)
+
+ // --- Admin-only resources (OAuth2 metadata, not energy/PII) ---
.requestMatchers(HttpMethod.GET, "/espi/1_1/resource/ApplicationInformation/**")
- .hasAnyAuthority("SCOPE_DataCustodian_Admin_Access", "SCOPE_ThirdParty_Admin_Access")
-
- // /Authorization is admin (DataCustodian admin) or client (ThirdParty admin via
- // client_credentials) only — never reachable with a customer FB-scoped token,
- // because the resource exposes OAuth2 authorization metadata, not energy/PII data.
+ .access(adminOrTpAdmin)
.requestMatchers(HttpMethod.GET, "/espi/1_1/resource/Authorization/**")
- .hasAnyAuthority(
- "SCOPE_DataCustodian_Admin_Access",
- "SCOPE_ThirdParty_Admin_Access"
- )
-
- // ESPI Usage Point endpoints
- .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/UsagePoint/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_READ_3rd_party",
- "SCOPE_FB_16_READ_3rd_party",
- "SCOPE_FB_36_READ_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
- .requestMatchers(HttpMethod.POST, "/espi/1_1/resource/UsagePoint/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_WRITE_3rd_party",
- "SCOPE_FB_16_WRITE_3rd_party",
- "SCOPE_FB_36_WRITE_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
- // ESPI SubscriptionEntity-based endpoints
- .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/Subscription/*/UsagePoint/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_READ_3rd_party",
- "SCOPE_FB_16_READ_3rd_party",
- "SCOPE_FB_36_READ_3rd_party"
- )
-
- .requestMatchers(HttpMethod.POST, "/espi/1_1/resource/Subscription/*/UsagePoint/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_WRITE_3rd_party",
- "SCOPE_FB_16_WRITE_3rd_party",
- "SCOPE_FB_36_WRITE_3rd_party"
- )
-
- // ESPI Meter Reading endpoints
- .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/MeterReading/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_READ_3rd_party",
- "SCOPE_FB_16_READ_3rd_party",
- "SCOPE_FB_36_READ_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
- .requestMatchers(HttpMethod.POST, "/espi/1_1/resource/MeterReading/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_WRITE_3rd_party",
- "SCOPE_FB_16_WRITE_3rd_party",
- "SCOPE_FB_36_WRITE_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
- // ESPI Interval Reading endpoints
- .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/IntervalReading/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_READ_3rd_party",
- "SCOPE_FB_16_READ_3rd_party",
- "SCOPE_FB_36_READ_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
- // ESPI Batch endpoints
- .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/Batch/**")
- .hasAnyAuthority(
- "SCOPE_FB_15_READ_3rd_party",
- "SCOPE_FB_16_READ_3rd_party",
- "SCOPE_FB_36_READ_3rd_party",
- "SCOPE_DataCustodian_Admin_Access"
- )
-
+ .access(adminOrTpAdmin)
+
+ // --- Usage summaries (GET) : data-shape FBs, or admin. The summary/PQ leaf rules
+ // (including Subscription-/RetailCustomer-scoped XPath forms) MUST precede the
+ // broad usage-data rule, so a summary reached via /Subscription/.../UsageSummary is
+ // gated by its own FB rather than FB_4+commodity. ---
+ .requestMatchers(HttpMethod.GET,
+ "/espi/1_1/resource/UsageSummary/**",
+ "/espi/1_1/resource/ElectricPowerUsageSummary/**",
+ "/espi/1_1/resource/Subscription/*/UsagePoint/*/UsageSummary/**",
+ "/espi/1_1/resource/Subscription/*/UsagePoint/*/ElectricPowerUsageSummary/**",
+ "/espi/1_1/resource/RetailCustomer/*/UsagePoint/*/UsageSummary/**",
+ "/espi/1_1/resource/RetailCustomer/*/UsagePoint/*/ElectricPowerUsageSummary/**")
+ .access(usageSummary)
+
+ // --- Power-quality summary (GET) : FB_17, or admin ---
+ .requestMatchers(HttpMethod.GET,
+ "/espi/1_1/resource/ElectricPowerQualitySummary/**",
+ "/espi/1_1/resource/Subscription/*/UsagePoint/*/ElectricPowerQualitySummary/**",
+ "/espi/1_1/resource/RetailCustomer/*/UsagePoint/*/ElectricPowerQualitySummary/**")
+ .access(powerQuality)
+
+ // --- Usage data (GET, read-only) : FB_4 + commodity, or admin ---
+ .requestMatchers(HttpMethod.GET,
+ "/espi/1_1/resource/UsagePoint/**",
+ "/espi/1_1/resource/MeterReading/**",
+ "/espi/1_1/resource/ReadingType/**",
+ "/espi/1_1/resource/IntervalBlock/**",
+ "/espi/1_1/resource/IntervalReading/**",
+ "/espi/1_1/resource/LocalTimeParameter/**",
+ "/espi/1_1/resource/LocalTimeParameters/**",
+ "/espi/1_1/resource/Batch/**",
+ "/espi/1_1/resource/Subscription/**",
+ "/espi/1_1/resource/RetailCustomer/*/UsagePoint/**")
+ .access(usageData)
+
+ // --- Customer / PII resources (GET) : FB_53 base + resource-specific FB, or admin ---
+ // ROOT forms are gated 1:1 per the conformance-XSLT FB mapping.
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/CustomerAccount/**").access(customerAccount)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/CustomerAgreement/**").access(customerAgreement)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/ServiceSupplier/**").access(serviceSupplier)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/ServiceLocation/**").access(serviceLocation)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/Meter/**").access(meter)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/EndDevice/**").access(endDevice)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/ProgramDateIdMappings/**").access(programMapping)
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/Customer/**").access(customerBase)
+ // XPath customer subtree: customer-base floor (FB_53+FB_54). Per-leaf XPath gating and
+ // FB_55 (Address, a Customer field) are response-shaping refinements — Phase 2.
+ .requestMatchers(HttpMethod.GET, "/espi/1_1/resource/RetailCustomer/*/Customer/**").access(customerBase)
+
+ // --- All resource writes are admin-only (ESPI third-party access is read-only) ---
+ .requestMatchers(HttpMethod.POST, "/espi/1_1/resource/**").access(admin)
+ .requestMatchers(HttpMethod.PUT, "/espi/1_1/resource/**").access(admin)
+ .requestMatchers(HttpMethod.DELETE, "/espi/1_1/resource/**").access(admin)
+
// Admin endpoints
- .requestMatchers("/admin/**")
- .hasAuthority("SCOPE_DataCustodian_Admin_Access")
-
+ .requestMatchers("/admin/**").access(admin)
+
// All other ESPI endpoints require authentication
.requestMatchers("/espi/**").authenticated()
-
+
// All other requests require authentication
.anyRequest().authenticated()
)
-
- // Configure OAuth2 Resource Server with opaque token introspection
- // ESPI standard requires opaque tokens, not JWT tokens
+
+ // Configure OAuth2 Resource Server with opaque token introspection.
+ // ESPI standard requires opaque tokens, not JWT. The custom introspector
+ // (EspiScopeOpaqueTokenIntrospector) maps the ESPI FB scope to FB_ authorities.
.oauth2ResourceServer(oauth2 -> oauth2
- .opaqueToken(opaque -> opaque
- .introspectionUri(introspectionUri)
- .introspectionClientCredentials(clientId, clientSecret)
- )
+ .opaqueToken(opaque -> opaque.introspector(introspector))
)
// Security headers configuration
@@ -228,14 +252,42 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
*/
/**
- * Configures the Opaque Token Introspector to validate tokens against the AuthorizationEntity Server.
+ * Configures the Opaque Token Introspector to validate tokens against the Authorization Server,
+ * wrapped so the ESPI FB scope is translated into FB_<n> authorities the resource rules enforce
+ * (see {@link EspiScopeOpaqueTokenIntrospector}).
*/
@Bean
public OpaqueTokenIntrospector introspector() {
- return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
+ OpaqueTokenIntrospector delegate = SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
.clientId(clientId)
.clientSecret(clientSecret)
.build();
+ return new EspiScopeOpaqueTokenIntrospector(delegate);
+ }
+
+ /**
+ * The {@code FB_} authorities for every commodity Function Block (electricity/gas/water/
+ * temperature), derived from the single ESPI FB catalog in {@link FunctionBlock} so this never
+ * drifts from the spec.
+ */
+ private static String[] commodityAuthorities() {
+ return Stream.of(FunctionBlock.values())
+ .filter(fb -> fb.getCategory() == FunctionBlockCategory.COMMODITY)
+ .map(fb -> "FB_" + fb.getId())
+ .toArray(String[]::new);
+ }
+
+ /**
+ * Customer-PII endpoint gate: {@code admin OR (FB_53 base AND the resource-specific FB)}.
+ * FB_53 (Connect My Data, Retail Customer) is the prerequisite base for any customer/PII access;
+ * the resource FB (54/56/57/58/59/60/61/62) enforces least privilege per the ESPI Customer-PII
+ * catalog (mapping derived from the DMD-validator conformance XSLTs).
+ */
+ private static AuthorizationManager piiGate(
+ AuthorizationManager admin, String resourceFb) {
+ AuthorizationManager base = AuthorityAuthorizationManager.hasAuthority("FB_53");
+ AuthorizationManager specific = AuthorityAuthorizationManager.hasAuthority(resourceFb);
+ return AuthorizationManagers.anyOf(admin, AuthorizationManagers.allOf(base, specific));
}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
index 6499d35c..cd1b8b5a 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java
@@ -37,7 +37,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@@ -80,8 +79,6 @@ public class CustomerAccountRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party')")
public ResponseEntity getCustomerAccountCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -114,8 +111,6 @@ public ResponseEntity getCustomerAccountCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party')")
public ResponseEntity getCustomerAccount(
@Parameter(description = "Unique identifier of the CustomerAccount", required = true)
@PathVariable UUID customerAccountId) {
@@ -145,8 +140,6 @@ public ResponseEntity getCustomerAccount(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_16_WRITE_3rd_party')")
public ResponseEntity createCustomerAccount(@RequestBody CustomerAccountDto dto) {
CustomerAccountEntity entity = customerAccountMapper.toEntity(dto);
CustomerAccountEntity savedEntity = customerAccountService.save(entity);
@@ -175,8 +168,6 @@ public ResponseEntity createCustomerAccount(@RequestBody Cus
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_16_WRITE_3rd_party')")
public ResponseEntity updateCustomerAccount(
@PathVariable UUID customerAccountId,
@RequestBody CustomerAccountDto dto) {
@@ -206,8 +197,6 @@ public ResponseEntity updateCustomerAccount(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_16_WRITE_3rd_party')")
public ResponseEntity deleteCustomerAccount(@PathVariable UUID customerAccountId) {
if (!customerAccountRepository.existsById(customerAccountId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "CustomerAccount not found for id: " + customerAccountId);
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
index 05868d81..5ca51712 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java
@@ -38,7 +38,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@@ -81,8 +80,6 @@ public class CustomerRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party')")
public ResponseEntity getCustomerCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -115,8 +112,6 @@ public ResponseEntity getCustomerCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party')")
public ResponseEntity getCustomer(
@Parameter(description = "Unique identifier of the Customer", required = true)
@PathVariable UUID customerId) {
@@ -147,8 +142,6 @@ public ResponseEntity getCustomer(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_WRITE_3rd_party')")
public ResponseEntity createCustomer(@Valid @RequestBody CustomerDto customerDto) {
CustomerEntity entity = customerMapper.toEntity(customerDto);
CustomerEntity savedEntity = customerService.save(entity);
@@ -178,8 +171,6 @@ public ResponseEntity createCustomer(@Valid @RequestBody CustomerDt
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_WRITE_3rd_party')")
public ResponseEntity updateCustomer(
@Parameter(description = "Unique identifier of the Customer", required = true)
@PathVariable UUID customerId,
@@ -209,8 +200,6 @@ public ResponseEntity updateCustomer(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_WRITE_3rd_party')")
public ResponseEntity deleteCustomer(
@Parameter(description = "Unique identifier of the Customer", required = true)
@PathVariable UUID customerId) {
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
index 50aee5d5..df1c1e78 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java
@@ -36,7 +36,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -82,10 +81,6 @@ public class ElectricPowerQualitySummaryRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getElectricPowerQualitySummaryCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -119,10 +114,6 @@ public ResponseEntity getElectricPowerQualitySummaryCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getElectricPowerQualitySummary(
@Parameter(description = "Unique identifier of the ElectricPowerQualitySummary", required = true)
@PathVariable UUID electricPowerQualitySummaryId) {
@@ -153,9 +144,6 @@ public ResponseEntity getElectricPowerQualitySummary(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionElectricPowerQualitySummaries(
@Parameter(description = "Unique identifier of the subscription", required = true)
@PathVariable UUID subscriptionId,
@@ -194,9 +182,6 @@ public ResponseEntity getSubscriptionElectricPowerQualitySummaries(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionElectricPowerQualitySummary(
@Parameter(description = "Unique identifier of the subscription", required = true)
@PathVariable UUID subscriptionId,
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
index 035ee444..4374995c 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java
@@ -35,7 +35,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -75,10 +74,6 @@ public class IntervalBlockRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getIntervalBlockCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -112,10 +107,6 @@ public ResponseEntity getIntervalBlockCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getIntervalBlock(
@Parameter(description = "Unique identifier of the Interval Block", required = true)
@PathVariable UUID intervalBlockId,
@@ -147,9 +138,6 @@ public ResponseEntity getIntervalBlock(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionIntervalBlocks(
@Parameter(description = "Unique identifier of the Subscription", required = true)
@PathVariable UUID subscriptionId,
@@ -186,9 +174,6 @@ public ResponseEntity getSubscriptionIntervalBlocks(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionIntervalBlock(
@Parameter(description = "Unique identifier of the Subscription", required = true)
@PathVariable UUID subscriptionId,
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
index d688d11a..77ef4894 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java
@@ -35,7 +35,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -80,10 +79,6 @@ public class MeterReadingController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getAllMeterReadings(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -117,10 +112,6 @@ public ResponseEntity getAllMeterReadings(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getMeterReading(
@Parameter(description = "Unique identifier of the Meter Reading", required = true)
@PathVariable UUID meterReadingId,
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
index 23f13281..a3304756 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java
@@ -35,7 +35,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -78,10 +77,6 @@ public class ReadingTypeRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getReadingTypeCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -115,10 +110,6 @@ public ResponseEntity getReadingTypeCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getReadingType(
@Parameter(description = "Unique identifier of the Reading Type", required = true)
@PathVariable UUID readingTypeId,
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
index c739524d..2d6d2392 100644
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java
@@ -34,7 +34,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -85,10 +84,6 @@ public UsagePointController(UsagePointRepository usagePointRepository, UsagePoin
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
@GetMapping(value = "/UsagePoint", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity getAllUsagePoints(@RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset) {
@@ -134,10 +129,6 @@ public ResponseEntity getAllUsagePoints(@RequestParam(defaultValue = "50
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getUsagePoint(
@Parameter(description = "Unique identifier of the Usage Point", required = true)
@PathVariable UUID usagePointId) {
@@ -167,9 +158,6 @@ public ResponseEntity getUsagePoint(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionUsagePoints(
@Parameter(description = "Unique identifier of the SubscriptionEntity", required = true)
@PathVariable UUID subscriptionId,
@@ -206,9 +194,6 @@ public ResponseEntity getSubscriptionUsagePoints(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionUsagePoint(
@Parameter(description = "Unique identifier of the SubscriptionEntity", required = true)
@PathVariable UUID subscriptionId,
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
index f33fa29d..f2f0a318 100755
--- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java
@@ -35,7 +35,6 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -79,10 +78,6 @@ public class UsageSummaryRESTController {
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getUsageSummaryCollection(
@Parameter(description = "Maximum number of results to return", example = "50")
@RequestParam(defaultValue = "50") int limit,
@@ -116,10 +111,6 @@ public ResponseEntity getUsageSummaryCollection(
@ApiResponse(responseCode = "403", description = "Forbidden - insufficient scope")
}
)
- @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or " +
- "hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getUsageSummary(
@Parameter(description = "Unique identifier of the Usage Summary", required = true)
@PathVariable UUID usageSummaryId,
@@ -148,9 +139,6 @@ public ResponseEntity getUsageSummary(
@ApiResponse(responseCode = "404", description = "Subscription or Usage Point not found")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionUsageSummaries(
@Parameter(description = "Unique identifier of the Subscription", required = true)
@PathVariable UUID subscriptionId,
@@ -186,9 +174,6 @@ public ResponseEntity getSubscriptionUsageSummaries(
@ApiResponse(responseCode = "404", description = "Usage Summary, Subscription, or Usage Point not found")
}
)
- @PreAuthorize("hasAuthority('SCOPE_FB_15_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_16_READ_3rd_party') or " +
- "hasAuthority('SCOPE_FB_36_READ_3rd_party')")
public ResponseEntity getSubscriptionUsageSummary(
@Parameter(description = "Unique identifier of the Subscription", required = true)
@PathVariable UUID subscriptionId,
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospectorTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospectorTest.java
new file mode 100644
index 00000000..aa5bca1d
--- /dev/null
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/config/EspiScopeOpaqueTokenIntrospectorTest.java
@@ -0,0 +1,96 @@
+/*
+ *
+ * Copyright (c) 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.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link EspiScopeOpaqueTokenIntrospector} — the ESPI-scope → FB-authority
+ * translation that fixes the AS↔DC vocabulary mismatch (#157).
+ */
+@DisplayName("ESPI FB-scope introspector (#157)")
+class EspiScopeOpaqueTokenIntrospectorTest {
+
+ /** A stub delegate that returns a principal whose {@code scope} attribute is the given value. */
+ private static OpaqueTokenIntrospector delegateWithScope(Object scopeAttribute) {
+ return token -> new DefaultOAuth2AuthenticatedPrincipal(
+ "42", Map.of("scope", scopeAttribute, "active", true),
+ AuthorityUtils.NO_AUTHORITIES);
+ }
+
+ private static List authorities(OAuth2AuthenticatedPrincipal p) {
+ return p.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
+ }
+
+ @Test
+ @DisplayName("customer FB scope -> one FB_ authority per function block")
+ void customerFbScopeMapsToFbAuthorities() {
+ OpaqueTokenIntrospector introspector = new EspiScopeOpaqueTokenIntrospector(delegateWithScope(
+ List.of("openid", "profile",
+ "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13")));
+
+ List authorities = authorities(introspector.introspect("tok"));
+
+ assertThat(authorities)
+ .contains("FB_4", "FB_5", "FB_15") // function blocks
+ .contains("SCOPE_openid", "SCOPE_profile") // non-FB scopes pass through
+ .doesNotContain("SCOPE_FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13");
+ }
+
+ @Test
+ @DisplayName("admin scope -> SCOPE_ authority (unchanged)")
+ void adminScopeMapsToScopeAuthority() {
+ OpaqueTokenIntrospector introspector = new EspiScopeOpaqueTokenIntrospector(
+ delegateWithScope(List.of("DataCustodian_Admin_Access")));
+
+ assertThat(authorities(introspector.introspect("tok")))
+ .containsExactly("SCOPE_DataCustodian_Admin_Access");
+ }
+
+ @Test
+ @DisplayName("scope as a single space-delimited String is also supported")
+ void scopeAsStringIsParsed() {
+ OpaqueTokenIntrospector introspector = new EspiScopeOpaqueTokenIntrospector(
+ delegateWithScope("profile FB=4_10;IntervalDuration=3600"));
+
+ assertThat(authorities(introspector.introspect("tok")))
+ .contains("SCOPE_profile", "FB_4", "FB_10");
+ }
+
+ @Test
+ @DisplayName("attributes (e.g. ESPI URI claims) are preserved")
+ void attributesArePreserved() {
+ OAuth2AuthenticatedPrincipal p = new EspiScopeOpaqueTokenIntrospector(
+ delegateWithScope(List.of("DataCustodian_Admin_Access"))).introspect("tok");
+
+ assertThat(p.getName()).isEqualTo("42");
+ assertThat((Boolean) p.getAttribute("active")).isTrue();
+ }
+}
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java
index 96a5cb42..eae27e6c 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationControllerTest.java
@@ -69,7 +69,7 @@ void unauthenticated_is_401() throws Exception {
@DisplayName("returns 403 with a customer FB-scoped token (energy-data scope)")
void customerFbScope_is_403() throws Exception {
mockMvc.perform(get("/espi/1_1/resource/Authorization")
- .with(opaqueAuthority("SCOPE_FB_15_READ_3rd_party")))
+ .with(opaqueAuthority("FB_15")))
.andExpect(status().isForbidden());
}
@@ -115,7 +115,7 @@ void unauthenticated_is_401() throws Exception {
@DisplayName("returns 403 with a customer FB-scoped token (PII scope)")
void customerPiiScope_is_403() throws Exception {
mockMvc.perform(get("/espi/1_1/resource/Authorization/" + UUID.randomUUID())
- .with(opaqueAuthority("SCOPE_FB_54_READ_3rd_party")))
+ .with(opaqueAuthority("FB_54")))
.andExpect(status().isForbidden());
}
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTControllerTest.java
index 6c88caee..d9832c0b 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTControllerTest.java
@@ -79,6 +79,50 @@ void shouldReturn403ForWrongAuthority() throws Exception {
}
}
+ /**
+ * ESPI Customer-PII least-privilege enforcement (#157): a customer token may read /Customer only
+ * with the base Connect-My-Data FB (FB_53) AND the Customer-specific FB (FB_54). FB_53 alone, the
+ * specific FB without the base, or a different resource's FB (e.g. FB_56 CustomerAccount) are all
+ * denied — proving the Customer-PII catalog is honored rather than collapsed to a single gate.
+ */
+ @Nested
+ @DisplayName("ESPI Customer-PII FB authorization (#157)")
+ class CustomerPiiScopeGating {
+
+ @Test
+ @WithMockUser(authorities = {"FB_53", "FB_54"})
+ @DisplayName("FB_53 base + FB_54 (Customer) -> 200 OK")
+ void baseAndCustomerFbReturns200() throws Exception {
+ when(customerRepository.findAll(any(Pageable.class)))
+ .thenReturn(new PageImpl<>(List.of(new CustomerEntity())));
+ when(customerMapper.toDto(any(CustomerEntity.class))).thenReturn(new CustomerDto());
+
+ mockMvc.perform(get("/espi/1_1/resource/Customer").accept(MediaType.APPLICATION_XML))
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ @WithMockUser(authorities = "FB_53")
+ @DisplayName("FB_53 base alone (no FB_54) -> 403 (least privilege)")
+ void baseWithoutCustomerFbForbidden() throws Exception {
+ mockMvc.perform(get("/espi/1_1/resource/Customer")).andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = "FB_54")
+ @DisplayName("FB_54 without base FB_53 -> 403")
+ void customerFbWithoutBaseForbidden() throws Exception {
+ mockMvc.perform(get("/espi/1_1/resource/Customer")).andExpect(status().isForbidden());
+ }
+
+ @Test
+ @WithMockUser(authorities = {"FB_53", "FB_56"})
+ @DisplayName("FB_53 + FB_56 (CustomerAccount FB) -> 403 on Customer (wrong resource FB)")
+ void wrongResourceFbForbidden() throws Exception {
+ mockMvc.perform(get("/espi/1_1/resource/Customer")).andExpect(status().isForbidden());
+ }
+ }
+
@Nested
@DisplayName("GET /espi/1_1/resource/Customer/{customerId}")
class GetCustomer {
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTControllerTest.java
index cb142715..3004d36d 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTControllerTest.java
@@ -73,7 +73,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_17")
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(electricPowerQualitySummaryRepository.findAll(any(Pageable.class)))
@@ -146,7 +146,7 @@ void shouldReturn403ForWrongAuthority() throws Exception {
@DisplayName("Subscription scoped GET tests")
class SubscriptionTests {
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_17")
@DisplayName("Should return 200 OK for subscription collection")
void shouldReturn200ForSubscriptionCollection() throws Exception {
UUID subId = UUID.randomUUID();
@@ -162,7 +162,7 @@ void shouldReturn200ForSubscriptionCollection() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_17")
@DisplayName("Should return 200 OK for subscription resource")
void shouldReturn200ForSubscriptionResource() throws Exception {
UUID subId = UUID.randomUUID();
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTControllerTest.java
index 185accdb..8888ace6 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTControllerTest.java
@@ -71,7 +71,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(intervalBlockRepository.findAll(any(Pageable.class)))
@@ -137,7 +137,7 @@ void shouldReturn404WhenNotExists() throws Exception {
class GetSubscriptionIntervalBlocks {
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK with list of interval blocks")
void shouldReturn200() throws Exception {
UUID subId = UUID.randomUUID();
@@ -174,7 +174,7 @@ void shouldReturn403ForWrongAuthority() throws Exception {
class GetSubscriptionIntervalBlock {
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK when interval block exists")
void shouldReturn200WhenExists() throws Exception {
UUID subId = UUID.randomUUID();
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java
index 4f6b6a0c..10b32f9c 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingControllerTest.java
@@ -73,7 +73,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(meterReadingRepository.findAll(any(Pageable.class)))
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTControllerTest.java
index 47afa72b..ad0a899c 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTControllerTest.java
@@ -73,7 +73,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(readingTypeRepository.findAll(any(Pageable.class)))
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java
index 9995a98c..872ecccf 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointControllerTest.java
@@ -81,7 +81,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(usagePointRepository.findAll(any(Pageable.class)))
@@ -160,7 +160,7 @@ void shouldReturn401WhenNotAuthenticated() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(usagePointRepository.findAll(any(Pageable.class)))
@@ -193,7 +193,7 @@ void shouldReturn401WhenNotAuthenticated() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 200 OK when usage point exists and user has scope")
void shouldReturn200WhenExists() throws Exception {
UUID subId = UUID.randomUUID();
@@ -211,7 +211,7 @@ void shouldReturn200WhenExists() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = {"FB_4", "FB_5"})
@DisplayName("Should return 404 Not Found when usage point does not exist")
void shouldReturn404WhenNotExists() throws Exception {
UUID subId = UUID.randomUUID();
diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTControllerTest.java
index 3fc1dd56..aed12190 100644
--- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTControllerTest.java
+++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTControllerTest.java
@@ -72,7 +72,7 @@ void shouldReturn200ForAdmin() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_15")
@DisplayName("Should return 200 OK for FB_15 scope")
void shouldReturn200ForFB15() throws Exception {
when(usageSummaryRepository.findAll(any(Pageable.class)))
@@ -123,7 +123,7 @@ void shouldReturn404WhenNotExists() throws Exception {
class SubscriptionScopedTests {
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_15")
@DisplayName("GET /Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary should return 200 OK")
void shouldReturn200ForSubscriptionCollection() throws Exception {
UUID subId = UUID.randomUUID();
@@ -138,7 +138,7 @@ void shouldReturn200ForSubscriptionCollection() throws Exception {
}
@Test
- @WithMockUser(authorities = "SCOPE_FB_15_READ_3rd_party")
+ @WithMockUser(authorities = "FB_15")
@DisplayName("GET /Subscription/{subscriptionId}/UsagePoint/{usagePointId}/UsageSummary/{usageSummaryId} should return 200 OK")
void shouldReturn200ForSubscriptionResource() throws Exception {
UUID subId = UUID.randomUUID();