Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.service;

import java.util.List;
import java.util.UUID;

/**
* Provisions an {@link org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity}
* aggregate (Authorization + 1–2 Subscriptions) from an OAuth2 grant.
*
* <p>This is the data-custodian side of the AS&harr;DC back-channel: the AS calls into DC at
* token-mint time with the granted scope, the customer identity, and the set of usage points the
* customer chose to share. DC parses the scope (see {@link org.greenbuttonalliance.espi.common.scope.EspiScope}),
* creates the Authorization aggregate (PR B1 model), and returns the canonical resource URIs that
* the AS will include in the token response.</p>
*
* <p>Invariants (from PR B1):</p>
* <ul>
* <li>Always creates exactly one <em>energy</em> Subscription (the {@code resource_uri} target).</li>
* <li>Creates a <em>customer/PII</em> Subscription only when the granted scope includes a
* Customer/PII Function Block (FB 54&ndash;62). The {@code customer_resource_uri} from the
* command is stored verbatim on the Authorization aggregate.</li>
* <li>Persistence happens through the Authorization aggregate; Subscriptions are never saved
* independently.</li>
* </ul>
*
* <p>Not part of the ESPI standard &mdash; this is an implementation contract between the
* GBA Authorization Server and a Data Custodian sandbox.</p>
*
* @see SubscriptionProvisionCommand
* @see SubscriptionProvisionResult
*/
public interface SubscriptionProvisioningService {

/**
* Provisions the Authorization aggregate for a completed OAuth2 grant.
*
* @param command the grant details from the AS
* @return the canonical URIs and IDs the AS should publish in the token response
* @throws IllegalArgumentException if the command is null, references unknown entities, or
* carries an unparseable scope
*/
SubscriptionProvisionResult provisionFromGrant(SubscriptionProvisionCommand command);

/**
* Command-side payload for {@link #provisionFromGrant(SubscriptionProvisionCommand)}.
*
* @param correlationId opaque AS-supplied trace id; logged but not persisted
* @param clientId OAuth2 {@code client_id} of the granted TP (must match a
* registered {@code ApplicationInformation})
* @param grantedScope the ESPI scope string the customer approved (must parse via
* {@link org.greenbuttonalliance.espi.common.scope.EspiScope#parse})
* @param retailCustomerId primary key of the {@code RetailCustomer} that authenticated
* (note: {@code RetailCustomerEntity} still uses {@code Long}
* ids; tracked separately from the UUID5 migration)
* @param selectedUsagePointIds usage points the customer chose to share with this TP; may be
* empty for grants that include only customer/PII scope
* @param customerResourceUri absolute URI for the customer/PII resource the TP may GET;
* must be present iff the scope includes a Customer/PII FB
*/
record SubscriptionProvisionCommand(
String correlationId,
String clientId,
String grantedScope,
Long retailCustomerId,
List<UUID> selectedUsagePointIds,
String customerResourceUri
) {}

/**
* Result of a successful provisioning call.
*
* @param authorizationId UUID of the persisted Authorization aggregate
* @param resourceSubscriptionId UUID of the energy Subscription, or {@code null} for a
* PII-only grant (no usage points selected)
* @param customerSubscriptionId UUID of the customer/PII Subscription, or {@code null} if the
* grant did not include Customer/PII scope
* @param resourceUri absolute URI the TP polls for energy data
* ({@code .../resource/Subscription/{id}}), or {@code null} for
* a PII-only grant
* @param authorizationUri absolute URI of the Authorization resource
* ({@code .../resource/Authorization/{id}})
* @param customerResourceUri absolute URI the TP polls for customer/PII data, or
* {@code null} if no Customer/PII scope was granted
*/
record SubscriptionProvisionResult(
UUID authorizationId,
UUID resourceSubscriptionId,
UUID customerSubscriptionId,
String resourceUri,
String authorizationUri,
String customerResourceUri
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/*
* Copyright 2025 Green Button Alliance, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.greenbuttonalliance.espi.common.service.impl;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity;
import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity;
import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository;
import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository;
import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository;
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
import org.greenbuttonalliance.espi.common.scope.EspiScope;
import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService;
import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

/**
* Default implementation of {@link SubscriptionProvisioningService}.
*
* <p>Builds the {@link AuthorizationEntity} aggregate and persists it via the aggregate root:
* {@code cascade = ALL} on {@code AuthorizationEntity.subscriptions} (PR B1) carries the
* Subscriptions through with one {@code save}. URIs returned to the AS are absolute and use
* {@code espi.resources.base-uri}.</p>
*/
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SubscriptionProvisioningServiceImpl implements SubscriptionProvisioningService {

private final AuthorizationRepository authorizationRepository;
private final ApplicationInformationRepository applicationInformationRepository;
private final RetailCustomerRepository retailCustomerRepository;
private final UsagePointRepository usagePointRepository;
private final EspiIdGeneratorService idGeneratorService;

/**
* Canonical absolute base URI for ESPI resources, used to build {@code resource_uri} and
* {@code authorization_uri} returned to the AS. Defaulted for unit tests; overridden in
* production by {@code application.yml}.
*/
@Value("${espi.resources.base-uri:http://localhost:8081/DataCustodian/espi/1_1/resource}")
private String resourceBaseUri;

@Override
public SubscriptionProvisionResult provisionFromGrant(SubscriptionProvisionCommand command) {
validate(command);

EspiScope scope = EspiScope.parse(command.grantedScope());

ApplicationInformationEntity application = applicationInformationRepository
.findByClientId(command.clientId())
.orElseThrow(() -> new IllegalArgumentException(
"Unknown client_id: " + command.clientId()));

RetailCustomerEntity customer = retailCustomerRepository
.findById(command.retailCustomerId())
.orElseThrow(() -> new IllegalArgumentException(
"Unknown retail_customer_id: " + command.retailCustomerId()));

List<UsagePointEntity> usagePoints = resolveUsagePoints(command.selectedUsagePointIds(), customer);
boolean includesEnergy = !usagePoints.isEmpty();
boolean includesPii = scope.includesCustomerPii();

if (!includesEnergy && !includesPii) {
throw new IllegalArgumentException(
"Grant must include at least one selected usage point OR a Customer/PII FB scope");
}
if (includesPii && (command.customerResourceUri() == null || command.customerResourceUri().isBlank())) {
throw new IllegalArgumentException(
"customer_resource_uri is required when scope includes a Customer/PII FB (54-62)");
}
if (!includesPii && command.customerResourceUri() != null && !command.customerResourceUri().isBlank()) {
throw new IllegalArgumentException(
"customer_resource_uri must be absent when scope does not include a Customer/PII FB");
}

AuthorizationEntity authorization = newAuthorization(command, application, customer, includesPii);

SubscriptionEntity resourceSubscription = includesEnergy
? newSubscription(command, application, customer, usagePoints, authorization)
: null;
SubscriptionEntity customerSubscription = includesPii
? newSubscription(command, application, customer, List.of(), authorization)
: null;

String resourceUri = resourceSubscription != null ? subscriptionUri(resourceSubscription.getId()) : null;
if (resourceUri != null) {
authorization.setResourceURI(resourceUri);
}
String authorizationUri = authorizationUri(authorization.getId());
authorization.setAuthorizationURI(authorizationUri);

AuthorizationEntity persisted = authorizationRepository.save(authorization);

log.info("Provisioned authorization {} for client {} customer {} (correlation_id={}, pii={}, usagePoints={})",
persisted.getId(), command.clientId(), command.retailCustomerId(),
command.correlationId(), includesPii, usagePoints.size());

return new SubscriptionProvisionResult(
persisted.getId(),
resourceSubscription != null ? resourceSubscription.getId() : null,
customerSubscription != null ? customerSubscription.getId() : null,
resourceUri,
authorizationUri,
persisted.getCustomerResourceURI()
);
}

private void validate(SubscriptionProvisionCommand command) {
Objects.requireNonNull(command, "command");
if (command.clientId() == null || command.clientId().isBlank()) {
throw new IllegalArgumentException("client_id is required");
}
if (command.grantedScope() == null || command.grantedScope().isBlank()) {
throw new IllegalArgumentException("granted_scope is required");
}
if (command.retailCustomerId() == null) {
throw new IllegalArgumentException("retail_customer_id is required");
}
}

private List<UsagePointEntity> resolveUsagePoints(List<UUID> ids, RetailCustomerEntity customer) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
List<UsagePointEntity> resolved = new ArrayList<>(ids.size());
for (UUID id : ids) {
UsagePointEntity up = usagePointRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Unknown usage_point_id: " + id));
if (up.getRetailCustomer() == null || !customer.getId().equals(up.getRetailCustomer().getId())) {
throw new IllegalArgumentException(
"usage_point_id " + id + " does not belong to retail_customer_id " + customer.getId());
}
resolved.add(up);
}
return resolved;
}

private AuthorizationEntity newAuthorization(SubscriptionProvisionCommand command,
ApplicationInformationEntity application,
RetailCustomerEntity customer,
boolean includesPii) {
AuthorizationEntity authorization = new AuthorizationEntity(customer, application, command.grantedScope());
authorization.setId(UUID.randomUUID());
authorization.setThirdParty(command.clientId());
authorization.setStatus(AuthorizationEntity.STATUS_ACTIVE);
if (includesPii) {
authorization.setCustomerResourceURI(command.customerResourceUri());
}
return authorization;
}

private SubscriptionEntity newSubscription(SubscriptionProvisionCommand command,
ApplicationInformationEntity application,
RetailCustomerEntity customer,
List<UsagePointEntity> usagePoints,
AuthorizationEntity authorization) {
UUID id = idGeneratorService.generateSubscriptionId(command.clientId(), String.valueOf(customer.getId()));
SubscriptionEntity subscription = new SubscriptionEntity(id);
subscription.setRetailCustomer(customer);
subscription.setApplicationInformation(application);
subscription.setAuthorization(authorization);
subscription.setUsagePoints(new ArrayList<>(usagePoints));
authorization.getSubscriptions().add(subscription);
return subscription;
}

private String subscriptionUri(UUID subscriptionId) {
return resourceBaseUri + "/Subscription/" + subscriptionId;
}

private String authorizationUri(UUID authorizationId) {
return resourceBaseUri + "/Authorization/" + authorizationId;
}
}
Loading
Loading