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>
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