refactor(common): subscription↔authorization N:1, authorization as aggregate root (#122 PR B1)#137
Merged
Conversation
…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>
5 tasks
6 tasks
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>
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.
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 toauthorizations.customer_resource_uri). The old@OneToOnecould 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, cascadeDETACH(owning side; FKsubscriptions.authorization_id).AuthorizationEntity:@OneToOne subscription→@OneToMany subscriptions(mappedBy,cascade=ALL,orphanRemoval=true).This enforces the agreed lifecycle invariants:
cascade=ALL+orphanRemovaland DBON DELETE CASCADE.customerResourceURIsubscription — drop that child from the collection;orphanRemovaldeletes it; the energy subscription stays. (customer_resource_uristays nullable: an authorization has 1 or 2 subscriptions.)Migration (edit-in-place, vendor-neutral
db/migration)authorizations.subscription_id+ FKfk_authorization_subscription(the back-reference that blocked N:1).subscriptions.authorization_id→ NOT NULL + FK →authorizations.id ON DELETE CASCADE(was index-only).Consequent code
findByAuthorization_Id/findByAuthorizationId:Optional→List.NotificationServiceImpliterates each authorization's subscriptions.AuthorizationServiceImpl.createAuthorizationEntitysets the owning side and maintains both directions.AuthorizationMapperignores thesubscriptionscollection (aggregate children).ResourceValidationFiltermatches the requested{subscriptionId}against the authorization's subscription collection (removed a dead local var).Tests
findByAuthorization_Id.createValidSubscription()now attaches a required authorization; fixed three tests that assumed a nullable authorization (one repurposed to the bare-entityisActive()null branch).openespi-commonsuite green (SubscriptionRepositoryTest24/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