Skip to content

Commit db1a2e8

Browse files
dfcoffinclaude
andauthored
feat(#119 F1): resource-server scope foundation + dev XML capture/sample (#167)
Foundation for the canonical DC ESPI resource surface (#119): the security-critical scope core every resource endpoint will enforce, so subscription scoping / the data-leakage fix lives in one tested component rather than per controller. Scope core (web/api/scope): - ResourceScope: resolved per-request visibility — admin/unfiltered, or a granted energy UsagePoint-id set + PII RetailCustomer-id set; fail-closed denied(). UUID resources, Long RetailCustomer correlation key. - ScopeResolver: token -> ResourceScope. DataCustodian-admin authority or Batch/Bulk -> admin; Batch/Subscription resourceURI -> the subscription's granted UsagePoint ids (read from the DC's own store via the new lazy-safe SubscriptionRepository.findUsagePointIdsBySubscriptionId, never from the token body); Batch/RetailCustomer customerResourceURI -> the PII RetailCustomer id; otherwise denied. URIs parsed via the canonical EspiBatchUri. - EspiQueryOptions: REQ.21.6.2.8 feed query params — ALL optional. published/updated min/max are required-to-support timestamp filters; max-results/start-index/start-after/depth are optional best-effort DB knobs. No limit/offset pagination (ESPI CMD/DMD are machine-to-machine). Dev tooling (web/api/devtools): - EspiXmlCaptureFilter: dev-only (@Profile dev-*) servlet filter that tees ESPI XML responses under /espi/1_1/resource/** to per-run sub-dirs under repo-root dev-xml-capture/ (git-ignored) so a developer can view/diff the marshalled XML before committing. Zero impact on test/prod/CI. - DevXmlSampleGenerator: on-demand generator (not run by CI) that marshals a sample resource via the real UsageExportService into dev-xml-capture/sample/ — view ESPI XML without a full server. Reuses the existing tested Atom/DTO/IdentifiedObject marshalling — F1 adds none. The resource engine + entry-assembly (self/up/related FB-gated atom:links, per REQ.21.6.2) lands in E1 with the first real resource (UsagePoint/MeterReading/ReadingType), exercised end-to-end. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 86b9fc1 commit db1a2e8

11 files changed

Lines changed: 946 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ temp-services/
6262
node_modules/
6363
package.json
6464
package-lock.json
65+
66+
# Dev-only ESPI XML capture output (EspiXmlCaptureFilter, #119) — never committed
67+
dev-xml-capture/

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
2323
import org.springframework.data.jpa.repository.JpaRepository;
24+
import org.springframework.data.jpa.repository.Query;
25+
import org.springframework.data.repository.query.Param;
2426
import org.springframework.stereotype.Repository;
2527

2628
import java.util.List;
@@ -73,4 +75,15 @@ public interface SubscriptionRepository extends JpaRepository<SubscriptionEntity
7375
* @return list of subscriptions
7476
*/
7577
List<SubscriptionEntity> findByApplicationInformation_Id(UUID id);
78+
79+
/**
80+
* Returns the ids of the UsagePoints granted to a subscription, projected directly so callers
81+
* (e.g. the resource-server scope resolver, #119) can resolve a subscription's granted set
82+
* without traversing the lazy {@code usagePoints} collection outside a transaction.
83+
*
84+
* @param subscriptionId the subscription UUID
85+
* @return the granted UsagePoint ids (empty if none / unknown subscription)
86+
*/
87+
@Query("select up.id from SubscriptionEntity s join s.usagePoints up where s.id = :subscriptionId")
88+
List<UUID> findUsagePointIdsBySubscriptionId(@Param("subscriptionId") UUID subscriptionId);
7689
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
*
3+
* Copyright (c) 2025 Green Button Alliance, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.greenbuttonalliance.espi.datacustodian.web.api.devtools;
20+
21+
import jakarta.servlet.FilterChain;
22+
import jakarta.servlet.ServletException;
23+
import jakarta.servlet.http.HttpServletRequest;
24+
import jakarta.servlet.http.HttpServletResponse;
25+
import org.slf4j.Logger;
26+
import org.slf4j.LoggerFactory;
27+
import org.springframework.beans.factory.annotation.Value;
28+
import org.springframework.context.annotation.Profile;
29+
import org.springframework.stereotype.Component;
30+
import org.springframework.web.filter.OncePerRequestFilter;
31+
import org.springframework.web.util.ContentCachingResponseWrapper;
32+
33+
import java.io.IOException;
34+
import java.nio.file.Files;
35+
import java.nio.file.Path;
36+
import java.time.LocalDateTime;
37+
import java.time.format.DateTimeFormatter;
38+
39+
/**
40+
* Dev-only diagnostic: tees ESPI resource XML responses to files on disk so a developer can view
41+
* (and diff) the actual marshalled Atom XML before committing changes (#119).
42+
*
43+
* <p>Active only under the development profiles ({@code dev-mysql}, {@code dev-postgresql},
44+
* {@code local}, {@code dev}) — never in {@code test}/{@code prod}/{@code docker}, so it has zero
45+
* impact on CI or production. It is controller-agnostic: it captures every XML response under
46+
* {@code /espi/1_1/resource/**}, so it works for the existing controllers and every resource added
47+
* to the canonical surface.</p>
48+
*
49+
* <p>Layout — a persistent, git-ignored, repo-root directory ({@code espi.dev.xml-capture.dir},
50+
* default {@code dev-xml-capture}) with one timestamped sub-directory <b>per application run</b>:</p>
51+
* <pre>
52+
* dev-xml-capture/
53+
* run-20260605-231007-413/
54+
* GET_UsagePoint_503888.xml
55+
* GET_Customer.xml
56+
* run-20260605-233512-088/
57+
* GET_UsagePoint_503888.xml
58+
* </pre>
59+
* <p>Per-run sub-dirs (rather than a flat overwrite) let you {@code diff -r} the output of one run
60+
* against another to see the effect of a change. Within a run, filenames are stable per
61+
* method+path (a repeated call overwrites with the latest response). The response body is always
62+
* copied through to the client unchanged.</p>
63+
*/
64+
@Component
65+
@Profile({"dev-mysql", "dev-postgresql", "local", "dev"})
66+
public class EspiXmlCaptureFilter extends OncePerRequestFilter {
67+
68+
private static final Logger log = LoggerFactory.getLogger(EspiXmlCaptureFilter.class);
69+
private static final String RESOURCE_PATH = "/espi/1_1/resource";
70+
private static final DateTimeFormatter RUN_ID =
71+
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
72+
73+
private final boolean enabled;
74+
private final Path runDir;
75+
76+
public EspiXmlCaptureFilter(
77+
@Value("${espi.dev.xml-capture.enabled:true}") boolean enabled,
78+
@Value("${espi.dev.xml-capture.dir:dev-xml-capture}") String captureDir) {
79+
this.enabled = enabled;
80+
// One sub-directory per application run, so successive runs can be compared rather than
81+
// overwriting each other.
82+
this.runDir = Path.of(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID));
83+
}
84+
85+
@Override
86+
protected boolean shouldNotFilter(HttpServletRequest request) {
87+
return !enabled || !request.getRequestURI().contains(RESOURCE_PATH);
88+
}
89+
90+
@Override
91+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
92+
FilterChain filterChain) throws ServletException, IOException {
93+
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
94+
try {
95+
filterChain.doFilter(request, wrapper);
96+
} finally {
97+
capture(request, wrapper);
98+
wrapper.copyBodyToResponse();
99+
}
100+
}
101+
102+
private void capture(HttpServletRequest request, ContentCachingResponseWrapper wrapper) {
103+
byte[] body = wrapper.getContentAsByteArray();
104+
String contentType = wrapper.getContentType();
105+
if (body.length == 0 || contentType == null
106+
|| !contentType.toLowerCase().contains("xml")) {
107+
return;
108+
}
109+
try {
110+
Files.createDirectories(runDir);
111+
Path file = runDir.resolve(fileName(request));
112+
Files.write(file, body);
113+
log.info("[dev] captured ESPI XML ({} bytes) -> {}", body.length, file.toAbsolutePath());
114+
} catch (IOException e) {
115+
// dev aid only — never let a capture failure affect the response
116+
log.warn("[dev] ESPI XML capture failed: {}", e.getMessage());
117+
}
118+
}
119+
120+
/** Stable per-run filename from the request, e.g. {@code GET_UsagePoint_503888.xml}. */
121+
private String fileName(HttpServletRequest request) {
122+
String path = request.getRequestURI();
123+
int idx = path.indexOf(RESOURCE_PATH);
124+
String tail = idx >= 0 ? path.substring(idx + RESOURCE_PATH.length()) : path;
125+
String safe = tail.replaceAll("^/+", "").replaceAll("[^A-Za-z0-9._-]+", "_");
126+
if (safe.isBlank()) {
127+
safe = "root";
128+
}
129+
return request.getMethod() + "_" + safe + ".xml";
130+
}
131+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
*
3+
* Copyright (c) 2025 Green Button Alliance, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.greenbuttonalliance.espi.datacustodian.web.api.scope;
20+
21+
import java.time.OffsetDateTime;
22+
23+
/**
24+
* The ESPI feed query parameters of REQ.21.6.2.8, as bound from a resource request (#119).
25+
*
26+
* <p><b>All parameters are optional on the request</b> — a feed request need supply none of them.
27+
* The standard states only {@code published-min/max} and {@code updated-min/max} are
28+
* <em>required to be supported</em> by the Data Custodian (they filter by the Atom
29+
* {@code published}/{@code updated} timestamps); {@code max-results}, {@code start-index},
30+
* {@code start-after} and {@code depth} are optional DB-query knobs that certification checks only
31+
* for data-format acceptance, not filtering behavior — they are accepted (never a {@code 400}) and
32+
* applied best-effort.</p>
33+
*
34+
* <p>ESPI does not define {@code limit}/{@code offset} pagination; CMD/DMD are machine-to-machine.</p>
35+
*
36+
* @param publishedMin filter: Atom {@code published} &ge; this instant (required-to-support)
37+
* @param publishedMax filter: Atom {@code published} &le; this instant (required-to-support)
38+
* @param updatedMin filter: Atom {@code updated} &ge; this instant (required-to-support)
39+
* @param updatedMax filter: Atom {@code updated} &le; this instant (required-to-support)
40+
* @param maxResults optional cap on results (best-effort)
41+
* @param startIndex optional 0-based start offset (best-effort)
42+
* @param startAfter optional id cursor (best-effort; accepted, no-op until needed)
43+
* @param depth optional feed-expansion depth (best-effort; sandbox returns natural depth)
44+
*/
45+
public record EspiQueryOptions(
46+
OffsetDateTime publishedMin,
47+
OffsetDateTime publishedMax,
48+
OffsetDateTime updatedMin,
49+
OffsetDateTime updatedMax,
50+
Integer maxResults,
51+
Integer startIndex,
52+
String startAfter,
53+
Integer depth) {
54+
55+
/** An options object with nothing set — i.e. return the full (scoped) feed. */
56+
public static EspiQueryOptions none() {
57+
return new EspiQueryOptions(null, null, null, null, null, null, null, null);
58+
}
59+
60+
/** True when no parameter is set (the request supplied no query filters). */
61+
public boolean isEmpty() {
62+
return publishedMin == null && publishedMax == null && updatedMin == null && updatedMax == null
63+
&& maxResults == null && startIndex == null && startAfter == null && depth == null;
64+
}
65+
66+
/** True when any of the required-to-support timestamp filters is present. */
67+
public boolean hasTimestampFilter() {
68+
return publishedMin != null || publishedMax != null || updatedMin != null || updatedMax != null;
69+
}
70+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
*
3+
* Copyright (c) 2025 Green Button Alliance, Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
package org.greenbuttonalliance.espi.datacustodian.web.api.scope;
20+
21+
import java.util.Set;
22+
import java.util.UUID;
23+
24+
/**
25+
* The data-visibility scope resolved for a single resource-server request (#119).
26+
*
27+
* <p>Foundation for ESPI subscription scoping: a request is either {@code admin} (DataCustodian
28+
* admin / Bulk — sees everything) or constrained to a concrete set of granted ids derived from the
29+
* token's {@code resourceURI} / {@code customerResourceURI} (per #160):</p>
30+
* <ul>
31+
* <li><b>energy</b> resources (UsagePoint → MeterReading → IntervalBlock, EPQS, UsageSummary) are
32+
* visible iff their owning UsagePoint id is in {@link #usagePointIds()};</li>
33+
* <li><b>PII</b> resources (Customer, CustomerAccount, …) are visible iff their owning
34+
* RetailCustomer id is in {@link #retailCustomerIds()} (RetailCustomer is a {@code Long}
35+
* local correlation key, not an ESPI resource);</li>
36+
* <li><b>ReadingType</b> (shared metadata) is visible by reachability — i.e. referenced by a
37+
* MeterReading whose UsagePoint is in {@link #usagePointIds()} — resolved by callers.</li>
38+
* </ul>
39+
*
40+
* <p>A non-admin request with no resolvable grant yields {@link #denied()} (empty sets): it sees
41+
* nothing, never everything — the fail-closed default.</p>
42+
*
43+
* @param admin true for DataCustodian-admin / Bulk tokens (unfiltered access)
44+
* @param usagePointIds the granted energy UsagePoint ids (ignored when {@code admin})
45+
* @param retailCustomerIds the granted PII RetailCustomer ids (ignored when {@code admin})
46+
*/
47+
public record ResourceScope(boolean admin, Set<UUID> usagePointIds, Set<Long> retailCustomerIds) {
48+
49+
public ResourceScope {
50+
usagePointIds = usagePointIds == null ? Set.of() : Set.copyOf(usagePointIds);
51+
retailCustomerIds = retailCustomerIds == null ? Set.of() : Set.copyOf(retailCustomerIds);
52+
}
53+
54+
/** Unfiltered access (DataCustodian admin / Bulk). */
55+
public static ResourceScope unfiltered() {
56+
return new ResourceScope(true, Set.of(), Set.of());
57+
}
58+
59+
/** Fail-closed: a non-admin caller with no resolvable grant — sees nothing. */
60+
public static ResourceScope denied() {
61+
return new ResourceScope(false, Set.of(), Set.of());
62+
}
63+
64+
/** A non-admin scope granting the given energy + PII id sets. */
65+
public static ResourceScope of(Set<UUID> usagePointIds, Set<Long> retailCustomerIds) {
66+
return new ResourceScope(false, usagePointIds, retailCustomerIds);
67+
}
68+
69+
/** Whether an energy resource owned by {@code usagePointId} is visible to this scope. */
70+
public boolean permitsUsagePoint(UUID usagePointId) {
71+
return admin || (usagePointId != null && usagePointIds.contains(usagePointId));
72+
}
73+
74+
/** Whether a PII resource owned by {@code retailCustomerId} is visible to this scope. */
75+
public boolean permitsRetailCustomer(Long retailCustomerId) {
76+
return admin || (retailCustomerId != null && retailCustomerIds.contains(retailCustomerId));
77+
}
78+
79+
/** True when this scope can see no resource at all (non-admin with empty grants). */
80+
public boolean isDenied() {
81+
return !admin && usagePointIds.isEmpty() && retailCustomerIds.isEmpty();
82+
}
83+
}

0 commit comments

Comments
 (0)