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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,6 @@ temp-services/
node_modules/
package.json
package-lock.json

# Dev-only ESPI XML capture output (EspiXmlCaptureFilter, #119) — never committed
dev-xml-capture/
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
Expand Down Expand Up @@ -73,4 +75,15 @@ public interface SubscriptionRepository extends JpaRepository<SubscriptionEntity
* @return list of subscriptions
*/
List<SubscriptionEntity> findByApplicationInformation_Id(UUID id);

/**
* Returns the ids of the UsagePoints granted to a subscription, projected directly so callers
* (e.g. the resource-server scope resolver, #119) can resolve a subscription's granted set
* without traversing the lazy {@code usagePoints} collection outside a transaction.
*
* @param subscriptionId the subscription UUID
* @return the granted UsagePoint ids (empty if none / unknown subscription)
*/
@Query("select up.id from SubscriptionEntity s join s.usagePoints up where s.id = :subscriptionId")
List<UUID> findUsagePointIdsBySubscriptionId(@Param("subscriptionId") UUID subscriptionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
*
* 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.web.api.devtools;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* Dev-only diagnostic: tees ESPI resource XML responses to files on disk so a developer can view
* (and diff) the actual marshalled Atom XML before committing changes (#119).
*
* <p>Active only under the development profiles ({@code dev-mysql}, {@code dev-postgresql},
* {@code local}, {@code dev}) — never in {@code test}/{@code prod}/{@code docker}, so it has zero
* impact on CI or production. It is controller-agnostic: it captures every XML response under
* {@code /espi/1_1/resource/**}, so it works for the existing controllers and every resource added
* to the canonical surface.</p>
*
* <p>Layout — a persistent, git-ignored, repo-root directory ({@code espi.dev.xml-capture.dir},
* default {@code dev-xml-capture}) with one timestamped sub-directory <b>per application run</b>:</p>
* <pre>
* dev-xml-capture/
* run-20260605-231007-413/
* GET_UsagePoint_503888.xml
* GET_Customer.xml
* run-20260605-233512-088/
* GET_UsagePoint_503888.xml
* </pre>
* <p>Per-run sub-dirs (rather than a flat overwrite) let you {@code diff -r} the output of one run
* against another to see the effect of a change. Within a run, filenames are stable per
* method+path (a repeated call overwrites with the latest response). The response body is always
* copied through to the client unchanged.</p>
*/
@Component
@Profile({"dev-mysql", "dev-postgresql", "local", "dev"})
public class EspiXmlCaptureFilter extends OncePerRequestFilter {

private static final Logger log = LoggerFactory.getLogger(EspiXmlCaptureFilter.class);
private static final String RESOURCE_PATH = "/espi/1_1/resource";
private static final DateTimeFormatter RUN_ID =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");

private final boolean enabled;
private final Path runDir;

public EspiXmlCaptureFilter(
@Value("${espi.dev.xml-capture.enabled:true}") boolean enabled,
@Value("${espi.dev.xml-capture.dir:dev-xml-capture}") String captureDir) {
this.enabled = enabled;
// One sub-directory per application run, so successive runs can be compared rather than
// overwriting each other.
this.runDir = Path.of(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID));
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !enabled || !request.getRequestURI().contains(RESOURCE_PATH);
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
try {
filterChain.doFilter(request, wrapper);
} finally {
capture(request, wrapper);
wrapper.copyBodyToResponse();
}
}

private void capture(HttpServletRequest request, ContentCachingResponseWrapper wrapper) {
byte[] body = wrapper.getContentAsByteArray();
String contentType = wrapper.getContentType();
if (body.length == 0 || contentType == null
|| !contentType.toLowerCase().contains("xml")) {
return;
}
try {
Files.createDirectories(runDir);
Path file = runDir.resolve(fileName(request));
Files.write(file, body);
log.info("[dev] captured ESPI XML ({} bytes) -> {}", body.length, file.toAbsolutePath());
} catch (IOException e) {
// dev aid only — never let a capture failure affect the response
log.warn("[dev] ESPI XML capture failed: {}", e.getMessage());
}
}

/** Stable per-run filename from the request, e.g. {@code GET_UsagePoint_503888.xml}. */
private String fileName(HttpServletRequest request) {
String path = request.getRequestURI();
int idx = path.indexOf(RESOURCE_PATH);
String tail = idx >= 0 ? path.substring(idx + RESOURCE_PATH.length()) : path;
String safe = tail.replaceAll("^/+", "").replaceAll("[^A-Za-z0-9._-]+", "_");
if (safe.isBlank()) {
safe = "root";
}
return request.getMethod() + "_" + safe + ".xml";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
*
* 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.web.api.scope;

import java.time.OffsetDateTime;

/**
* The ESPI feed query parameters of REQ.21.6.2.8, as bound from a resource request (#119).
*
* <p><b>All parameters are optional on the request</b> — a feed request need supply none of them.
* The standard states only {@code published-min/max} and {@code updated-min/max} are
* <em>required to be supported</em> by the Data Custodian (they filter by the Atom
* {@code published}/{@code updated} timestamps); {@code max-results}, {@code start-index},
* {@code start-after} and {@code depth} are optional DB-query knobs that certification checks only
* for data-format acceptance, not filtering behavior — they are accepted (never a {@code 400}) and
* applied best-effort.</p>
*
* <p>ESPI does not define {@code limit}/{@code offset} pagination; CMD/DMD are machine-to-machine.</p>
*
* @param publishedMin filter: Atom {@code published} &ge; this instant (required-to-support)
* @param publishedMax filter: Atom {@code published} &le; this instant (required-to-support)
* @param updatedMin filter: Atom {@code updated} &ge; this instant (required-to-support)
* @param updatedMax filter: Atom {@code updated} &le; this instant (required-to-support)
* @param maxResults optional cap on results (best-effort)
* @param startIndex optional 0-based start offset (best-effort)
* @param startAfter optional id cursor (best-effort; accepted, no-op until needed)
* @param depth optional feed-expansion depth (best-effort; sandbox returns natural depth)
*/
public record EspiQueryOptions(
OffsetDateTime publishedMin,
OffsetDateTime publishedMax,
OffsetDateTime updatedMin,
OffsetDateTime updatedMax,
Integer maxResults,
Integer startIndex,
String startAfter,
Integer depth) {

/** An options object with nothing set — i.e. return the full (scoped) feed. */
public static EspiQueryOptions none() {
return new EspiQueryOptions(null, null, null, null, null, null, null, null);
}

/** True when no parameter is set (the request supplied no query filters). */
public boolean isEmpty() {
return publishedMin == null && publishedMax == null && updatedMin == null && updatedMax == null
&& maxResults == null && startIndex == null && startAfter == null && depth == null;
}

/** True when any of the required-to-support timestamp filters is present. */
public boolean hasTimestampFilter() {
return publishedMin != null || publishedMax != null || updatedMin != null || updatedMax != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
*
* 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.web.api.scope;

import java.util.Set;
import java.util.UUID;

/**
* The data-visibility scope resolved for a single resource-server request (#119).
*
* <p>Foundation for ESPI subscription scoping: a request is either {@code admin} (DataCustodian
* admin / Bulk — sees everything) or constrained to a concrete set of granted ids derived from the
* token's {@code resourceURI} / {@code customerResourceURI} (per #160):</p>
* <ul>
* <li><b>energy</b> resources (UsagePoint → MeterReading → IntervalBlock, EPQS, UsageSummary) are
* visible iff their owning UsagePoint id is in {@link #usagePointIds()};</li>
* <li><b>PII</b> resources (Customer, CustomerAccount, …) are visible iff their owning
* RetailCustomer id is in {@link #retailCustomerIds()} (RetailCustomer is a {@code Long}
* local correlation key, not an ESPI resource);</li>
* <li><b>ReadingType</b> (shared metadata) is visible by reachability — i.e. referenced by a
* MeterReading whose UsagePoint is in {@link #usagePointIds()} — resolved by callers.</li>
* </ul>
*
* <p>A non-admin request with no resolvable grant yields {@link #denied()} (empty sets): it sees
* nothing, never everything — the fail-closed default.</p>
*
* @param admin true for DataCustodian-admin / Bulk tokens (unfiltered access)
* @param usagePointIds the granted energy UsagePoint ids (ignored when {@code admin})
* @param retailCustomerIds the granted PII RetailCustomer ids (ignored when {@code admin})
*/
public record ResourceScope(boolean admin, Set<UUID> usagePointIds, Set<Long> retailCustomerIds) {

public ResourceScope {
usagePointIds = usagePointIds == null ? Set.of() : Set.copyOf(usagePointIds);
retailCustomerIds = retailCustomerIds == null ? Set.of() : Set.copyOf(retailCustomerIds);
}

/** Unfiltered access (DataCustodian admin / Bulk). */
public static ResourceScope unfiltered() {
return new ResourceScope(true, Set.of(), Set.of());
}

/** Fail-closed: a non-admin caller with no resolvable grant — sees nothing. */
public static ResourceScope denied() {
return new ResourceScope(false, Set.of(), Set.of());
}

/** A non-admin scope granting the given energy + PII id sets. */
public static ResourceScope of(Set<UUID> usagePointIds, Set<Long> retailCustomerIds) {
return new ResourceScope(false, usagePointIds, retailCustomerIds);
}

/** Whether an energy resource owned by {@code usagePointId} is visible to this scope. */
public boolean permitsUsagePoint(UUID usagePointId) {
return admin || (usagePointId != null && usagePointIds.contains(usagePointId));
}

/** Whether a PII resource owned by {@code retailCustomerId} is visible to this scope. */
public boolean permitsRetailCustomer(Long retailCustomerId) {
return admin || (retailCustomerId != null && retailCustomerIds.contains(retailCustomerId));
}

/** True when this scope can see no resource at all (non-admin with empty grants). */
public boolean isDenied() {
return !admin && usagePointIds.isEmpty() && retailCustomerIds.isEmpty();
}
}
Loading
Loading