feat(#122 PR B2): AS↔DC subscription-provisioning back-channel#139
Merged
dfcoffin merged 1 commit intoMay 31, 2026
Merged
Conversation
Adds the data-custodian side of the AS↔DC back-channel that the GBA
Authorization Server calls at token-mint time to materialize a
customer-approved grant into a persistent Authorization aggregate.
Service (openespi-common)
- SubscriptionProvisioningService — interface + SubscriptionProvisionCommand
/ SubscriptionProvisionResult records.
- SubscriptionProvisioningServiceImpl — parses the granted scope via
EspiScope (PR A), looks up the registered client, the retail customer,
and the customer-selected usage points, then builds the Authorization
aggregate (PR B1 N:1 model). Three branch shapes:
* energy-only → 1 resource Subscription
* energy + PII → resource + customer Subscription
* PII-only → customer Subscription, no resource_uri
- Aggregate-root persistence: one authorizationRepository.save carries
Subscriptions through via cascade=ALL (PR B1).
REST surface (openespi-datacustodian)
- POST /internal/backchannel/v1/subscriptions
- Request: {correlation_id, client_id, granted_scope, retail_customer_id,
selected_usage_point_ids[], customer_resource_uri?}
- Response (201): {authorization_id, resource_subscription_id?,
customer_subscription_id?, resource_uri?, authorization_uri,
customer_resource_uri?}
- 400 returns an OAuth2-style {error, error_description} on validation
failure or service-level rejection.
Security
- BackchannelSecurityConfiguration — dedicated SecurityFilterChain at
HIGHEST_PRECEDENCE matching /internal/**, HTTP Basic against a private
DaoAuthenticationProvider (no top-level UserDetailsService bean — the
public OAuth2 resource-server chain in SecurityConfiguration is
unaffected). CSRF/CORS disabled, STATELESS.
- Implementation contract, NOT ESPI standard. Network-level isolation
(internal-interface bind / ingress allow-list) is expected on top of
this in production; mTLS is a future enhancement tracked separately.
Config
- espi.backchannel.client-id / client-secret in application.yml,
overridable via BACKCHANNEL_CLIENT_ID / BACKCHANNEL_CLIENT_SECRET.
Tests
- SubscriptionProvisioningServiceImplTest — 12 tests covering happy
paths (energy-only, energy+PII, PII-only), every validation rejection
path (empty grant, missing/extra customer_resource_uri, unknown
client/customer/usage-point, cross-customer usage-point, unparseable
scope, blank required fields), and aggregate persistence shape.
- SubscriptionProvisioningControllerTest — 7 MockMvc tests covering
unauthenticated 401, wrong-credentials 401, happy-path 201 with
canonical URIs, missing/blank field 400, service-rejection 400 with
error body, and snake_case DTO round-trip.
Verification
- openespi-common: 848 tests pass.
- openespi-datacustodian: 97 tests pass (+1 pre-existing @disabled skip).
Refs: #122. Builds on #136 (PR A) and #137 (PR B1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6 tasks
dfcoffin
added a commit
that referenced
this pull request
Jun 1, 2026
Adds the tamper-evident value object the GBA Authorization Server and a
Data Custodian sandbox will exchange via URL parameter during the
customer-facing OAuth2 flow (replaces a shared Spring Session as the
cross-app state mechanism). Mechanism only — AS and DC sides that
*use* this land in subsequent PRs (C2a, C2b, C3, C4).
Wire format (two dot-separated base64URL segments):
{base64URL(JSON(payload))} . {base64URL(HMAC-SHA256(key, payload))}
Direction tag in the payload (outbound | return) prevents one direction's
token from being replayed as the other. Single-use nonce table on the
receiver prevents replay within a direction. Short expiry (5 min default).
Codec — openespi-common.handoff
- SignedHandoff sealed interface + Outbound / Return records
(snake_case JSON via Jackson 3.x).
- SignedHandoffCodec — HMAC-SHA256 encode + verify; rejects malformed,
tampered (constant-time compare), wrong-direction, wrong-version,
expired payloads. Constructor-injected signing key (≥32 chars).
- InvalidHandoffException — uniform rejection signal (callers must not
reveal which sub-check failed to the user-agent).
Replay protection
- HandoffNonceEntity implements Persistable<String> with isNew() == true
so JpaRepository.save() routes to entityManager.persist() (INSERT-only),
NOT merge(); a duplicate consume surfaces as PK violation rather than
silently UPDATEing the existing row.
- HandoffNonceService.consume runs in Propagation.REQUIRES_NEW: a
partially-completed grant must not allow the same nonce to be reused
even if the surrounding business transaction rolls back.
- V4__Create_Handoff_Nonces.sql — vendor-neutral DDL (H2 / MySQL /
PostgreSQL).
Scan-path wiring
- DataCustodianApplication and TestApplication EntityScan +
EnableJpaRepositories now include the handoff package.
- application.yml documents espi.handoff.signing-key (default for dev;
ESPI_HANDOFF_SIGNING_KEY env var in production).
AS-side mirror
- Deferred to PR C3 where the AS actually starts using the codec. C1
ships only what DC consumers in C2a/C2b will need.
Tests
- SignedHandoffCodecTest — 12 unit tests: round-trip both directions,
tampered-payload / tampered-signature / wrong-key rejection (all via
constant-time compare), expiry, wrong-direction, wrong-version,
malformed / empty token, short signing key.
- HandoffNonceServiceTest — 6 @DataJpaTest cases: first-consume success,
replay rejection (PK violation), distinct-nonces independence,
uniqueness over 10k generates, blank-nonce rejection, reaper sweep.
Assertions are by id-lookup (not row count) because REQUIRES_NEW
commits escape the @DataJpaTest rollback.
Verification
- openespi-common handoff tests: 18 / 18 pass.
- DataCustodianApplicationH2Test (full SpringBootTest context): 3 / 3
pass — confirms the Spring auto-wiring of the dual-constructor codec
(the public ctor is @Autowired; the package-private one is for tests).
- openespi-datacustodian full suite: BUILD SUCCESS (97 / 97 + 1 pre-
existing @disabled skip).
Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the data-custodian side of the AS↔DC back-channel that the GBA Authorization Server calls at OAuth2 token-mint time to materialize a customer-approved grant into a persistent
Authorizationaggregate (PR B1 model) + 1–2Subscriptionchildren.This is the back-channel half of the #122 contract. The AS-side caller comes in a later PR (Step 3.5 / "PR C").
What's in this PR
Service layer (
openespi-common)SubscriptionProvisioningService— interface withSubscriptionProvisionCommand/SubscriptionProvisionResultrecords as the API surface.SubscriptionProvisioningServiceImpl— parses the granted scope viaEspiScope(PR A), resolves the registered client + retail customer + selected usage points, and builds the Authorization aggregate. Three branch shapes:SubscriptionSubscription+ customer/PIISubscriptionSubscription, noresource_uriauthorizationRepository.savecarries Subscriptions through viacascade=ALL(the model PR B1 just shipped).REST surface (
openespi-datacustodian)POST /internal/backchannel/v1/subscriptions{ "correlation_id": "...", "client_id": "...", "granted_scope": "FB=4_5_15;IntervalDuration=3600", "retail_customer_id": 42, "selected_usage_point_ids": ["uuid", ...], "customer_resource_uri": "https://..." // present iff Customer/PII FB granted }NON_NULL):{ "authorization_id": "uuid", "resource_subscription_id": "uuid", "customer_subscription_id": "uuid", "resource_uri": "https://.../resource/Subscription/<id>", "authorization_uri": "https://.../resource/Authorization/<id>", "customer_resource_uri": "https://.../resource/RetailCustomer/.../Customer/<id>" }{error, error_description}body on validation failure or service-level rejection.Security
BackchannelSecurityConfiguration— dedicatedSecurityFilterChainatHIGHEST_PRECEDENCEmatching/internal/**, HTTP Basic against a privateDaoAuthenticationProvider. No top-levelUserDetailsServicebean — the public OAuth2 resource-server chain inSecurityConfigurationis completely unaffected. CSRF/CORS disabled,STATELESSsession.Config
application.yml:Architectural decisions (recap)
web/internal/backchannel/), NOT a separate Maven module. Module split would prepay cost for a future need (separate deploy cadence / team / runtime) we don't have. YAGNI. Package boundary makes a future extraction a refactor, not a rewrite.SecurityFilterChain, NOT routing through the public ESPI OAuth2 chain. Different audience (AS, not TPs), different auth (HTTP Basic, not bearer-token introspection), different versioning cadence (our contract, not NAESB).loginPage/consentPagehooks). Senior-architect call: convention over invention, lower maintenance, supports future MFA / step-up auth / IdP federation, no need to revise the pinned Phase 2.0 — Stand up auth-server in dev with opaque tokens + subscription-bound introspection (Phase 2 precursor) #122 contract.cascade=ALLcarries the children through,orphanRemoval=truecleans them up later when the lifecycle policy in Consent withdrawal must trigger token revocation + subscription cleanup #138 is wired.Test plan
openespi-common, 12 tests): happy paths for all three branch shapes, every validation rejection (empty grant, missing/extra customer_resource_uri, unknown client / customer / usage-point, cross-customer usage-point, unparseable scope, blank required fields), and aggregate persistence shape.openespi-datacustodian, 7 tests): unauthenticated 401, wrong-credentials 401, happy-path 201 with canonical URIs, missing/blank field 400, service-rejection 400 with error body, snake_case DTO round-trip.openespi-commontest suite locally: 848 / 848 pass, 0 failures, 0 skipped.openespi-datacustodiantest suite locally: 97 / 97 pass, 1 pre-existing@Disabledskip.Refs
EspiScope+ Function Block catalog) and refactor(common): subscription↔authorization N:1, authorization as aggregate root (#122 PR B1) #137 (PR B1: subscription↔authorization N:1).🤖 Generated with Claude Code