From eadb2b8a702674558bca974eaf55118d28863401 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 14 Oct 2025 11:46:30 +0100 Subject: [PATCH 01/49] Add support for scheduling with `schedule_type` and `schedule_expression` fields - Updated database schema to include scheduling fields for consumers, products, and product-consumers. - Extended entity models and their converters for new schedule attributes. - Adjusted tests and configs for scheduling feature. --- .../converter/impl/ConsumerConverter.java | 5 +- .../impl/OrganisationProducerConverter.java | 8 +- .../impl/ProductConsumerConverter.java | 8 +- .../converter/impl/ProductConverter.java | 4 + .../model/dto/ConsumerConfigDTO.java | 3 + .../management/model/dto/ConsumerDTO.java | 4 + .../model/dto/ProductConsumerDTO.java | 12 + .../node/management/model/dto/ProductDTO.java | 6 + .../persistency/entity/Consumer.java | 6 + .../persistency/entity/Product.java | 7 + .../persistency/entity/ProductConsumer.java | 9 + .../persistency/entity/ProductType.java | 28 + .../repository/ConsumerRepository.java | 4 +- .../repository/ProducerRepository.java | 9 +- .../repository/ProductRepository.java | 4 +- .../ConfigurationProviderImpl.java | 43 +- src/main/resources/application.yml | 2 +- .../V20251013135858__add_product_type.sql | 34 + ...0251013135880__add_schedule_expression.sql | 21 + .../ConfigurationProviderImplTest.java | 586 +++++------------- src/test/resources/application.yml | 22 + 21 files changed, 356 insertions(+), 469 deletions(-) create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java create mode 100644 src/main/resources/db/migration/V20251013135858__add_product_type.sql create mode 100644 src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql create mode 100644 src/test/resources/application.yml diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java index 26d2f01..457db0d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java @@ -48,6 +48,8 @@ public ConsumerDTO toDto(Consumer entity) { .name(entity.getName()) .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) .idpClientId(entity.getIdpClientId()) + .scheduleExpression(entity.getScheduleExpression()) + .scheduleType(entity.getScheduleType()) .build(); // Populate attributes from associated ProductConsumers @@ -89,7 +91,8 @@ public Consumer toEntity(ConsumerDTO dto) { entity.setId(dto.getId()); entity.setName(dto.getName()); entity.setIdpClientId(dto.getIdpClientId()); - + entity.setScheduleExpression(dto.getScheduleExpression()); + entity.setScheduleType(dto.getScheduleType()); // Set the organisation if orgId is provided if (dto.getOrgId() != null) { Organisation organisation = diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java index e0c7b30..141f296 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java @@ -106,10 +106,10 @@ public Producer toEntity(ProducerDTO dto) { if (dataProviderDTO.getProducerId() == null && dto.getId() != null) { dataProviderDTO.setProducerId(dto.getId()); } - Product dataProvider = productConverter.toEntity(dataProviderDTO); - if (dataProvider != null) { - dataProvider.setProducer(entity); - dataProviders.add(dataProvider); + Product product = productConverter.toEntity(dataProviderDTO); + if (product != null) { + product.setProducer(entity); + dataProviders.add(product); } }); entity.setProducts(dataProviders); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java index e59d656..f327ab6 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java @@ -39,6 +39,10 @@ public ProductConsumerDTO toDto(ProductConsumer entity) { .consumerId(entity.getConsumer() != null ? entity.getConsumer().getId() : null) .grantedTs(entity.getGrantedTs()) .validity(entity.getValidity()) + .destination(entity.getDestination()) + .scheduleExpression(entity.getScheduleExpression()) + .scheduleType(entity.getScheduleType()) + .destination(entity.getDestination()) .build(); // Map attributes if available @@ -72,7 +76,9 @@ public ProductConsumer toEntity(ProductConsumerDTO dto) { entity.setGrantedTs(dto.getGrantedTs()); entity.setValidity(dto.getValidity()); - + entity.setDestination(dto.getDestination()); + entity.setScheduleExpression(dto.getScheduleExpression()); + entity.setScheduleType(dto.getScheduleType()); if (dto.getProductId() != null) { Product product = new Product(); product.setId(dto.getProductId()); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java index bc10ad4..c51e79d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java @@ -46,7 +46,10 @@ public ProductDTO toDto(Product entity) { .id(entity.getId()) .name(entity.getName()) .topic(entity.getTopic()) + .type(entity.getProductType().getName()) + .source(entity.getSource()) .producerId(entity.getProducer() != null ? entity.getProducer().getId() : null) + .type(entity.getProductType().getName()) .build(); } @@ -66,6 +69,7 @@ public Product toEntity(ProductDTO dto) { entity.setId(dto.getId()); entity.setName(dto.getName()); entity.setTopic(dto.getTopic()); + entity.setSource(dto.getSource()); // Set the producer if producerId is provided if (dto.getProducerId() != null) { diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java index 93c566b..65b5dfd 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java @@ -15,5 +15,8 @@ public class ConsumerConfigDTO { private final String clientId; + private final String name; + private final String scheduleType; + private final String scheduleExpression; private final List producers; } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java index 89477b6..bcf3d6a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java @@ -30,5 +30,9 @@ public class ConsumerDTO { private String idpClientId; + private String scheduleType; + + private String scheduleExpression; + private final List attributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java index 8ce94a7..26baef0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java @@ -6,6 +6,7 @@ package uk.gov.dbt.ndtp.ia.node.management.model.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import java.math.BigDecimal; import java.sql.Timestamp; import java.util.ArrayList; @@ -21,9 +22,20 @@ @NoArgsConstructor @AllArgsConstructor public class ProductConsumerDTO { + @JsonIgnore private Long productId; + + @JsonIgnore private Long consumerId; + + @JsonIgnore private Timestamp grantedTs; + + @JsonIgnore private BigDecimal validity; + + private String scheduleType; + private String scheduleExpression; + private String destination; private final List attributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java index cdddd9a..0480026 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java @@ -31,5 +31,11 @@ public class ProductDTO { private String topic; + private String type; + + private String source; + private List consumers = new ArrayList<>(); + + private List configurations = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java index 10d7e22..a382d58 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java @@ -24,6 +24,12 @@ public class Consumer { @Column(name = "name", nullable = false, length = 50) private String name; + @Column(name = "schedule_type", nullable = false) + private String scheduleType; + + @Column(name = "schedule_expression") + private String scheduleExpression; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "org_id", nullable = false) private Organisation org; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java index 1492166..7093256 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java @@ -27,10 +27,17 @@ public class Product { @Column(name = "topic", nullable = false, length = 150) private String topic; + @Column(name = "source", length = 500) + private String source; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "producer_id", nullable = false) private Producer producer; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_type_id") + private ProductType productType; + @OneToMany(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", referencedColumnName = "id", insertable = false, updatable = false) private List productConsumer; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java index c707e8b..2ee8f15 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java @@ -30,6 +30,15 @@ public class ProductConsumer { @Column(name = "validity", nullable = false) private BigDecimal validity; + @Column(name = "schedule_type", nullable = false) + private String scheduleType; + + @Column(name = "schedule_expression") + private String scheduleExpression; + + @Column(name = "destination") + private String destination; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id", nullable = false) private Product product; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java new file mode 100644 index 0000000..040aae1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "product_type") +public class ProductType { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java index c3ea1ea..ac6f79a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java @@ -24,7 +24,9 @@ public interface ConsumerRepository extends JpaRepository { * @param providers a list of IDs of the providers whose associated consumers need to be retrieved * @return a list of {@link Consumer} entities associated with the specified provider IDs */ - @Query("SELECT c FROM Consumer c JOIN fetch c.productConsumers cp " + "inner join fetch cp.product p " + @Query("SELECT c FROM Consumer c JOIN fetch c.productConsumers cp " + + "inner join fetch cp.product p " + + "JOIN FETCH p.productType t " + "inner join fetch cp.consumer consumer " + " WHERE p.id IN :providers") List findConsumersByProviderIds(List providers); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java index a929301..0509622 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java @@ -31,8 +31,9 @@ public interface ProducerRepository extends JpaRepository { * @param consumerIds a list of consumer IDs used to filter the {@link Producer} and associated entities * @return a list of {@link Producer} entities along with their associated {@link Product} entities and product consumers */ - @Query( - "SELECT o FROM Producer o JOIN FETCH o.products p JOIN p.productConsumer pc WHERE pc.consumer.id IN :consumerIds") + @Query(" SELECT o FROM Producer o " + "JOIN FETCH o.products p JOIN p.productConsumer pc " + + "JOIN FETCH p.productType t " + + "WHERE pc.consumer.id IN :consumerIds ") List findByConsumerIds(List consumerIds); /** @@ -42,6 +43,8 @@ public interface ProducerRepository extends JpaRepository { * @param idpClientId the Identity Provider client identifier used to retrieve corresponding {@link Producer} entities * @return a list of {@link Producer} entities with their associated {@link Product} entities */ - @Query("SELECT o FROM Producer o JOIN FETCH o.products WHERE o.idpClientId IN :idpClientId") + @Query("SELECT o FROM Producer o " + "JOIN FETCH o.products p " + + "JOIN FETCH p.productType t " + + "WHERE o.idpClientId IN :idpClientId") List findByIdpClientId(String idpClientId); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java index ad27315..2c74b10 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java @@ -30,7 +30,7 @@ public interface ProductRepository extends JpaRepository { * @param ids a list of product IDs for which the {@link Product} entities are to be retrieved * @return a list of {@link Product} entities matching the provided IDs */ - @Query("SELECT o FROM Product o WHERE o.id IN :ids") + @Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + "WHERE o.id IN :ids") List findByIds(List ids); /** @@ -39,6 +39,6 @@ public interface ProductRepository extends JpaRepository { * @param producers a list of producer IDs whose associated {@link Product} entities need to be retrieved * @return a list of {@link Product} entities linked to the specified producer IDs */ - @Query("SELECT o FROM Product o WHERE o.producer.id IN :producers") + @Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + " WHERE o.producer.id IN :producers") List findByProducerIds(List producers); } 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 index 74f71ba..68e2880 100644 --- 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 @@ -23,7 +23,7 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final ConsumerService consumerService; - private final ProductConsumerService consumerAllowedDataProvidersService; + private final ProductConsumerService productConsumerService; private final ProducerService producerService; @@ -33,7 +33,7 @@ public ConfigurationProviderImpl( ProducerService producerService) { this.consumerService = consumerService; - this.consumerAllowedDataProvidersService = consumerAllowedDataProviders; + this.productConsumerService = consumerAllowedDataProviders; this.producerService = producerService; } @@ -50,13 +50,10 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumers = getFilteredConsumers(clientId, consumerId); List consumerIds = consumers.stream().map(ConsumerDTO::getId).toList(); - List validProductIds = new ArrayList<>(); - - consumers.forEach(consumer -> - validProductIds.addAll(consumerAllowedDataProvidersService.findByConsumerId(consumer.getId()).stream() - .filter(this::isValidProvider) - .map(ProductConsumerDTO::getProductId) - .toList())); + List validProductConsumers = getValidProductConsumers(consumers); + List validProductIds = validProductConsumers.stream() + .map(ProductConsumerDTO::getProductId) + .toList(); List producers = producerService.getProducersByConsumerIds(consumerIds).stream() .filter(ProducerDTO::getActive) @@ -72,12 +69,37 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional p.getProducts().clear()); } + // finding the products and adding the configurations + producers.forEach(producer -> producer.getProducts().forEach(product -> { + List configs = validProductConsumers.stream() + .filter(pc -> pc.getProductId().equals(product.getId())) + .toList(); + product.setConfigurations(configs); + })); + + ConsumerDTO firstConsumer = consumers.getFirst(); return ConsumerConfigDTO.builder() + .scheduleExpression(firstConsumer.getScheduleExpression()) + .scheduleType(firstConsumer.getScheduleType()) .clientId(clientId) + .name(firstConsumer.getName()) .producers(producers) .build(); } + private List getValidProductConsumers(List consumers) { + List validProductIds = new ArrayList<>(); + + consumers.forEach(consumer -> { + List list = productConsumerService.findByConsumerId(consumer.getId()).stream() + .filter(this::isValidProvider) + .toList(); + + validProductIds.addAll(list); + }); + return validProductIds; + } + @Override public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) { List producers = getFilteredActiveProducers(clientId, producerId); @@ -174,8 +196,7 @@ private void processConsumersForProducers(List producers) { private void processConsumersForProvider(ProductDTO provider) { // Get consumer providers for this data provider - List consumerProviders = - consumerAllowedDataProvidersService.findByDataProviderId(provider.getId()); + List consumerProviders = productConsumerService.findByDataProviderId(provider.getId()); // Filter valid providers and add their consumers addValidConsumersToProvider(consumerProviders, provider); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0532b6f..3e2dc7c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,8 +10,8 @@ spring: audiences: management-node authorities-claim-name: resource_access opaquetoken: - introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect client-secret: + introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect client-id: MANAGEMENT_NODE_CLIENT # required client id for introspect endpoint flyway: create-schemas: on diff --git a/src/main/resources/db/migration/V20251013135858__add_product_type.sql b/src/main/resources/db/migration/V20251013135858__add_product_type.sql new file mode 100644 index 0000000..0a18c66 --- /dev/null +++ b/src/main/resources/db/migration/V20251013135858__add_product_type.sql @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Create product_type table with autogenerated primary key, name, and description (max 255) +create table if not exists product_type +( + id bigserial + constraint pk_product_type + primary key, + name varchar(150) not null, + description varchar(255) +); + +-- Add foreign key column to product referencing product_type(id) +alter table product add column if not exists product_type_id bigint; + +alter table product + add constraint fk_product__product_type_id + foreign key (product_type_id) references product_type (id); + +-- Optional but recommended: index for faster lookups on FK +create index if not exists idx_product__product_type_id on product (product_type_id); + + +-- Add default values for product_type +INSERT INTO product_type (name, description) VALUES ('topic', 'data exchange using kafka topics'); +INSERT INTO product_type (name, description) VALUES ('file', 'file exchange using cloud file storage'); + +---- update all existing products to use topic product type +UPDATE product set product_type_id = (select id from product_type where name = 'topic') where product_type_id is null; + diff --git a/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql b/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql new file mode 100644 index 0000000..786447e --- /dev/null +++ b/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Add foreign key column to product referencing product_type(id) +alter table product_consumer add column if not exists schedule_type varchar(100); +alter table product_consumer add column if not exists schedule_expression varchar(255); +alter table product_consumer add column if not exists destination varchar(500); + + +alter table consumer add column if not exists schedule_type varchar(100); +alter table consumer add column if not exists schedule_expression varchar(255); + +-- add source to product +alter table product add column if not exists source varchar(500); + +-- schedule_type: cron, interval +update consumer set schedule_type = 'cron', schedule_expression='*/5 * * * *' where 1=1; +update product_consumer set schedule_type = 'cron', schedule_expression='*/5 * * * *' where 1=1; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index 1f72d7c..ef2635d 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -1,43 +1,33 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; 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.MockitoAnnotations; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; -@ExtendWith(MockitoExtension.class) class ConfigurationProviderImplTest { @Mock private ConsumerService consumerService; @Mock - private ProductConsumerService consumerAllowedDataProvidersService; + private ProductConsumerService productConsumerService; @Mock private ProducerService producerService; @@ -45,462 +35,168 @@ class ConfigurationProviderImplTest { @InjectMocks private ConfigurationProviderImpl configurationProvider; - private final String clientId = "test-client-id"; - private final Long consumerId = 1L; - private final Long producerId = 2L; - private final Long productId = 3L; - - private ConsumerDTO consumerDTO; - private ProducerDTO producerDTO; - private ProductDTO productDTO; - private ProductConsumerDTO productConsumerDTO; - @BeforeEach void setUp() { - // Set up consumer - consumerDTO = ConsumerDTO.builder() - .id(consumerId) - .name("Test Consumer") - .idpClientId(clientId) - .build(); + MockitoAnnotations.openMocks(this); + configurationProvider = new ConfigurationProviderImpl(consumerService, productConsumerService, producerService); + } - // Set up producer - producerDTO = ProducerDTO.builder() - .id(producerId) - .name("Test Producer") + private ConsumerDTO consumer( + long id, String clientId, String name, String scheduleType, String scheduleExpression) { + ConsumerDTO dto = ConsumerDTO.builder() .idpClientId(clientId) - .active(true) - .build(); - - // Set up product - productDTO = ProductDTO.builder() - .id(productId) - .name("Test Product") - .producerId(producerId) - .consumers(new ArrayList<>()) + .name(name) + .scheduleType(scheduleType) + .scheduleExpression(scheduleExpression) .build(); - - // Set up product consumer relationship - productConsumerDTO = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(null) // No validity constraint - .build(); - } - - // Tests for getConsumerConfigByClientId - - @Test - void getConsumerConfigByClientId_withValidClientIdAndNoConsumerId_shouldReturnConfig() { - // Arrange - List consumers = List.of(consumerDTO); - List producers = List.of(producerDTO); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); - } - - @Test - void getConsumerConfigByClientId_withValidClientIdAndConsumerId_shouldReturnFilteredConfig() { - // Arrange - List allConsumers = List.of(consumerDTO); - List producers = List.of(producerDTO); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(allConsumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); - } - - @Test - void getConsumerConfigByClientId_withNoMatchingConsumers_shouldReturnEmptyConfig() { - // Arrange - when(consumerService.findByIdpClientId(clientId)).thenReturn(Collections.emptyList()); - when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(Collections.emptyList()); - verifyNoInteractions(consumerAllowedDataProvidersService); - } - - @Test - void getConsumerConfigByClientId_withNoMatchingConsumerForSpecificId_shouldReturnEmptyConfig() { - // Arrange - ConsumerDTO differentConsumer = - ConsumerDTO.builder().id(999L).idpClientId(clientId).build(); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(differentConsumer)); - when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(Collections.emptyList()); - verifyNoInteractions(consumerAllowedDataProvidersService); - } - - @Test - void getConsumerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { - // Arrange - List consumers = List.of(consumerDTO); - - // Create inactive producer - ProducerDTO inactiveProducer = - ProducerDTO.builder().id(producerId).active(false).build(); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(List.of(inactiveProducer)); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); + dto.setId(id); + return dto; } - // Tests for getProducerConfigByClientId - - @Test - void getProducerConfigByClientId_withValidClientIdAndNoProducerId_shouldReturnConfig() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - assertEquals(1, result.getProducers().getFirst().getProducts().size()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + private ProducerDTO producer(long id, boolean active, ProductDTO... products) { + ProducerDTO p = ProducerDTO.builder() + .id(id) + .active(active) + .idpClientId("cid") + .name("p") + .build(); + for (ProductDTO pr : products) { + p.getProducts().add(pr); + } + return p; } - @Test - void getProducerConfigByClientId_withValidClientIdAndProducerId_shouldReturnFilteredConfig() { - // Arrange - List allProducers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(allProducers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + private ProductDTO product(Long id, String name) { + ProductDTO d = ProductDTO.builder().name(name).build(); + d.setId(id); + return d; } - @Test - void getProducerConfigByClientId_withNoMatchingProducers_shouldReturnEmptyConfig() { - // Arrange - when(producerService.getProducersByClientId(clientId)).thenReturn(Collections.emptyList()); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + private ProductConsumerDTO productConsumer( + long productId, long consumerId, BigDecimal validityDays, Instant grantedAt) { + return ProductConsumerDTO.builder() + .productId(productId) + .consumerId(consumerId) + .validity(validityDays) + .grantedTs(grantedAt != null ? Timestamp.from(grantedAt) : null) + .scheduleType("CRON") + .scheduleExpression("0 0 * * * *") + .destination("topic") + .build(); } @Test - void getProducerConfigByClientId_withNoMatchingProducerForSpecificId_shouldReturnEmptyConfig() { - // Arrange - ProducerDTO differentProducer = ProducerDTO.builder() - .id(999L) - .idpClientId(clientId) - .active(true) - .build(); - - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(differentProducer)); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + void getConsumerConfigByClientId_filtersInactiveProducers_andProductsByValidIds_andSetsConfigs() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); + + // Valid configs for product 100 only (null validity treated as valid) + ProductConsumerDTO pc1 = productConsumer(100L, 1L, null, null); + ProductConsumerDTO pc2 = productConsumer(100L, 1L, BigDecimal.ZERO, null); + when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of(pc1, pc2)); + + // One active and one inactive producer; active has products 100 (kept) and 102 (removed) + ProducerDTO active = producer(10L, true, product(100L, "dp-100"), product(102L, "dp-102")); + ProducerDTO inactive = producer(11L, false, product(100L, "dp-100")); + when(producerService.getProducersByConsumerIds(List.of(1L))).thenReturn(List.of(active, inactive)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + // Only active producer remains + assertThat(cfg.getProducers()).containsExactly(active); + // Products filtered to valid productIds (only 100) + assertThat(active.getProducts()).extracting(ProductDTO::getId).containsExactly(100L); + // Configurations set on product 100 + assertThat(active.getProducts().get(0).getConfigurations()).containsExactlyInAnyOrder(pc1, pc2); + // Schedule and name propagated from first consumer + assertThat(cfg.getScheduleType()).isEqualTo("CRON"); + assertThat(cfg.getScheduleExpression()).isEqualTo("@hourly"); + assertThat(cfg.getClientId()).isEqualTo(clientId); } @Test - void getProducerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { - // Arrange - ProducerDTO inactiveProducer = ProducerDTO.builder() - .id(producerId) - .idpClientId(clientId) - .active(false) - .build(); + void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts() { + String clientId = "clientB"; + ConsumerDTO c1 = consumer(2L, clientId, "c2", "FIXED", "PT10M"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(inactiveProducer)); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); + // No valid product-consumers returned + when(productConsumerService.findByConsumerId(2L)).thenReturn(List.of()); - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + ProducerDTO active = producer(20L, true, product(200L, "dp-200"), product(201L, "dp-201")); + when(producerService.getProducersByConsumerIds(List.of(2L))).thenReturn(List.of(active)); - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + assertThat(cfg.getProducers()).hasSize(1); + assertThat(cfg.getProducers().get(0).getProducts()).isEmpty(); } @Test - void getProducerConfigByClientId_withNullConsumers_shouldInitializeConsumersList() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list with null consumers - productDTO.setConsumers(null); // Null consumers list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertNotNull(result.getProducers().getFirst().getProducts().getFirst().getConsumers()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() { + String clientId = "clientC"; + ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily"); + ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther)); + + ProductConsumerDTO pc = productConsumer(300L, 3L, null, null); + when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc)); + + ProductDTO pNull = product(null, "no-id"); + ProductDTO pKept = product(300L, "ok"); + ProducerDTO active = producer(30L, true, pNull, pKept); + when(producerService.getProducersByConsumerIds(List.of(3L))).thenReturn(List.of(active)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(3L)); + + // Only products with ids in valid set are kept => null removed, only 300 remains + assertThat(cfg.getProducers().get(0).getProducts()) + .extracting(ProductDTO::getId) + .containsExactly(300L); + // And configurations attached to remaining product + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConfigurations()) + .containsExactly(pc); } @Test - void getProducerConfigByClientId_withExpiredValidity_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - // Create expired product consumer relationship - ProductConsumerDTO expiredProductConsumer = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(BigDecimal.valueOf(30)) // 30 days validity - .grantedTs(Timestamp.from(Instant.now().minus(60, ChronoUnit.DAYS))) // 60 days ago - .build(); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(expiredProductConsumer)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService, never()).findById(any()); - } - - @Test - void getProducerConfigByClientId_withValidityButNoGrantedTs_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - // Create product consumer relationship with validity but no grantedTs - ProductConsumerDTO invalidProductConsumer = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(BigDecimal.valueOf(30)) // 30 days validity - .grantedTs(null) // No granted timestamp - .build(); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(invalidProductConsumer)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService, never()).findById(any()); - } - - @Test - void getProducerConfigByClientId_withConsumerNotFound_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.empty()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersAdded() { + String clientId = "clientP"; + ProductDTO pr1 = product(900L, "prov1"); + ProductDTO pr2 = product(901L, "prov2"); + ProducerDTO active = producer(91L, true, pr1, pr2); + ProducerDTO inactive = producer(92L, false, product(902L, "prov3")); + + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(active, inactive)); + + // product ids should be collected and passed to consumerService.getConsumersOfProviders + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + + // For pr1: one valid consumer-provider (validity 10 days from now) and one invalid (expired) + ProductConsumerDTO validCP = productConsumer(900L, 501L, BigDecimal.TEN, Instant.now()); + ProductConsumerDTO expiredCP = + productConsumer(900L, 502L, BigDecimal.ONE, Instant.now().minusSeconds(86400 * 5)); + when(productConsumerService.findByDataProviderId(900L)).thenReturn(List.of(validCP, expiredCP)); + when(productConsumerService.findByDataProviderId(901L)).thenReturn(List.of()); + + // Resolve consumer lookups + ConsumerDTO c501 = consumer(501L, "cid501", "c501", "CRON", "@hourly"); + when(consumerService.findById(501L)).thenReturn(Optional.of(c501)); + when(consumerService.findById(502L)).thenReturn(Optional.empty()); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Only active producer present + assertThat(cfg.getProducers()).containsExactly(active); + + // Verify consumersOfProviders called with both product ids + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(consumerService).getConsumersOfProviders(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(900L, 901L); + + // For pr1, only valid consumer added + assertThat(pr1.getConsumers()).containsExactly(c501); + // pr2 has none + assertThat(pr2.getConsumers()).isEmpty(); } - - // Tests for isValidProvider method through public methods - } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..61b6538 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,22 @@ +spring: + flyway: + enabled: false + datasource: + url: jdbc:h2:mem:mn_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + show_sql: false + format_sql: false +server: + port: 0 +logging: + level: + root: WARN + uk.gov.dbt.ndtp.ia.node.management: INFO From 5e350c20ce9de58a1b5e636e7785864d4e2d1c42 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 14 Oct 2025 14:25:08 +0100 Subject: [PATCH 02/49] Remove `OrganisationServiceImpl` and associated tests, streamline annotations and logging, and update DTO mappings. - Deleted unused `OrganisationServiceImpl` and corresponding test classes. - Simplified redundant Swagger annotations in `ConfigurationController`. - Replaced `Collectors.toList()` with `toList()` for stream mappings. - Refactored DTO mappings in `EntityDtoConverter` and `ProductConverter`. - Introduced `equals` and `hashCode` in `CustomJwtAuthenticationToken`. - General cleanup of variable names, logging, and unused imports. - Added `h2` dependency in `pom.xml` for improved test configurations. --- pom.xml | 5 ++ .../config/CustomJwtAuthenticationToken.java | 14 ++++ .../KeycloakJwtAuthenticationConverter.java | 35 +++++----- .../v1/ConfigurationController.java | 59 ++++++++-------- .../converter/EntityDtoConverter.java | 5 +- .../converter/impl/ProductConverter.java | 5 +- .../handlers/GlobalExceptionHandler.java | 4 +- .../service/data/ProductService.java | 4 +- .../data/impl/OrganisationServiceImpl.java | 29 -------- .../ManagementNodeApplicationTests.java | 17 ----- .../v1/ConfigurationControllerTest.java | 24 +++---- .../OrganisationProducerConverterTest.java | 67 +++++++++++++------ .../converter/impl/ProducerConverterTest.java | 67 +++++++++++++------ ...erProviderOrganisationServiceImplTest.java | 2 +- .../data/impl/ConsumerServiceImplTest.java | 1 - .../impl/OrganisationServiceImplTest.java | 39 ----------- 16 files changed, 179 insertions(+), 198 deletions(-) delete mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java delete mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java delete mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java diff --git a/pom.xml b/pom.xml index 9761ca6..4593ceb 100644 --- a/pom.xml +++ b/pom.xml @@ -155,6 +155,11 @@ ${mockito-junit-jupiter.version} test + + com.h2database + h2 + test + diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java index ee1501c..3024367 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java @@ -7,6 +7,7 @@ package uk.gov.dbt.ndtp.ia.node.management.config; import java.util.Collection; +import java.util.Objects; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; @@ -37,4 +38,17 @@ public CustomJwtAuthenticationToken( public EnhancedPrincipal getPrincipal() { return this.principal; } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + CustomJwtAuthenticationToken that = (CustomJwtAuthenticationToken) o; + return Objects.equals(getPrincipal(), that.getPrincipal()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getPrincipal()); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java index a103a47..447ecc0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java @@ -7,7 +7,6 @@ package uk.gov.dbt.ndtp.ia.node.management.config; import java.util.*; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.convert.converter.Converter; @@ -61,10 +60,7 @@ public class KeycloakJwtAuthenticationConverter implements Converter extractAuthoritiesFromIntrospection(JwtToken jwtToken) { Collection authorities = new ArrayList<>(); - String clientId = extractClientIdFromIntrospection(jwtToken); + String extractedClientId = extractClientIdFromIntrospection(jwtToken); try { - log.debug("Extracting authorities from introspection data for client ID: {}", clientId); + log.debug("Extracting authorities from introspection data for client ID: {}", extractedClientId); // Process resource_access if (jwtToken.getResourceAccess() != null) { jwtToken.getResourceAccess().forEach((resource, resourceAccess) -> { if (resourceAccess != null && resourceAccess.getRoles() != null) { - resourceAccess.getRoles().forEach(role -> { - authorities.add(new SimpleGrantedAuthority( - ROLE_PREFIX + resource + RESOURCE_ROLE_SEPARATOR + role)); - }); + resourceAccess + .getRoles() + .forEach(role -> authorities.add(new SimpleGrantedAuthority( + ROLE_PREFIX + resource + RESOURCE_ROLE_SEPARATOR + role))); } }); } - log.trace("Successfully extracted {} authorities for client ID: {}", authorities.size(), clientId); + log.trace("Successfully extracted {} authorities for client ID: {}", authorities.size(), extractedClientId); } catch (Exception e) { - log.error("Error extracting authorities from introspection data for client ID: {}", clientId, e); + log.error("Error extracting authorities from introspection data for client ID: {}", extractedClientId, e); throw new ResourceAccessParsingException( - "Failed to parse resource access from introspection data", e, clientId); + "Failed to parse resource access from introspection data", e, extractedClientId); } return authorities; @@ -330,17 +326,17 @@ private Collection processResourceRoles(String resourceName, M } }) .map(authority -> authority) - .collect(Collectors.toList())) + .toList()) .orElse(Collections.emptyList()); } private Collection extractAuthorities(Jwt jwt) { // Add default authorities if any Collection authorities = new ArrayList<>(defaultGrantedAuthoritiesConverter.convert(jwt)); - String clientId = extractClientId(jwt); + String extractClientId = extractClientId(jwt); try { - log.trace("Extracting authorities from JWT for client ID: {}", clientId); + log.trace("Extracting authorities from JWT for client ID: {}", extractClientId); // Extract and process resource_access claim extractMap(jwt.getClaim(CLAIM_RESOURCE_ACCESS)) @@ -349,9 +345,12 @@ private Collection extractAuthorities(Jwt jwt) { .ifPresent(resourceData -> authorities.addAll(processResourceRoles(resource, resourceData))))); - log.trace("Successfully extracted {} authorities from JWT for client ID: {}", authorities.size(), clientId); + log.trace( + "Successfully extracted {} authorities from JWT for client ID: {}", + authorities.size(), + extractClientId); } catch (Exception e) { - log.error("Error extracting authorities from JWT for client ID: {}", clientId, e); + log.error("Error extracting authorities from JWT for client ID: {}", extractClientId, e); // We're not throwing the exception here because we want to continue with default authorities // This is a fallback method, so we want to be more lenient } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java index 323fbde..88e12cf 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java @@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Optional; @@ -46,28 +45,26 @@ public ConfigurationController(ConfigurationProvider configurationProvider) { description = "Returns configuration for the authenticated client, optionally scoped to a specific producer.", security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "Federator Producer configuration returned", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProducerConfigDTO.class))), - @ApiResponse(responseCode = "400", description = "Invalid request parameters"), - @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Not found"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) + @ApiResponse( + responseCode = "200", + description = "Federator Producer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProducerConfigDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "404", description = "Not found") + @ApiResponse(responseCode = "500", description = "Internal server error") public ProducerConfigDTO getProducerConfigurations( @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, @Parameter(name = "producer_id", description = "Optional Producer identifier to filter configuration") @RequestParam(value = "producer_id", required = false) - Long producer_id) { - log.info("Preparing Federator Producer Config for producer {}", producer_id); + Long producerId) { + log.info("Preparing Federator Producer Config for producer {}", producerId); return configurationProvider.getProducerConfigByClientId( - principal.clientId(), producer_id != null ? Optional.of(producer_id) : Optional.empty()); + principal.clientId(), producerId != null ? Optional.of(producerId) : Optional.empty()); } @GetMapping("/consumer") @@ -77,20 +74,18 @@ public ProducerConfigDTO getProducerConfigurations( description = "Returns configuration for the authenticated client, optionally scoped to a specific consumer.", security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "Consumer configuration returned", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = ConsumerConfigDTO.class))), - @ApiResponse(responseCode = "400", description = "Invalid request parameters"), - @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Not found"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) + @ApiResponse( + responseCode = "200", + description = "Consumer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ConsumerConfigDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "404", description = "Not found") + @ApiResponse(responseCode = "500", description = "Internal server error") public ConsumerConfigDTO getConsumerConfigurations( @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, @Parameter(name = "consumer_id", description = "Optional Consumer identifier to filter configuration") diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java index fa798c9..ee57b85 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java @@ -7,7 +7,6 @@ package uk.gov.dbt.ndtp.ia.node.management.converter; import java.util.List; -import java.util.stream.Collectors; /** * Generic interface for converting between entity and DTO objects. @@ -43,7 +42,7 @@ default List toDtoList(List entities) { if (entities == null) { return List.of(); } - return entities.stream().map(this::toDto).collect(Collectors.toList()); + return entities.stream().map(this::toDto).toList(); } /** @@ -56,6 +55,6 @@ default List toEntityList(List dtos) { if (dtos == null) { return List.of(); } - return dtos.stream().map(this::toEntity).collect(Collectors.toList()); + return dtos.stream().map(this::toEntity).toList(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java index c51e79d..6802fcb 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java @@ -42,14 +42,15 @@ public ProductDTO toDto(Product entity) { return null; } + String typeName = + entity.getProductType() != null ? entity.getProductType().getName() : null; return ProductDTO.builder() .id(entity.getId()) .name(entity.getName()) .topic(entity.getTopic()) - .type(entity.getProductType().getName()) + .type(typeName) .source(entity.getSource()) .producerId(entity.getProducer() != null ? entity.getProducer().getId() : null) - .type(entity.getProductType().getName()) .build(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java index aa311c2..cd29bbe 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java @@ -97,8 +97,8 @@ public ResponseEntity handleAllExceptions(Exception ex, WebReques String errorId = generateErrorId(); log.debug("Runtime exception occurred, error_id={}, path={}: ", errorId, request.getContextPath(), ex); - ErrorResponse errorResponse = new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred: " + ex.getMessage(), errorId); + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred", errorId); return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java index edcbfec..1507ed7 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java @@ -25,8 +25,8 @@ public interface ProductService { /** * Retrieves a list of DataProviderDTO objects associated with the specified producer IDs. * - * @param ProducerIds the list of producer IDs for which data providers need to be retrieved + * @param producerIds the list of producer IDs for which data providers need to be retrieved * @return a list of DataProviderDTO objects corresponding to the given producer IDs */ - List getProductsByProducerIds(List ProducerIds); + List getProductsByProducerIds(List producerIds); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java deleted file mode 100644 index 4d38476..0000000 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; - -import org.springframework.stereotype.Service; -import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; -import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationService; - -/** - * Implementation of the OrganisationService interface. - */ -@Service -public class OrganisationServiceImpl implements OrganisationService { - - private final OrganisationRepository organisationRepository; - - /** - * Constructor-based dependency injection. - * - * @param organisationRepository the organisation repository - */ - public OrganisationServiceImpl(OrganisationRepository organisationRepository) { - this.organisationRepository = organisationRepository; - } -} 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 deleted file mode 100644 index 7e7663a..0000000 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -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/controller/v1/ConfigurationControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java index e75ac25..a0a667e 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java @@ -39,9 +39,9 @@ class ConfigurationControllerTest { @InjectMocks private ConfigurationController configurationController; - private final String CLIENT_ID = "test-client-id"; - private final Long PRODUCER_ID = 1L; - private final Long CONSUMER_ID = 2L; + private final String clientId = "test-client-id"; + private final Long producerId = 1L; + private final Long consumerId = 2L; private ProducerConfigDTO producerConfigDTO; private ConsumerConfigDTO consumerConfigDTO; @@ -52,19 +52,19 @@ void setUp() { // Set up producer config ProducerDTO producerDTO = ProducerDTO.builder() - .id(PRODUCER_ID) + .id(producerId) .name("Test Producer") .active(true) .build(); producerConfigDTO = ProducerConfigDTO.builder() - .clientId(CLIENT_ID) + .clientId(clientId) .producers(Collections.singletonList(producerDTO)) .build(); // Set up consumer config consumerConfigDTO = ConsumerConfigDTO.builder() - .clientId(CLIENT_ID) + .clientId(clientId) .producers(new ArrayList<>()) .build(); } @@ -86,7 +86,7 @@ void getProducerConfigurations_shouldReturnConfig() throws Exception { // Act & Assert mockMvc.perform(get("/api/v1/configuration/producer").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -96,10 +96,10 @@ void getProducerConfigurations_withProducerId_shouldReturnFilteredConfig() throw // Act & Assert mockMvc.perform(get("/api/v1/configuration/producer") - .param("producer_id", PRODUCER_ID.toString()) + .param("producer_id", producerId.toString()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -110,7 +110,7 @@ void getConsumerConfigurations_shouldReturnConfig() throws Exception { // Act & Assert mockMvc.perform(get("/api/v1/configuration/consumer").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -120,9 +120,9 @@ void getConsumerConfigurations_withConsumerId_shouldReturnFilteredConfig() throw // Act & Assert mockMvc.perform(get("/api/v1/configuration/consumer") - .param("consumer_id", CONSUMER_ID.toString()) + .param("consumer_id", consumerId.toString()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java index 2c11ac1..a03df33 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java @@ -259,7 +259,7 @@ void toEntity_withNullDTO_shouldReturnNull() { } @Test - void toEntity_withValidDTO_shouldReturnCorrectEntity() { + void toEntity_withValidDTO_shouldMapBasicFields() { // Arrange when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); @@ -276,35 +276,62 @@ void toEntity_withValidDTO_shouldReturnCorrectEntity() { assertEquals(port, result.getPort()); assertEquals(tls, result.getTls()); assertEquals(idpClientId, result.getIdpClientId()); + } + + @Test + void toEntity_withValidDTO_shouldMapOrganisation() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getOrg()); assertEquals(orgId, result.getOrg().getId()); assertEquals(orgName, result.getOrg().getName()); + } - // Verify dataProviders mapping + @Test + void toEntity_withValidDTO_shouldMapProductsAndBackReference() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getProducts()); assertEquals(2, result.getProducts().size()); - // Verify first data provider - Product dataProvider1 = result.getProducts().get(0); - assertEquals(dataProviderId1, dataProvider1.getId()); - assertEquals(dataProviderName1, dataProvider1.getName()); - assertEquals(topic1, dataProvider1.getTopic()); - assertNotNull(dataProvider1.getProducer()); - assertEquals(result, dataProvider1.getProducer()); + // Verify first product + Product product1 = result.getProducts().get(0); + assertEquals(dataProviderId1, product1.getId()); + assertEquals(dataProviderName1, product1.getName()); + assertEquals(topic1, product1.getTopic()); + assertNotNull(product1.getProducer()); + assertEquals(result, product1.getProducer()); + + // Verify second product + Product product2 = result.getProducts().get(1); + assertEquals(dataProviderId2, product2.getId()); + assertEquals(dataProviderName2, product2.getName()); + assertEquals(topic2, product2.getTopic()); + assertNotNull(product2.getProducer()); + assertEquals(result, product2.getProducer()); + } - // Verify second data provider - Product dataProvider2 = result.getProducts().get(1); - assertEquals(dataProviderId2, dataProvider2.getId()); - assertEquals(dataProviderName2, dataProvider2.getName()); - assertEquals(topic2, dataProvider2.getTopic()); - assertNotNull(dataProvider2.getProducer()); - assertEquals(result, dataProvider2.getProducer()); - - // Verify productConverter was called for each data provider DTO + @Test + void toEntity_withValidDTO_shouldInvokeDependencies() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + converter.toEntity(dto); + + // Assert / Verify verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); - - // Verify organisation repository was called verify(organisationRepository, times(1)).findById(orgId); } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java index 10b42ac..e98f354 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java @@ -259,7 +259,7 @@ void toEntity_withNullDTO_shouldReturnNull() { } @Test - void toEntity_withValidDTO_shouldReturnCorrectEntity() { + void toEntity_withValidDTO_shouldMapBasicFields() { // Arrange when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); @@ -276,35 +276,62 @@ void toEntity_withValidDTO_shouldReturnCorrectEntity() { assertEquals(port, result.getPort()); assertEquals(tls, result.getTls()); assertEquals(idpClientId, result.getIdpClientId()); + } + + @Test + void toEntity_withValidDTO_shouldMapOrganisation() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getOrg()); assertEquals(orgId, result.getOrg().getId()); assertEquals(orgName, result.getOrg().getName()); + } - // Verify dataProviders mapping + @Test + void toEntity_withValidDTO_shouldMapProductsAndBackReference() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getProducts()); assertEquals(2, result.getProducts().size()); - // Verify first data provider - Product dataProvider1 = result.getProducts().get(0); - assertEquals(dataProviderId1, dataProvider1.getId()); - assertEquals(dataProviderName1, dataProvider1.getName()); - assertEquals(topic1, dataProvider1.getTopic()); - assertNotNull(dataProvider1.getProducer()); - assertEquals(result, dataProvider1.getProducer()); + // Verify first product + Product product1 = result.getProducts().get(0); + assertEquals(dataProviderId1, product1.getId()); + assertEquals(dataProviderName1, product1.getName()); + assertEquals(topic1, product1.getTopic()); + assertNotNull(product1.getProducer()); + assertEquals(result, product1.getProducer()); + + // Verify second product + Product product2 = result.getProducts().get(1); + assertEquals(dataProviderId2, product2.getId()); + assertEquals(dataProviderName2, product2.getName()); + assertEquals(topic2, product2.getTopic()); + assertNotNull(product2.getProducer()); + assertEquals(result, product2.getProducer()); + } - // Verify second data provider - Product dataProvider2 = result.getProducts().get(1); - assertEquals(dataProviderId2, dataProvider2.getId()); - assertEquals(dataProviderName2, dataProvider2.getName()); - assertEquals(topic2, dataProvider2.getTopic()); - assertNotNull(dataProvider2.getProducer()); - assertEquals(result, dataProvider2.getProducer()); - - // Verify productConverter was called for each data provider DTO + @Test + void toEntity_withValidDTO_shouldInvokeDependencies() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + converter.toEntity(dto); + + // Assert / Verify verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); - - // Verify organisation repository was called verify(organisationRepository, times(1)).findById(orgId); } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java index 9f2c238..1d5f222 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java @@ -29,7 +29,7 @@ import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductConsumerRepository; @ExtendWith(MockitoExtension.class) -public class ConsumerProviderOrganisationServiceImplTest { +class ConsumerProviderOrganisationServiceImplTest { @Mock private ProductConsumerRepository productConsumerRepository; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java index ad89403..752bd27 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java @@ -138,7 +138,6 @@ void getConsumersOfProviders_withValidProviderIds_shouldReturnMappedConsumers() // Arrange List providerIds = List.of(1L, 2L); List consumers = List.of(consumer); - List consumerDTOs = List.of(consumerDTO); when(consumerRepository.findConsumersByProviderIds(providerIds)).thenReturn(consumers); when(consumerConverter.toDto(consumer)).thenReturn(consumerDTO); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java deleted file mode 100644 index 99ffb52..0000000 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; - -import static org.junit.jupiter.api.Assertions.assertNotNull; - -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.junit.jupiter.MockitoExtension; -import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; - -@ExtendWith(MockitoExtension.class) -class OrganisationServiceImplTest { - - @Mock - private OrganisationRepository organisationRepository; - - @InjectMocks - private OrganisationServiceImpl organisationService; - - @BeforeEach - void setUp() { - // No setup needed as the service has no methods to test yet - } - - @Test - void organisationService_shouldBeInitialized() { - // This test verifies that the service is properly initialized with its dependencies - assertNotNull(organisationService); - assertNotNull(organisationRepository); - } -} From d46e469f6a4194b0fa88b699bd6e5dd5e3691754 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 14 Oct 2025 14:46:39 +0100 Subject: [PATCH 03/49] Add detailed documentation for job scheduling and database schema updates - Introduced `docs/jobs-scheduler.md` with comprehensive examples for CRON and Interval scheduling. - Updated `DATABASE_SCHEMA.md` to include newly added fields such as `schedule_type` and `schedule_expression`. - Elaborated on schema relationships, usage examples, and migration details. --- docs/DATABASE_SCHEMA.md | 44 ++++++++++++++--- docs/jobs-scheduler.md | 102 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 docs/jobs-scheduler.md diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8e9d303..5429395 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -4,9 +4,6 @@ This document describes the relational database schema used by the Management No - `src/main/resources/db/migration/` -The current schema is based on the following migrations: -- `V20250728142253__intial_database_tables.sql` -- `V20250914182403__productConsumersAttributesTable.sql` The database is designed to model Organisations, their Producers and Consumers, the Products offered by Producers, and the access grants that allow specific Consumers to access specific Products. Additional attributes can be attached to each grant. @@ -29,6 +26,7 @@ erDiagram PRODUCT ||--o{ PRODUCT_CONSUMER : grants CONSUMER ||--o{ PRODUCT_CONSUMER : consumes PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has + PRODUCT_TYPE ||--o{ PRODUCT : categorizes ORGANISATION { BIGSERIAL id PK @@ -51,12 +49,21 @@ erDiagram VARCHAR name BIGINT org_id FK VARCHAR idp_client_id + VARCHAR schedule_type + VARCHAR schedule_expression + } + PRODUCT_TYPE { + BIGSERIAL id PK + VARCHAR name + VARCHAR description } PRODUCT { BIGSERIAL id PK VARCHAR name VARCHAR topic BIGINT producer_id FK + BIGINT product_type_id FK + VARCHAR source } PRODUCT_CONSUMER { BIGSERIAL id PK @@ -64,7 +71,9 @@ erDiagram BIGINT consumer_id FK TIMESTAMP granted_ts NUMERIC validity - + VARCHAR schedule_type + VARCHAR schedule_expression + VARCHAR destination } PRODUCT_CONSUMER_ATTRIBUTE { BIGSERIAL id PK @@ -119,9 +128,25 @@ Columns: - `name` VARCHAR(50), not null - `org_id` BIGINT, not null, foreign key → `organisation(id)` - `idp_client_id` VARCHAR(50), not null — identity provider client id (e.g., Keycloak). Informational; not an FK +- `schedule_type` VARCHAR(100), nullable — type of schedule, e.g., `cron`, `interval` +- `schedule_expression` VARCHAR(255), nullable — schedule expression matching the chosen schedule_type Usage: - Participates in access grants via `product_consumer`. +- Optional scheduling metadata for consumer-driven jobs. + +--- + +### product_type +Represents a category/type of Product (e.g., topic-based, file-based). + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(150), not null +- `description` VARCHAR(255), nullable — brief description of the product type + +Usage: +- Lookup table used to categorize products. Initial values seeded by migration: `topic` and `file`. --- @@ -133,21 +158,27 @@ Columns: - `name` VARCHAR(50), not null - `topic` VARCHAR(150), not null — logical topic or channel for the product - `producer_id` BIGINT, not null, foreign key → `producer(id)` +- `product_type_id` BIGINT, nullable, foreign key → `product_type(id)` — categorization of the product +- `source` VARCHAR(500), nullable — optional source identifier/URI for the product Usage: - The resource being granted to Consumers via `product_consumer`. +- Migration defaults existing rows to the `topic` product type. --- ### product_consumer Join table representing an access grant that allows a Consumer to access a Product. -Columns (after migration `V20250914182403`): +Columns: - `id` BIGSERIAL, primary key - `product_id` BIGINT, not null, foreign key → `product(id)` - `consumer_id` BIGINT, not null, foreign key → `consumer(id)` - `granted_ts` TIMESTAMP, not null — timestamp when access was granted - `validity` NUMERIC, not null — validity period/units are application-defined +- `schedule_type` VARCHAR(100), nullable — e.g., `cron`, `interval` +- `schedule_expression` VARCHAR(255), nullable — expression matching the schedule_type +- `destination` VARCHAR(500), nullable — optional destination identifier/URI for scheduled deliveries - `uq_product_consumer_pair` UNIQUE (`product_id`, `consumer_id`) — ensures one grant per pair Notes: @@ -155,6 +186,7 @@ Notes: Usage: - Central record for authorization decisions: which Consumer can access which Product and since when. +- Optional scheduling metadata for grant-level processing/delivery. --- @@ -176,7 +208,7 @@ Usage: ## Migration Notes - Schema is versioned and applied with Flyway on application startup. - Foreign keys enforce referential integrity among core entities. -- Consider adding database indexes on foreign key columns (`producer.producer_id`, `consumer.org_id`, `product.producer_id`, `product_consumer.product_id`, `product_consumer.consumer_id`, `product_consumer_attribute.product_consumer_id`) to optimize query performance, if not already present in future migrations. +- Consider adding database indexes on foreign key columns (`producer.producer_id`, `consumer.org_id`, `product.producer_id`, `product_consumer.product_id`, `product_consumer.consumer_id`, `product_consumer_attribute.product_consumer_id`) to optimize query performance. ## Data Protection and Security - Identity fields like `idp_client_id` are not foreign keys; they link to external IdP configuration (e.g., Keycloak) at the application layer. diff --git a/docs/jobs-scheduler.md b/docs/jobs-scheduler.md new file mode 100644 index 0000000..b82a471 --- /dev/null +++ b/docs/jobs-scheduler.md @@ -0,0 +1,102 @@ +# Jobs Scheduler + +This document describes the two ways to schedule recurring jobs in the system: CRON and Interval. It also includes examples for both CRON expressions and ISO‑8601 durations. + +## 1. CRON type + +Use CRON when you want precise calendar-based schedules (e.g., "every weekday at 09:00" or "at 2:30 AM on the first of every month"). + +A typical CRON expression uses 5 or 6 space-separated fields, depending on the scheduler implementation: + +- Second (optional) — 0–59 +- Minute — 0–59 +- Hour — 0–23 +- Day of month — 1–31 +- Month — 1–12 or JAN–DEC +- Day of week — 0–7 (0 or 7 = Sunday) or SUN–SAT + +Common special characters: +- * — any value +- , — value list separator +- - — range of values +- / — step values (e.g., */5) +- ? — no specific value (used in some cron dialects where both DOM and DOW exist) + +Examples: +- Every day at 02:30 (with seconds): 0 30 2 * * * +- Every day at 02:30 (5-field style): 30 2 * * * +- Every 5 minutes: */5 * * * * (or 0 */5 * * * * when using seconds) +- Every Monday at 09:00: 0 0 9 * * MON +- At 00:00 on the first of every month: 0 0 0 1 * * +- Weekdays at 18:15: 0 15 18 * * MON-FRI + +Tips: +- If your scheduler expects the seconds field, use 6 fields; otherwise use 5. +- If both Day-of-month and Day-of-week are present, some schedulers require one of them to be ?, indicating "not specified." + +## 2. Interval type + +Use Interval when you want a fixed duration between runs (e.g., "every 15 minutes"), independent of calendar concepts. Intervals are represented as ISO‑8601 duration strings. + +ISO‑8601 Duration format: PnYnMnDTnHnMnS +- P — designator meaning "period" +- nY — years +- nM — months (in the date part) +- nW — weeks (alternative to days; if used, don’t combine with D) +- nD — days +- T — time designator that precedes the time components +- nH — hours +- nM — minutes (in the time part) +- nS — seconds + +Common duration examples: +- PT15M — every 15 minutes +- PT1H — every 1 hour +- PT1H30M — every 1 hour and 30 minutes +- P1D — every 1 day (24 hours) +- P2DT12H — every 2 days and 12 hours + +Some systems also support repeating intervals using the ISO‑8601 repeating interval notation: +- Rn/start/duration, where Rn is the repeat count (R without a number means unlimited repeats) +- Example (repeat 5 times starting on a given instant, once per day): R5/2025-10-14T00:00:00Z/P1D + +Notes: +- When only a duration is provided (e.g., PT15M), the next run is typically computed from the last run time plus the duration. +- If your platform supports a startAt or firstRunAt property, pair it with the duration to control the initial trigger time. + +## Choosing between CRON and Interval +- Choose CRON for calendar-aware schedules or when you need specific days/times (like "every weekday at 09:00"). +- Choose Interval for simple, uniform spacing between runs (like "every 15 minutes"), irrespective of wall-clock boundaries. + +## Quick reference examples + +CRON: +- 0 0 9 * * MON-FRI — Weekdays at 09:00 +- 0 0 0 1 * * — Midnight on the first day of each month +- 0 */10 * * * * — Every 10 minutes (with seconds field) + +ISO‑8601 durations (Interval): +- PT5M — every five minutes +- PT2H — every two hours +- P1D — every day +- R/2025-10-14T08:00:00Z/PT30M — from 2025-10-14 08:00Z, every 30 minutes, repeat indefinitely + +## Database tables that accept schedule expressions and types + +The following tables store schedule configuration and accept both CRON expressions and Interval (ISO‑8601 duration) values: + +- consumer + - schedule_type (varchar): expected values are 'cron' or 'interval' (case-insensitive depending on DB usage). + - schedule_expression (varchar): + - If schedule_type = 'cron' → a CRON expression (e.g., "0 */10 * * * *" or "*/5 * * * *"). + - If schedule_type = 'interval' → an ISO‑8601 duration (e.g., "PT15M", "P1D"). + +- product_consumer + - schedule_type (varchar): expected values are 'cron' or 'interval'. + - schedule_expression (varchar): + - If schedule_type = 'cron' → a CRON expression. + - If schedule_type = 'interval' → an ISO‑8601 duration. + +Notes: +- Default/backfill in migration sets schedule_type to 'cron' with a sample expression (*/5 * * * *) for existing rows. +- Ensure expressions match the scheduler dialect in use (5-field or 6-field with seconds). \ No newline at end of file From b0192956d7c9532f3ced0a2232bbe25c050856da Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 14 Oct 2025 14:49:18 +0100 Subject: [PATCH 04/49] Add SPDX license headers and repository metadata to documentation - Updated all documentation files to include `Repository`, `Description`, and `SPDX-License-Identifier` metadata. --- docs/DATABASE_SCHEMA.md | 8 ++++++++ docs/JACOCO_COVERAGE.md | 5 +++++ docs/MOCKITO_USAGE.md | 4 ++++ docs/MTLS_CONFIGURATION.md | 5 +++++ docs/entity-dto-converter-pattern.md | 6 ++++++ docs/jobs-scheduler.md | 5 +++++ 6 files changed, 33 insertions(+) diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 5429395..94ca1a5 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,5 +1,13 @@ # Database Schema +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- + + + This document describes the relational database schema used by the Management Node. The schema is applied via Flyway migrations located at: - `src/main/resources/db/migration/` diff --git a/docs/JACOCO_COVERAGE.md b/docs/JACOCO_COVERAGE.md index 345c33a..7dcb69b 100644 --- a/docs/JACOCO_COVERAGE.md +++ b/docs/JACOCO_COVERAGE.md @@ -1,5 +1,10 @@ # JaCoCo Code Coverage Setup +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- ## Overview This document describes the JaCoCo code coverage setup for the Management Node application. JaCoCo has been configured to measure code coverage and ensure that it meets the specified thresholds. diff --git a/docs/MOCKITO_USAGE.md b/docs/MOCKITO_USAGE.md index 888864d..e2b7de0 100644 --- a/docs/MOCKITO_USAGE.md +++ b/docs/MOCKITO_USAGE.md @@ -1,5 +1,9 @@ # Mockito Testing Tool Usage Guide +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` +--- ## Overview Mockito is a popular mocking framework for Java that allows you to create and configure mock objects. Using Mockito, you can verify that certain methods are called with certain parameters, stub method calls to return specific values, and more. diff --git a/docs/MTLS_CONFIGURATION.md b/docs/MTLS_CONFIGURATION.md index 7cdcdb5..190279e 100644 --- a/docs/MTLS_CONFIGURATION.md +++ b/docs/MTLS_CONFIGURATION.md @@ -1,5 +1,10 @@ # MTLS Configuration Guide +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- This guide provides detailed instructions on how to configure Mutual TLS (MTLS) for both the Keycloak authentication server and the Management Node Spring Boot application. ## What is MTLS and Why It's Needed diff --git a/docs/entity-dto-converter-pattern.md b/docs/entity-dto-converter-pattern.md index 62b4998..2c3d1cc 100644 --- a/docs/entity-dto-converter-pattern.md +++ b/docs/entity-dto-converter-pattern.md @@ -1,5 +1,11 @@ # Entity-DTO Converter Pattern +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + + + ## Overview This document describes the Entity-DTO converter pattern implemented in the project to handle conversions between entity objects and DTOs (Data Transfer Objects). This pattern replaces the previous approach of using ModelMapper for these conversions. diff --git a/docs/jobs-scheduler.md b/docs/jobs-scheduler.md index b82a471..c21bad6 100644 --- a/docs/jobs-scheduler.md +++ b/docs/jobs-scheduler.md @@ -1,5 +1,10 @@ # Jobs Scheduler +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- This document describes the two ways to schedule recurring jobs in the system: CRON and Interval. It also includes examples for both CRON expressions and ISO‑8601 durations. ## 1. CRON type From 7e8c9931535f403c4eee244050922a159ac8f54f Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 11 Nov 2025 14:32:25 +0000 Subject: [PATCH 05/49] Add SonarCloud analysis to Maven workflow --- .github/workflows/maven.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 5a1c2c5..ed24d47 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -31,6 +31,7 @@ on: permissions: contents: read + pull-requests: read env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" @@ -40,6 +41,9 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v5 + with: + # Full history is recommended for accurate Sonar analysis and PR decoration + fetch-depth: 0 - name: Set up JDK 21 uses: actions/setup-java@v5 with: @@ -51,6 +55,13 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify + - name: SonarCloud analysis + # Uses the SonarCloud GitHub App for auth (no tokens/secrets needed) + uses: sonarsource/sonarcloud-github-action@v2 + with: + # Coverage report produced by JaCoCo during 'mvn verify' + args: > + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() uses: actions/upload-artifact@v4 From e30f0e389fa88c86f35cc789a4264e5261418166 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 11 Nov 2025 14:46:55 +0000 Subject: [PATCH 06/49] Update Maven workflow to include project key and organization for SonarCloud analysis --- .github/workflows/maven.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index ed24d47..348e754 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -56,11 +56,11 @@ jobs: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - name: SonarCloud analysis - # Uses the SonarCloud GitHub App for auth (no tokens/secrets needed) uses: sonarsource/sonarcloud-github-action@v2 with: - # Coverage report produced by JaCoCo during 'mvn verify' args: > + -Dsonar.projectKey=management-node + -Dsonar.organization=National-Digital-Twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() From 7e610f594ca1c2999554046550c6556bd150dd7c Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:26:04 +0000 Subject: [PATCH 07/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 348e754..b6c944f 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -57,6 +57,8 @@ jobs: run: ./mvnw $MAVEN_CLI_OPTS verify - name: SonarCloud analysis uses: sonarsource/sonarcloud-github-action@v2 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > -Dsonar.projectKey=management-node From 60c65004969e03729cfcf94ded50a07dbaf15657 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:31:04 +0000 Subject: [PATCH 08/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b6c944f..93ea769 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -56,7 +56,7 @@ jobs: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - name: SonarCloud analysis - uses: sonarsource/sonarcloud-github-action@v2 + uses: sonarsource/sonarqube-scan-action@v4.2.2 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: From b924c2473b5fbf99c498f876bb832c7f890bd6c2 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:34:25 +0000 Subject: [PATCH 09/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 93ea769..3ab746c 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -61,7 +61,6 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > - -Dsonar.projectKey=management-node -Dsonar.organization=National-Digital-Twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report From 383aec7c807bd30aa04803d608c0c18dddb95126 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:37:04 +0000 Subject: [PATCH 10/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 3ab746c..5c03fb0 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -61,6 +61,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > + -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=National-Digital-Twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report From d07c91a7ed30603d1a21021401eeb51767ddf090 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:39:05 +0000 Subject: [PATCH 11/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 5c03fb0..1bc7116 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -62,7 +62,6 @@ jobs: with: args: > -Dsonar.projectKey=National-Digital-Twin_management-node - -Dsonar.organization=National-Digital-Twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() From 8412aa062b353fe1ed627e2269cb2447b6ab856e Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:44:50 +0000 Subject: [PATCH 12/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1bc7116..d20b14e 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -62,6 +62,7 @@ jobs: with: args: > -Dsonar.projectKey=National-Digital-Twin_management-node + -Dsonar.organization=National Digital Twin programme -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() From f44e3569e1bf41ed0537e6f2df7e3e62a43e8835 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:48:30 +0000 Subject: [PATCH 13/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d20b14e..6633ebd 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -62,7 +62,7 @@ jobs: with: args: > -Dsonar.projectKey=National-Digital-Twin_management-node - -Dsonar.organization=National Digital Twin programme + -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() From 982bbb344c6e063ab6be06a22e221bd56ea3570f Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 10:55:01 +0000 Subject: [PATCH 14/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 6633ebd..24c71fc 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,15 +55,6 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - - name: SonarCloud analysis - uses: sonarsource/sonarqube-scan-action@v4.2.2 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: > - -Dsonar.projectKey=National-Digital-Twin_management-node - -Dsonar.organization=national-digital-twin - -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml - name: Upload JaCoCo HTML report if: always() uses: actions/upload-artifact@v4 From 4e29f7c45ee334b7e50de02de979ff2e644d5632 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:10:59 +0000 Subject: [PATCH 15/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 24c71fc..2b4b77a 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,13 +55,10 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - - name: Upload JaCoCo HTML report - if: always() - uses: actions/upload-artifact@v4 - with: - name: jacoco-report - path: target/site/jacoco - if-no-files-found: warn + - name: run code coverage + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From c4522f8617ae7312102d8ae60c80793385c60bcf Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:14:19 +0000 Subject: [PATCH 16/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 2b4b77a..b2cdce5 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -58,7 +58,7 @@ jobs: - name: run code coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From c900ce2dc3d804aa0b4ec9a2ebd5c7e793a43703 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:16:02 +0000 Subject: [PATCH 17/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index b2cdce5..0c9325c 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -58,6 +58,7 @@ jobs: - name: run code coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest From cd3b9a62ec9f18088ffad13a883cae1e2afe6fe0 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:19:16 +0000 Subject: [PATCH 18/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0c9325c..2b4b77a 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -58,8 +58,7 @@ jobs: - name: run code coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From db0dd18051ac5982f4a9fb9682cc5f9932df8dbd Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:21:40 +0000 Subject: [PATCH 19/49] Add SONAR_TOKEN environment variable to Maven workflow for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 2b4b77a..edb55e4 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -58,7 +58,7 @@ jobs: - name: run code coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From e853f809e0e394e45b5162d143d696ea74b862ee Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:36:16 +0000 Subject: [PATCH 20/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index edb55e4..59ec414 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -58,7 +58,8 @@ jobs: - name: run code coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=national-digital-twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From 9630dd5e2135057be52b48f9dbe828172ca4ffea Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:38:32 +0000 Subject: [PATCH 21/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 59ec414..1250799 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -59,7 +59,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=national-digital-twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=national-digital-twin -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From 0795c2b7b68aa588ab4840785fc49ad4765cb856 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:43:47 +0000 Subject: [PATCH 22/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1250799..1d4e027 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -59,7 +59,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=national-digital-twin -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From 5de09b7a252f9ca65a12f6d522d10a6977a3a759 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:46:35 +0000 Subject: [PATCH 23/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- sonar-project.properties | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..2d780d8 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=National-Digital-Twin_management-node +sonar.organization=national-digital-twin +sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml \ No newline at end of file From 7e3337289528071aa6b2c01fefc8c791c8e2f2d7 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 11:46:45 +0000 Subject: [PATCH 24/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1d4e027..7abc9b7 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,11 +55,11 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - - name: run code coverage - env: - GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage +# - name: run code coverage +# env: +# GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From 6a2213fc97115050fab0e9308ba815ce59504c98 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 12:53:04 +0000 Subject: [PATCH 25/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 7abc9b7..1d4e027 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,11 +55,11 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify -# - name: run code coverage -# env: -# GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} -# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -# run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + - name: run code coverage + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: runs-on: ubuntu-latest steps: From fcc4f1ede238472191a2f2f6ed16a416683079b2 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Mon, 17 Nov 2025 12:59:16 +0000 Subject: [PATCH 26/49] Add SONAR_TOKEN and update Maven workflow with projectKey for SonarCloud analysis --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1d4e027..355b150 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,7 +55,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - - name: run code coverage + - name: Code Coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 3bbdbe964ec15789f3c61b408910e80d48c1fa87 Mon Sep 17 00:00:00 2001 From: Neil South Date: Tue, 21 Oct 2025 15:08:49 +0100 Subject: [PATCH 27/49] docs: improving documentation for local dev setup Signed-off-by: Neil South --- README.md | 369 ++-- docker/Dockerfile | 5 +- docker/Dockerfile-dev | 5 +- docker/keycloak/README.md | 2 +- docker/keycloak/management-node.json | 2563 ++++++++++++++++++++++++++ src/main/resources/application.yml | 29 +- 6 files changed, 2851 insertions(+), 122 deletions(-) create mode 100644 docker/keycloak/management-node.json diff --git a/README.md b/README.md index fb2bd14..ac12711 100644 --- a/README.md +++ b/README.md @@ -24,22 +24,27 @@ For a full description of the database tables, relationships, and constraints, s - Docker and Docker Compose - OpenSSL (for certificate generation) - Keycloak (for authentication and authorization) - +- The below assumes your running in Linux - bash, it has been tested under WSL2. --- ## Quick Start +Note. see lower for setting up prerequisites for local deployment certs, keycloak etc. ### Run the Spring Boot application This project is a Spring Boot application. You can run it by supplying configuration via either: -- A default application.yml (or application.yaml) file, or +- A default application.yml file, or - A profile-specific file application-{profile}.yml and passing the profile argument at startup. Quick options: 1. Provide a default config: - - Create src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. - - Run: + - Modify src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. + - Run: (change to suit your local if different) + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` ```bash mvn spring-boot:run ``` @@ -69,70 +74,11 @@ Notes: ```bash java -jar target/management-node-0.0.1.jar --spring.config.location=/path/to/your.yml ``` -- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). - -### Setting up Keycloak with Docker Compose - -The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: - -1. Navigate to the docker directory: - ```bash - cd docker - ``` - -2. Make sure you have the required certificates in the `docker` directory: - - `keystore.jks` - Java keystore containing the server certificate - - `truststore.jks` - Java truststore containing trusted certificates - - `localhost.p12` - PKCS12 keystore for client authentication - - `localhost.crt` - Certificate file - - `localhost.key` - Private key file +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). - If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. - -3. Start Keycloak and PostgreSQL using Docker Compose: - ```bash - docker compose -f keycloak/docker-compose.yml up -d - ``` - -4. Verify that Keycloak is running: - ```bash - curl -k https://localhost:8443/health - ``` - -5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: - - Username: `admin` - - Password: `password` - -### Configuration - -For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: - -``` -POSTGRES_DB=keycloak_db -POSTGRES_USER=keycloak_db_user -POSTGRES_PASSWORD=keycloak_db_user_password -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=password -KC_HOSTNAME_STRICT_BACKCHANNEL=false -SERVER_SSL_KEY_STORE_PASSWORD=changeit -SERVER_SSL_TRUST_STORE_PASSWORD=changeit -KC_HTTPS_KEY_STORE_PASSWORD=changeit -KC_HTTPS_TRUST_STORE_PASSWORD=changeit -KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit -KC_HOSTNAME=keycloak -KC_HOSTNAME_PORT=8080 -KC_HTTP_ENABLED=false -KC_HOSTNAME_STRICT_HTTPS=false -KC_HEALTH_ENABLED=true -KC_DB=postgres -KC_HTTPS_CLIENT_AUTH=required -KC_HTTPS_ENABLED=true -KC_HTTPS_PORT=8443 -KC_LOG_LEVEL=INFO -``` -This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. +# Prerequisites setup ## Certificate Setup The Management Node Module implements a zero-trust security architecture using Mutual TLS (MTLS) for secure communication between all components. This section explains why certificates are needed, how to generate them, and where they are used in the system. @@ -178,6 +124,11 @@ The system requires several certificate files: For development purposes, follow these steps to generate certificates for mTLS. All passwords used are `changeit`. When generating these certficates, for the `Country Name`, you can use the value of 'UK'. All remaining certificate fields can be left to their default values. +move to the docker folder +```bash +cd docker +``` + 1. **Generate a Root CA certificate**: ```bash openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt @@ -191,12 +142,8 @@ For development purposes, follow these steps to generate certificates for mTLS. This creates a private key and certificate signing request (CSR) for the host. 3. **Sign the host certificate with the Root CA**: - ```bash - openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext - ``` - This signs the host CSR with the Root CA, creating a certificate valid for 365 days. - - The content of the `localhost.ext` file should be: + + Create a file called `localhost.ext` file should contain: ``` authorityKeyIdentifier=keyid,issuer basicConstraints=CA:FALSE @@ -205,6 +152,13 @@ For development purposes, follow these steps to generate certificates for mTLS. DNS.1 = localhost DNS.2 = keycloak ``` + + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + ``` + This signs the host CSR with the Root CA, creating a certificate valid for 365 days. + + This configuration specifies that the certificate is valid for both `localhost` and `keycloak` hostnames. 4. **Create a PKCS12 keystore for the server**: @@ -265,8 +219,14 @@ For development purposes, follow these steps to generate certificates for mTLS. After generating the certificates, place them in the appropriate locations: +if you've followed the above the follow with +```bash +cp keystore.jks ./keystore.jks +cp truststore.jks ../truststore.jks +``` + 1. **For Keycloak**: - - Place all certificate files in the `docker` directory + - All the certificate files should now be in the `docker` directory - The docker-compose.yml maps these files into the Keycloak container: ```yaml volumes: @@ -315,48 +275,184 @@ KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit For production environments, use strong, unique passwords and secure storage solutions for managing these credentials. + +### Setting up Keycloak with Docker Compose + +#### Configuration + +For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: + +``` +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. + +The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: + +1. Navigate to the docker directory: + ```bash + cd docker + ``` + +2. Make sure you have the required certificates in the `docker` directory: see lower for local certificate setup + - `keystore.jks` - Java keystore containing the server certificate + - `truststore.jks` - Java truststore containing trusted certificates + - `localhost.p12` - PKCS12 keystore for client authentication + - `localhost.crt` - Certificate file + - `localhost.key` - Private key file + + If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. + +3. Start Keycloak and PostgreSQL using Docker Compose: + ```bash + docker compose -f keycloak/docker-compose.yml up -d + ``` + +4. Verify that Keycloak is running: + ```bash + curl -k https://localhost:8443/realms/master --cert client.crt --key client.key + ``` + Note: The client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. + +5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: + - Username: `admin` + - Password: `password` + + you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. for chrome got to. settings - privacy and security - security - manage certificate - manage imported certificates from windows, then import and follow the wizard. + + + + ## Keycloak Realm Setup -After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin, you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. +After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin. ### Option 1: Import the Realm Configuration (Recommended) 1. Log in to the Keycloak admin console at https://localhost:8443/admin 2. Click on the dropdown menu in the top-left corner (it may show "master" if you haven't created any realms yet) -3. Click on "Create Realm" or "Add realm" button -4. Click on the "Browse" or "Select file" button -5. Navigate to and select the `docker/keycloak/management-node-realm.json` file from your project directory -6. Click "Create" or "Import" -7. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations -8. Note the client secret for the `ztf-client` from the Credentials tab (Clients → ztf-client → Credentials) and update it in your application.yml if needed +3. Click on "Manage realms" +4. Click on "Create Realm" or "Add realm" button +5. Click on the "Browse" or "Select file" button +6. Navigate to and select the `docker/keycloak/management-node.json` file from your project directory +7. Click "Create" or "Import" +8. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations +9. Note the client secret for the `management-node` client from the Credentials tab (Clients → management-node → Credentials) click regenerate, view it update it in your application.yml if needed (or do ```export KEYCLOAK_CLIENTID=newClientSecret```) ### Option 2: Manual Configuration -If you prefer to set up the realm manually: +If you prefer to set up the realm manually (updated for Keycloak 26.x): -1. Log in to the Keycloak admin console at https://localhost:8443/admin -2. Create a new realm named `management-node` -3. Create a client with the following settings: - - Client ID: `ztf-client` - - Client Protocol: `openid-connect` - - Access Type: `confidential` - - Valid Redirect URIs: `https://localhost:8090/*` - - Web Origins: `+` -4. Note the client secret from the Credentials tab and update it in your application.yml if needed +1. Log in to the Keycloak admin console at https://localhost:8443/admin (you must first import your `client.p12` certificate into your browser) + +2. Create a new realm named `management-node` by clicking the dropdown in the top-left and selecting "Create Realm" + +3. Create the **management-node** client: + - In the `management-node` realm, navigate to **Clients** and click **Create client** + + **General Settings:** + - Client type: `OpenID Connect` + - Client ID: `management-node` + - Click **Next** + + **Capability config:** + - Client authentication: **ON** (this enables the Credentials tab) + - Authorization: **OFF** + - Authentication flow: Enable **Service accounts roles** + - Click **Next** + + **Login settings:** + - Valid redirect URIs: `https://localhost:8090/*` + - Valid post logout redirect URIs: `+` + - Web origins: `+` + - Click **Save** + +4. After saving, click on the **Credentials** tab to view the **Client Secret**. Copy this secret. + +5. Add required roles to the client: + - Go to **Clients** → **management-node** → **Roles** tab + - Click **Create role** and add the following roles: + - `access_producer_configurations` + - `access_consumer_configurations` + +6. Assign roles to the service account: + - Go to **Clients** → **management-node** → **Service accounts roles** tab + - Click **Assign role** + - Filter by **Filter by clients** and select **management-node** + - Check both roles (`access_producer_configurations` and `access_consumer_configurations`) + - Click **Assign** + +7. Update your `application.yml` with the client configuration, if needed: + ```yaml + spring: + 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: account + opaquetoken: + introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect + client-secret: "client_secret=${KEYCLOAK_CLIENTID}" + client-id: management-node + + application: + client: + key-store: keystore.jks + key-store-password: changeit + keyStoreType: JKS + ``` ### Testing mTLS connectivity: -Once KeyCloak is running and configured, you can test mTLS connectivity using the below command: +Once Keycloak is running and configured, you can test mTLS connectivity using the command below. Replace `YOUR_CLIENT_SECRET` with the actual client secret obtained from the Keycloak Credentials tab (step 4 in the manual configuration above): - ```bash - curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ - --cert client.crt --key client.key \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'client_id=ztf-client' \ - --data-urlencode 'grant_type=client_credentials' - ``` +```bash +export KEYCLOAK_CLIENTID=`YOUR_CLIENT_SECRET` +cd docker # or where your certificates are stored +``` + +```bash +curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + --data-urlencode 'grant_type=client_credentials' +``` -This tests the mTLS setup by attempting to obtain a token from Keycloak using client certificate authentication. +**Note:** The `client_secret` parameter is required for confidential clients. Make sure to: +1. Copy the client secret from Keycloak admin console: **Clients** → **management-node** → **Credentials** tab +2. Replace `YOUR_CLIENT_SECRET` in the command above with your actual client secret +3. The `-k` flag is used to allow insecure connections (self-signed certificates) for development + +If successful, you will receive a JSON response containing an `access_token` with the assigned roles in the `resource_access.management-node.roles` claim. This confirms that: +- ✅ mTLS authentication is working (client certificates validated) +- ✅ Client credentials are correct +- ✅ Keycloak is properly configured +- ✅ Service account has the required roles assigned ## Building and Running with Maven @@ -386,7 +482,13 @@ The Management Node Module uses Maven for dependency management and build automa ### Running the Application -After building, you can run the application using one of these methods: +After building, you can run the application using one of these methods: + +Note: if running with defaults export your passwords first.eg + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` 1. Using the Java command: ```bash @@ -406,6 +508,45 @@ After building, you can run the application using one of these methods: The application will be available at https://localhost:8090 +### Testing API Endpoints: + +Once you have a valid token, you can test the protected API endpoints: + +```bash +cd docker # or where your certificates are stored + +# Get a token and save it +TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq -r '.access_token') + +# Test the producer endpoint +curl -k https://localhost:8090/api/v1/configuration/producer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Test the consumer endpoint +curl -k https://localhost:8090/api/v1/configuration/consumer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +Expected response (if no configuration data exists yet): +```json +{ + "clientId": "management-node", + "producers": [] +} +``` + +If successful, you will receive a JSON response containing an `access_token`. This confirms that: +- ✅ mTLS authentication is working (client certificates validated) +- ✅ Client credentials are correct +- ✅ Keycloak is properly configured + ### Using Profile-Specific Configuration Files Spring Boot supports profile-specific property files, which are essential for local development environments where you need to configure sensitive information like passwords and URLs without committing them to version control. @@ -557,12 +698,32 @@ How Springdoc OpenAPI works in this project ## Authentication Requirements All protected endpoints require JWT bearer tokens. Tokens must: -- Include the audience (aud) "management-node". -- Contain a `resource_access` claim with client roles used for authorization. +- Include the audience (aud) claim with value `account` (default Keycloak audience for service accounts). +- Contain a `resource_access` claim with client-specific roles under `resource_access.management-node.roles`. + +**Required Client Roles:** +- `access_producer_configurations` - Required to access `/api/v1/configuration/producer` endpoint +- `access_consumer_configurations` - Required to access `/api/v1/configuration/consumer` endpoint + +**Token Structure Example:** +```json +{ + "aud": "account", + "resource_access": { + "management-node": { + "roles": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + } + }, + "client_id": "management-node" +} +``` -Role-specific access: -- Producer Federator: must have role `access_producer_configurations` to access `/api/v1/configuration/producer`. -- Consumer Federator: must have role `access_consumer_configurations` to access `/api/v1/configuration/consumer`. +These roles must be: +1. Created as client roles in the Keycloak `management-node` client +2. Assigned to the service account of the `management-node` client Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). diff --git a/docker/Dockerfile b/docker/Dockerfile index dacf6f9..8252968 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,7 +6,9 @@ # # Build stage +ARG JAR_FILE=management-node-1.0.1.jar FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +ARG JAR_FILE WORKDIR /build # Copy the project files @@ -18,6 +20,7 @@ RUN mvn -B clean package -DskipTests # Runtime stage FROM eclipse-temurin:21-jdk-alpine +ARG JAR_FILE # Create non-root user and group RUN addgroup -S app && adduser -S -G app -u 10001 app @@ -28,7 +31,7 @@ WORKDIR /app RUN mkdir -p /app/docker /app/logs /app/tmp && chown -R app:app /app # Copy application jar from build stage -COPY --from=build /build/target/management-node-0.90.0.jar /app/app.jar +COPY --from=build /build/target/${JAR_FILE} /app/app.jar RUN chown app:app /app/app.jar # Use non-root user from here on diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index cc4073a..c246113 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -6,7 +6,9 @@ # # Build stage +ARG JAR_FILE=management-node-1.0.1.jar FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +ARG JAR_FILE WORKDIR /build # Copy the project files @@ -18,6 +20,7 @@ RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:23-jdk-alpine +ARG JAR_FILE WORKDIR /app @@ -25,7 +28,7 @@ WORKDIR /app RUN mkdir -p /app/docker # Copy application jar from build stage and certificates -COPY --from=build /build/target/management-node-0.90.0.jar /app/app.jar +COPY --from=build /build/target/${JAR_FILE} /app/app.jar COPY docker/keystore.jks /app/docker/keystore.jks COPY docker/truststore.jks /app/docker/truststore.jks diff --git a/docker/keycloak/README.md b/docker/keycloak/README.md index 6625d1c..4dd8c7e 100644 --- a/docker/keycloak/README.md +++ b/docker/keycloak/README.md @@ -77,7 +77,7 @@ Import client key and crt in keystore to create the "certificate" to be used in curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ --cert client.crt --key client.key \ --header 'Content-Type: application/x-www-form-urlencoded' \ ---data-urlencode 'client_id=ztf-client' \ +--data-urlencode 'client_id=management-node' \ --data-urlencode 'grant_type=client_credentials' --- diff --git a/docker/keycloak/management-node.json b/docker/keycloak/management-node.json new file mode 100644 index 0000000..74ab02f --- /dev/null +++ b/docker/keycloak/management-node.json @@ -0,0 +1,2563 @@ +{ + "id": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "realm": "management-node", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", + "name": "default-roles-management-node", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + }, + { + "id": "15d3f8ba-a0d8-4b4a-9e18-77a621db3e81", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + }, + { + "id": "58d30605-273e-4508-8441-2f0c3cda905c", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "84684efe-c21d-4eb7-b8ce-30c707c3d369", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "8f8d1c6e-b2ad-4dbd-804e-0107e85de363", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "a943f735-711f-45c0-9e9a-0f58e766a011", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "da22339c-bd2f-446a-869b-57d7c8fa9b94", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "93aad473-812c-424a-a4bc-5c54752f033d", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "d0f1cc46-4277-43a1-8933-614580e823f5", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "e29aceb4-0833-4115-a4ff-bd00b9cc7d31", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "f0fe073e-3113-4fc5-bdfb-f827dbfaf2c6", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-events", + "view-identity-providers", + "query-realms", + "view-authorization", + "manage-authorization", + "query-clients", + "query-groups", + "manage-realm", + "create-client", + "manage-clients", + "impersonation", + "view-events", + "query-users", + "manage-identity-providers", + "manage-users", + "view-realm", + "view-users", + "view-clients" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "ec4d3a28-6146-44c2-b47c-a9a7d4718fe0", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "7a3951cb-f2e1-42af-b52b-265b1f2ccc90", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "caff9476-f77f-47ac-9370-44301f57b1ff", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "6fb26b30-2525-4a37-9df1-9da873b52f77", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "63517e42-0c33-4f41-9040-ec6b5b0531c1", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "3131211e-b69d-46b0-bfbb-f63ab3dd633a", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "082137ad-03cf-40e4-9e0b-89bc7b91048f", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "6ecc7af4-b24c-40e5-8344-2d1cb836ff97", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "0dd8bf55-e548-4ba0-ae12-581eca4e4b4f", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "754d0d70-23de-46aa-9269-6c88fda2a477", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "63e09485-7240-41fc-93e2-b8206af47a1e", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "management-node": [ + { + "id": "6d8b6df9-bcf2-4f36-a39f-2a07ccd22e3b", + "name": "access_producer_configurations", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + }, + { + "id": "26909837-6df2-4802-9622-0f9eed78f3fb", + "name": "access_consumer_configurations", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + } + ], + "broker": [ + { + "id": "d7c37fdb-8298-4db6-8536-adc3bb6e73ae", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "cde19f67-0386-469c-8c74-9831e13be86f", + "attributes": {} + } + ], + "account": [ + { + "id": "79d4c2c1-7a8a-47e1-9da1-a2f4180d7e9a", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "dc2e806b-5ad2-469a-b7d2-314e0e10cff7", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "6c85fa9c-7972-4a3c-9840-0ddfb465ab7d", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "2eae4a32-aa35-4ec1-9f90-bcf5c92ace4d", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "fe0a910e-b0cf-4261-b743-e6a2c8d1b4cc", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "e71206b1-5c1e-4331-95c9-ad67eb3e29ca", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "528badc3-8b60-4951-ba30-ab0fa6dd2590", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "1a959cc8-f7e7-47e2-916b-67e9453eee4d", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", + "name": "default-roles-management-node", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "8e8067b1-545e-483e-868d-fbb8e3089cc2", + "username": "service-account-management-node", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1761124708028, + "totp": false, + "serviceAccountClientId": "management-node", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-management-node" + ], + "clientRoles": { + "management-node": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/management-node/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/management-node/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9776bc0a-9a4d-43cf-af0e-8c71bc18bb8b", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/management-node/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/management-node/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "e6d1cfd0-f5e9-4d97-92b0-55203f27da3c", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d263346e-e131-4499-81a5-69260556d460", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "cde19f67-0386-469c-8c74-9831e13be86f", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "clientId": "management-node", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "https://localhost:8090/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1761124708", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "service_account", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5ca67f36-bceb-4177-9fb1-80056174ef74", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/management-node/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/management-node/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "f7639ee5-9130-4dd2-bd81-8ede2caa1efd", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "6580612b-4596-4ac4-b1c9-6f5e9991acb4", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f94e282e-fa71-4ed0-a0c0-4165c51a3790", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "19a947e9-4653-4265-966e-d1d4f1b6a1ff", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "c65991c8-1fd8-4a1f-a228-1887a5d5b4ce", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "29e7dde8-6ff7-49e6-b66b-7396ed2802c0", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "109642e5-5424-4e69-b0b6-6840a5f13c81", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "9aa57e69-40cb-41c0-9630-64941bf75218", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "08509e70-25a3-4ad5-a4a5-74e65a722fd2", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "fafdabcc-4b58-48d3-b9f7-731048a7e87e", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "3d31c53e-3cad-462e-9a7d-65a10ed07a97", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "bb83c5a3-23c5-4643-a4d0-2e2c6d2414e9", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "45a4c177-7504-4b13-b090-9dd90d8e2980", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "7c29bb5b-d247-4e0f-87d4-d92eb0857d7f", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "bdb02fe5-552b-487d-9701-8158b56b623d", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "c98adf31-f223-455a-b636-7701707aa54c", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "18e97211-2e01-434d-a9b3-3eec6e10c5e2", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "e65477a4-c6bc-4663-8d3e-eb3b2f63da38", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9e0ae40d-5f69-4002-b631-f27cb25c169e", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "e782994a-8c93-4a44-b776-6e5774e3906c", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "93c4cc79-c25d-4626-9d22-1f96a5135c51", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "16342e52-fc66-428b-96a6-e30c366fc6f9", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f487705e-8a12-4c7f-aef9-e91e9348a31a", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "aabcbbad-d6ee-4300-b3d9-b6be652b82ae", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "31660997-c4c2-4ea6-a2d0-9d17ba0f32b4", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "78791873-e71b-4e73-9145-3d66897ef5f0", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ac046319-00b3-4afa-884c-a6fb8c312ab3", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "6edeb2fb-933a-4780-9e31-054807807881", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "2fc61305-b5a4-4ef2-8a17-5e626b9ae8c7", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "e70b6969-a64c-47bb-938d-999507f3c58b", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0d408c4c-8782-4e59-80bc-9880bb183f81", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0d94acc9-832d-4000-a70a-e897c9754429", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "100c3c5c-84af-4e17-a83e-829a234e1608", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "2b80a380-d57a-4287-b859-c64afc9a99f8", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "903e03d0-d136-4613-885e-7d1f3595e780", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "1148319d-003b-4b7b-b8f7-6cebff6c4f90", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "6850a449-f816-4394-b286-fe345af4d9bf", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "b6a9b41f-61fb-4ff2-930e-b7c21b658959", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "67e1860b-60a8-40b4-8743-03d80a2a6842", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "0206ca5a-3d98-42d7-90d8-df22bf93beed", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "159cac93-247b-4217-935a-2e30e42759ba", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "8e22e1b6-f57c-4819-a5e0-f785e1d6b01f", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0dd6163c-7bfc-4e36-b370-cc91dead391c", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "db1effbb-c5d8-43ba-bf73-784cfd517eb8", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "57754a78-720d-4fb5-bb0d-8a9c050747b1", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "08264050-785f-46fd-8789-1b319575f223", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "55a2f1ed-297f-4b3b-a624-38ca83c5e74e", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "7bb3656a-31b6-4306-ba00-270f445b659f", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "8a2c23b2-801b-42d2-a5e9-e8a1f8f8a5d4", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "3ac3a49c-3e37-4773-95f8-4b1ecb57164a", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "865d6500-9b8f-498a-aa90-e31bf845bf21", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "226606fc-5198-467f-b577-1a69383e7ceb", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "1e7b13ca-3dd3-480b-af93-94f54bcd3306", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "3306d643-bd5b-48ed-8d50-c20d7fed5d22", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "27294c2a-3d43-4c4a-81e8-d0f0e5080b3c", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "eb92e86a-c365-4319-82c9-8e6a69935386", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "0356b6bf-37e6-4fac-9191-57db508900dd", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "3d456641-36b1-4465-be74-952ef91bbf95", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "f02b1302-cd56-4187-a3ca-12039963f357", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "e711f578-053f-4feb-8b71-821457d12606", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "95be6872-65a3-499a-aa62-cc5c16690e2b", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "authenticationFlows": [ + { + "id": "34e99d27-4d0d-462e-a409-e5c8f127ef69", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "25cb3a1a-38ef-4783-831c-9e99a99eb729", + "alias": "Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d3f0a418-1fbd-4e60-aba2-47f919cfce8d", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "94a2c6fa-b339-420f-935e-87be463c3365", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "bdfb13c2-d548-4f9f-a9f7-d14d5e9db17e", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a557bad3-e44f-42b3-8bd5-0c0a89c52906", + "alias": "First broker login - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a1bba2a0-f37e-420b-98cc-9b4a9f3db2d2", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "0d7fdf57-ea7e-4071-9335-7fd2f83320ed", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "c81ad6ec-f28b-4863-b02b-1569b13b3d97", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8d2c5a14-ce1f-4cb7-bf35-f56897d9c4ec", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "ea254a4a-d14e-457f-ba67-38a3544ea80e", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "982f63af-52e7-4516-9cb2-5ace5c5617c4", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "1cbc6444-d817-4df3-9da2-3f99e6b7f0cb", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "cb73cfbd-5395-4e87-b9af-9c501e4a9f3e", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "391915b7-33a8-4d0c-9ab1-67ed8537bec1", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f4c7ab68-c48d-4fe5-a8c1-f2519e959ee2", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 50, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "37896020-5c13-4400-b67f-415a54a72788", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "9e558209-b106-4811-9f5f-e71a4b5014f0", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "e3428f93-bfc6-4485-bc0f-df92d3ddadb7", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "aad14193-222f-46e5-a3e7-68024ab2351b", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "05167100-d696-4634-a589-1801e0ccdf70", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "568604a7-c191-4926-9277-533befe7a52e", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "f9f4534b-a6a6-4e0d-9019-331fec99d5fb", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.3.2", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3e2dc7c..da20d7e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,14 +5,13 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: https://localhost:8443/realms/mng-node - jwk-set-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/certs - audiences: management-node - authorities-claim-name: resource_access + issuer-uri: https://localhost:8443/realms/management-node + jwk-set-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/certs + audiences: account opaquetoken: - client-secret: - introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect - client-id: MANAGEMENT_NODE_CLIENT # required client id for introspect endpoint + introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect + client-secret: ${KEYCLOAK_CLIENTID:} # Client secret from keycloak + client-id: management-node # MANAGEMENT_NODE_CLIENT required client id for introspect endpoint flyway: create-schemas: on default-schema: mn @@ -20,9 +19,9 @@ spring: enabled: true baseline-on-migrate: true datasource: - url: jdbc:postgresql://localhost:5433/postgres - username: # required postgress username - password: # required postgress username + url: jdbc:postgresql://localhost:5433/keycloak_db # this is setup to get you going easily using the Keycloak postgress so change this ! + username: ${POSTGRES_USER:keycloak_db_user} + password: ${POSTGRES_PASSWORD:} jpa: properties: hibernate: @@ -37,16 +36,16 @@ server: key-alias: localhost key-store: keystore.jks #path to ssl keystore key-store-type: JKS - key-store-password: #keystore password - trust-store: #path to ssl truststore - trust-store-password: #truststore password + key-store-password: ${CERTPASSWORD:} #keystore password + trust-store: truststore.jks #path to ssl truststore + trust-store-password: ${CERTPASSWORD:} #truststore password trust-store-type: JKS client-auth: need enabled: true # disable for local development Only application: client: key-store: keystore.jks # path to MTLS client keystore - keyStorePassword: # MTLS client keystore password + key-store-password: ${CERTPASSWORD:} # MTLS client keystore password keyStoreType: JKS # Actuator Configuration @@ -69,4 +68,4 @@ logging: 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 + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n" From 02d65ff4eaf1ecbc50aca14ae4a917138001089e Mon Sep 17 00:00:00 2001 From: neildsouth <104848880+neildsouth@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:11:41 +0000 Subject: [PATCH 28/49] feat(OSPO): synchronise OSPO workflows --- .github/workflows/publish-github-release.yml | 2 +- README.md | 124 ++++++++++++++----- docker/Dockerfile-dev | 12 +- src/main/resources/application.yml | 6 +- 4 files changed, 106 insertions(+), 38 deletions(-) diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 8e35068..9449b6c 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -100,7 +100,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: sbom diff --git a/README.md b/README.md index ac12711..f1d9996 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Quick options: ``` or: ```bash - java -jar target/management-node-0.0.1.jar + java -jar target/management-node-1.0.1.jar ``` 2. Use a profile-specific config: @@ -61,7 +61,7 @@ Quick options: ``` or: ```bash - java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + java -jar target/management-node-1.0.1.jar --spring.profiles.active=local ``` - You can also set the environment variable: ```bash @@ -197,34 +197,41 @@ cd docker ``` This bundles the client certificate and private key into a PKCS12 format for use in browsers or client applications. -10. **Create a Java keystore using keytool**: +10. **Create a Java keystore using keytool** (PKCS12 format, compatible with modern Java): ```bash - keytool -importkeystore -destkeystore keystore.jks -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + keytool -importkeystore -destkeystore keystore.jks -deststoretype PKCS12 -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" ``` - This converts the PKCS12 keystore to a Java KeyStore (JKS) format used by Java applications. + This converts the PKCS12 keystore. Note: Despite the `.jks` extension, modern keytool creates PKCS12 format by default, which is more secure and standard. -11. **Create a Java truststore using keytool**: +11. **Create a Java truststore using keytool** (PKCS12 format): ```bash - keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks -storetype PKCS12 ``` - This creates a truststore containing the Root CA certificate, which will be used to validate client certificates. + This creates a truststore containing the Root CA certificate in PKCS12 format, which will be used to validate client certificates. -12. **Import the Root CA into the truststore**: +12. **Verify the truststore** (optional but recommended): ```bash - keytool -importcert -file rootCA.crt -alias rootCA -keystore truststore.jks -storetype JKS + keytool -list -keystore truststore.jks -storetype PKCS12 -storepass changeit ``` - This ensures the Root CA is properly imported into the Java truststore. + This verifies that the Root CA is properly imported into the truststore. ### Certificate Placement and Configuration After generating the certificates, place them in the appropriate locations: -if you've followed the above the follow with +if you've followed the above then follow with ```bash -cp keystore.jks ./keystore.jks +cp keystore.jks ../keystore.jks cp truststore.jks ../truststore.jks +cp client.crt ../client.crt +cp client.key ../client.key ``` +This copies the necessary files to the management-node root directory: +- `keystore.jks` - Used by the Management Node application for its SSL server configuration +- `truststore.jks` - Used by the Management Node to validate client certificates +- `client.crt` and `client.key` - Used for testing API endpoints with mTLS authentication + 1. **For Keycloak**: - All the certificate files should now be in the `docker` directory - The docker-compose.yml maps these files into the Keycloak container: @@ -333,7 +340,7 @@ The application uses Keycloak for authentication and authorization. Follow these ```bash curl -k https://localhost:8443/realms/master --cert client.crt --key client.key ``` - Note: The client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. + Note: Keycloak takes about 30 seconds before its ready and the client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. 5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: - Username: `admin` @@ -358,7 +365,7 @@ After starting Keycloak, you need to set up a realm for the Management Node. You 6. Navigate to and select the `docker/keycloak/management-node.json` file from your project directory 7. Click "Create" or "Import" 8. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations -9. Note the client secret for the `management-node` client from the Credentials tab (Clients → management-node → Credentials) click regenerate, view it update it in your application.yml if needed (or do ```export KEYCLOAK_CLIENTID=newClientSecret```) +9. Note the client secret for the `management-node` client from the Credentials tab (Clients → management-node → Credentials) click regenerate, view it and do ```export KEYCLOAK_CLIENTID=*************``` ### Option 2: Manual Configuration @@ -403,7 +410,7 @@ If you prefer to set up the realm manually (updated for Keycloak 26.x): - Check both roles (`access_producer_configurations` and `access_consumer_configurations`) - Click **Assign** -7. Update your `application.yml` with the client configuration, if needed: +7. Update your `application.yml` with the client configuration, if needed (or do ```export KEYCLOAK_CLIENTID=*************```): ```yaml spring: security: @@ -467,6 +474,7 @@ The Management Node Module uses Maven for dependency management and build automa 2. Build the application: ```bash + cd management-node # change to suit, if following along do cd ../ (from the docker folder) mvn clean package ``` This command will: @@ -489,10 +497,17 @@ Note: if running with defaults export your passwords first.eg export POSTGRES_PASSWORD=keycloak_db_user_password export CERTPASSWORD=changeit ``` +Ensure certificate files are in the management-node root directory (if not already there from certificate setup): +```sh +cp docker/keystore.jks keystore.jks +cp docker/truststore.jks truststore.jks +cp docker/client.crt client.crt +cp docker/client.key client.key +``` 1. Using the Java command: ```bash - java -jar target/management-node-0.0.1.jar + java -jar target/management-node-1.0.1.jar ``` 2. Using the Maven Spring Boot plugin: @@ -500,20 +515,36 @@ Note: if running with defaults export your passwords first.eg mvn spring-boot:run ``` -3. Using Docker: - ```bash - docker build -t management-node -f docker/Dockerfile . - docker run -p 8090:8090 management-node - ``` - The application will be available at https://localhost:8090 ### Testing API Endpoints: Once you have a valid token, you can test the protected API endpoints: +**Step 1: Get your Keycloak Client Secret** + +1. Log in to Keycloak admin console at https://localhost:8443/admin +2. Navigate to: **management-node realm** → **Clients** → **management-node** → **Credentials** tab +3. Copy the **Client Secret** value (you can regenerate if needed) +4. Export it as an environment variable: + ```bash -cd docker # or where your certificates are stored +export KEYCLOAK_CLIENTID=your_actual_client_secret_here +``` + +**Step 2: Get a JWT token and test the endpoints** + +```bash +# Navigate to the root directory where client certificates are located +cd /path/to/management-node + +# First, verify you can get a token (view the full response) +curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq . # Get a token and save it TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ @@ -523,6 +554,11 @@ TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-co --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ -s | jq -r '.access_token') +# Verify the token was retrieved successfully +echo "Token (first 50 chars): ${TOKEN:0:50}..." + +# If TOKEN is "null", check that KEYCLOAK_CLIENTID is set correctly + # Test the producer endpoint curl -k https://localhost:8090/api/v1/configuration/producer \ --cert client.crt --key client.key \ @@ -638,16 +674,48 @@ The current configuration aims for 80% code coverage across instructions, branch ### Common Issues 1. **Certificate Issues**: + - **Error**: `SSL routines::sslv3 alert certificate unknown` + - The server doesn't trust your client certificate + - **Solution**: Regenerate the truststore with the current rootCA: + ```bash + cd docker + mv truststore.jks truststore.jks.old + keytool -import -trustcacerts -noprompt -alias ca -file rootCA.crt -keystore truststore.jks -storepass changeit + # Rebuild the Docker image + cd .. + docker build -t management-node -f docker/Dockerfile-dev . + ``` - Ensure that the paths to the keystore and truststore files in application.yml are correct - Verify that the certificate passwords match those in the .env file + - If certificates were regenerated, ensure the truststore contains the new rootCA 2. **Keycloak Connection Issues**: - - Check that Keycloak is running and accessible at https://localhost:8443 - - Verify that the client secret in application.yml matches the one in Keycloak + - **Error**: `Connection refused` when trying to reach Keycloak + - **From Docker container**: Use `--network keycloak_keycloak_network` and connect to `keycloak:8443` + - **From host machine**: Use `localhost:8443` or `host.docker.internal:8443` + - **Error**: Token validation fails with 401 Unauthorized + - Check that `KEYCLOAK_CLIENTID` environment variable is set correctly + - Verify the token contains required roles using: `echo $TOKEN | cut -d. -f2 | base64 -d | jq .` + - Check that Keycloak is running: `docker ps | grep keycloak` + - Verify that the client secret matches the one in Keycloak admin console 3. **Database Connection Issues**: - - Ensure PostgreSQL is running and accessible - - Check the database credentials in the .env file + - **Error**: `Connection to localhost:5433 refused` from Docker container + - Docker containers can't reach `localhost` on the host + - **Solution**: Use `--network keycloak_keycloak_network` and `jdbc:postgresql://keycloak-postgres-1:5432/keycloak_db` + - Or use `--add-host=host.docker.internal:host-gateway` and `jdbc:postgresql://host.docker.internal:5433/keycloak_db` + - Ensure PostgreSQL is running: `docker ps | grep postgres` + - Check the database credentials match those in the .env file + - Verify you can connect manually: `docker exec -it keycloak-postgres-1 psql -U keycloak_db_user -d keycloak_db` + +4. **Docker-Specific Issues**: + - **Issue**: Management Node can't fetch JWKs from Keycloak (SSL trust issues between containers) + - **Symptom**: Application starts but JWT validation fails silently + - **Workaround**: Run the application directly using Maven instead of Docker for local development + - **Alternative**: Use docker-compose to set up all services with proper SSL configuration + - **Issue**: Environment variables not being passed to container + - Ensure you use `-e` flag for each environment variable + - Verify with: `docker exec env | grep VARIABLE_NAME` ## Security Considerations diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index c246113..6890770 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -24,14 +24,14 @@ ARG JAR_FILE WORKDIR /app -# Create directory for certificates -RUN mkdir -p /app/docker - # Copy application jar from build stage and certificates COPY --from=build /build/target/${JAR_FILE} /app/app.jar -COPY docker/keystore.jks /app/docker/keystore.jks -COPY docker/truststore.jks /app/docker/truststore.jks +COPY docker/keystore.jks /app/keystore.jks +COPY docker/truststore.jks /app/truststore.jks + +# Set default certificate password +ENV CERTPASSWORD=changeit -EXPOSE 8443 +EXPOSE 8090 ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index da20d7e..f864a44 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,7 +19,7 @@ spring: enabled: true baseline-on-migrate: true datasource: - url: jdbc:postgresql://localhost:5433/keycloak_db # this is setup to get you going easily using the Keycloak postgress so change this ! + url: jdbc:postgresql://localhost:5433/keycloak_db # this is setup to get you going easily using the Keycloak postgres so change this ! username: ${POSTGRES_USER:keycloak_db_user} password: ${POSTGRES_PASSWORD:} jpa: @@ -35,11 +35,11 @@ server: ssl: key-alias: localhost key-store: keystore.jks #path to ssl keystore - key-store-type: JKS + key-store-type: PKCS12 key-store-password: ${CERTPASSWORD:} #keystore password trust-store: truststore.jks #path to ssl truststore trust-store-password: ${CERTPASSWORD:} #truststore password - trust-store-type: JKS + trust-store-type: PKCS12 client-auth: need enabled: true # disable for local development Only application: From e04f9a3a5f2eae662e5a4cbd44069ce14dd7f940 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 12:32:18 +0000 Subject: [PATCH 29/49] Add MkDocs workflow, setup scripts, and initial configuration for documentation deployment - Introduced GitHub Actions workflow for MkDocs publishing, with semantic versioning support and optional `latest` alias. - Added setup script for virtual environment creation and plugin installation, supporting Python version preferences. - Added uninstall script for virtual environment cleanup. - Created initial `mkdocs.yml` configuration with Material theme, plugins, and custom styling. - Ready for documentation deployment and local development. --- .github/workflows/publish-mkdocs.yml | 106 ++++++++++++++++ mkdocs.yml | 81 +++++++++++++ scripts/script.sh | 174 +++++++++++++++++++++++++++ scripts/uninstall.sh | 92 ++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 .github/workflows/publish-mkdocs.yml create mode 100644 mkdocs.yml create mode 100644 scripts/script.sh create mode 100644 scripts/uninstall.sh diff --git a/.github/workflows/publish-mkdocs.yml b/.github/workflows/publish-mkdocs.yml new file mode 100644 index 0000000..6f4c014 --- /dev/null +++ b/.github/workflows/publish-mkdocs.yml @@ -0,0 +1,106 @@ +name: publish mkdocs +on: + pull_request: + types: [closed] + branches: + - develop + - main + workflow_dispatch: +permissions: + contents: write + pages: write +jobs: + versioning: + if: ${{ github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'develop' || github.event.pull_request.base.ref == 'main') }} + name: Extract Release Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract_version.outputs.version }} + is_release: ${{ steps.extract_version.outputs.is_release }} + steps: + - name: Extract Version from Source/Target Branch + id: extract_version + run: | + SOURCE_BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + TARGET_BRANCH="${GITHUB_BASE_REF}" + + if [[ "$TARGET_BRANCH" == "develop" ]]; then + # When targeting develop, version should be the target branch name itself + VERSION="$TARGET_BRANCH" + else + # Target is main (per job condition). Keep existing behavior based on the source branch + # If branch contains a slash, take the second segment; otherwise use the whole branch name + if [[ "$SOURCE_BRANCH" == *"/"* ]]; then + VERSION="$(echo "$SOURCE_BRANCH" | cut -d'/' -f2)" + else + VERSION="$SOURCE_BRANCH" + fi + + # If this is a release branch, trim to major.minor (e.g., 1.2.3 -> 1.2) + if [[ "$SOURCE_BRANCH" == release/* ]]; then + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + if [[ -n "$MAJOR" && -n "$MINOR" ]]; then + VERSION="${MAJOR}.${MINOR}.x" + fi + fi + fi + + if [ -z "$VERSION" ]; then + echo "Error: No semantic release version found. Source: $SOURCE_BRANCH, Target: $TARGET_BRANCH" + exit 1 + fi + + # Determine if this is a release branch (source starts with release/) + IS_RELEASE="false" + if [[ "$SOURCE_BRANCH" == release/* ]]; then + IS_RELEASE="true" + fi + + # Expose outputs for downstream steps/jobs + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Print Tag Version + run: | + echo "Identified release semantic version: ${{ steps.extract_version.outputs.version }}" + + deploy: + runs-on: ubuntu-latest + needs: [ versioning ] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.merge_commit_sha }} + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Configure Git user for mike + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: ~/.cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material + - run: pip install mkdocs-git-revision-date-localized-plugin + - run: pip install mkdocs-git-committers-plugin-2 + - run: pip install Pygments + - run: pip install mkdocs-include-markdown-plugin + - run: pip install pymdown-extensions + - run: pip install mike + - run: pip install mkdocs-open-in-new-tab==1.0.8 + - name: Deploy docs without latest alias + if: ${{ needs.versioning.outputs.is_release != 'true' }} + run: mike deploy ${{ needs.versioning.outputs.version }} --push + - name: Deploy docs with latest alias + if: ${{ needs.versioning.outputs.is_release == 'true' }} + run: mike deploy ${{ needs.versioning.outputs.version }} latest --push --update-aliases + - name: Set default latest + if: ${{ needs.versioning.outputs.is_release == 'true' }} + run: mike set-default --push latest + diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..47e6f0c --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,81 @@ +site_name: Management-Node Documentation +site_description: Management Node is is the control plane for the National Digital Twin Programme IA Node Net. +site_author: NDTP +site_url: https://docs.ndtp.co.uk/ +#repo_url: https://github.com/National-Digital-Twin/federator +edit_uri: edit/main/docs/ +nav: [] +theme: + features: + - content.code.annotate + - content.code.copy + - content.code.select + - content.tooltips + - navigation.indexes + - navigation.tracking + - search.highlight + - search.share + - search.suggest + - search.share + - navigation.instant + - navigation.instant.prefetch + - navigation.instant + - navigation.instant.progress + - navigation.path + - toc.follow + + + icon: + repo: fontawesome/brands/github + language: en + name: material + logo: assets/light-page_header_logo.png + favicon: assets/android-chrome-512x512-1-150x150.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Roboto + code: Roboto Mono +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - tables + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format +extra_css: + - stylesheets/extra.css +extra: + version: + provider: mike + generator: false +plugins: + - include-markdown: + rewrite_relative_urls: true + - search + - git-revision-date-localized: + enabled: true +copyright: | + ©Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + diff --git a/scripts/script.sh b/scripts/script.sh new file mode 100644 index 0000000..ca2a342 --- /dev/null +++ b/scripts/script.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Automate setting up a Python venv and installing MkDocs and common plugins. +# Optionally runs `mkdocs serve`. +# +# Usage examples: +# bash script.sh # Do everything except `mkdocs serve` +# bash script.sh --serve # Do everything and start the dev server +# bash script.sh --no-apt # Skip apt steps (useful on non-Debian or if already installed) +# bash script.sh --py 3.12 # Prefer python3.12 for venv if available +# bash script.sh --help # Show help +# +# This script is idempotent: it will skip steps that are already satisfied. + +set -euo pipefail + +PREFERRED_PY_MINOR="" +DO_APT=1 +DO_SERVE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --serve) + DO_SERVE=1 + shift + ;; + --no-apt) + DO_APT=0 + shift + ;; + --py) + PREFERRED_PY_MINOR="${2:-}" + if [[ -z "$PREFERRED_PY_MINOR" ]]; then + echo "--py requires a version like 3.12" >&2 + exit 1 + fi + shift 2 + ;; + -h|--help) + cat < Prefer pythonX.Y for the virtual environment (e.g., 3.12). + -h, --help Show this help message. + +The script will: + - (Optionally) apt update and install python3-venv and python3-pip. + - Create/Reuse a Python virtual environment at ./venv and upgrade pip. + - Install mkdocs and common plugins (material, git plugins, etc.). + - Initialize mkdocs project only if mkdocs.yml is missing. + - (Optionally) start mkdocs dev server with livereload. +EOF + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +run_sudo() { + if need_cmd sudo; then + sudo "$@" + else + "$@" + fi +} + +apt_install_if_missing() { + local pkg="$1" + dpkg -s "$pkg" >/dev/null 2>&1 || run_sudo apt-get install -y "$pkg" +} + +if [[ "$DO_APT" -eq 1 ]]; then + if need_cmd apt-get; then + + echo "[INFO] Ensuring python3-pip and python3-venv are installed..." + apt_install_if_missing python3-pip || true + apt_install_if_missing python3-venv || true + + # Also try the explicit minor version venv if requested or if 3.12 exists + if [[ -n "$PREFERRED_PY_MINOR" ]]; then + apt_install_if_missing "python${PREFERRED_PY_MINOR}-venv" || true + else + # Best-effort for common newer Python + if apt-cache show python3.12-venv >/dev/null 2>&1; then + apt_install_if_missing python3.12-venv || true + fi + fi + else + echo "[WARN] apt-get not found. Skipping apt steps. Use --no-apt to silence." + fi +fi + +# Choose python executable for venv +PY=python3 +if [[ -n "$PREFERRED_PY_MINOR" ]] && need_cmd "python${PREFERRED_PY_MINOR}"; then + PY="python${PREFERRED_PY_MINOR}" +elif need_cmd python3; then + PY=python3 +elif need_cmd python; then + # Fallback to 'python' if it is Python 3 + if python -c 'import sys; exit(0 if sys.version_info.major==3 else 1)' 2>/dev/null; then + PY=python + else + echo "[ERROR] Python 3 is required but not found." >&2 + exit 1 + fi +else + echo "[ERROR] python3 not found. Install Python 3 and try again." >&2 + exit 1 +fi + +echo "[INFO] Using Python interpreter: $(command -v "$PY")" + +# Create venv if missing +if [[ ! -d venv ]]; then + echo "[INFO] Creating virtual environment in ./venv ..." + "$PY" -m venv venv +else + echo "[INFO] Reusing existing virtual environment at ./venv" +fi + +# shellcheck disable=SC1091 +source venv/bin/activate + +# Ensure recent pip +python -m pip install --upgrade pip + +# Install mkdocs and plugins +PKGS=( + mkdocs + mkdocs-material + mkdocs-git-revision-date-localized-plugin + mkdocs-git-committers-plugin-2 + Pygments + mkdocs-include-markdown-plugin + pymdown-extensions + mkdocs-open-in-new-tab==1.0.8 + mike +) + +echo "[INFO] Installing Python packages: ${PKGS[*]}" +pip install -U "${PKGS[@]}" + +# Initialize mkdocs project if needed +if [[ ! -f mkdocs.yml ]]; then + echo "[INFO] mkdocs.yml not found. Initializing a new MkDocs project in current directory..." + mkdocs new . +else + echo "[INFO] mkdocs.yml exists. Skipping 'mkdocs new .'" +fi + +# Optionally run the dev server +if [[ "$DO_SERVE" -eq 1 ]]; then + echo "[INFO] Starting MkDocs dev server with livereload... (Ctrl+C to stop)" + exec mkdocs serve --livereload +else + echo "[INFO] Setup complete. To start the dev server, run:" + echo " source venv/bin/activate && mkdocs serve --livereload" +fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..b739192 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Uninstall helper: deactivate the virtual environment (if possible) and remove ./venv +# +# Usage: +# bash scripts/uninstall.sh # Prompt, then remove ./venv +# bash scripts/uninstall.sh --yes # Do not prompt, proceed immediately +# bash scripts/uninstall.sh -h|--help # Show help +# +# Notes: +# - Deactivating a virtual environment from a child process (this script) cannot +# affect your parent shell session. If your current shell has the venv active, +# this script will try to deactivate if possible, otherwise it will instruct you +# to run 'deactivate' after it finishes. + +set -euo pipefail + +CONFIRM=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes) + CONFIRM=0 + shift + ;; + -h|--help) + cat <&2 + exit 1 + ;; + esac +done + +PROJ_VENV_DIR="$(pwd)/venv" + +# Inform and confirm +if [[ $CONFIRM -eq 1 ]]; then + read -r -p "This will remove the virtual environment at ./venv. Continue? [y/N] " ans + case "${ans:-}" in + y|Y|yes|YES) + ;; + *) + echo "Aborted." + exit 0 + ;; + esac +fi + +# Try to deactivate if the current shell is using this venv +if [[ "${VIRTUAL_ENV:-}" != "" ]]; then + if [[ "${VIRTUAL_ENV}" == "$PROJ_VENV_DIR" ]]; then + echo "[INFO] Detected active virtual environment: $VIRTUAL_ENV" + if declare -F deactivate >/dev/null 2>&1; then + echo "[INFO] Attempting to deactivate current shell venv..." + deactivate || true + else + echo "[WARN] Cannot deactivate the parent shell from this script." + echo " After this script finishes, run: deactivate" + fi + fi +fi + +# Remove the venv directory +if [[ -d "$PROJ_VENV_DIR" ]]; then + echo "[INFO] Removing $PROJ_VENV_DIR ..." + rm -rf "$PROJ_VENV_DIR" + echo "[INFO] Removed ./venv" +else + echo "[INFO] No ./venv directory found. Nothing to remove." +fi + +# Final note if shell still shows (venv) +echo "[INFO] Uninstall complete. If your shell still shows (venv), run: deactivate" From d75138b445c32a291f220c0ac4b06a888346be7f Mon Sep 17 00:00:00 2001 From: nikan-negaresh-informed <84400913+nikan-negaresh-informed@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:33:16 +0000 Subject: [PATCH 30/49] feat(OSPO): synchronise OSPO workflows --- .github/workflows/oss-checker.yml | 446 +++++++++++++++---- .github/workflows/publish-github-release.yml | 8 +- 2 files changed, 375 insertions(+), 79 deletions(-) diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 65b5f1b..e940929 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -4,6 +4,13 @@ name: Run OSS check helper on: + pull_request: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled workflow_dispatch: permissions: @@ -11,7 +18,14 @@ permissions: jobs: oss-checks: + if: github.actor != 'dependabot[bot]' && + (github.event.repository.private == false || + (github.event.repository.private == true && + contains(join(github.event.pull_request.labels.*.name), 'oss-preparation'))) runs-on: ubuntu-latest + outputs: + summary-table: ${{ steps.summarise_results.outputs.summaryTable }} + has-results: ${{ steps.summarise_results.outputs.hasResults }} steps: - name: Fetch GitHub App token for target repo @@ -33,95 +47,377 @@ jobs: permission-contents: read - name: Checkout target repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: token: ${{ steps.target_token.outputs.token }} - name: Checkout OSPO source repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: National-Digital-Twin/ospo-resources path: ospo-resources token: ${{ steps.ospo_token.outputs.token }} - name: Checkout archetypes source repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: National-Digital-Twin/archetypes path: archetypes - name: Test for presence of OSS files and variation from templated content - run: | - missing_files=() - unchanged_files=() - - while IFS= read -r file || [ -n "$file" ]; do - # Skip comments and empty lines - if [[ -z "$file" || "$file" == \#* ]]; then - continue - fi - - target_path="$file" - archetypes_path="archetypes/$file" - - if [ ! -f "$target_path" ]; then - echo "Missing OSS file in target repository: $target_path" - missing_files+=("$file") - elif cmp -s "$target_path" "$archetypes_path"; then - echo "OSS file unchanged from archetypes template: $target_path" - unchanged_files+=("$file") - else - echo "OSS file present and different from the archetypes template: $target_path" - fi - done < ospo-resources/oss-checklist-files.txt - - echo "" - if [ ${#missing_files[@]} -ne 0 ]; then - echo "The following OSS required files are missing:" - printf '%s\n' "${missing_files[@]}" - fi - - if [ ${#unchanged_files[@]} -ne 0 ]; then - echo "The following OSS required files are unchanged from the archetypes template:" - printf '%s\n' "${unchanged_files[@]}" - fi - - if [ ${#missing_files[@]} -ne 0 ] || [ ${#unchanged_files[@]} -ne 0 ]; then - echo "OSS required file check failed." - exit 1 - else - echo "All OSS files are present and have been updated from their original templated content." - fi + uses: actions/github-script@v8 + with: + script: | + const { existsSync, readFileSync, writeFileSync } = require('fs'); + + const checklistPath = 'ospo-resources/oss-checklist-files.txt'; + const checklist = readFileSync(checklistPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + const results = []; + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const commitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown'; + + for (const relativePath of checklist) { + const record = { + path: relativePath, + status: 'passed', + checks: { + exists: false, + differsFromTemplate: null, + }, + failureReasons: [], + }; + + const targetPath = relativePath; + const archetypePath = `archetypes/${relativePath}`; + + const fileExists = existsSync(targetPath); + record.checks.exists = fileExists; + + if (!fileExists) { + record.status = 'failed'; + record.failureReasons.push('missing or misnamed'); + core.info(`Missing or misnamed OSS file in target repository: ${targetPath}`); + results.push(record); + continue; + } + + const targetContent = readFileSync(targetPath, 'utf8'); + + if (existsSync(archetypePath)) { + const archetypeContent = readFileSync(archetypePath, 'utf8'); + const differsFromTemplate = targetContent !== archetypeContent; + record.checks.differsFromTemplate = differsFromTemplate; + + if (!differsFromTemplate) { + record.failureReasons.push('unchanged from archetype template'); + core.info(`OSS file unchanged from archetypes template: ${targetPath}`); + } else { + core.info(`OSS file present and different from the archetypes template: ${targetPath}`); + } + } else { + record.checks.differsFromTemplate = null; + core.info(`Template file missing for ${relativePath}; skipping template comparison.`); + } + + record.status = record.failureReasons.length > 0 ? 'failed' : 'passed'; + results.push(record); + } + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const report = { + runMetadata: { + checklistFile: checklistPath, + timestamp: new Date().toISOString(), + repo: repoFullName, + commit: commitSha, + checkType: 'OSS', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'oss-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote checklist summary to ${reportPath}`); + + if (failed > 0) { + const failedFiles = results + .filter((result) => result.status === 'failed') + .map((result) => result.path); + core.setFailed(`The following files failed checks:\n${failedFiles.join('\n')}`); + } else { + core.info('All OSS files are present and have been updated from their original templated content.'); + } - name: Check GitHub template files are present - run: | - echo "Checking for pull request and issue template files" - - missing_templates=() - - files_to_check=( - ".github/PULL_REQUEST_TEMPLATE.md" - ".github/ISSUE_TEMPLATE/bug_report.md" - ".github/ISSUE_TEMPLATE/feature_request.md" - ) - - for file in "${files_to_check[@]}"; do - if [ ! -f "$file" ]; then - missing_templates+=("$file") - fi - done - - if [ ${#missing_templates[@]} -ne 0 ]; then - echo "" - echo "Required GitHub template files not found:" - printf ' - %s\n' "${missing_templates[@]}" - echo "" - echo "These files help improve project collaboration and are considered best practice." - echo "These need to be included in repository contents to improve the developer and repository consumer experience." - - # Fail the job - echo "Missing required GitHub template files." - exit 1 - else - echo "Required pull request and issue template files present." - fi + uses: actions/github-script@v8 + if: success() || failure() + with: + script: | + const { existsSync, writeFileSync } = require('fs'); + + core.info('Checking for pull request and issue template files'); + + const filesToCheck = [ + '.github/PULL_REQUEST_TEMPLATE.md', + '.github/ISSUE_TEMPLATE/bug_report.md', + '.github/ISSUE_TEMPLATE/feature_request.md', + ]; + + const results = filesToCheck.map((filePath) => { + const exists = existsSync(filePath); + return { + path: filePath, + status: exists ? 'passed' : 'failed', + checks: { + exists, + differsFromTemplate: null, + }, + failureReasons: exists ? [] : ['missing or misnamed'], + }; + }); + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const prHead = context.payload.pull_request?.head; + const report = { + runMetadata: { + timestamp: new Date().toISOString(), + repo: prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown', + commit: prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown', + checkType: 'template', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'template-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote template checklist summary to ${reportPath}`); + + if (failed > 0) { + const missingTemplates = results + .filter((result) => result.status === 'failed') + .map((result) => result.path); + + core.info(''); + core.info('Required GitHub template files were not found or did not match expected casing:'); + missingTemplates.forEach((file) => core.info(` - ${file}`)); + core.info(''); + core.info('These files help improve project collaboration and are considered best practice.'); + core.info('These need to be included in repository contents to improve the developer and repository consumer experience.'); + core.setFailed('Missing or misnamed GitHub template files.'); + } else { + core.info('Required pull request and issue template files present.'); + } + + - name: Generate summary + id: summarise_results + if: always() + uses: actions/github-script@v8 + with: + script: | + const { existsSync, readFileSync } = require('fs'); + + const reportFiles = [ + 'oss-results.json', + 'template-results.json', + ]; + + const reports = reportFiles + .filter((reportPath) => { + const present = existsSync(reportPath); + if (!present) { + core.info(`Summary step skipping missing report: ${reportPath}`); + } + return present; + }) + .map((reportPath) => JSON.parse(readFileSync(reportPath, 'utf8'))); + + if (reports.length === 0) { + core.info('No report files found; skipping combined summary.'); + core.setOutput('hasResults', 'false'); + return; + } + + const allResults = reports.flatMap((report) => + report.files.map((file) => ({ + ...file, + category: report.runMetadata?.checkType ?? 'unknown', + repo: report.runMetadata?.repo ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown', + commit: report.runMetadata?.commit ?? process.env.GITHUB_SHA ?? 'unknown', + })), + ); + + const combinedTableMarkdown = [ + '| 📄 File | ✅ Result | 🧾 Details |', + '| :--- | :---: | :--- |', + ...allResults.map((result) => { + const href = `https://github.com/${result.repo}/blob/${result.commit}/${result.path}`; + const details = result.failureReasons.length > 0 + ? result.failureReasons.join('; ') + : 'Compliant'; + const statusLabel = result.status === 'passed' ? '🟢 Pass' : '🔴 Fail'; + return `| [${result.path}](${href}) | ${statusLabel} | ${details} |`; + }), + ].join('\n'); + + const total = allResults.length; + const passed = allResults.filter((result) => result.status === 'passed').length; + const failed = total - passed; + const score = total > 0 ? (passed / total) * 100 : 0; + const summary = { total, passed, failed, score }; + + const overallStatus = summary.failed === 0 + ? '🎉 Overall status: PASS (all files compliant).' + : '⚠️ Overall status: FAIL (see table below for details).'; + + const summaryMarkdown = [ + '| 📊 Total Files | 🟢 Passed | 🔴 Failed | 🧮 Score |', + '| ---: | ---: | ---: | ---: |', + `| ${summary.total} | ${summary.passed} | ${summary.failed} | ${summary.score.toFixed(0)}% |` + ].join('\n'); + + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const fullSha = prHead?.sha ?? process.env.GITHUB_SHA ?? ''; + const shortSha = fullSha?.slice(0, 7) ?? 'unknown'; + const commitUrl = fullSha + ? `https://github.com/${repoFullName}/commit/${fullSha}` + : null; + const commitLine = commitUrl + ? `Results from commit [\`${shortSha}\`](${commitUrl}).` + : `Results from commit \`${shortSha}\`.`; + + await core.summary + .addRaw('# OSS Check Results ⚙️\n', true) + .addRaw(`\n${combinedTableMarkdown}\n`, true) + .addRaw('\n# Summary 🏁\n', true) + .addRaw(`\n${overallStatus}\n`, true) + .addRaw(`\n${summaryMarkdown}\n`, true) + .addRaw(`\n${commitLine}\n`, true) + .write(); + + core.setOutput('hasResults', 'true'); + core.setOutput('summaryTable', summaryMarkdown); + + if (summary.failed > 0) { + core.setFailed('OSS checks detected one or more failing files.'); + } + + - name: Upload OSS result artifacts + if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} + uses: actions/upload-artifact@v6 + with: + name: oss-checks-${{ github.run_id }} + retention-days: 30 + path: | + oss-results.json + template-results.json + + comment-on-results: + needs: oss-checks + if: >- + always() && + github.event_name == 'pull_request' && + needs.oss-checks.outputs.has-results == 'true' + runs-on: ubuntu-latest + + permissions: + pull-requests: write + + steps: + - name: Comment with OSS summary + uses: actions/github-script@v8 + env: + SUMMARY_TABLE: ${{ needs.oss-checks.outputs.summary-table }} + JOB_RESULT: ${{ needs.oss-checks.result }} + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request?.number; + + if (!prNumber) { + core.info('No pull request context; skipping comment step.'); + return; + } + + const jobSummaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + const prHead = context.payload.pull_request?.head; + const headCommitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? ''; + const shortSha = headCommitSha ? headCommitSha.slice(0, 7) : 'unknown'; + const runResult = process.env.JOB_RESULT?.toLowerCase() ?? ''; + const isFailure = runResult === 'failure'; + + const marker = ''; + const heading = isFailure + ? '## ⚠️ OSS Checks Failed' + : '## ✅ OSS Checks Passed'; + const narration = isFailure + ? 'One or more OSS checks failed in this run.' + : 'All tracked OSS checks passed in this run.'; + + const bodySections = [ + heading, + narration, + process.env.SUMMARY_TABLE, + `Results from commit ${shortSha}, view the full [job summary↗️](${jobSummaryUrl}) for detailed results.` + ]; + + const existingComments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }, + ); + + const previous = existingComments.find((comment) => + comment.body?.includes(marker), + ); + + if(previous) { + bodySections.push(':recycle: This comment has been updated with latest results.'); + } + + const body = `${marker}\n${bodySections.join('\n\n')}\n${marker}`; + + if (previous) { + core.info(`Updating existing OSS summary comment (${previous.id}).`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + } else { + core.info('Creating new OSS summary comment.'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9449b6c..90ff017 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -57,7 +57,7 @@ jobs: needs: [versioning] steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Generate SPDX SBOM run: | @@ -72,7 +72,7 @@ jobs: echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: sbom path: sbom.spdx.json @@ -83,7 +83,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -100,7 +100,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: sbom From f59d4940eb5fdddec5ef0b8ddf85d85c5be2e936 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 12:36:13 +0000 Subject: [PATCH 31/49] Rename pull request template file to uppercase for consistency --- .github/{pull_request_template.md => PULL_REQUEST_TEMPLATE.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{pull_request_template.md => PULL_REQUEST_TEMPLATE.md} (100%) diff --git a/.github/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/pull_request_template.md rename to .github/PULL_REQUEST_TEMPLATE.md From bb389b8033efbae420cfd3986ccbc33827fdb045 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 15:12:18 +0000 Subject: [PATCH 32/49] Add initial project documentation with setup instructions and API overview - Created `Index.md` with comprehensive setup instructions, including certificate generation, Keycloak configuration, and API usage. - Included sections for prerequisites, quick start, profile-specific configuration, and security considerations. - Added details for building and testing the application locally and with Docker. - Documented mTLS setup, Keycloak realm configuration, and API authentication requirements. - Provided links to related documentation files such as database schema, MTLS configuration, and JaCoCo coverage. --- docs/Index.md | 819 ++++++++++++++++++ .../android-chrome-512x512-1-150x150.png | Bin 0 -> 3738 bytes docs/assets/light-page_footer_logo.png | Bin 0 -> 4238 bytes docs/assets/light-page_header_logo.png | Bin 0 -> 2891 bytes docs/stylesheets/extra.css | 140 +++ 5 files changed, 959 insertions(+) create mode 100644 docs/Index.md create mode 100644 docs/assets/android-chrome-512x512-1-150x150.png create mode 100644 docs/assets/light-page_footer_logo.png create mode 100644 docs/assets/light-page_header_logo.png create mode 100644 docs/stylesheets/extra.css diff --git a/docs/Index.md b/docs/Index.md new file mode 100644 index 0000000..f1d9996 --- /dev/null +++ b/docs/Index.md @@ -0,0 +1,819 @@ +# README + +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- + +## Overview + +The Management Node Module is a Spring Boot application that provides APIs to be accessed by Consumer and Producer Federators. It implements a secure communication architecture using Mutual TLS (MTLS) connectivity between Federator instances and itself, as well as establishing zero trust connectivity with Keycloak for authentication and authorization. + +--- + +## Database Schema + +For a full description of the database tables, relationships, and constraints, see the Database Schema documentation: [docs/DATABASE_SCHEMA.md](docs/DATABASE_SCHEMA.md). + +--- + +## Prerequisites +- Java 21 +- Maven 3.9+ +- Docker and Docker Compose +- OpenSSL (for certificate generation) +- Keycloak (for authentication and authorization) +- The below assumes your running in Linux - bash, it has been tested under WSL2. +--- + +## Quick Start +Note. see lower for setting up prerequisites for local deployment certs, keycloak etc. + +### Run the Spring Boot application + +This project is a Spring Boot application. You can run it by supplying configuration via either: +- A default application.yml file, or +- A profile-specific file application-{profile}.yml and passing the profile argument at startup. + +Quick options: + +1. Provide a default config: + - Modify src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. + - Run: (change to suit your local if different) + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` + ```bash + mvn spring-boot:run + ``` + or: + ```bash + java -jar target/management-node-1.0.1.jar + ``` + +2. Use a profile-specific config: + - Create src/main/resources/application-local.yml (replace "local" with your profile name) with your settings. + - Run with the profile: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + or: + ```bash + java -jar target/management-node-1.0.1.jar --spring.profiles.active=local + ``` + - You can also set the environment variable: + ```bash + export SPRING_PROFILES_ACTIVE=local + ``` + +Notes: +- Spring Boot will load application.yml and then override with application-{profile}.yml if a profile is active. +- You may also point to an external YAML using: + ```bash + java -jar target/management-node-0.0.1.jar --spring.config.location=/path/to/your.yml + ``` +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). + + + +# Prerequisites setup +## Certificate Setup + +The Management Node Module implements a zero-trust security architecture using Mutual TLS (MTLS) for secure communication between all components. This section explains why certificates are needed, how to generate them, and where they are used in the system. + +> **Note:** For detailed instructions on configuring MTLS for both Keycloak and the Management Node, see the [MTLS Configuration Guide](docs/MTLS_CONFIGURATION.md). + +### Why Certificates Are Needed + +1. **Zero-Trust Security Model**: The system follows a zero-trust approach where all communications must be authenticated and encrypted, regardless of whether they occur inside or outside the network perimeter. + +2. **Mutual TLS (MTLS)**: Unlike standard TLS where only the server authenticates itself to the client, MTLS requires both parties to authenticate each other using X.509 certificates. + +3. **Service-to-Service Authentication**: Certificates provide a secure way for services to verify each other's identity without relying on passwords or API keys. + +### Certificate Types and Their Purpose + +The system requires several certificate files: + +1. **Private Key (`localhost.key`)**: + - The private key used to sign and decrypt data + - Must be kept secure and never shared + - Used by both Keycloak and the Management Node + +2. **Certificate (`localhost.crt`)**: + - The public certificate containing the public key + - Shared with other services to verify the identity + - Used in both server and client authentication + +3. **PKCS12 Keystore (`localhost.p12`)**: + - A container format that stores the private key and certificate + - Used primarily for client authentication + - Imported by Keycloak for client certificate validation + +4. **Java Keystore (`keystore.jks`)**: + - Java-specific format for storing the server's private key and certificate + - Used by both Keycloak and the Management Node for their TLS endpoints + +5. **Java Truststore (`truststore.jks`)**: + - Contains certificates that the server trusts + - Used to validate client certificates during MTLS + +### Step-by-Step Certificate Generation + +For development purposes, follow these steps to generate certificates for mTLS. All passwords used are `changeit`. When generating these certficates, for the `Country Name`, you can use the value of 'UK'. All remaining certificate fields can be left to their default values. + +move to the docker folder +```bash +cd docker +``` + +1. **Generate a Root CA certificate**: + ```bash + openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt + ``` + This creates a Root Certificate Authority (CA) that will be used to sign other certificates. The certificate is valid for 10 years (3650 days). + +2. **Generate a host certificate**: + ```bash + openssl req -new -newkey rsa:4096 -keyout localhost.key -out localhost.csr -nodes + ``` + This creates a private key and certificate signing request (CSR) for the host. + +3. **Sign the host certificate with the Root CA**: + + Create a file called `localhost.ext` file should contain: + ``` + authorityKeyIdentifier=keyid,issuer + basicConstraints=CA:FALSE + subjectAltName = @alt_names + [alt_names] + DNS.1 = localhost + DNS.2 = keycloak + ``` + + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + ``` + This signs the host CSR with the Root CA, creating a certificate valid for 365 days. + + + This configuration specifies that the certificate is valid for both `localhost` and `keycloak` hostnames. + +4. **Create a PKCS12 keystore for the server**: + ```bash + openssl pkcs12 -export -out localhost.p12 -name "localhost" -inkey localhost.key -in localhost.crt + ``` + This bundles the host certificate and private key into a PKCS12 format. + +5. **Create a PEM file for Linux keystore**: + ```bash + openssl pkcs12 -in localhost.p12 -clcerts -nokeys -out localhost.pem + ``` + This extracts the certificate (without the private key) in PEM format. + +6. **Add the Root CA to the Trust Store**: + ```bash + keytool -importcert -file rootCA.crt -alias clientca -keystore localhost.p12 -storetype PKCS12 -storepass changeit + ``` + This adds the Root CA to the trust store so that clients signed by this CA will be trusted. + +7. **Generate a client certificate**: + ```bash + openssl req -new -newkey rsa:4096 -nodes -keyout client.key -out client.csr + ``` + This creates a private key and CSR for the client. + +8. **Sign the client certificate with the Root CA**: + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in client.csr -out client.crt -days 365 -CAcreateserial + ``` + This signs the client CSR with the Root CA, creating a certificate valid for 365 days. + +9. **Create a PKCS12 keystore for the client**: + ```bash + openssl pkcs12 -export -out client.p12 -name "client" -inkey client.key -in client.crt + ``` + This bundles the client certificate and private key into a PKCS12 format for use in browsers or client applications. + +10. **Create a Java keystore using keytool** (PKCS12 format, compatible with modern Java): + ```bash + keytool -importkeystore -destkeystore keystore.jks -deststoretype PKCS12 -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + ``` + This converts the PKCS12 keystore. Note: Despite the `.jks` extension, modern keytool creates PKCS12 format by default, which is more secure and standard. + +11. **Create a Java truststore using keytool** (PKCS12 format): + ```bash + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks -storetype PKCS12 + ``` + This creates a truststore containing the Root CA certificate in PKCS12 format, which will be used to validate client certificates. + +12. **Verify the truststore** (optional but recommended): + ```bash + keytool -list -keystore truststore.jks -storetype PKCS12 -storepass changeit + ``` + This verifies that the Root CA is properly imported into the truststore. + +### Certificate Placement and Configuration + +After generating the certificates, place them in the appropriate locations: + +if you've followed the above then follow with +```bash +cp keystore.jks ../keystore.jks +cp truststore.jks ../truststore.jks +cp client.crt ../client.crt +cp client.key ../client.key +``` + +This copies the necessary files to the management-node root directory: +- `keystore.jks` - Used by the Management Node application for its SSL server configuration +- `truststore.jks` - Used by the Management Node to validate client certificates +- `client.crt` and `client.key` - Used for testing API endpoints with mTLS authentication + +1. **For Keycloak**: + - All the certificate files should now be in the `docker` directory + - The docker-compose.yml maps these files into the Keycloak container: + ```yaml + volumes: + - ./localhost.p12:/keystores/localhost.p12 + - ./localhost.crt:/cert/localhost.crt + - ./localhost.key:/key/localhost.key + - ./keystore.jks:/cert/keystore.jks + - ./truststore.jks:/cert/truststore.jks + ``` + - Keycloak uses these certificates for: + - Securing its HTTPS endpoint (port 8443) + - Validating client certificates for MTLS + +2. **For Management Node**: + - The application.yml references the certificate files: + ```yaml + server: + ssl: + key-store: /path/to/keystore.jks + key-store-password: changeit + trust-store: /path/to/truststore.jks + trust-store-password: changeit + ``` + - When running in Docker, the Dockerfile copies these files: + ```dockerfile + COPY docker/keystore.jks /app/docker/keystore.jks + COPY docker/truststore.jks /app/docker/truststore.jks + ``` + +3. **For Client Applications**: + - Client applications connecting to the Management Node need: + - The client certificate and private key for authentication + - The server's certificate in their truststore to validate the server + +### Certificate Password Management + +All certificates use the password "changeit" for development. These passwords are configured in the `.env` file: + +``` +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +``` + +For production environments, use strong, unique passwords and secure storage solutions for managing these credentials. + + +### Setting up Keycloak with Docker Compose + +#### Configuration + +For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: + +``` +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. + +The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: + +1. Navigate to the docker directory: + ```bash + cd docker + ``` + +2. Make sure you have the required certificates in the `docker` directory: see lower for local certificate setup + - `keystore.jks` - Java keystore containing the server certificate + - `truststore.jks` - Java truststore containing trusted certificates + - `localhost.p12` - PKCS12 keystore for client authentication + - `localhost.crt` - Certificate file + - `localhost.key` - Private key file + + If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. + +3. Start Keycloak and PostgreSQL using Docker Compose: + ```bash + docker compose -f keycloak/docker-compose.yml up -d + ``` + +4. Verify that Keycloak is running: + ```bash + curl -k https://localhost:8443/realms/master --cert client.crt --key client.key + ``` + Note: Keycloak takes about 30 seconds before its ready and the client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. + +5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: + - Username: `admin` + - Password: `password` + + you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. for chrome got to. settings - privacy and security - security - manage certificate - manage imported certificates from windows, then import and follow the wizard. + + + + +## Keycloak Realm Setup + +After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin. + +### Option 1: Import the Realm Configuration (Recommended) + +1. Log in to the Keycloak admin console at https://localhost:8443/admin +2. Click on the dropdown menu in the top-left corner (it may show "master" if you haven't created any realms yet) +3. Click on "Manage realms" +4. Click on "Create Realm" or "Add realm" button +5. Click on the "Browse" or "Select file" button +6. Navigate to and select the `docker/keycloak/management-node.json` file from your project directory +7. Click "Create" or "Import" +8. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations +9. Note the client secret for the `management-node` client from the Credentials tab (Clients → management-node → Credentials) click regenerate, view it and do ```export KEYCLOAK_CLIENTID=*************``` + +### Option 2: Manual Configuration + +If you prefer to set up the realm manually (updated for Keycloak 26.x): + +1. Log in to the Keycloak admin console at https://localhost:8443/admin (you must first import your `client.p12` certificate into your browser) + +2. Create a new realm named `management-node` by clicking the dropdown in the top-left and selecting "Create Realm" + +3. Create the **management-node** client: + - In the `management-node` realm, navigate to **Clients** and click **Create client** + + **General Settings:** + - Client type: `OpenID Connect` + - Client ID: `management-node` + - Click **Next** + + **Capability config:** + - Client authentication: **ON** (this enables the Credentials tab) + - Authorization: **OFF** + - Authentication flow: Enable **Service accounts roles** + - Click **Next** + + **Login settings:** + - Valid redirect URIs: `https://localhost:8090/*` + - Valid post logout redirect URIs: `+` + - Web origins: `+` + - Click **Save** + +4. After saving, click on the **Credentials** tab to view the **Client Secret**. Copy this secret. + +5. Add required roles to the client: + - Go to **Clients** → **management-node** → **Roles** tab + - Click **Create role** and add the following roles: + - `access_producer_configurations` + - `access_consumer_configurations` + +6. Assign roles to the service account: + - Go to **Clients** → **management-node** → **Service accounts roles** tab + - Click **Assign role** + - Filter by **Filter by clients** and select **management-node** + - Check both roles (`access_producer_configurations` and `access_consumer_configurations`) + - Click **Assign** + +7. Update your `application.yml` with the client configuration, if needed (or do ```export KEYCLOAK_CLIENTID=*************```): + ```yaml + spring: + 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: account + opaquetoken: + introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect + client-secret: "client_secret=${KEYCLOAK_CLIENTID}" + client-id: management-node + + application: + client: + key-store: keystore.jks + key-store-password: changeit + keyStoreType: JKS + ``` + +### Testing mTLS connectivity: + +Once Keycloak is running and configured, you can test mTLS connectivity using the command below. Replace `YOUR_CLIENT_SECRET` with the actual client secret obtained from the Keycloak Credentials tab (step 4 in the manual configuration above): + +```bash +export KEYCLOAK_CLIENTID=`YOUR_CLIENT_SECRET` +cd docker # or where your certificates are stored +``` + +```bash +curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + --data-urlencode 'grant_type=client_credentials' +``` + +**Note:** The `client_secret` parameter is required for confidential clients. Make sure to: +1. Copy the client secret from Keycloak admin console: **Clients** → **management-node** → **Credentials** tab +2. Replace `YOUR_CLIENT_SECRET` in the command above with your actual client secret +3. The `-k` flag is used to allow insecure connections (self-signed certificates) for development + +If successful, you will receive a JSON response containing an `access_token` with the assigned roles in the `resource_access.management-node.roles` claim. This confirms that: +- ✅ mTLS authentication is working (client certificates validated) +- ✅ Client credentials are correct +- ✅ Keycloak is properly configured +- ✅ Service account has the required roles assigned + +## Building and Running with Maven + +### Building the Application + +The Management Node Module uses Maven for dependency management and build automation. To build the application: + +1. Ensure you have Maven 3.9+ installed: + ```bash + mvn --version + ``` + +2. Build the application: + ```bash + cd management-node # change to suit, if following along do cd ../ (from the docker folder) + mvn clean package + ``` + This command will: + - Clean the target directory + - Compile the source code + - Run the tests + - Package the application into a JAR file + +3. If you want to skip tests during the build: + ```bash + mvn clean package -DskipTests + ``` + +### Running the Application + +After building, you can run the application using one of these methods: + +Note: if running with defaults export your passwords first.eg + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` +Ensure certificate files are in the management-node root directory (if not already there from certificate setup): +```sh +cp docker/keystore.jks keystore.jks +cp docker/truststore.jks truststore.jks +cp docker/client.crt client.crt +cp docker/client.key client.key +``` + +1. Using the Java command: + ```bash + java -jar target/management-node-1.0.1.jar + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run + ``` + +The application will be available at https://localhost:8090 + +### Testing API Endpoints: + +Once you have a valid token, you can test the protected API endpoints: + +**Step 1: Get your Keycloak Client Secret** + +1. Log in to Keycloak admin console at https://localhost:8443/admin +2. Navigate to: **management-node realm** → **Clients** → **management-node** → **Credentials** tab +3. Copy the **Client Secret** value (you can regenerate if needed) +4. Export it as an environment variable: + +```bash +export KEYCLOAK_CLIENTID=your_actual_client_secret_here +``` + +**Step 2: Get a JWT token and test the endpoints** + +```bash +# Navigate to the root directory where client certificates are located +cd /path/to/management-node + +# First, verify you can get a token (view the full response) +curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq . + +# Get a token and save it +TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq -r '.access_token') + +# Verify the token was retrieved successfully +echo "Token (first 50 chars): ${TOKEN:0:50}..." + +# If TOKEN is "null", check that KEYCLOAK_CLIENTID is set correctly + +# Test the producer endpoint +curl -k https://localhost:8090/api/v1/configuration/producer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Test the consumer endpoint +curl -k https://localhost:8090/api/v1/configuration/consumer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +Expected response (if no configuration data exists yet): +```json +{ + "clientId": "management-node", + "producers": [] +} +``` + +If successful, you will receive a JSON response containing an `access_token`. This confirms that: +- ✅ mTLS authentication is working (client certificates validated) +- ✅ Client credentials are correct +- ✅ Keycloak is properly configured + +### Using Profile-Specific Configuration Files + +Spring Boot supports profile-specific property files, which are essential for local development environments where you need to configure sensitive information like passwords and URLs without committing them to version control. + +#### Why Use Profile-Specific Configuration? + +1. **Security**: Keep sensitive information like passwords and API keys out of version control +2. **Environment-Specific Settings**: Configure different settings for development, testing, and production +3. **Local Development**: Each developer can have their own configuration without affecting others + +#### Creating a Profile-Specific YAML File + +1. Create a file named `application-{profile}.yml` in the `src/main/resources` directory, where `{profile}` is the name of your profile (e.g., `application-local.yml` for a "local" profile) + +2. Add your environment-specific configuration to this file. For example: + + ```yaml + spring: + security: + oauth2: + resourceserver: + opaquetoken: + client-secret: your-client-secret-here + client-id: ztf-client + datasource: + password: your-database-password-here + + server: + ssl: + key-store-password: your-keystore-password-here + trust-store-password: your-truststore-password-here + key-store: /path/to/your/local/keystore.jks + trust-store: /path/to/your/local/truststore.jks + ``` + +3. Make sure not to commit this file to version control by adding it to your `.gitignore` file: + ``` + src/main/resources/application-local.yml + ``` + +#### Running the Application with a Specific Profile + +To run the application with your profile, use one of these methods: + +1. Using the Java command with the `spring.profiles.active` parameter: + ```bash + java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + +3. Using environment variables: + ```bash + export SPRING_PROFILES_ACTIVE=local + java -jar target/management-node-0.0.1.jar + ``` + +4. When running with Docker, you can pass the profile as an environment variable: + ```bash + docker run -p 8090:8090 -e "SPRING_PROFILES_ACTIVE=local" management-node + ``` + +The application will load both the default `application.yml` and your profile-specific `application-local.yml`, with the latter overriding any duplicate properties. + +## Code Coverage with JaCoCo + +The project uses JaCoCo for code coverage analysis. For detailed information about the JaCoCo setup, thresholds, and recommendations, see the [JaCoCo Coverage Documentation](docs/JACOCO_COVERAGE.md). + +### Running Code Coverage + +To generate code coverage reports: + +1. Run the Maven verify goal: + ```bash + mvn clean verify + ``` + +2. The JaCoCo report will be generated in the `target/site/jacoco` directory. + +3. Open `target/site/jacoco/index.html` in a web browser to view the detailed coverage report. + +The current configuration aims for 80% code coverage across instructions, branches, lines, methods, and 50% for classes. + +## Troubleshooting + +### Common Issues + +1. **Certificate Issues**: + - **Error**: `SSL routines::sslv3 alert certificate unknown` + - The server doesn't trust your client certificate + - **Solution**: Regenerate the truststore with the current rootCA: + ```bash + cd docker + mv truststore.jks truststore.jks.old + keytool -import -trustcacerts -noprompt -alias ca -file rootCA.crt -keystore truststore.jks -storepass changeit + # Rebuild the Docker image + cd .. + docker build -t management-node -f docker/Dockerfile-dev . + ``` + - Ensure that the paths to the keystore and truststore files in application.yml are correct + - Verify that the certificate passwords match those in the .env file + - If certificates were regenerated, ensure the truststore contains the new rootCA + +2. **Keycloak Connection Issues**: + - **Error**: `Connection refused` when trying to reach Keycloak + - **From Docker container**: Use `--network keycloak_keycloak_network` and connect to `keycloak:8443` + - **From host machine**: Use `localhost:8443` or `host.docker.internal:8443` + - **Error**: Token validation fails with 401 Unauthorized + - Check that `KEYCLOAK_CLIENTID` environment variable is set correctly + - Verify the token contains required roles using: `echo $TOKEN | cut -d. -f2 | base64 -d | jq .` + - Check that Keycloak is running: `docker ps | grep keycloak` + - Verify that the client secret matches the one in Keycloak admin console + +3. **Database Connection Issues**: + - **Error**: `Connection to localhost:5433 refused` from Docker container + - Docker containers can't reach `localhost` on the host + - **Solution**: Use `--network keycloak_keycloak_network` and `jdbc:postgresql://keycloak-postgres-1:5432/keycloak_db` + - Or use `--add-host=host.docker.internal:host-gateway` and `jdbc:postgresql://host.docker.internal:5433/keycloak_db` + - Ensure PostgreSQL is running: `docker ps | grep postgres` + - Check the database credentials match those in the .env file + - Verify you can connect manually: `docker exec -it keycloak-postgres-1 psql -U keycloak_db_user -d keycloak_db` + +4. **Docker-Specific Issues**: + - **Issue**: Management Node can't fetch JWKs from Keycloak (SSL trust issues between containers) + - **Symptom**: Application starts but JWT validation fails silently + - **Workaround**: Run the application directly using Maven instead of Docker for local development + - **Alternative**: Use docker-compose to set up all services with proper SSL configuration + - **Issue**: Environment variables not being passed to container + - Ensure you use `-e` flag for each environment variable + - Verify with: `docker exec env | grep VARIABLE_NAME` + +## Security Considerations + +This setup implements a zero-trust security model with: +- MTLS for all service-to-service communication +- JWT-based authentication and authorization via Keycloak +- HTTPS for all endpoints +- Client certificate authentication + +For production deployments, consider: +- Using properly signed certificates from a trusted CA +- Implementing network segmentation +- Regularly rotating secrets and certificates +- Setting up monitoring and alerting for security events +## API Documentation + +The project includes interactive API documentation powered by Springdoc OpenAPI (OAS 3.1). This exposes both a human-friendly Swagger UI and machine-readable OpenAPI definitions. + +How to access locally (default settings): +- Swagger UI: https://localhost:8090/swagger-ui.html +- OpenAPI JSON: https://localhost:8090/v3/api-docs + + +Notes +- HTTPS: The application serves over HTTPS by default (see server.ssl in application.yml). If you use development certificates, your browser may warn about trust; proceed after trusting the dev CA as described in Certificate Setup. +- Security: The security configuration explicitly permits unauthenticated access to the documentation endpoints (/v3/api-docs/**, /swagger-ui/**, /swagger-ui.html) while keeping all other endpoints protected via OAuth2 Resource Server (JWT). See src/main/java/.../config/SecurityConfig.java for details. +- Port/environment: If you run on a different port or behind a reverse proxy, adjust the base URL accordingly. + +How Springdoc OpenAPI works in this project +- Auto-scanning: The springdoc-openapi-starter-webmvc-ui dependency scans Spring MVC controllers at startup and automatically builds an OpenAPI 3.1 specification from your request mappings, parameters, request/response bodies, and status codes. +- Annotations (optional but recommended): + - @Operation(summary = "...", description = "...") adds summaries, descriptions, and operation-level metadata. + - @Tag(name = "...") groups endpoints in the UI. + - @Parameter, @Schema, @ApiResponse add fine-grained control over params, models, and responses. +- Security schema: Because this app is an OAuth2 Resource Server (JWT), you can declare a bearerAuth security scheme to document Authorization: Bearer . Example: + + @io.swagger.v3.oas.annotations.security.SecurityScheme( + name = "bearerAuth", + type = io.swagger.v3.oas.annotations.enums.SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" + ) + + Then add @SecurityRequirement(name = "bearerAuth") on secured controllers or operations. +- Global metadata: You can set title, version, and contact details using @OpenAPIDefinition on a @Configuration class if desired. + + +## Authentication Requirements + +All protected endpoints require JWT bearer tokens. Tokens must: +- Include the audience (aud) claim with value `account` (default Keycloak audience for service accounts). +- Contain a `resource_access` claim with client-specific roles under `resource_access.management-node.roles`. + +**Required Client Roles:** +- `access_producer_configurations` - Required to access `/api/v1/configuration/producer` endpoint +- `access_consumer_configurations` - Required to access `/api/v1/configuration/consumer` endpoint + +**Token Structure Example:** +```json +{ + "aud": "account", + "resource_access": { + "management-node": { + "roles": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + } + }, + "client_id": "management-node" +} +``` + +These roles must be: +1. Created as client roles in the Keycloak `management-node` client +2. Assigned to the service account of the `management-node` client + +Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). + +## Public Funding Acknowledgment +This repository has been developed with public funding as part of the National Digital Twin Programme (NDTP), a UK Government initiative. NDTP, alongside its partners, has invested in this work to advance open, secure, and reusable digital twin technologies for any organisation, whether from the public or private sector, irrespective of size. +## License +This repository contains both source code and documentation, which are covered by different licenses: +- **Code:** Developed and maintained by National Digital Twin Programme. Licensed under the Apache License 2.0. +- **Documentation:** Licensed under the Open Government Licence v3.0. + See `LICENSE.md`, `OGL_LICENCE.md`, and `NOTICE.md` for details. +## Security and Responsible Disclosure +We take security seriously. If you believe you have found a security vulnerability in this repository, please follow our responsible disclosure process outlined in `SECURITY.md`. +## Software Bill of Materials (SBOM) +This project provides a Software Bill of Materials (SBOM) to help users and integrators understand its dependencies. +### Current SBOM +Download the [latest SBOM for this codebase](https://github.com/National-digital-twin/management-node/dependency-graph/sbom) to view the current list of components used in this repository. +## Contributing +We welcome contributions that align with the Programme’s objectives. Please read our `CONTRIBUTING.md` guidelines before submitting pull requests. +## Acknowledgements +This repository has benefited from collaboration with various organisations. For a list of acknowledgments, see `ACKNOWLEDGEMENTS.md`. +## Support and Contact +For questions or support, check our Issues or contact the NDTP team on ndtp@businessandtrade.gov.uk. + +**Maintained by the National Digital Twin Programme (NDTP).** +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entityright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. \ No newline at end of file diff --git a/docs/assets/android-chrome-512x512-1-150x150.png b/docs/assets/android-chrome-512x512-1-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..72b9b2c369c84d40bc7050510c9e7620d9720a56 GIT binary patch literal 3738 zcma)9XH*jl)AoXha0R3YNL2_ugb+iEAWD%Up@$+x=_JkZd)iTa^7+WWBz>=J*4t?d^o#aaHxYk1wK{?1)cxb9;BsEIHvkt7Zc&Yy0%* zg{g%1Rp5!H?0d?$H9r@2hD10V96Z|@)fgTO3|d5E_t8^=5#zkFVbGpp*!i$%p=dL} zT@*;UW=jXSKM~;mjcn=u%L^(gdCD*4%qv~!h;D%_NRoYLb%-Xsdvns+?ul^ghps1P zRs`kKUDS?c|EAJ#m5Yl7A5W7MoBkCVb%TunGYwPzat%ExOyx#IZskSy`XH4+4LPG5 zbcqh^k21884p)h_F7_RQPp2eI`rmyD%muMMoT|1ElNA5it|20iIpUFv?9t>dXx+)Mh|NizN5*GX7(_6aszR-jmfhpyhAR zHcW1+ zFH&R0tj$p&__&u3V!6DKy&q3mZoFsN9H5jRv4olF^P?=`99$k-5++mPzKx(*>Hpb~ zCim%qNF3u2C1O>e`t8Go7qD)YgwamWBE)z5<90P>cX@G_%qa|0K#ISy_7Lk|_cC0P zdfSv2p0cp#g7)HF9RZ6hN!>>sPs0X>DwlsTF^Tm|}wJyL|D`Z8=W1~Gem zq6-5)Du40khQw=_K<0=Ke%t|Z<>QrgHm9N4O2?Rh4cPGvv%sL3uy9c{vZoR(QBAeX zb?ZHCaA?%Sw;|&=FRqdDJU4TidlK3u<&d7toT!ZJF$+bX0-|ll>W~_K{JK%PD0Ati zioW&R;(oD(t6W{V=Kw=8lu9FC*dvgNSP)m{!uahp=VR4y4S|eXlHy2W=O$DcxXE0*GQV)i#0Ukq5+u&Q6P?Z%T`H0XjO}gl05NIkvJ7h8 zIOU4LwC7yE_l(a{{V7qkaiQ{T#ma= zoX3dHSd)x*WH=7c5S55qPf7`!36pS1M;5Kw7|<8v3}bO*FBE!-l~m)#XJ;--UmP1? zVQoe7K=@?o(xM?E8;T-;KjeWtAioc+b4}Lf&cgpA^nU#`L{Mb>V4ipqdOK9E-q1$e zbzqR(Lp)ipKk7L>3XS7l8JqmpUOka%cHbauZa6z@X7oTKby-3SGl{`W9z|vK-0)zv zV4q`^9@nj-n5>HMob2+q)Ev2hc{LH|LU%_VN5)Ctmx_wKfYa{&L6{{m#@r*4)#k z?!2?x8YUl{ekZTFj%gxM3Xmef->RL8H)MDhy-cOtd0gh&dqkF(R=VKjXr-dEj4R!R zj{p4AiGG!R5SDWqpe)(9I#yeoSj0VI$g`X8%%s0HMIx+OL^&0O*SXqp3U|!0@+b`U zbJI@9`z{FNCw_@aSz))wUw2L0Wn`q3$}w#ph6z z<(Z%eccfOZe|RY{n;vUm+W4%z)x&Gs_S%PL``U_$?^^Pse!b!gI$8A_9sbtte7X~V zp1Reg`PbDzveP1Dd;FvW5*0UxlZszO@s>KRy8}ECEt31i4&F2W~92sRFJC@tNIuHGwZNWDqQcqVw`AU9w=OHDvq$+J~QCCglMYB zD;e(-;z4%Rx!?WtzG*KKZIop^el1;_H4b@09)5Rf8hWgn|5C3rT1QtJCi^jN#?zK` zqu#e;{D}OZ^(&#i(@?F9CWGBIQw^r>4+6}qX9|u|CmF+$c-}4_EBwJtRErYzQw3hL zIwZpN;e>38s~tIs0GAw!^!)AAQL};AGkyJoZ&O`xm<(;hbq!r$!s}(127RF}4U2w5 z82#IAQp8r0la2Y_)8l@7cn>u$VM!l@KiyqyLAd z#!PRF0~gCyoh*NnSk43KmF#^y`kgHAj5#X*gy-*cvYb`?_u<*CKam>vy5P@VBeFnB z0`pJi-(W#g-sbV>9P>R3i^)iU=52BDEQkeXrcwGwEmSCpD~%pZWuTI$kT$7d$#hlCLCwJ&~5 z-V|0Q#*k)ikHCSR&jr6@pzeTb6)*s3Q&>2smM7gUYzz28$5~MFW0n%O#~qze9hV9A zN_NPD%|Lzqfv4FaL{;_2%x@gj*{&R(kzZ>?Mnu9-D9Nje3ZAQC7^3_vua8=e5J2OPS26yS4OxjQb!BcV`u_rRm`NF(*Kh|fC( zSB`daPO$p9@wgq^So&jmUIY#|nc@hkn-Yx;BjwKdEV22wu0}JA*2O)(4>A+>@*LSaLb6K0>CRK06~ z5^IYG^q~6Jn4K$LtW(8j9?pMXo2b6>z9*YO$TCu8%8#-lfk)e}Yu}H@&I}Bae0=t# zdG+kq!>^Tg?Vu@Vx`kV=Y-Zjn)7+^3_3T@IR15KT>DPRJCjW|C@1rF3D(on%@jLaw z+f<8?r`x`(B3Uk*(@%)!w$%(fo46rG&sSnuu;gDL062$6KhOt<(pr(K3d8Zv3I+DE zWE@W0+%th}@q7)lB7IlT;MU=qqB}q0?!*^gAkrbRRJIBjFXflm$ef85fVaWr;1-Q^ zzR7Re=xOSOrP*aJhpsy(_M5WUsX-k;ES-U9`LN#73U8L_#|0EPs-m*2>Lvg2mYDTu zV@%}t+MjreBUzcHwc?J_h9Y0E56$YT^Fe9OT3+osylpHvjlxT&Bnj^waE4dSs>mvD zivpmFURb^hppl3C?#r%3+BkeR)!)A36LRH!;kr*TqD#Pm}jI~E# zA7X5i0J^bD)auR$#*+nPw0x5kOO=63TJ$oU_GH~ci!mOG(OqGoXPfrLZ#7Uvw;Hz5 z6wk#*$76-XCvZ5b0K_&;w0<t+M3mq1#K3f})(sP@4;pcN>TK3-`M(CO&#;4@1JC)TjeYJQ-@)JC{o=DRAQ2wgxYeE6#4)e=z z`sGlG3JkkQNRX;Hx#9+b8Z+e>>pYLvgqi|q0gUpBsIh_Z#YywUc{Y}&jxh1q91PKC zaG6ksZe#+*Vw3czAqHPlqGcwQ*fL*vpLlVc@pSDvn`z*MG;EAitjJ1o2|N|?-n)^4 zXbarDxN0&|Rqr4eXd!D@UlXR7O0h^16Bs66utQ+q;`c8UNm6Du_x!&SZ z#(KoJnlTK)%0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU?FiAu~RCwC#U2AA$NfoX*tKxHeQI}P4 zrw1SC{&0GH;4X-{Ehvhxli-_ONy7TTKh8vapg&CV&p(Onf@w$QmNbTPIo8Y2Z!#id%LRcqrQ92sZ-}VM-T)- z5ClOSE+#tJd3Sd=Cm#i#Hev)(o0yoWntL@bA91VNC%2!bF;U<5%BBrt*?2oe}U5F{{yAc&#h zhCw%gWgf{GoQu-L#6;TV@hSEe6Prcv;R0j37(UhZS}i)3eJZAneG6Tse9F{U(S6Of zy?TRk-9HzpE3s|H`fSSy;#d$E$Sbx^ZB1l*2*-TV)ff7P(7~B@QJ-JGrT;yJ?*)`= zop#;md2=rG_@~jAg(me+m^O;2Q;9_v)lZrI7Nkr`cn-h!>Xq_2DKDXX8P{a5uHZOy z|5Jv{3ufB{hYWGt2rLUz&=ATxn7h$_I?$4GXm4hK+F)KaA~}w=Ci_eV#O{f&5|pXd zR%>!RvxdAb;5_Q@S=m;OXi9qAr8cxk+qV*j2QlgxwhycTjbKLx(U!=0m^l3)tpe&( zP35x4$|0DtXWKYl2Xe}(nWvhOX7>m(e9TA6EY5lIl6`dFY1c?`6zf9*ld_qXoJF(* zK@F@;GAn?5Y8a3zR{~SV`;NRX$Kg|d--yp-2>q`?1DekwRP@tpS4j_=jwix(Q?_o; zVbHG}Ov=R;n&A@6&V)mQAb~Y7je#}e+RKueGXvbYz(*ux(64@_7_{g5P}dqrV%EhI zK3ene5`Is1N??H|WzFttKyG2bW||O~?^_AyTFv2HCz&w`;wYPtIvckratdTNZ2!u2 z2fFqL&h35-)Qk9>X`$t88?xy})7c}iz;9SHk6!rDeH-b2l1m?`3 zTfoDX9w3S!)@#evCly?mnogQwPUZqlEYvt1Ovzk^W;nH5 zx6Nmds|JRNpN($*d+pk_H;ClS?`M!+29Fy?v|mNna6bEibYq5QvuOsd*Mf8qE)1dH zCA3?}&~jop-68R(#s`=;gjNVNsC^F$jS<6nX}50$gr6KI)?C@6k^oC$(O_FLFi+P2~0Xd|0+k z2yt!8=Yt}8y*K1H-{f_aOvodQa!i}S=XO9s-D)~Md_u!ER2B^pmNUq8EkbTdhSpYz zX=mC_Ajgg1PT^WAQGH$HIU-LJxmDy>B2N{01VrahewVrpgvdU^p>d2furbCOG^-p2 zeJ*C+VU43Dl1?TSE2>Z>50z<7FCw5_|b|GPwf38MR~$mcjbNMNIer9jiEA(%A}jVI^#u?xF_dDnO; ztF0@|Xd^Xk6k!rhqI}z|ceLbUNk(TQub8^je|RZEMC-wROBu(W9 zUVj#OiHOF9{Z-^UHeQS%ff2;OAm*O>yk6z%6C3Iad{pG+BHt2uipalUZfh8dnua<< zsXno9h{!iq~Fe5e0Lc>_Da?}iQuy(pu3;~ZxlkFS32a)vH&&5va zL?4na+lpbw26>ueTr_1GfON5$_p#|HLUF=dMx=XtS<rz7@arb2+2_3Z&Yk%wGI% zvq`jS$FC-^QTV4LWqTS|8*R>x__bBP*Hs~(&xpJdJ}wPb)fvacPHW za`m9qck8kuSC0pKk!ozB%~d#5=mQ%?l#wRLr-An7VGQ1ERMILU&TGzWI{=endOj@E z^_9kaOe190Hd?h~2+5LjA!34_#OG!k&CZPSg%#3y6b~64FYj~pkcvOSg#47N&pv*| z)uZlp>5#^LeN*J)*ylq^c2du8IZQ}kBMta~DlN1iUtTeOstU-j*k#jpxl-DOKdam0 zs{|%j&h5{#TQCIPjs|vJW5p}tMw!Hvh}B824*Pe=@Arbpbf)ozB5%QX`lGAIi+$47 z!z=VZx5HOP^ci&G4+fjifbtpJefhQ_hMMbbZQ*24XC8eRgvsKwVdMidGco35yC$Sh zd41M(wjOjCB&N>!dri?7M1CRi3H=X9Wf)dJ0|9S45R?SCQ4*g;6^G5L6mxAEbFKCsciKBsvXutcR{ z3tpqk4Jc=qH%o2&S851rAvBR&AexGsAV^@t!DO2n$c-==D{X2k-Dq&h0FKXwZRE{w zKjWAWh^{Gc{HYt!46tev#~uWJ5(C?lSlA5m>?h5%XP=aE)F(1XV8;OP|0FFmkXYPS zz7K?{FD{utdgxNHt#81DLu!gPgX8MNgbcN?QG}YdvG}|VknSwL&qnTrEu#^K_loYn zgSvGRV>JwdJ_6&R>ldYvzz!EPi2U{!D~;p@^YAI53+Bn&%gc3>2w`kyXke2;TIksx zwJ@t`*|N057vq+Wz&#LN!>JYzI?bl5{y%-ypQhe9h0V)HXk6sn0N-#$0_*QndI8#p z4&3P$CX*lZ`kz%i$ve(cV9k?Y{or1}Y4G~B1Z{fh*6(y6zd!t7wJx+T|KG|OUw`d? zXlkBoa!vKztM72m6FttsQlP!;m?c*$W{Fn8n4r5bLzm6ITQF@G@qNQA2ir2s*mlf* zJEpyzU}@Th)u>GQGRnhxxA0*0|JxlzTq0*7d&g2lGpPjHM;RJX#SGXY-b^%!o4oAy zhb5#gp2|5auerujBufaoxO*wmhY*z}19GVyjVU-*pH91yah}qkKWVVf=7?VBe2cY} zDXzVe)*J{XkMk7Ma`&AhyV%~Gt5sarGp_Oe18=_1puH)h>1aFtWzoyne-r<$;-PvD zzx{qIrhj={zbe|EvTcKa7SL7^RLZ+>19*dUOV20GZMlu_O+ zCA8jLi>=BL`Co0LZ>0{8E82b={VTO|o?`pO(qNAn)-hP;Kon{oUTQ&l&X|pfA+#b0 zVcK6~DVxMTWf0iBYXkuM2cdrmrXV2yKqw*$i1w@G&%gY(eW?00=Tct9Z{PNcYXnD^ zOkY#nRy?)e~9{G6~5A8htv6r2{{8* zx8)3mN#9g+jfKp4jw517P+~gY%4Ap4k$YG7S&+}OuCaGm!X(Q*(Id2-W%SuEljNQ7`dKn-OV>aizTH&z zJ!8g>YDuNY%wIV$`Rwns$$m@TjFvhprq3m`9jzDVW6m{Z=Z9{>^_-D4n6^DkT*lOJ zA?Qz68Cfxfz0aJDl?BH<71W{A-{*Z4w~yQoBfEXHckH)~1UK#H%{41T>OL}BYQHkF z5<|a^8-ob7=o^7yux{**2DW?0fxTo>iiZpvRrSPOLI5TYy@=Dyn0M1K6o4LsOnQ@IkMIUYN z&&{MEFfBWYcB&DdV2s#i;1n*mm?a|!5?Gt0rH;sT9|YHfCX|9GDuzf3AcHA4*xAYX zz-d|rsrlUZ&4~0Qw6R5_aayf1F`t&ueI$134mwD+j`I;>tGI&>T7no$3^NW)$x35Z zZo+!7fus}GSrDDg5z&RRMK^#&!w76F1!X1INWFZWz@Q;c8uF+alh{JFw2OvtPvd;4 zHdipt{iJw{~ zeIS8`S_(9>DYK@cP`f*=SI7(oyu zFoGb6@k2LDi3(f~NsJhL%AycH_ZcxB==d#T6P@ggMaO)V88M2e*>$BRxg{}P=qQOP kK@bE%5Cn19_`d)H0IqZH0gupi4*&oF07*qoM6N<$f|K16G5`Po literal 0 HcmV?d00001 diff --git a/docs/assets/light-page_header_logo.png b/docs/assets/light-page_header_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d62a452bcd5455038a90c7cf21ec0dabc46d7c25 GIT binary patch literal 2891 zcmZ8jc{tQ<7ydB}FC#>fHB>|xHApd0mL_{OW6cb*WQ|6)F*7KPU2h_i@S+k!Otu-u z5*4zq#fZ1DWyz2s%QwA$e&>&8xz4%obDrnEp6fXz8!Iy*0Vx3h0E7@%jjsa$*EDAx z#t-Ig93W!}WG}&_@q_01#M47#rOT zoA@#LB}mLcj&FKIeASSrECsOk2R?`fPB@vB?{C)I9pU8uKf}7B=8Q&1N0T-@3fPXJ@Z6pe_ek6P zON4#vopti=Z1XCk`2ZcL4ZXkR1J`KXs-N+lRF(C84?rh@srSeWQzf&1&+L~l2DR)U z0C(LT;eo1dh1~$1+&pVqADK*^tG9U0TzB1F-8KTPZZ@Q7Bz%28)YZH^!Z`LlypOqR zK@QT6X1(dv7>vAok{B8)Zg#=Q!yZEKi8NA*cY zM!@Azjd+!MQi>xZ+Xn!>tJ~CLU@5ePNcE@t?BZF5eDuZ{S^2@gcHy$U!5ASpwx}dO zHEp4DYg=0Yol|*}hgS2--no4aj_$&0_Adcb5-rj_%B!ruc3*pw<6;B=)CA zrF}!ivuxhWe{;zw$sgE;3~Ex?R$&26lMmK4YY9gjj0aqgFjT6TXC9ozZ+pxwx zWAxbkBT&&lF#*z zaYyYU*^#D5*M$hVwG%!BQ~kGQ&2!R@_o=^r^ewX}xZ08XXjG4-Q!;P-6s*d|tOlmz zaJ8O9T{)BNy{(UZ9mUX01i;Hz#XQS&2eb74Exfqw5o^77Z9!nVDe>a4E+rHQ2{qP%NqE5@}}j>?)h zu$b65E3(nj&Herhp|+Wuf@YzuxHpkStb+}%w)5FyUxO^`8WwU?DK~StB_$>9#WV_k zLn7fi?T3pv`)ocP!szR63bbRYYnQo89|t!BU}({AZ@mJ6gMs$I20pFhx#?@Tp^6VFscd(K(g7L zi8cVJtqieOGS-lpTVO6>s&&{A0Lh|1X!<&T$l4L%Lkqg7z_}ohY6lmd8 zUT7cf!tXt)qSOjgh z&94vY%nTO9yf9nb;MF|hYYn ztF_EWyj+l7=e(#G0!(@A)Vff8Qm1Sq)Cx8T(2X&k$fVW-5x)>6ODdVsFM0GanbM9ih|teOiLF&}yV#&S8aBuTo8*|35zIeL3#2Yij4DiJmtf9catiQmK$%>=^Vi`RIvQJhpuTr7sCI1R)IUNI=OD-ndYT@*Mu47**u15t9fx%mhXTAvMGUt z65#r3&cenVWLU3-3pQ(B#$_}!a8XT12C{q(L;9q-s99@K{f6}&(`EOHPZJ}L(baiZ zZ#h(?DU8I}ojx;S@cT?k0rN|{WsiBWo&ngE!cqunDYB+uK3y72!cqfOaKm`syN%J0 zQ@zAqBk;+>zEn?Fi%#eHu8Lb#Uh5`1zY1HHMoEwIN+Q*t*Y$fzEfvAbOU55Wrl;A3 zL%T1A(rVOA$4gj&$V~rCU9j*YO*(sx2HPV_&_wCz){Vm1W(7|tnTxWezM7VO^&~x?2tu*^fk@LOYNHEE*K+9S zg1zctM;otvOwLyO1cYfvF9u~W|I8daT{Q_4k!F$hAEf@iNj=^DSepqx9BR|u>RZGT8@tpzIs6MqGTZ(g z9#7W9AFy!VC4~!rUftwFG9E4U*Yvwf)U54<^jk<>*4&7Y!PX)lWw%GbnNRPTup@ri zr{zbP#ICbe4b#3S>l7%Ap(yUI&O~PIoebZbJ3kfhaNRSawXn!0Ze}7I2r#L^81&DX zGeB(B_?5;*PrP$rt_zFtZlXeO&P2zpeQWm9hro!>&!$HLf~}J8GoA1Y{RpYh1mQtZ zbZ>lEYX`5oEO}P@A9;C{?!Db)o#I|d$3$9Pm0t3`%Qtg*5ZnLI%lPdRvPhfv@fwV) ze(_c1kOk>`F}Y+5<25;-V5ZkZK#WW2Y)!)*7Z>Z|be8O09JH53tguf#m-AAnqL6iO z5Z+<}9`x^kfx^x$91MB>Li1r|OOvJ$P4gy_y?Ke)5Ac>yvSItrkW+EKQc=RwJ;6y` z)Tag({Rm2Al9?LimA*2&GWxEM(*wr0rY_H!Ie)}4U!i#;pzYsHS_!QE#dr>hc=k^d f{C~FcgTM^|3T2{Boqh_|{o_QKSQ%Gb#>D&&vkG;N literal 0 HcmV?d00001 diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..dcd897e --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +/* Custom primary color for Material for MkDocs */ + +/* Light (default) scheme */ +:root { + /* Primary brand color used for header, active nav, etc. */ + --md-primary-fg-color: #002244; + /* Optional variants (used for hover/focus states) */ + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Dark (slate) scheme */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #002244; + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Increase logo size in the header */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 0.8rem; /* default is ~1.8rem; increase for better visibility */ + width: auto; +} + +/* Improve link readability in dark (slate) mode */ +[data-md-color-scheme="slate"] .md-typeset a:link { + /* Light blue for high contrast on dark backgrounds */ + color: #8ab4f8; +} +[data-md-color-scheme="slate"] .md-typeset a:visited { + /* Slightly desaturated/lighter to distinguish visited links */ + color: #b3c7ff; +} +[data-md-color-scheme="slate"] .md-typeset a:hover, +[data-md-color-scheme="slate"] .md-typeset a:focus { + color: #c4ddff; +} +/* Also adjust visited links in navigation (sidebar, toc) */ +[data-md-color-scheme="slate"] .md-nav__link:visited { + color: #b3c7ff; +} + +/* Custom primary color for Material for MkDocs */ + +/* Light (default) scheme */ +:root { + /* Primary brand color used for header, active nav, etc. */ + --md-primary-fg-color: #002244; + /* Optional variants (used for hover/focus states) */ + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Dark (slate) scheme */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #002244; + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Increase logo size in the header */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 3.6rem; /* default is ~1.8rem; increase for better visibility */ + width: auto; +} + +/* Improve link readability in dark (slate) mode */ +[data-md-color-scheme="slate"] .md-typeset a:link { + /* Light blue for high contrast on dark backgrounds */ + color: #8ab4f8; +} +[data-md-color-scheme="slate"] .md-typeset a:visited { + /* Slightly desaturated/lighter to distinguish visited links */ + color: #b3c7ff; +} +[data-md-color-scheme="slate"] .md-typeset a:hover, +[data-md-color-scheme="slate"] .md-typeset a:focus { + color: #c4ddff; +} +/* Also adjust visited links in navigation (sidebar, toc) */ +[data-md-color-scheme="slate"] .md-nav__link:visited { + color: #b3c7ff; +} + +/* Footer styling: background #00152e and white text/links */ +.md-footer, +.md-footer__inner, +.md-footer-meta, +.md-footer-meta__inner { + background-color: #00152e !important; +} + +/* Ensure all footer text is white for readability */ +.md-footer, +.md-footer * { + color: #ffffff !important; +} + +/* Footer links states */ +.md-footer a, +.md-footer a:visited { + color: #ffffff !important; + text-decoration: underline; +} + +.md-footer a:hover, +.md-footer a:focus { + color: #ffffff !important; + opacity: 0.85; + text-decoration: underline; +} + +/* Reduce header logo size for better balance */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 1.6rem !important; /* smaller than the previous 3.6rem */ + width: auto; +} + +/* Emphasize active/selected links in navigation and tabs */ +/* Sidebar (primary) and Table of contents (secondary) active links */ +.md-nav__item .md-nav__link--active, +.md-nav__item .md-nav__link[aria-current="page"], +.md-nav__item .md-nav__link[aria-current="true"], +.md-nav--primary .md-nav__item--active > .md-nav__link, +.md-nav--secondary .md-nav__link--active, +.md-nav--secondary .md-nav__link[aria-current="true"], +/* Top navigation tabs */ +.md-tabs__link--active, +.md-tabs__link[aria-current="page"] { + font-weight: 700 !important; +} From 9e9976a55fb09adacb8fccc7b515a760b48d4e6e Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 15:21:07 +0000 Subject: [PATCH 33/49] Add initial project documentation with setup instructions and API overview - Created `Index.md` with comprehensive setup instructions, including certificate generation, Keycloak configuration, and API usage. - Included sections for prerequisites, quick start, profile-specific configuration, and security considerations. - Added details for building and testing the application locally and with Docker. - Documented mTLS setup, Keycloak realm configuration, and API authentication requirements. - Provided links to related documentation files such as database schema, MTLS configuration, and JaCoCo coverage. --- docs/{Index.md => index.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{Index.md => index.md} (100%) diff --git a/docs/Index.md b/docs/index.md similarity index 100% rename from docs/Index.md rename to docs/index.md From 61bd1c71b45230275a9e857cef57d44c389eec56 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 16:06:26 +0000 Subject: [PATCH 34/49] Update GitHub workflows to define permissions at the job level for improved security --- .github/workflows/maven.yml | 10 +++++++--- .github/workflows/release.yaml | 22 +++++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 355b150..4c0b9d6 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -29,15 +29,16 @@ on: - 'main' workflow_call: -permissions: - contents: read - pull-requests: read + env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" jobs: build: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v5 @@ -61,6 +62,9 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage lint: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fc39819..ed437c8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,14 +37,13 @@ on: default: false type: boolean -permissions: - contents: read - packages: write - id-token: write - jobs: # Make sure that the current code runs verify: + permissions: + contents: read + packages: write + id-token: write runs-on: ubuntu-latest outputs: project_version: ${{ steps.get-version.outputs.project_version }} @@ -74,6 +73,10 @@ jobs: publish: + permissions: + contents: read + packages: write + id-token: write name: Publish to github packages needs: verify runs-on: ubuntu-latest @@ -95,6 +98,10 @@ jobs: run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests release-ghcr: + permissions: + contents: read + packages: write + id-token: write name: "Build and release docker images to GHCR with tags '${{ inputs.image_tag }}, latest'" needs: verify uses: ./.github/workflows/docker-ghcr.yml @@ -106,6 +113,11 @@ jobs: docker_target: management-node cleanup: + permissions: + contents: read + packages: write + id-token: write + name: Artifact cleanup runs-on: ubuntu-latest needs: From e20e191671a87f66955816c0481021b0be540263 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 16:45:32 +0000 Subject: [PATCH 35/49] Update Maven workflow to include SonarCloud analysis during verification phase and adjust JaCoCo execution phase --- .github/workflows/maven.yml | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 4c0b9d6..3ff7473 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,12 +55,12 @@ jobs: - name: Build and Test env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS verify + run: ./mvnw $MAVEN_CLI_OPTS verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - name: Code Coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Pcoverage + run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar lint: permissions: contents: read diff --git a/pom.xml b/pom.xml index 4593ceb..dd2127f 100644 --- a/pom.xml +++ b/pom.xml @@ -269,7 +269,7 @@ report - test + verify check From bfc382297bcff6f1d2e223ae75aba88db54c63c6 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 16:51:58 +0000 Subject: [PATCH 36/49] Update Maven workflow to include SonarCloud analysis during verification phase and adjust JaCoCo execution phase --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 3ff7473..9da58f3 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -55,7 +55,7 @@ jobs: - name: Build and Test env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + run: ./mvnw $MAVEN_CLI_OPTS verify - name: Code Coverage env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} From ad2cbe30c8f2ae0bfb02333662d7721fbf959323 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 16:54:13 +0000 Subject: [PATCH 37/49] Update Maven workflow to include SonarCloud analysis during verification phase and adjust JaCoCo execution phase --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 9da58f3..1052e39 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -60,7 +60,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar lint: permissions: contents: read From 7443e38f1429332b726bffdcffeebbf47aad9e0d Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 16:58:23 +0000 Subject: [PATCH 38/49] Update Maven workflow to include SonarCloud analysis during verification phase and adjust JaCoCo execution phase --- .github/workflows/maven.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 1052e39..0d45dd2 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -61,6 +61,8 @@ jobs: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + - name: Verify JaCoCo XML exists + run: ls -l target/site/jacoco/jacoco.xml lint: permissions: contents: read From d5505c0813f2d75a2b35b86b94b0e546c562cc7b Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Tue, 30 Dec 2025 17:26:20 +0000 Subject: [PATCH 39/49] Update Maven workflow to include SonarCloud analysis during verification phase and adjust JaCoCo execution phase --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0d45dd2..aee93be 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -60,7 +60,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_federator2 -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - name: Verify JaCoCo XML exists run: ls -l target/site/jacoco/jacoco.xml lint: From 7f8bce05b6d960d66915933784eb5623631a17ef Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Wed, 31 Dec 2025 09:27:27 +0000 Subject: [PATCH 40/49] Minor cleanup in Maven workflow: remove extra newline in lint step --- .github/workflows/maven.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index aee93be..44a07c8 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -80,5 +80,4 @@ jobs: - name: Lint env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS spotless:check - + run: ./mvnw $MAVEN_CLI_OPTS spotless:check \ No newline at end of file From abd431a7923d4fcddc0f7eb5c2fd50edea490101 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Wed, 31 Dec 2025 09:33:59 +0000 Subject: [PATCH 41/49] Minor cleanup in Maven workflow: remove extra newline in lint step --- .github/workflows/maven.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 44a07c8..d06d31e 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -60,7 +60,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_federator2 -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - name: Verify JaCoCo XML exists run: ls -l target/site/jacoco/jacoco.xml lint: From 878f6330028b515a58d852d2f8502af87605b189 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 2 Jan 2026 13:58:32 +0000 Subject: [PATCH 42/49] Remove client secret dependency in Keycloak JWT converter and update resource configurations --- .../KeycloakJwtAuthenticationConverter.java | 18 +++++------------- src/main/resources/application.yml | 14 +++++++------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java index 447ecc0..34dffba 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -69,7 +69,6 @@ public class KeycloakJwtAuthenticationConverter implements Converter formData = new LinkedMultiValueMap<>(); formData.add(FORM_CLIENT_ID, clientId); - formData.add(FORM_CLIENT_SECRET, clientSecret); formData.add(FORM_TOKEN, tokenValue); // Create the request entity @@ -135,10 +127,10 @@ private JwtToken performTokenIntrospection(String tokenValue) throws TokenIntros @Override public AbstractAuthenticationToken convert(Jwt jwt) { try { - log.debug("Converting JWT to authentication token"); - + String client_id = jwt.getClaimAsString(CLAIM_AZP); + log.debug("Converting JWT to authentication token:{}", client_id); // Perform token introspection - JwtToken introspectionData = performTokenIntrospection(jwt.getTokenValue()); + JwtToken introspectionData = performTokenIntrospection(jwt.getTokenValue(),client_id); // Extract authorities from the introspection data Collection authorities = extractAuthoritiesFromIntrospection(introspectionData); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f864a44..4d629ac 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,13 +5,13 @@ spring: 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: account + issuer-uri: https://localhost:8443/realms/mng-node + jwk-set-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/certs + audiences: management-node opaquetoken: - introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect - client-secret: ${KEYCLOAK_CLIENTID:} # Client secret from keycloak - client-id: management-node # MANAGEMENT_NODE_CLIENT required client id for introspect endpoint + introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect + client-id: management-node + client-secret: flyway: create-schemas: on default-schema: mn @@ -26,7 +26,7 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect - show_sql: true + show_sql: false default_schema: mn # Server configuration From dc1d6fcf88eec118a6c4e0f3da3f7998aedd343f Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 2 Jan 2026 14:00:39 +0000 Subject: [PATCH 43/49] Remove client secret dependency in Keycloak JWT converter and update resource configurations --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4d629ac..f8b919b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,4 +68,4 @@ logging: 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" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n" \ No newline at end of file From 67d6ed439c2c5f00c436d67804d5808c3186be19 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 2 Jan 2026 14:03:23 +0000 Subject: [PATCH 44/49] Refactor Keycloak JWT converter: improve readability by renaming variables and fixing formatting inconsistencies --- .../config/KeycloakJwtAuthenticationConverter.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java index 34dffba..ff1d69b 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java @@ -85,7 +85,7 @@ public class KeycloakJwtAuthenticationConverter implements Converter authorities = extractAuthoritiesFromIntrospection(introspectionData); - // Extract client_id from introspection data + // Extract clientId from introspection data String tokenClientId = extractClientIdFromIntrospection(introspectionData); // Extract subject from introspection data From 55ba0dcdb1f64ee1ffc3126ca6a5012086a50037 Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 20 Feb 2026 13:42:46 +0000 Subject: [PATCH 45/49] build: Release v1.1.0: release Prep Release v1.1.0: Add job scheduling support, documentation site with MkDocs, enhanced GitHub Actions workflows, and Keycloak improvements. --- CHANGELOG.md | 22 ++++++++++++++++++++++ pom.xml | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1040ff1..680bf05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,29 @@ This project follows **Semantic Versioning (SemVer)** ([semver.org](https://semv --- +## [1.1.0] - 2026-02-20 +### Added +- Support for job scheduling with `schedule_type` and `schedule_expression` fields in configurations. +- `ProductType` domain entity and expanded `Product`, `Consumer`, and `ProductConsumer` models. +- Comprehensive documentation site using MkDocs, including setup guides, architecture overview, and API documentation. +- GitHub Actions workflows for: + - SonarCloud static code analysis and quality gate verification. + - Automated Docker image builds and deployment to GitHub Container Registry (GHCR). + - MkDocs documentation publishing. + - Automated release processes and tagging. +- Keycloak realm configuration for local development and testing. + +### Changed +- Refactored `KeycloakJwtAuthenticationConverter` to remove client secret dependency and improve security. +- Updated Maven workflow to include SonarCloud analysis and optimized JaCoCo reporting phases. +- Enhanced GitHub workflows with job-level permission definitions for improved security. +- Standardized pull request templates and repository metadata. +- Improved local development setup documentation and scripts. + +### Removed +- `OrganisationServiceImpl` and related tests, streamlining the service layer. +- Redundant Maven settings references in CI workflows. ## [1.0.1] - 2025-10-1 diff --git a/pom.xml b/pom.xml index dd2127f..5804698 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ @@ -14,7 +14,7 @@ uk.gov.dbt.ndtp.ia.management.node management-node - 1.0.1 + 1.1.0 jar management-node Provides Management capabilities over IA Node Net From 191cd409a7369d3f3ce815608d11a199b50755aa Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 20 Feb 2026 14:24:39 +0000 Subject: [PATCH 46/49] test: Add new test cases for producer and consumer configuration providers - Removed unused fields from `KeycloakJwtAuthenticationConverterTest`. - Added multiple test scenarios to `ProducerConverterTest` and `ConfigurationProviderImplTest` for improved coverage and validation. --- ...eycloakJwtAuthenticationConverterTest.java | 4 +- .../converter/impl/ProducerConverterTest.java | 28 +++++++- .../ConfigurationProviderImplTest.java | 66 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java index 21e8c6b..4b40b52 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -50,8 +50,6 @@ void setUp() { 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<>(); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java index e98f354..27c2dae 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -442,4 +442,30 @@ void toEntity_withNullDataProviderFromConverter_shouldNotAddToProducts() { // Verify only one data provider was added assertEquals(dataProviderId1, result.getProducts().get(0).getId()); } + + @Test + void toEntity_withNullId_shouldNotSetProducerId() { + // Arrange + dto.setId(null); + dataProviderDTOs.get(0).setProducerId(null); + + // Act + converter.toEntity(dto); + + // Assert + assertNull(dataProviderDTOs.get(0).getProducerId()); + } + + @Test + void toEntity_withExistingProducerId_shouldNotOverwrite() { + // Arrange + Long existingId = 999L; + dataProviderDTOs.get(0).setProducerId(existingId); + + // Act + converter.toEntity(dto); + + // Assert + assertEquals(existingId, dataProviderDTOs.get(0).getProducerId()); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index ef2635d..ee1cf80 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -1,3 +1,9 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; import static org.assertj.core.api.Assertions.assertThat; @@ -199,4 +205,64 @@ void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersA // pr2 has none assertThat(pr2.getConsumers()).isEmpty(); } + @Test + void getProducerConfigByClientId_withValidValidity_includesConsumer() { + String clientId = "producerClient"; + ProductDTO p1 = product(100L, "p1"); + ProducerDTO pr1 = producer(1L, true, p1); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); + + // Consumer with valid validity + ProductConsumerDTO pc1 = productConsumer(100L, 1L, new BigDecimal("20"), Instant.now().minusSeconds(86400 * 10)); + when(productConsumerService.findByDataProviderId(100L)).thenReturn(List.of(pc1)); + when(consumerService.findById(1L)).thenReturn(Optional.of(consumer(1L, "c1", "c1", null, null))); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()).hasSize(1); + } + + @Test + void getConsumerConfigByClientId_withConsumerId_filtersByConsumerId() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + ConsumerDTO c2 = consumer(2L, clientId, "c2", "CRON", "@daily"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, c2)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(1L)); + + assertThat(cfg.getName()).isEqualTo("c1"); + } + + @Test + void getProducerConfigByClientId_withProducerId_filtersByProducerId() { + String clientId = "producerClient"; + ProductDTO p1 = product(100L, "p1"); + ProductDTO p2 = product(101L, "p2"); + ProducerDTO pr1 = producer(1L, true, p1); + ProducerDTO pr2 = producer(2L, true, p2); + + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2)); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L)); + + assertThat(cfg.getProducers()).hasSize(1); + assertThat(cfg.getProducers().get(0).getId()).isEqualTo(1L); + } + + @Test + void getProducerConfigByClientId_withInvalidValidity_filtersOutConsumer() { + String clientId = "producerClient"; + ProductDTO p1 = product(100L, "p1"); + ProducerDTO pr1 = producer(1L, true, p1); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); + + // Consumer with expired validity + ProductConsumerDTO pc1 = productConsumer(100L, 1L, new BigDecimal("5"), Instant.now().minusSeconds(86400 * 10)); + when(productConsumerService.findByDataProviderId(100L)).thenReturn(List.of(pc1)); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()).isEmpty(); + } } From 1b03472eacdb7a0c5b06378431f85721ed970fec Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 20 Feb 2026 14:37:58 +0000 Subject: [PATCH 47/49] test: Add new test cases for producer and consumer configuration providers - Removed unused fields from `KeycloakJwtAuthenticationConverterTest`. - Added multiple test scenarios to `ProducerConverterTest` and `ConfigurationProviderImplTest` for improved coverage and validation. --- .../ConfigurationProviderImplTest.java | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index ee1cf80..2948fb4 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -205,21 +205,24 @@ void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersA // pr2 has none assertThat(pr2.getConsumers()).isEmpty(); } + @Test void getProducerConfigByClientId_withValidValidity_includesConsumer() { String clientId = "producerClient"; ProductDTO p1 = product(100L, "p1"); ProducerDTO pr1 = producer(1L, true, p1); when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); - + // Consumer with valid validity - ProductConsumerDTO pc1 = productConsumer(100L, 1L, new BigDecimal("20"), Instant.now().minusSeconds(86400 * 10)); + ProductConsumerDTO pc1 = + productConsumer(100L, 1L, new BigDecimal("20"), Instant.now().minusSeconds(86400 * 10)); when(productConsumerService.findByDataProviderId(100L)).thenReturn(List.of(pc1)); when(consumerService.findById(1L)).thenReturn(Optional.of(consumer(1L, "c1", "c1", null, null))); - + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()).hasSize(1); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) + .hasSize(1); } @Test @@ -241,11 +244,11 @@ void getProducerConfigByClientId_withProducerId_filtersByProducerId() { ProductDTO p2 = product(101L, "p2"); ProducerDTO pr1 = producer(1L, true, p1); ProducerDTO pr2 = producer(2L, true, p2); - + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1, pr2)); - + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(1L)); - + assertThat(cfg.getProducers()).hasSize(1); assertThat(cfg.getProducers().get(0).getId()).isEqualTo(1L); } @@ -256,13 +259,15 @@ void getProducerConfigByClientId_withInvalidValidity_filtersOutConsumer() { ProductDTO p1 = product(100L, "p1"); ProducerDTO pr1 = producer(1L, true, p1); when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); - + // Consumer with expired validity - ProductConsumerDTO pc1 = productConsumer(100L, 1L, new BigDecimal("5"), Instant.now().minusSeconds(86400 * 10)); + ProductConsumerDTO pc1 = + productConsumer(100L, 1L, new BigDecimal("5"), Instant.now().minusSeconds(86400 * 10)); when(productConsumerService.findByDataProviderId(100L)).thenReturn(List.of(pc1)); - + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()).isEmpty(); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) + .isEmpty(); } } From 896c5bc0f38fad4b5eeea6c5ba71473bbb29d89c Mon Sep 17 00:00:00 2001 From: nikan Negaresh Date: Fri, 20 Feb 2026 14:54:59 +0000 Subject: [PATCH 48/49] test: Add new test cases for producer and consumer configuration providers - Removed unused fields from `KeycloakJwtAuthenticationConverterTest`. - Added multiple test scenarios to `ProducerConverterTest` and `ConfigurationProviderImplTest` for improved coverage and validation. --- .../ManagementNodeApplicationTests.java | 20 ++++++ .../converter/EntityDtoConverterTest.java | 57 +++++++++++++++ .../management/model/jwt/JwtModelTest.java | 69 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverterTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtModelTest.java 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..909d014 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java @@ -0,0 +1,20 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +class ManagementNodeApplicationTests { + + @Test + void applicationInstanceTest() { + ManagementNodeApplication app = new ManagementNodeApplication(); + assertNotNull(app); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverterTest.java new file mode 100644 index 0000000..ad7384d --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverterTest.java @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class EntityDtoConverterTest { + + private final EntityDtoConverter stubConverter = new EntityDtoConverter<>() { + @Override + public Integer toDto(String entity) { + return entity == null ? null : Integer.parseInt(entity); + } + + @Override + public String toEntity(Integer dto) { + return dto == null ? null : String.valueOf(dto); + } + }; + + @Test + void testToDtoList() { + // Test null input + List resultNull = stubConverter.toDtoList(null); + assertNotNull(resultNull); + assertTrue(resultNull.isEmpty()); + + // Test non-null input + List result = stubConverter.toDtoList(List.of("1", "2")); + assertEquals(2, result.size()); + assertEquals(1, result.get(0)); + assertEquals(2, result.get(1)); + } + + @Test + void testToEntityList() { + // Test null input + List resultNull = stubConverter.toEntityList(null); + assertNotNull(resultNull); + assertTrue(resultNull.isEmpty()); + + // Test non-null input + List result = stubConverter.toEntityList(List.of(1, 2)); + assertEquals(2, result.size()); + assertEquals("1", result.get(0)); + assertEquals("2", result.get(1)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtModelTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtModelTest.java new file mode 100644 index 0000000..d96d22a --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtModelTest.java @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.jwt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JwtModelTest { + + @Test + void testEnhancedPrincipal() { + EnhancedPrincipal principal = new EnhancedPrincipal("user123", "client456"); + assertEquals("user123", principal.subject()); + assertEquals("client456", principal.clientId()); + String toString = principal.toString(); + assertTrue(toString.contains("user123")); + assertTrue(toString.contains("client456")); + } + + @Test + void testJwtToken() { + JwtToken token = JwtToken.builder() + .sub("subject") + .clientId("client") + .active(true) + .aud(List.of("aud1")) + .resourceAccess(Map.of( + "res", + JwtToken.ResourceAccess.builder() + .roles(List.of("role1")) + .build())) + .build(); + + assertEquals("subject", token.getSub()); + assertEquals("client", token.getClientId()); + assertTrue(token.getActive()); + assertEquals(List.of("aud1"), token.getAud()); + assertNotNull(token.getResourceAccess()); + assertEquals(List.of("role1"), token.getResourceAccess().get("res").getRoles()); + + // Exercise toString, equals, and hashCode via Lombok + assertNotNull(token.toString()); + JwtToken token2 = JwtToken.builder() + .sub("subject") + .clientId("client") + .active(true) + .aud(List.of("aud1")) + .resourceAccess(Map.of( + "res", + JwtToken.ResourceAccess.builder() + .roles(List.of("role1")) + .build())) + .build(); + assertEquals(token, token2); + assertEquals(token.hashCode(), token2.hashCode()); + + JwtToken emptyToken = new JwtToken(); + assertNotNull(emptyToken); + } +} From a98da9eb3996e83e40429167635d18660e4007da Mon Sep 17 00:00:00 2001 From: nikan-negaresh-informed <84400913+nikan-negaresh-informed@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:55:26 +0000 Subject: [PATCH 49/49] feat(OSPO): synchronise OSPO workflows --- .github/workflows/oss-checker.yml | 156 +++++++++++++++++-- .github/workflows/publish-github-release.yml | 56 +++++-- 2 files changed, 179 insertions(+), 33 deletions(-) diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index e940929..6033c7d 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -13,15 +13,14 @@ on: - unlabeled workflow_dispatch: -permissions: - contents: read - jobs: oss-checks: - if: github.actor != 'dependabot[bot]' && + permissions: + contents: read + if: github.actor != 'dependabot[bot]' && (github.event.repository.private == false || - (github.event.repository.private == true && - contains(join(github.event.pull_request.labels.*.name), 'oss-preparation'))) + (github.event.repository.private == true && + contains(join(github.event.pull_request.labels.*.name), 'oss-preparation'))) runs-on: ubuntu-latest outputs: summary-table: ${{ steps.summarise_results.outputs.summaryTable }} @@ -30,7 +29,7 @@ jobs: steps: - name: Fetch GitHub App token for target repo id: target_token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -38,7 +37,7 @@ jobs: - name: Fetch GitHub App token for OSPO source repo (read-only) id: ospo_token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -47,25 +46,147 @@ jobs: permission-contents: read - name: Checkout target repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.target_token.outputs.token }} - name: Checkout OSPO source repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: National-Digital-Twin/ospo-resources path: ospo-resources token: ${{ steps.ospo_token.outputs.token }} - name: Checkout archetypes source repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: National-Digital-Twin/archetypes path: archetypes + - name: Fetch Repository Metadata + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + with: + script: | + const { owner, repo } = context.repo; + const { writeFileSync } = require('fs'); + + // Check specifically for 'develop' branch existence + let hasDevelopBranch = false; + try { + await github.rest.repos.getBranch({ + owner, + repo, + branch: 'develop', + }); + hasDevelopBranch = true; + } catch (error) { + if (error.status !== 404) { + core.warning(`Error checking for develop branch: ${error.message}`); + } + } + + const metadata = { + repository: { + defaultBranch: process.env.DEFAULT_BRANCH, + hasDevelopBranch: hasDevelopBranch + } + }; + + const rawMetadata = JSON.stringify(metadata, null, 2); + + core.info('Content for repository-metadata.json:'); + core.info(rawMetadata); + + writeFileSync('repository-metadata.json', rawMetadata); + core.info('Generated repository-metadata.json for policy context.'); + + - name: Install Conftest + run: | + LATEST_VERSION=$(curl --proto "=https" -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+') + curl --proto "=https" -L "https://github.com/open-policy-agent/conftest/releases/download/v${LATEST_VERSION}/conftest_${LATEST_VERSION}_Linux_x86_64.tar.gz" | tar -xz + sudo mv conftest /usr/local/bin/ + + - name: Run Policy Checks + id: run_conftest + run: | + conftest test .github/dependabot.yml \ + -p ospo-resources/tools/policy-as-code/policy \ + --data repository-metadata.json \ + --namespace github.dependabot \ + --output json > policy-report.json || true + + - name: Process Policy Results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { existsSync, readFileSync, writeFileSync } = require('fs'); + + let resultJson = []; + if (existsSync('policy-report.json')) { + try { + const rawContent = readFileSync('policy-report.json', 'utf8'); + if (rawContent.trim()) { + resultJson = JSON.parse(rawContent); + } + } catch(e) { + core.error(`Failed to parse policy-report.json: ${e.message}`); + core.setFailed(`Failed to parse policy-report.json: ${e.message}`); + return; + } + } + + const results = resultJson.map(r => { + const failureReasons = (r.failures || []).map(f => f.msg); + return { + path: r.filename, + status: failureReasons.length > 0 ? 'failed' : 'passed', + failureReasons: failureReasons, + checks: { + namespace: r.namespace, + successes: r.successes + } + }; + }); + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const commitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown'; + + const report = { + runMetadata: { + timestamp: new Date().toISOString(), + repo: repoFullName, + commit: commitSha, + checkType: 'policy', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'policy-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote policy summary to ${reportPath}`); + + if (failed > 0) { + core.setFailed('Policy checks failed for one or more files.'); + } else { + core.info('All policy checks passed.'); + } + - name: Test for presence of OSS files and variation from templated content - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + if: success() || failure() with: script: | const { existsSync, readFileSync, writeFileSync } = require('fs'); @@ -163,7 +284,7 @@ jobs: } - name: Check GitHub template files are present - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 if: success() || failure() with: script: | @@ -234,7 +355,7 @@ jobs: - name: Generate summary id: summarise_results if: always() - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | const { existsSync, readFileSync } = require('fs'); @@ -242,6 +363,7 @@ jobs: const reportFiles = [ 'oss-results.json', 'template-results.json', + 'policy-results.json', ]; const reports = reportFiles @@ -327,13 +449,14 @@ jobs: - name: Upload OSS result artifacts if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: oss-checks-${{ github.run_id }} retention-days: 30 path: | oss-results.json template-results.json + policy-results.json comment-on-results: needs: oss-checks @@ -344,11 +467,12 @@ jobs: runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - name: Comment with OSS summary - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: SUMMARY_TABLE: ${{ needs.oss-checks.outputs.summary-table }} JOB_RESULT: ${{ needs.oss-checks.result }} diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 90ff017..9832c9d 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -2,7 +2,7 @@ # © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. # This workflow is triggered when a pull request is merged into the main branch -# from a release/* branch. It extracts the release version from the source branch, +# from a release/* or hotfix/* branch. It extracts the release version from the source branch, # generates a Software Bill of Materials (SBOM) using the GitHub API, # creates a Git tag with the version, and publishes a GitHub release including the SBOM file. @@ -15,12 +15,13 @@ on: branches: - main -permissions: - contents: write - jobs: versioning: - if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') + if: | + github.event.pull_request.merged == true && + (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) + permissions: + contents: read name: Extract Release Version runs-on: ubuntu-latest outputs: @@ -28,8 +29,10 @@ jobs: steps: - name: Extract Version from Source Branch Name id: extract_version + env: + HEAD_REF: ${{ github.head_ref }} run: | - SOURCE_BRANCH="${{ github.head_ref }}" + SOURCE_BRANCH="$HEAD_REF" VERSION=$(echo "$SOURCE_BRANCH" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+') if [ -z "$VERSION" ]; then @@ -41,71 +44,90 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Validate Version Format (Semantic Versioning) + env: + VERSION: ${{ env.VERSION }} run: | - if [[ ! "${{ env.VERSION }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Invalid version format found. Expected semantic version in release branch name (e.g., release/0.9.0)" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format found. Expected semantic version in release or hotfix branch name (e.g., release/0.9.0 or hotfix/0.9.1)" exit 1 fi - name: Print Tag Version + id: print_tag + env: + EXTRACTED_VERSION: ${{ steps.extract_version.outputs.version }} run: | - echo "Identified release semantic version: ${{ steps.extract_version.outputs.version }}" + echo "Identified release semantic version: $EXTRACTED_VERSION" generate-sbom: + permissions: + contents: read name: Generate SPDX SBOM runs-on: ubuntu-latest needs: [versioning] steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate SPDX SBOM + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} run: | # Call GitHub API to generate SBOM api_response=$(curl -sSL \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "$GITHUB_API_URL/repos/${{ github.repository }}/dependency-graph/sbom") + "$GITHUB_API_URL/repos/$REPO/dependency-graph/sbom") # Extract nested "sbom" object into a valid SPDX file echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sbom path: sbom.spdx.json create-git-tag: + permissions: + contents: write name: Create Git Tag needs: [versioning, generate-sbom] runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Create Git Tag - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 with: tag: "v${{ needs.versioning.outputs.version }}" message: "Release v${{ needs.versioning.outputs.version }}" force_push_tag: true + # Tag the HEAD commit from the merged release branch not the merge commit to + # ensure the tag points to the correct source code state for the release. + # This ensures that the release tag is also visible on any branch which does + # not contain the merge commit such as develop. + commit_sha: ${{ github.event.pull_request.head.sha }} create-git-release: + permissions: + contents: write name: Create GitHub Release needs: [versioning, generate-sbom, create-git-tag] runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sbom - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: tag_name: "v${{ needs.versioning.outputs.version }}" name: "Release v${{ needs.versioning.outputs.version }}"