Skip to content

feat(#122 PR B2): AS↔DC subscription-provisioning back-channel#139

Merged
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-b2-subscription-create-endpoint
May 31, 2026
Merged

feat(#122 PR B2): AS↔DC subscription-provisioning back-channel#139
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-b2-subscription-create-endpoint

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

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 Authorization aggregate (PR B1 model) + 1–2 Subscription children.

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 with SubscriptionProvisionCommand / SubscriptionProvisionResult records as the API surface.
  • SubscriptionProvisioningServiceImpl — parses the granted scope via EspiScope (PR A), resolves the registered client + retail customer + selected usage points, and builds the Authorization aggregate. Three branch shapes:
    • energy-only → 1 resource Subscription
    • energy + PII → resource Subscription + customer/PII Subscription
    • PII-only → customer/PII Subscription, no resource_uri
  • Aggregate-root persistence: one authorizationRepository.save carries Subscriptions through via cascade=ALL (the model PR B1 just shipped).

REST surface (openespi-datacustodian)

  • POST /internal/backchannel/v1/subscriptions
  • Request body (snake_case JSON, Jakarta validation):
    {
      "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
    }
  • Response body (201, snake_case JSON, 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>"
    }
  • 400 returns an OAuth2-style {error, error_description} body 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 completely unaffected. CSRF/CORS disabled, STATELESS session.
  • 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

  • New keys in application.yml:
    espi:
      backchannel:
        client-id: ${BACKCHANNEL_CLIENT_ID:as-backchannel}
        client-secret: ${BACKCHANNEL_CLIENT_SECRET:change-me-in-production}

Architectural decisions (recap)

  1. Single deployable + dedicated package (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.
  2. Dedicated 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).
  3. Two-bounce AS↔DC flow stays (orthodox Spring Authorization Server loginPage/consentPage hooks). 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.
  4. Aggregate-root persistence (PR B1 model): the service never directly creates/saves Subscriptions — it builds the aggregate and saves the Authorization once. cascade=ALL carries the children through, orphanRemoval=true cleans them up later when the lifecycle policy in Consent withdrawal must trigger token revocation + subscription cleanup #138 is wired.

Test plan

  • Service unit tests (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.
  • Controller MockMvc tests (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.
  • Full openespi-common test suite locally: 848 / 848 pass, 0 failures, 0 skipped.
  • Full openespi-datacustodian test suite locally: 97 / 97 pass, 1 pre-existing @Disabled skip.
  • CI: 3-DB integration tests (MySQL / PostgreSQL / H2 via TestContainers).
  • CI: Security vulnerability scan + SonarCloud.

Refs

🤖 Generated with Claude Code

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>
@dfcoffin dfcoffin merged commit 08809a5 into main May 31, 2026
4 checks passed
@dfcoffin dfcoffin deleted the feature/issue-122-pr-b2-subscription-create-endpoint branch May 31, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant