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

@dfcoffin dfcoffin commented May 31, 2026

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