Skip to content

refactor(common): subscription↔authorization N:1, authorization as aggregate root (#122 PR B1)#137

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

refactor(common): subscription↔authorization N:1, authorization as aggregate root (#122 PR B1)#137
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-b-subscription-create

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

PR B1 — subscription↔authorization N:1 rework (Authorization as aggregate root)

First half of the #122 "PR B" split (B1 = schema + entity rework; B2 = the subscription-create back-channel endpoint that consumes this). Pure refactor — no new endpoint, no behavior change to request handling.

Why

The ESPI grant model produces two subscriptions per authorization: an Energy subscription (keyed to authorizations.resource_uri) and, when the grant includes Customer/PII scope, a Customer subscription (keyed to authorizations.customer_resource_uri). The old @OneToOne could not represent that. The authorization is the aggregate root; a subscription has no independent lifecycle — it is created and removed only through its authorization.

Relationship

  • SubscriptionEntity.authorization: @OneToOne@ManyToOne, NOT NULL, cascade DETACH (owning side; FK subscriptions.authorization_id).
  • AuthorizationEntity: @OneToOne subscription@OneToMany subscriptions (mappedBy, cascade=ALL, orphanRemoval=true).

This enforces the agreed lifecycle invariants:

  1. Removed authorization ⇒ its subscriptions removed — JPA cascade=ALL + orphanRemoval and DB ON DELETE CASCADE.
  2. A subscription is never added/removed independently — only through the authorization aggregate.
  3. Revoking Customer/PII access removes only the customerResourceURI subscription — drop that child from the collection; orphanRemoval deletes it; the energy subscription stays. (customer_resource_uri stays nullable: an authorization has 1 or 2 subscriptions.)

Migration (edit-in-place, vendor-neutral db/migration)

  • Dropped authorizations.subscription_id + FK fk_authorization_subscription (the back-reference that blocked N:1).
  • subscriptions.authorization_idNOT NULL + FK → authorizations.id ON DELETE CASCADE (was index-only).
  • The 3-DB TestContainers integration run (MySQL/PostgreSQL/H2) is the safety net for vendor divergence.

Consequent code

  • findByAuthorization_Id / findByAuthorizationId: OptionalList.
  • NotificationServiceImpl iterates each authorization's subscriptions.
  • AuthorizationServiceImpl.createAuthorizationEntity sets the owning side and maintains both directions.
  • AuthorizationMapper ignores the subscriptions collection (aggregate children).
  • ResourceValidationFilter matches the requested {subscriptionId} against the authorization's subscription collection (removed a dead local var).

Tests

  • New N:1 test: two subscriptions (energy + customer/PII) backed by one authorization, both found via findByAuthorization_Id.
  • createValidSubscription() now attaches a required authorization; fixed three tests that assumed a nullable authorization (one repurposed to the bare-entity isActive() null branch).
  • Full openespi-common suite green (SubscriptionRepositoryTest 24/24; zero failures/errors across all module reports).

Known follow-up (not this PR)

Customer/PII access has no independent revocation trigger yet — the consent-withdrawal-vs-token-revocation lifecycle gap. B1 makes the mechanism correct (CASCADE/orphanRemoval); the policy/trigger is deferred. I can file a tracking issue.

Refs #122. Depends on nothing; PR B2 (subscription-create endpoint) builds on this.

🤖 Generated with Claude Code

…gregate root (#122 PR B1)

Reworks the subscription↔authorization relationship so a single OAuth2
authorization can back the two subscriptions the ESPI grant model produces — an
Energy subscription (authorizations.resource_uri) and, when the grant includes
Customer/PII scope, a Customer subscription (authorizations.customer_resource_uri).

The authorization is the aggregate root; a subscription has no independent
lifecycle (created and removed only through its authorization):

- SubscriptionEntity.authorization: @OnetoOne@manytoone, NOT NULL, cascade
  DETACH (owning side; FK subscriptions.authorization_id).
- AuthorizationEntity: @OnetoOne subscription → @onetomany subscriptions
  (mappedBy, cascade=ALL, orphanRemoval=true). Removing the authorization removes
  its subscriptions; dropping a subscription from the collection (e.g. revoking
  Customer/PII access) deletes that subscription.

Migration (edit-in-place, vendor-neutral db/migration; 3-DB integration tests are
the safety net):
- Drop authorizations.subscription_id + FK fk_authorization_subscription (V1/V3).
- subscriptions.authorization_id → NOT NULL + FK to authorizations.id
  ON DELETE CASCADE (was index-only).

Consequent changes:
- SubscriptionRepository.findByAuthorization_Id / SubscriptionService
  .findByAuthorizationId: Optional → List (N:1).
- NotificationServiceImpl iterates the subscriptions of each authorization.
- AuthorizationServiceImpl.createAuthorizationEntity sets the owning side.
- AuthorizationMapper ignores the subscriptions collection.
- ResourceValidationFilter matches the requested {subscriptionId} against the
  authorization's subscription collection (the dead local var removed).

Tests: new findByAuthorization_Id N:1 test (energy + customer share one auth);
createValidSubscription() now attaches a required authorization; fixed three
tests that assumed a nullable authorization. Full openespi-common suite green.

Customer/PII access has no independent revocation trigger yet (consent withdrawal
vs token revocation) — tracked separately; this PR only makes the mechanism correct.

Refs #122.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dfcoffin dfcoffin merged commit 4df9838 into main May 31, 2026
4 checks passed
dfcoffin added a commit that referenced this pull request May 31, 2026
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 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