diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java
new file mode 100644
index 00000000..0de71b5b
--- /dev/null
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java
@@ -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.
+ *
+ *
This is the data-custodian side of the AS↔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.
+ *
+ * Invariants (from PR B1):
+ *
+ * - Always creates exactly one energy Subscription (the {@code resource_uri} target).
+ * - Creates a customer/PII Subscription only when the granted scope includes a
+ * Customer/PII Function Block (FB 54–62). The {@code customer_resource_uri} from the
+ * command is stored verbatim on the Authorization aggregate.
+ * - Persistence happens through the Authorization aggregate; Subscriptions are never saved
+ * independently.
+ *
+ *
+ * Not part of the ESPI standard — this is an implementation contract between the
+ * GBA Authorization Server and a Data Custodian sandbox.
+ *
+ * @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 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
+ ) {}
+}
diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java
new file mode 100644
index 00000000..001ae019
--- /dev/null
+++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java
@@ -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}.
+ *
+ * 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}.
+ */
+@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 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 resolveUsagePoints(List ids, RetailCustomerEntity customer) {
+ if (ids == null || ids.isEmpty()) {
+ return List.of();
+ }
+ List 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 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;
+ }
+}
diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java
new file mode 100644
index 00000000..844a7d04
--- /dev/null
+++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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 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.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.service.EspiIdGeneratorService;
+import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionCommand;
+import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link SubscriptionProvisioningServiceImpl}. Verifies aggregate construction,
+ * PII vs energy branch logic, and validation rejections against the PR B1 N:1 model.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class SubscriptionProvisioningServiceImplTest {
+
+ private static final String BASE_URI = "https://dc.example.com/DataCustodian/espi/1_1/resource";
+ private static final String CLIENT_ID = "test-tp";
+ private static final String CORRELATION_ID = "corr-123";
+ private static final Long CUSTOMER_ID = 42L;
+ private static final String PII_CUSTOMER_URI = BASE_URI + "/RetailCustomer/42/Customer/xyz";
+
+ @Mock private AuthorizationRepository authorizationRepository;
+ @Mock private ApplicationInformationRepository applicationInformationRepository;
+ @Mock private RetailCustomerRepository retailCustomerRepository;
+ @Mock private UsagePointRepository usagePointRepository;
+ @Mock private EspiIdGeneratorService idGeneratorService;
+
+ @InjectMocks
+ private SubscriptionProvisioningServiceImpl service;
+
+ private RetailCustomerEntity customer;
+ private ApplicationInformationEntity application;
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(service, "resourceBaseUri", BASE_URI);
+
+ customer = new RetailCustomerEntity();
+ customer.setId(CUSTOMER_ID);
+
+ application = new ApplicationInformationEntity();
+ application.setId(UUID.randomUUID());
+ application.setClientId(CLIENT_ID);
+
+ when(idGeneratorService.generateSubscriptionId(anyString(), anyString()))
+ .thenAnswer(invocation -> UUID.randomUUID());
+ when(authorizationRepository.save(any(AuthorizationEntity.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+ }
+
+ @Test
+ void energyOnlyGrant_createsResourceSubscriptionAndNoPiiSubscription() {
+ UUID upId = stubUsagePoint(customer);
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null));
+
+ assertThat(result.resourceSubscriptionId()).isNotNull();
+ assertThat(result.customerSubscriptionId()).isNull();
+ assertThat(result.resourceUri()).startsWith(BASE_URI + "/Subscription/");
+ assertThat(result.authorizationUri()).startsWith(BASE_URI + "/Authorization/");
+ assertThat(result.customerResourceUri()).isNull();
+
+ AuthorizationEntity saved = captureSavedAuthorization();
+ assertThat(saved).extracting(
+ AuthorizationEntity::getScope,
+ AuthorizationEntity::getThirdParty,
+ AuthorizationEntity::getStatus,
+ AuthorizationEntity::getResourceURI,
+ AuthorizationEntity::getCustomerResourceURI)
+ .containsExactly(
+ "FB=4_5_15",
+ CLIENT_ID,
+ AuthorizationEntity.STATUS_ACTIVE,
+ result.resourceUri(),
+ null);
+ assertThat(saved.getSubscriptions()).hasSize(1);
+ assertThat(saved.getSubscriptions().get(0).getUsagePoints()).hasSize(1);
+ }
+
+ @Test
+ void grantWithPiiScope_createsBothSubscriptions() {
+ UUID upId = stubUsagePoint(customer);
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15_54", CUSTOMER_ID, List.of(upId), PII_CUSTOMER_URI));
+
+ assertThat(result.resourceSubscriptionId()).isNotNull();
+ assertThat(result.customerSubscriptionId()).isNotNull();
+ assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI);
+
+ AuthorizationEntity saved = captureSavedAuthorization();
+ assertThat(saved.getCustomerResourceURI()).isEqualTo(PII_CUSTOMER_URI);
+ assertThat(saved.getSubscriptions()).hasSize(2);
+ }
+
+ @Test
+ void piiOnlyGrant_createsOnlyCustomerSubscription_noResourceUri() {
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_54", CUSTOMER_ID, List.of(), PII_CUSTOMER_URI));
+
+ assertThat(result.resourceSubscriptionId()).isNull();
+ assertThat(result.resourceUri()).isNull();
+ assertThat(result.customerSubscriptionId()).isNotNull();
+ assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI);
+
+ AuthorizationEntity saved = captureSavedAuthorization();
+ assertThat(saved.getResourceURI()).isNull();
+ assertThat(saved.getSubscriptions()).hasSize(1);
+ }
+
+ @Test
+ void emptyGrant_isRejected() {
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4", CUSTOMER_ID, List.of(), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at least one selected usage point OR a Customer/PII");
+ }
+
+ @Test
+ void piiScopeWithoutCustomerUri_isRejected() {
+ UUID upId = stubUsagePoint(customer);
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_54", CUSTOMER_ID, List.of(upId), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("customer_resource_uri is required");
+ }
+
+ @Test
+ void customerUriWithoutPiiScope_isRejected() {
+ UUID upId = stubUsagePoint(customer);
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), PII_CUSTOMER_URI)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("customer_resource_uri must be absent");
+ }
+
+ @Test
+ void unknownClientId_isRejected() {
+ when(applicationInformationRepository.findByClientId("ghost")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, "ghost", "FB=4_5_15", CUSTOMER_ID, List.of(UUID.randomUUID()), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown client_id");
+ }
+
+ @Test
+ void unknownRetailCustomer_isRejected() {
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+ when(retailCustomerRepository.findById(999L)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", 999L, List.of(UUID.randomUUID()), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown retail_customer_id");
+ }
+
+ @Test
+ void usagePointBelongingToDifferentCustomer_isRejected() {
+ UUID upId = UUID.randomUUID();
+ RetailCustomerEntity other = new RetailCustomerEntity();
+ other.setId(999L);
+ UsagePointEntity foreign = new UsagePointEntity();
+ foreign.setId(upId);
+ foreign.setRetailCustomer(other);
+
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(usagePointRepository.findById(upId)).thenReturn(Optional.of(foreign));
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("does not belong to retail_customer_id");
+ }
+
+ @Test
+ void unknownUsagePoint_isRejected() {
+ UUID upId = UUID.randomUUID();
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+ when(usagePointRepository.findById(upId)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Unknown usage_point_id");
+ }
+
+ @Test
+ void unparseableScope_isRejected() {
+ when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application));
+ when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer));
+
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=not_a_number", CUSTOMER_ID, List.of(), null)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void blankRequiredFields_areRejected() {
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, "", "FB=4_5_15", CUSTOMER_ID, List.of(), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("client_id");
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, " ", CUSTOMER_ID, List.of(), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("granted_scope");
+ assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand(
+ CORRELATION_ID, CLIENT_ID, "FB=4_5_15", null, List.of(), null)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("retail_customer_id");
+ }
+
+ private UUID stubUsagePoint(RetailCustomerEntity owner) {
+ UUID upId = UUID.randomUUID();
+ UsagePointEntity up = new UsagePointEntity();
+ up.setId(upId);
+ up.setRetailCustomer(owner);
+ when(usagePointRepository.findById(upId)).thenReturn(Optional.of(up));
+ return upId;
+ }
+
+ private AuthorizationEntity captureSavedAuthorization() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AuthorizationEntity.class);
+ org.mockito.Mockito.verify(authorizationRepository).save(captor.capture());
+ return captor.getValue();
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java
new file mode 100644
index 00000000..295b73f5
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java
@@ -0,0 +1,87 @@
+/*
+ * 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.datacustodian.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * Security filter chain for the AS↔DC back-channel under {@code /internal/**}.
+ *
+ * Separate from the public ESPI resource-server chain in {@link SecurityConfiguration}. This
+ * chain authenticates the GBA Authorization Server (the only legitimate caller) with HTTP Basic
+ * against a dedicated, single-purpose credential — not the OAuth2 token
+ * introspection used for the public API. Network-level isolation (binding {@code /internal/**} to
+ * an internal interface or gating it at ingress) is expected on top of this in production; mTLS is
+ * a future enhancement tracked separately.
+ *
+ * Ordered at {@link Ordered#HIGHEST_PRECEDENCE} so the {@code securityMatcher("/internal/**")}
+ * is evaluated first — back-channel requests never fall through to the public chain. The
+ * back-channel {@link UserDetailsService} and {@link PasswordEncoder} are wired into this chain
+ * via a private {@link DaoAuthenticationProvider} and are NOT exposed as top-level beans, so the
+ * public OAuth2 resource-server chain remains unaffected.
+ *
+ * @see SecurityConfiguration
+ */
+@Configuration
+public class BackchannelSecurityConfiguration {
+
+ private static final String BACKCHANNEL_ROLE = "BACKCHANNEL";
+
+ @Value("${espi.backchannel.client-id:as-backchannel}")
+ private String backchannelClientId;
+
+ @Value("${espi.backchannel.client-secret:change-me-in-production}")
+ private String backchannelClientSecret;
+
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE)
+ public SecurityFilterChain backchannelSecurityFilterChain(HttpSecurity http) throws Exception {
+ PasswordEncoder encoder = new BCryptPasswordEncoder();
+ UserDetailsService uds = new InMemoryUserDetailsManager(
+ User.withUsername(backchannelClientId)
+ .password(encoder.encode(backchannelClientSecret))
+ .roles(BACKCHANNEL_ROLE)
+ .build());
+ DaoAuthenticationProvider provider = new DaoAuthenticationProvider(uds);
+ provider.setPasswordEncoder(encoder);
+
+ return http
+ .securityMatcher("/internal/**")
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(AbstractHttpConfigurer::disable)
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(authz -> authz
+ .anyRequest().hasRole(BACKCHANNEL_ROLE))
+ .authenticationProvider(provider)
+ .httpBasic(httpBasic -> {})
+ .build();
+ }
+}
diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java
new file mode 100644
index 00000000..afcf8578
--- /dev/null
+++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java
@@ -0,0 +1,85 @@
+/*
+ * 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.datacustodian.web.internal.backchannel;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService;
+import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionCommand;
+import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionResult;
+import org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto.SubscriptionProvisionRequest;
+import org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto.SubscriptionProvisionResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * AS→DC back-channel: provisions an Authorization aggregate (Authorization + 1–2
+ * Subscriptions) at OAuth2 token-mint time.
+ *
+ * Not part of the ESPI standard. Implementation contract between the GBA
+ * Authorization Server and a Data Custodian sandbox. Mounted under {@code /internal/backchannel/}
+ * with a dedicated security filter chain (HTTP Basic with a shared back-channel credential, no
+ * OAuth2 introspection) so it is never reachable through the public ESPI resource-server chain.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/internal/backchannel/v1/subscriptions")
+@RequiredArgsConstructor
+public class SubscriptionProvisioningController {
+
+ private final SubscriptionProvisioningService provisioningService;
+
+ @PostMapping
+ public ResponseEntity provision(
+ @Valid @RequestBody SubscriptionProvisionRequest request) {
+
+ SubscriptionProvisionResult result = provisioningService.provisionFromGrant(
+ new SubscriptionProvisionCommand(
+ request.correlationId(),
+ request.clientId(),
+ request.grantedScope(),
+ request.retailCustomerId(),
+ request.selectedUsagePointIds(),
+ request.customerResourceUri()));
+
+ SubscriptionProvisionResponse body = new SubscriptionProvisionResponse(
+ result.authorizationId(),
+ result.resourceSubscriptionId(),
+ result.customerSubscriptionId(),
+ result.resourceUri(),
+ result.authorizationUri(),
+ result.customerResourceUri());
+
+ return ResponseEntity.status(HttpStatus.CREATED).body(body);
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity