dataProviders = productRepository.findByProducerIds(producerIds);
+ return Optional.ofNullable(dataProviders)
+ .map(productConverter::toDtoList)
+ .orElse(List.of());
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java
new file mode 100644
index 0000000..ae26fe2
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java
@@ -0,0 +1,42 @@
+package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration;
+
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerConfigDTO;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO;
+
+import java.util.Optional;
+
+/**
+ * Interface for retrieving organization configuration information for both consumers and producers.
+ *
+ * This provider interface defines methods to access configuration settings for organizations
+ * based on their client identifiers. It serves as a central point for retrieving configuration
+ * data that may be stored in various backend systems or repositories.
+ *
+ *
+ * @since 1.0
+ */
+public interface ConfigurationProvider {
+
+ /**
+ * Retrieves the configuration for a consumer organization identified by the given client ID.
+ *
+ * @param clientId The unique identifier for the consumer organization. Must not be null or blank.
+ * @param consumerId An optional identifier for the consumer. This can provide further specificity to the request.
+ * @return The configuration settings for the specified consumer organization.
+ * @throws IllegalArgumentException if the clientId is null or empty.
+ * @throws RuntimeException if the configuration cannot be retrieved due to system errors.
+ */
+ ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId);
+
+ /**
+ * Retrieves the configuration for a producer organization identified by the given client ID.
+ *
+ * @param clientId The unique identifier for the producer organization. Must not be null or blank.
+ * @param producerId An optional identifier for the producer. This can provide further specificity to the request.
+ * @return The configuration settings for the specified producer organization.
+ * @throws IllegalArgumentException if the clientId is null or empty.
+ * @throws RuntimeException if the configuration cannot be retrieved due to system errors.
+ */
+ ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId);
+
+}
diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java
new file mode 100644
index 0000000..1ca41ca
--- /dev/null
+++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java
@@ -0,0 +1,229 @@
+package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.stereotype.Service;
+import uk.gov.dbt.ndtp.ia.node.management.model.dto.*;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductService;
+import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService;
+
+@Service
+public class ConfigurationProviderImpl implements ConfigurationProvider {
+
+ private final ConsumerService consumerService;
+
+ private final ProductConsumerService consumerAllowedDataProvidersService;
+
+ private final ProductService dataProviderService;
+
+ private final ProducerService producerService;
+
+ public ConfigurationProviderImpl(
+ ConsumerService consumerService,
+ ProductConsumerService consumerAllowedDataProviders,
+ ProductService dataProviderService,
+ ProducerService producerService) {
+
+ this.consumerService = consumerService;
+ this.consumerAllowedDataProvidersService = consumerAllowedDataProviders;
+ this.dataProviderService = dataProviderService;
+ this.producerService = producerService;
+ }
+
+
+
+ @Override
+ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId) {
+ List consumers = getFilteredConsumers(clientId, consumerId);
+ List consumerAllowedDataProviders = getValidDataProviders(consumers);
+ List dataProviders = getDataProvidersForConsumers(consumerAllowedDataProviders);
+ List producers = getActiveProducersForDataProviders(dataProviders);
+
+ return ConsumerConfigDTO.builder().clientId(clientId).producers(producers).build();
+ }
+
+ @Override
+ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) {
+ List producers = getFilteredActiveProducers(clientId, producerId);
+ List dataProviderIds = collectDataProviderIds(producers);
+
+ // Get allowed consumers (not directly used but might be needed for side effects)
+ consumerService.getConsumersOfProviders(dataProviderIds);
+
+ // Process consumers for each provider
+ processConsumersForProducers(producers);
+
+ return ProducerConfigDTO.builder().clientId(clientId).producers(producers).build();
+ }
+
+ /**
+ * Filters consumers by client ID and optional consumer ID.
+ *
+ * @param clientId the client ID to filter by
+ * @param consumerId optional consumer ID for additional filtering
+ * @return filtered list of consumers
+ */
+ private List getFilteredConsumers(String clientId, Optional consumerId) {
+ List consumers = consumerService.findByIdpClientId(clientId);
+
+ if (consumerId.isPresent()) {
+ consumers = consumers.stream()
+ .filter(consumer -> consumer.getId().equals(consumerId.get()))
+ .toList();
+ }
+
+ return consumers;
+ }
+
+ /**
+ * Retrieves data providers for the given consumer-product relationships.
+ *
+ * @param consumerAllowedDataProviders list of consumer-product relationships
+ * @return list of data providers
+ */
+ private List getDataProvidersForConsumers(List consumerAllowedDataProviders) {
+ List dataProviderIds = consumerAllowedDataProviders.stream()
+ .map(ProductConsumerDTO::getProductId)
+ .toList();
+
+ return dataProviderService.getProductsByIds(dataProviderIds);
+ }
+
+ /**
+ * Retrieves and filters active producers for the given data providers.
+ *
+ * @param dataProviders list of data providers
+ * @return list of active producers
+ */
+ private List getActiveProducersForDataProviders(List dataProviders) {
+ List producerIds = dataProviders.stream()
+ .map(ProductDTO::getProducerId)
+ .toList();
+
+ return producerService.getProducersByIds(producerIds).stream()
+ .filter(ProducerDTO::getActive)
+ .toList();
+ }
+
+
+
+ private List getValidDataProviders(List consumers) {
+ return consumers.stream()
+ .map(consumer -> consumerAllowedDataProvidersService.findByConsumerId(consumer.getId()))
+ .flatMap(List::stream)
+ .filter(this::isValidProvider)
+ .toList();
+ }
+
+ /**
+ * Filters active producers by client ID and optional producer ID.
+ *
+ * @param clientId the client ID to filter by
+ * @param producerId optional producer ID for additional filtering
+ * @return filtered list of active producers
+ */
+ private List getFilteredActiveProducers(String clientId, Optional producerId) {
+ List producers = producerService.getProducersByClientId(clientId).stream()
+ .filter(ProducerDTO::getActive)
+ .toList();
+
+ if (producerId.isPresent()) {
+ producers = producers.stream()
+ .filter(producer -> producerId.get().equals(producer.getId()))
+ .toList();
+ }
+
+ return producers;
+ }
+
+ /**
+ * Collects all data provider IDs from the given producers.
+ *
+ * @param producers list of producers
+ * @return list of data provider IDs
+ */
+ private List collectDataProviderIds(List producers) {
+ List dataProviderIds = new ArrayList<>();
+
+ for (ProducerDTO producer : producers) {
+ List ids = producer.getDataProviders().stream()
+ .map(ProductDTO::getId)
+ .toList();
+ dataProviderIds.addAll(ids);
+ }
+
+ return dataProviderIds;
+ }
+
+ /**
+ * Processes consumers for each provider in the given producers.
+ *
+ * @param producers list of producers to process
+ */
+ private void processConsumersForProducers(List producers) {
+ for (ProducerDTO producer : producers) {
+ for (ProductDTO provider : producer.getDataProviders()) {
+ processConsumersForProvider(provider);
+ }
+ }
+ }
+
+ /**
+ * Processes consumers for a specific provider.
+ *
+ * @param provider the provider to process consumers for
+ */
+ private void processConsumersForProvider(ProductDTO provider) {
+ // Initialize consumers list if null
+ if (provider.getConsumers() == null) {
+ provider.setConsumers(new ArrayList<>());
+ }
+
+ // Get consumer providers for this data provider
+ List consumerProviders =
+ consumerAllowedDataProvidersService.findByDataProviderId(provider.getId());
+
+ // Filter valid providers and add their consumers
+ addValidConsumersToProvider(consumerProviders, provider);
+ }
+
+ /**
+ * Adds valid consumers to the given provider.
+ *
+ * @param consumerProviders list of consumer-provider relationships
+ * @param provider the provider to add consumers to
+ */
+ private void addValidConsumersToProvider(List consumerProviders, ProductDTO provider) {
+ consumerProviders.stream()
+ .filter(this::isValidProvider)
+ .forEach(consumerProvider -> {
+ Optional consumer = consumerService.findById(consumerProvider.getConsumerId());
+ consumer.ifPresent(provider.getConsumers()::add);
+ });
+ }
+
+
+
+
+ private boolean isValidProvider(ProductConsumerDTO provider) {
+
+ if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO))
+ return true;
+
+ return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity());
+ }
+
+ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity) {
+ return grantedTs != null
+ && grantedTs
+ .toInstant()
+ .plus(java.time.Duration.ofDays(validity.longValue()))
+ .isAfter(Instant.now());
+ }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..7ad2d89
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,60 @@
+spring:
+ application:
+ name: management-node
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ issuer-uri: https://localhost:8443/realms/management-node
+ jwk-set-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/certs
+ audiences: management-node
+ authorities-claim-name: resource_access
+ opaquetoken:
+ introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect
+ client-secret:
+ client-id:
+ flyway:
+ create-schemas: on
+ default-schema: mn
+ locations: classpath:db/migration,classpath:db/samples
+ enabled: true
+ baseline-on-migrate: true
+ datasource:
+ url: jdbc:postgresql://localhost:5433/postgres
+ username: keycloak_db_user
+ password:
+ jpa:
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ show_sql: true
+ default_schema: mn
+
+# Server configuration
+server:
+ port: 8090
+ ssl:
+ key-alias: localhost
+ key-store: keystore.jks
+ key-store-type: JKS
+ key-store-password:
+ trust-store: truststore.jks
+ trust-store-password:
+ trust-store-type: JKS
+# Actuator Configuration
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics
+ endpoint:
+ health:
+ show-details: when_authorized
+# Logging Configuration
+logging:
+ level:
+ org.springframework.security: DEBUG
+ uk.gov.dbt.ndtp.ia.node.management: DEBUG
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n"
+ file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n"
\ No newline at end of file
diff --git a/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql b/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql
new file mode 100644
index 0000000..96dfea3
--- /dev/null
+++ b/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql
@@ -0,0 +1,71 @@
+create table organisation
+(
+ id bigserial
+ constraint pk_organisation
+ primary key,
+ name varchar(150) not null
+);
+
+
+
+create table producer
+(
+ id bigserial
+ constraint pk_producer
+ primary key,
+ name varchar(50) not null,
+ description text not null,
+ org_id bigint not null
+ constraint fk___org_id
+ references organisation,
+ active boolean not null,
+ host varchar(500) not null,
+ port numeric not null,
+ tls boolean not null,
+ idp_client_id varchar(50) not null
+);
+
+
+
+create table consumer
+(
+ id bigserial
+ constraint pk_consumer
+ primary key,
+ name varchar(50) not null,
+ org_id bigint not null
+ constraint fk__org_id
+ references organisation,
+ idp_client_id varchar(50) not null
+);
+
+
+
+create table product
+(
+ id bigserial not null
+ constraint pk_3
+ primary key,
+ name varchar(50) not null,
+ topic varchar(150) not null,
+ producer_id bigint not null
+ constraint fk_2
+ references producer
+);
+
+
+
+create table product_consumer
+(
+ product_id bigint not null
+ constraint fk_organisation_data_provider__organisation_data_provider_id
+ references product,
+ consumer_id bigint not null
+ constraint fk_organisation_consumer__organisation_consumer_id
+ references consumer,
+ granted_ts timestamp not null,
+ validity numeric not null,
+ constraint pk_consumer_allowed_data_provider
+ primary key (product_id, consumer_id)
+);
+
diff --git a/src/main/resources/db/samples/V20250728152300__sample_data.sql b/src/main/resources/db/samples/V20250728152300__sample_data.sql
new file mode 100644
index 0000000..5a0de90
--- /dev/null
+++ b/src/main/resources/db/samples/V20250728152300__sample_data.sql
@@ -0,0 +1,61 @@
+-- Sample data for organisation table
+INSERT INTO organisation (name ) VALUES ('Environment Agency (ENV)');
+INSERT INTO organisation (name ) VALUES ('Bristol City Council (BCC)');
+INSERT INTO organisation (name ) VALUES ('Homes England (HEG)');
+
+
+-- Sample data for producer table
+INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id)
+VALUES ('ENV-PRODUCER-1', 'ENV Producer 1', (select id from organisation where name like '%ENV%'), true, 'https://env.gov.uk', 443, true, 'FEDERATOR_ENV');
+
+INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id)
+VALUES ('HEG-PRODUCER-1', 'HEG Producer 1', (select id from organisation where name like '%HEG%'), true, 'https://heg.gov.uk', 443, true, 'FEDERATOR_HEG');
+
+
+INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id)
+VALUES ('BCC-PRODUCER-1', 'BCC Producer 1', (select id from organisation where name like '%BCC%'), true, 'https://heg.gov.uk', 443, true, 'FEDERATOR_BCC');
+
+
+
+-- Sample data for consumer table
+
+
+INSERT INTO consumer (name, org_id, idp_client_id)
+VALUES ('ENV-CONSUMER-1', (select id from organisation where name like '%ENV%'), 'FEDERATOR_ENV');
+
+
+INSERT INTO consumer (name, org_id, idp_client_id)
+VALUES ('BCC-CONSUMER-1', (select id from organisation where name like '%BCC%'), 'FEDERATOR_BCC');
+
+
+INSERT INTO consumer (name, org_id, idp_client_id)
+VALUES ('HEG-CONSUMER-1', (select id from organisation where name like '%HEG%'), 'FEDERATOR_HEG');
+
+-- Sample data for data_provider table
+INSERT INTO product (name, topic, producer_id)
+VALUES ('BrownfieldLandAvailability', 'topic.BrownfieldLandAvailability', (select id from producer where name like '%HEG%'));;
+
+INSERT INTO product (name, topic, producer_id)
+VALUES ('PendingPlanningApplications', 'topic.PendingPlanningApplications', (select id from producer where name like '%BCC%'));;
+
+INSERT INTO product (name, topic, producer_id)
+VALUES ('FloodRiskMapZones', 'topic.FloodRiskMapZones', (select id from producer where name like '%ENV%'));;
+
+
+
+
+
+-- Sample data for consumer_provider table
+INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity)
+VALUES ((select id from product where name ='FloodRiskMapZones'), (select id from consumer where name like '%BCC%' ) , '2025-07-01 00:00:00', 365);
+
+
+INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity)
+VALUES ((select id from product where name ='PendingPlanningApplications'), (select id from consumer where name like '%HEG%' ) , '2025-07-01 00:00:00', 365);
+
+
+
+INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity)
+VALUES ((select id from product where name ='BrownfieldLandAvailability'), (select id from consumer where name like '%ENV%' ) , '2025-07-01 00:00:00', 365);
+
+
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java
new file mode 100644
index 0000000..85e8221
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java
@@ -0,0 +1,13 @@
+package uk.gov.dbt.ndtp.ia.node.management;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class ManagementNodeApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java
new file mode 100644
index 0000000..5073d33
--- /dev/null
+++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java
@@ -0,0 +1,196 @@
+package uk.gov.dbt.ndtp.ia.node.management.config;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestTemplate;
+import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal;
+
+import java.time.Instant;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests specifically for exception handling in KeycloakJwtAuthenticationConverter.
+ */
+@ExtendWith(MockitoExtension.class)
+class KeycloakJwtAuthenticationConverterExceptionTest {
+
+ @Mock
+ private RestTemplate restTemplate;
+
+ @InjectMocks
+ private KeycloakJwtAuthenticationConverter converter;
+
+ private Jwt mockJwt;
+
+ @BeforeEach
+ void setUp() {
+ // Set up configuration properties
+ ReflectionTestUtils.setField(converter, "introspectionUri", "http://localhost:8080/realms/management-node/protocol/openid-connect/token/introspect");
+ ReflectionTestUtils.setField(converter, "clientId", "management-node");
+ ReflectionTestUtils.setField(converter, "clientSecret", "0T5S4wNAPaaOUzFVFQyenorSEC6zxcb0");
+
+ // Create a mock JWT with the sample token data
+ Map headers = new HashMap<>();
+ headers.put("alg", "RS256");
+ headers.put("typ", "JWT");
+
+ Map claims = new HashMap<>();
+ claims.put("exp", 1753576065);
+ claims.put("iat", 1753575765);
+ claims.put("jti", "trrtcc:713a03f4-55fb-4198-e8ea-a1be37d5f52f");
+ claims.put("iss", "http://localhost:8080/realms/management-node");
+ claims.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842");
+ claims.put("typ", "Bearer");
+ claims.put("azp", "management-node");
+ claims.put("client_id", "management-node");
+
+ // Set up the aud claim as a list
+ List audiences = Arrays.asList("F1", "F2");
+ claims.put("aud", audiences);
+
+ // Set up resource_access claim with nested roles
+ Map resourceAccess = new HashMap<>();
+ Map f1Resource = new HashMap<>();
+ List f1Roles = Arrays.asList("TOPIC_2", "TOPIC_1");
+ f1Resource.put("roles", f1Roles);
+ resourceAccess.put("F1", f1Resource);
+ claims.put("resource_access", resourceAccess);
+
+ // Create the JWT with the headers and claims
+ mockJwt = new Jwt(
+ "token-value",
+ Instant.ofEpochSecond(1753575765),
+ Instant.ofEpochSecond(1753576065),
+ headers,
+ claims
+ );
+
+ // Inject mock RestTemplate
+ ReflectionTestUtils.setField(converter, "restTemplate", restTemplate);
+ }
+
+ @Test
+ void convert_withRestClientException_shouldFallbackToJwtParsing() {
+ // Arrange
+ // Configure RestTemplate to throw a RestClientException
+ when(restTemplate.postForEntity(
+ anyString(),
+ any(HttpEntity.class),
+ Mockito.>any()
+ )).thenThrow(new RestClientException("Connection refused"));
+
+ // Act
+ AbstractAuthenticationToken token = converter.convert(mockJwt);
+
+ // Assert
+ assertNotNull(token);
+ assertTrue(token instanceof CustomJwtAuthenticationToken);
+
+ // Verify the token has the correct principal
+ EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal();
+ assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.getSubject());
+ assertEquals("management-node", principal.getClientId());
+ }
+
+ @Test
+ void convert_withMalformedIntrospectionResponse_shouldFallbackToJwtParsing() {
+ // Arrange
+ // Create a malformed introspection response that will cause a ResourceAccessParsingException
+ Map malformedResponse = new HashMap<>();
+ malformedResponse.put("active", true);
+ malformedResponse.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842");
+ malformedResponse.put("client_id", "management-node");
+
+ // Add malformed resource_access (not a map but a string)
+ malformedResponse.put("resource_access", "not-a-map");
+
+ // Configure RestTemplate to return the malformed response
+ ResponseEntity