From d0231654b82db46e9dbd961dd8033f4ec777fcc1 Mon Sep 17 00:00:00 2001 From: Nikan Negaresh <84400913+nikan-negaresh-informed@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:20:12 +0000 Subject: [PATCH 01/18] Add Vault PKI service with enhancements and tests (#42) * feat(DPAV-2437): Add Vault-integrated PKI service for certificate management with tests - Implements `VaultPkiService` for managing PKI operations via HashiCorp Vault. - Adds supporting DTOs (`CreateKeyRequestDTO`, `CreateKeyResponseDTO`, `CreateCsrResponseDTO`, `IntermediateCertResponseDTO`, etc.). - Includes a controller for exposing PKI-related endpoints. - Adds unit and integration tests for the service and controller. - Updates configurations for Vault integration via a dedicated mount point. - Adds `docker-compose` setup for an isolated Vault instance. Refs: DPAV-2437 * feat(DPAV-2437): Add Vault-integrated PKI service for certificate management with tests - Implements `VaultPkiService` for managing PKI operations via HashiCorp Vault. - Adds supporting DTOs (`CreateKeyRequestDTO`, `CreateKeyResponseDTO`, `CreateCsrResponseDTO`, `IntermediateCertResponseDTO`, etc.). - Includes a controller for exposing PKI-related endpoints. - Adds unit and integration tests for the service and controller. - Updates configurations for Vault integration via a dedicated mount point. - Adds `docker-compose` setup for an isolated Vault instance. Refs: DPAV-2437 * Refactor: Standardize formatting, optimize imports, and enhance Vault PKI service functionality - Reformatted code for consistency and removed unused imports across multiple modules. - Enhanced `signCsr` method in `VaultPkiService` to use default role and TTL when not provided. - Updated related tests to validate role and TTL fallback functionality. - Applied minor cleanups in DTOs, exception handlers, and test classes for clarity. Refs: DPAV-2437 * Refactor: Standardize formatting, optimize imports, and enhance Vault PKI service functionality - Reformatted code for consistency and removed unused imports across multiple modules. - Enhanced `signCsr` method in `VaultPkiService` to use default role and TTL when not provided. - Updated related tests to validate role and TTL fallback functionality. - Applied minor cleanups in DTOs, exception handlers, and test classes for clarity. Refs: DPAV-2437 * Refactor: Standardize formatting, optimize imports, and enhance Vault PKI service functionality - Reformatted code for consistency and removed unused imports across multiple modules. - Enhanced `signCsr` method in `VaultPkiService` to use default role and TTL when not provided. - Updated related tests to validate role and TTL fallback functionality. - Applied minor cleanups in DTOs, exception handlers, and test classes for clarity. Refs: DPAV-2437 * Refactor: Standardize formatting, optimize imports, and enhance Vault PKI service functionality - Reformatted code for consistency and removed unused imports across multiple modules. - Enhanced `signCsr` method in `VaultPkiService` to use default role and TTL when not provided. - Updated related tests to validate role and TTL fallback functionality. - Applied minor cleanups in DTOs, exception handlers, and test classes for clarity. Refs: DPAV-2437 * Refactor: Standardize formatting, optimize imports, and enhance Vault PKI service functionality - Reformatted code for consistency and removed unused imports across multiple modules. - Enhanced `signCsr` method in `VaultPkiService` to use default role and TTL when not provided. - Updated related tests to validate role and TTL fallback functionality. - Applied minor cleanups in DTOs, exception handlers, and test classes for clarity. Refs: DPAV-2437 * Refactor: Remove `@Builder` from DTOs and standardize Lombok annotations - Removed `@Builder` annotations from multiple DTOs for alignment with project conventions. - Explicitly added required Lombok annotations (`@Getter`, `@Setter`, `@NoArgsConstructor`, `@AllArgsConstructor`). - Removed unnecessary exception classes from test coverage exclusions in `pom.xml`. * Add `@Builder` annotation to certificate DTOs for streamlined object construction --- .github/workflows/publish-mkdocs.yml | 2 +- docker/vault/docker-compose.yaml | 24 ++ pom.xml | 30 +- .../controller/v1/CertificateController.java | 139 +++++++++ .../management/exception/PkiException.java | 32 ++ .../handlers/GlobalExceptionHandler.java | 41 ++- .../management/model/dto/AttributesDTO.java | 8 +- .../management/model/dto/ConsumerDTO.java | 8 +- .../management/model/dto/ProducerDTO.java | 8 +- .../model/dto/ProductConsumerDTO.java | 8 +- .../dto/certificates/CertificateInfoDTO.java | 29 ++ .../dto/certificates/CreateCsrRequestDTO.java | 29 ++ .../certificates/CreateCsrResponseDTO.java | 23 ++ .../dto/certificates/CreateKeyRequestDTO.java | 23 ++ .../certificates/CreateKeyResponseDTO.java | 25 ++ .../IntermediateCertResponseDTO.java | 24 ++ .../dto/certificates/SignCertRequestDTO.java | 22 ++ .../dto/certificates/SignCertResponseDTO.java | 27 ++ .../certificate/VaultPkiService.java | 287 ++++++++++++++++++ .../ConfigurationProviderImpl.java | 72 ++++- .../utils/cryptography/PemUtil.java | 105 +++++++ src/main/resources/application.yml | 13 +- ...tAuthenticationConverterExceptionTest.java | 60 ++-- .../v1/CertificateControllerTest.java | 116 +++++++ .../handlers/GlobalExceptionHandlerTest.java | 22 +- .../persistency/entity/ProductTypeTest.java | 26 ++ .../certificate/VaultPkiServiceTest.java | 262 ++++++++++++++++ .../ConfigurationProviderImplTest.java | 33 +- .../utils/cryptography/PemUtilTest.java | 52 ++++ src/test/resources/application.yml | 3 + 30 files changed, 1473 insertions(+), 80 deletions(-) create mode 100644 docker/vault/docker-compose.yaml create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java diff --git a/.github/workflows/publish-mkdocs.yml b/.github/workflows/publish-mkdocs.yml index 03ef20a..0cb4072 100644 --- a/.github/workflows/publish-mkdocs.yml +++ b/.github/workflows/publish-mkdocs.yml @@ -80,7 +80,7 @@ jobs: 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 + - uses: actions/cache@v5 with: key: mkdocs-material-${{ env.cache_id }} path: ~/.cache diff --git a/docker/vault/docker-compose.yaml b/docker/vault/docker-compose.yaml new file mode 100644 index 0000000..ad5f94a --- /dev/null +++ b/docker/vault/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + vault: + image: hashicorp/vault:1.16 + container_name: vault + restart: unless-stopped + + cap_add: + - IPC_LOCK + + environment: + VAULT_ADDR: "http://127.0.0.1:8200" + VAULT_API_ADDR: "http://0.0.0.0:8200" + + ports: + - "8200:8200" + + volumes: + - vault-data:/vault/file + - ./vault/config:/vault/config:ro + + command: vault server -config=/vault/config/vault.hcl + +volumes: + vault-data: diff --git a/pom.xml b/pom.xml index 6c6e18b..183b632 100644 --- a/pom.xml +++ b/pom.xml @@ -55,11 +55,11 @@ 3.2.0 5.10.0 2.8.13 + 1.83 **/config/**, - **/exception/**, - **/dto/**, - **/entity/**, - **/ManagementNodeApplication.java + **/dto/**, + **/entity/**, + **/ManagementNodeApplication.java @@ -99,6 +99,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.cloud + spring-cloud-starter-vault-config + org.springframework.boot spring-boot-starter-web @@ -122,6 +126,16 @@ flyway-database-postgresql ${flyway.version} + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + org.springframework.boot spring-boot-devtools @@ -197,10 +211,7 @@ org.apache.maven.plugins maven-surefire-plugin - - - true - + com.diffplug.spotless @@ -258,7 +269,6 @@ uk/gov/dbt/ndtp/ia/node/management/model/dto/** uk/gov/dbt/ndtp/ia/node/management/entity/** uk/gov/dbt/ndtp/ia/node/management/config/** - uk/gov/dbt/ndtp/ia/node/management/exception/** uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplication.java @@ -311,7 +321,7 @@ CLASS COVEREDRATIO - 0.50 + 0.80 diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java new file mode 100644 index 0000000..dbfea83 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -0,0 +1,139 @@ +/* + * 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.controller.v1; + +import io.swagger.v3.oas.annotations.Operation; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; + +/** + * Controller for certificate and PKI management. + * Provides endpoints for creating key pairs, CSRs, and signing certificates. + */ +@RestController +@RequestMapping("/api/v1/certificate") +@Slf4j +@Tag(name = "Certificate", description = "Endpoints for certificate management.") +public class CertificateController { + + private final VaultPkiService pkiService; + + public CertificateController(VaultPkiService pkiService) { + this.pkiService = pkiService; + } + + /** + * Creates RSA key pair using configured PKI service. + * + * @return a DTO containing the generated public and private keys in PEM format + */ + @GetMapping("/keyPair") + @Operation( + summary = "Create RSA key pair", + description = "Creates a new RSA 2048-bit key pair using the configured PKI service.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "RSA key pair created successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = CreateKeyResponseDTO.class))) + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public CreateKeyResponseDTO createKeyPair() { + CreateKeyRequestDTO rsaRequest = + CreateKeyRequestDTO.builder().keySize(2048).algorithm("RSA").build(); + return pkiService.createKeyPair(rsaRequest.getAlgorithm(), rsaRequest.getKeySize()); + } + + /** + * Creates a Certificate Signing Request (CSR) from provided public and private keys. + * + * @param req the CSR request DTO containing keys and subject details + * @return a DTO containing the generated CSR PEM + */ + @PostMapping("/csr/create") + @Operation( + summary = "Create Certificate Signing Request (CSR)", + description = "Generates a CSR from the provided public and private keys and subject information.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "CSR created successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = CreateCsrResponseDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public CreateCsrResponseDTO createCsr(@RequestBody CreateCsrRequestDTO req) { + return pkiService.createCsr(req); + } + + /** + * Signs a CSR using the default role and TTL. + * + * @param req the sign request DTO containing the CSR PEM + * @return a DTO containing the signed certificate and its chain + */ + @PostMapping("/csr/sign") + @Operation( + summary = "Sign CSR", + description = + "Signs the provided CSR using the PKI service. Uses the provided role or default if not specified. Uses default TTL if not specified.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "CSR signed successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = SignCertResponseDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid CSR or parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public SignCertResponseDTO signCsr(@RequestBody SignCertRequestDTO req) { + return pkiService.signCsr(req.getCsr(), Optional.empty(), Optional.empty()); + } + + /** + * Retrieves the intermediate certificate and its chain. + * + * @return a DTO containing the PEM certificate, CA chain, and parsed info + */ + @GetMapping("/intermediate") + @Operation( + summary = "Get intermediate certificate", + description = "Retrieves the configured intermediate certificate and its CA chain.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "Intermediate certificate retrieved successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = IntermediateCertResponseDTO.class))) + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public IntermediateCertResponseDTO getIntermediateCertificate() { + return pkiService.getIntermediateCertificate(); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java new file mode 100644 index 0000000..56a7ab7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java @@ -0,0 +1,32 @@ +/* + * 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.exception; + +/** + * Exception thrown for errors during PKI and certificate management operations. + */ +public class PkiException extends RuntimeException { + + /** + * Constructs a new PkiException with the specified detail message. + * + * @param message the detail message + */ + public PkiException(String message) { + super(message); + } + + /** + * Constructs a new PkiException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + */ + public PkiException(String message, Throwable cause) { + super(message, cause); + } +} 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 cd29bbe..5862496 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 @@ -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. */ @@ -15,8 +15,10 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; /** * Global exception handler for the application. @@ -65,6 +67,43 @@ public ResponseEntity handleAuthenticationProcessingException( return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); } + /** + * Handles NoResourceFoundException. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 404 error message + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug("Resource not found, error_id={}, path={}: ", errorId, request.getContextPath(), ex); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.NOT_FOUND.value(), "Resource not found: " + ex.getResourcePath(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(PkiException.class) + public ResponseEntity handlePkiException(PkiException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.error( + "PKI exception occurred, error_id={}, path={}: {}", + errorId, + request.getDescription(false), + ex.getMessage(), + ex); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "PKI/Certificate error: " + ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + /** * Handles RuntimeException. * diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java index 77f55a1..5ba2214 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java @@ -1,12 +1,16 @@ /* * 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. */ package uk.gov.dbt.ndtp.ia.node.management.model.dto; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Builder @Getter 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 bcf3d6a..6ec60ff 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 @@ -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. */ @@ -9,7 +9,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for consumerId entity. diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java index dcbdac7..aaa858f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.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. */ @@ -10,7 +10,11 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for OrganisationProducer entity. 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 26baef0..bf1ac6f 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 @@ -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. */ @@ -11,7 +11,11 @@ import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for ConsumerAllowedDataProvider entity. diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java new file mode 100644 index 0000000..fdaa800 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java @@ -0,0 +1,29 @@ +/* + * 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.dto.certificates; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CertificateInfoDTO { + private String subject; + private String issuer; + private String serialNumber; + private Instant notBefore; + private Instant notAfter; + private String signatureAlgorithm; + private Integer version; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java new file mode 100644 index 0000000..060a771 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java @@ -0,0 +1,29 @@ +/* + * 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.dto.certificates; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateCsrRequestDTO { + private String publicKeyPem; + private String privateKeyPem; + private String commonName; + private String organization; + private String organizationalUnit; + private String country; + private List dnsSans; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java new file mode 100644 index 0000000..27c230d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java @@ -0,0 +1,23 @@ +/* + * 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.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateCsrResponseDTO { + private String csrId; + private String csrPem; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java new file mode 100644 index 0000000..7578bb7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java @@ -0,0 +1,23 @@ +/* + * 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.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateKeyRequestDTO { + private String algorithm; + private Integer keySize; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java new file mode 100644 index 0000000..8fb02c7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java @@ -0,0 +1,25 @@ +/* + * 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.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateKeyResponseDTO { + private String privateKeyPem; + private String publicKeyPem; + private String algorithm; + private String createdAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java new file mode 100644 index 0000000..7016b09 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java @@ -0,0 +1,24 @@ +/* + * 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.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class IntermediateCertResponseDTO { + private String certificate; + private Object caChain; + private CertificateInfoDTO info; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java new file mode 100644 index 0000000..5b111a8 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java @@ -0,0 +1,22 @@ +/* + * 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.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SignCertRequestDTO { + private String csr; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java new file mode 100644 index 0000000..7ddda17 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java @@ -0,0 +1,27 @@ +/* + * 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.dto.certificates; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SignCertResponseDTO { + private String certificate; + private List caChain; + private String issuingCa; + private String serialNumber; + private Number expiration; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java new file mode 100644 index 0000000..e9924eb --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java @@ -0,0 +1,287 @@ +/* + * 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.certificate; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +/** + * Service for managing PKI operations using HashiCorp Vault. + * Provides functionality for creating key pairs, CSRs, signing CSRs, and retrieving intermediate certificates. + */ +@Service +@Slf4j +public class VaultPkiService { + + private final VaultTemplate vault; + private final String defaultRole; + private final String defaultTtl; + + // Vault paths + private final String pathSign; + private final String pathCertCa; + private final String pathCertCaChain; + + // Vault response keys + private static final String KEY_CERTIFICATE = "certificate"; + private static final String KEY_CA_CHAIN = "ca_chain"; + private static final String KEY_ISSUING_CA = "issuing_ca"; + private static final String KEY_SERIAL_NUMBER = "serial_number"; + private static final String KEY_EXPIRATION = "expiration"; + + // Request parameter keys + private static final String PARAM_CSR = "csr"; + private static final String PARAM_TTL = "ttl"; + private static final String PARAM_FORMAT = "format"; + + public VaultPkiService( + VaultTemplate vault, + @Value("${application.vault.pki-mount:pki-int}") String pkiMount, + @Value("${application.vault.default-role:default-role}") String defaultRole, + @Value("${application.vault.default-ttl:24h}") String defaultTtl) { + this.vault = vault; + this.defaultRole = defaultRole; + this.defaultTtl = defaultTtl; + this.pathSign = pkiMount + "/sign/"; + this.pathCertCa = pkiMount + "/cert/ca"; + this.pathCertCaChain = pkiMount + "/cert/ca_chain"; + } + + /** + * Creates a new RSA or specified algorithm key pair. + * + * @param algorithm the key algorithm (defaults to RSA if null or blank) + * @param keySize the size of the key (defaults to 2048 if null) + * @return a DTO containing the public and private key in PEM format + * @throws PkiException if key pair generation fails + */ + public CreateKeyResponseDTO createKeyPair(String algorithm, Integer keySize) { + String alg = (algorithm == null || algorithm.isBlank()) ? "RSA" : algorithm; + int size = (keySize == null) ? 2048 : keySize; + + log.info("Creating key pair with algorithm: {} and size: {}", alg, size); + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg); + if ("RSA".equalsIgnoreCase(alg)) { + kpg.initialize(size); + } + KeyPair kp = kpg.generateKeyPair(); + + String privateKeyPem = PemUtil.toPem("PRIVATE KEY", kp.getPrivate().getEncoded()); + String publicKeyPem = PemUtil.toPem("PUBLIC KEY", kp.getPublic().getEncoded()); + + return CreateKeyResponseDTO.builder() + .createdAt(Instant.now().toString()) + .algorithm(alg) + .publicKeyPem(publicKeyPem) + .privateKeyPem(privateKeyPem) + .build(); + } catch (NoSuchAlgorithmException e) { + log.error("Failed to create key pair: algorithm {} not found", alg, e); + throw new PkiException("Key pair generation failed: " + alg, e); + } + } + + /** + * Creates a Certificate Signing Request (CSR) from provided public and private keys and subject information. + * + * @param req the CSR request DTO containing keys and subject details + * @return a DTO containing the generated CSR PEM and a unique ID + * @throws PkiException if CSR creation or signing fails + */ + public CreateCsrResponseDTO createCsr(CreateCsrRequestDTO req) { + log.info("Creating CSR for common name: {}", req.getCommonName()); + try { + String privateKeyPem = req.getPrivateKeyPem(); + String publicKeyPem = req.getPublicKeyPem(); + + PrivateKey privateKey = PemUtil.parsePkcs8PrivateKey(privateKeyPem); + var publicKey = PemUtil.parsePublicKey(publicKeyPem); + + // Build subject + String subject = String.format( + "CN=%s, OU=%s, O=%s, C=%s", + safe(req.getCommonName()), + safe(req.getOrganizationalUnit()), + safe(req.getOrganization()), + safe(req.getCountry())); + X500Name x500 = new X500Name(subject); + + // CSR builder + JcaPKCS10CertificationRequestBuilder csrBuilder = new JcaPKCS10CertificationRequestBuilder(x500, publicKey); + + // SANs + if (req.getDnsSans() != null && !req.getDnsSans().isEmpty()) { + log.debug("Adding DNS SANs to CSR: {}", req.getDnsSans()); + GeneralNames sans = new GeneralNames(req.getDnsSans().stream() + .map(d -> new GeneralName(GeneralName.dNSName, d)) + .toArray(GeneralName[]::new)); + csrBuilder.addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, + new Extensions(new Extension(Extension.subjectAlternativeName, false, sans.getEncoded()))); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(privateKey); + PKCS10CertificationRequest csr = csrBuilder.build(signer); + + String csrPem = PemUtil.toPem("CERTIFICATE REQUEST", csr.getEncoded()); + String csrId = UUID.randomUUID().toString(); + + return new CreateCsrResponseDTO(csrId, csrPem); + } catch (Exception e) { + log.error("Failed to create CSR for subject common name: {}", req.getCommonName(), e); + throw new PkiException("CSR creation failed", e); + } + } + + /** + * Signs a CSR using the specified role in Vault. + * + * @param csrPem the CSR in PEM format + * @param role the Vault PKI role to use for signing + * @param ttl the requested Time To Live for the certificate + * @return a DTO containing the signed certificate and its chain + * @throws PkiException if signing fails or Vault returns an empty response + */ + public SignCertResponseDTO signCsr(String csrPem, Optional role, Optional ttl) { + String effectiveRole = role.filter(StringUtils::isNotBlank).orElse(defaultRole); + String effectiveTtl = ttl.filter(StringUtils::isNotBlank).orElse(defaultTtl); + log.info("Signing CSR with role: {} and TTL: {}", effectiveRole, effectiveTtl); + + if (StringUtils.isEmpty(csrPem)) { + log.warn("Sign CSR request failed: CSR PEM is empty"); + throw new PkiException("CSR PEM cannot be empty"); + } + if (StringUtils.isEmpty(effectiveRole)) { + log.warn("Sign CSR request failed: Role is empty"); + throw new PkiException("Role cannot be empty"); + } + + String path = pathSign + effectiveRole; + + Map body = new HashMap<>(); + body.put(PARAM_CSR, csrPem); + if (effectiveTtl != null && !effectiveTtl.isBlank()) { + body.put(PARAM_TTL, effectiveTtl); + } + body.put(PARAM_FORMAT, "pem"); + + try { + VaultResponse resp = vault.write(path, body); + if (Optional.ofNullable(resp).map(VaultResponse::getData).isEmpty()) { + log.error("Vault returned empty response for CSR signing at path: {}", path); + throw new PkiException("No response from Vault PKI sign endpoint"); + } + + log.info("Successfully signed CSR with role: {}", effectiveRole); + Object caChainObj = resp.getData().get(KEY_CA_CHAIN); + List caChain = Collections.emptyList(); + if (caChainObj instanceof List list) { + caChain = list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + + return new SignCertResponseDTO( + resp.getData().get(KEY_CERTIFICATE).toString(), + caChain, + resp.getData().get(KEY_ISSUING_CA).toString(), + resp.getData().get(KEY_SERIAL_NUMBER).toString(), + (Number) resp.getData().get(KEY_EXPIRATION)); + } catch (Exception e) { + if (e instanceof PkiException) { + throw e; + } + log.error("Error occurred while signing CSR with role: {}", effectiveRole, e); + throw new PkiException("Failed to sign CSR via Vault", e); + } + } + + /** + * Retrieves the intermediate certificate and its chain from Vault. + * Parses the certificate to provide metadata in the response. + * + * @return a DTO containing the PEM certificate, CA chain, and parsed info + * @throws PkiException if certificate retrieval or parsing fails + */ + public IntermediateCertResponseDTO getIntermediateCertificate() { + log.info("Retrieving intermediate certificate from Vault"); + String path = pathCertCa; + VaultResponse resp = vault.read(path); + if (Optional.ofNullable(resp).map(VaultResponse::getData).isEmpty()) { + log.error("Failed to retrieve intermediate certificate from path: {}", path); + throw new PkiException("Could not retrieve intermediate certificate from Vault"); + } + + String pathToCaChain = pathCertCaChain; + VaultResponse caChainResp = vault.read(pathToCaChain); + if (Optional.ofNullable(caChainResp).map(VaultResponse::getData).isEmpty()) { + log.error("Failed to retrieve CA chain from path: {}", pathToCaChain); + throw new PkiException("Could not retrieve CA Chain certificate from Vault"); + } + + String certificatePem = (String) resp.getData().get(KEY_CERTIFICATE); + CertificateInfoDTO info; + try { + X509Certificate x509 = PemUtil.parseCertificate(certificatePem); + info = CertificateInfoDTO.builder() + .subject(x509.getSubjectX500Principal().getName()) + .issuer(x509.getIssuerX500Principal().getName()) + .serialNumber(x509.getSerialNumber().toString()) + .notBefore(x509.getNotBefore().toInstant()) + .notAfter(x509.getNotAfter().toInstant()) + .signatureAlgorithm(x509.getSigAlgName()) + .version(x509.getVersion()) + .build(); + log.debug("Successfully parsed intermediate certificate: {}", info.getSubject()); + } catch (Exception e) { + log.error("Failed to parse intermediate certificate PEM", e); + throw new PkiException("Error parsing intermediate certificate", e); + } + + return IntermediateCertResponseDTO.builder() + .certificate(certificatePem) + .caChain(caChainResp.getData().get(KEY_CA_CHAIN)) + .info(info) + .build(); + } + + /** + * Safely formats a subject component by replacing commas with spaces. + * + * @param s the string to safe format + * @return the safe string or empty string if null + */ + private static String safe(String s) { + return (s == null) ? "" : s.replace(",", " "); + } +} 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 68e2880..eff22aa 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 @@ -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. */ @@ -18,6 +18,9 @@ import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; +/** + * Implementation of {@link ConfigurationProvider} that retrieves configuration from database services. + */ @Service public class ConfigurationProviderImpl implements ConfigurationProvider { @@ -27,6 +30,13 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final ProducerService producerService; + /** + * Constructs a new ConfigurationProviderImpl with required services. + * + * @param consumerService the consumer service + * @param consumerAllowedDataProviders the product consumer service + * @param producerService the producer service + */ public ConfigurationProviderImpl( ConsumerService consumerService, ProductConsumerService consumerAllowedDataProviders, @@ -37,6 +47,13 @@ public ConfigurationProviderImpl( this.producerService = producerService; } + /** + * Checks if a granted timestamp is still valid based on the validity period. + * + * @param grantedTs the timestamp when access was granted + * @param validity the validity period in days + * @return true if valid, false otherwise + */ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity) { return grantedTs != null && grantedTs @@ -76,7 +93,12 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional getValidProductConsumers(List consumers) { List validProductIds = new ArrayList<>(); @@ -120,9 +148,9 @@ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional getFilteredConsumers(String clientId, Optional consumerId) { List consumers = consumerService.findByIdpClientId(clientId); @@ -139,9 +167,9 @@ private List getFilteredConsumers(String clientId, Optional c /** * Filters active producers by client ID and optional producer ID. * - * @param clientId the client ID to filter by - * @param producerId optional producer ID for additional filtering - * @return filtered list of active producers + * @param clientId the client ID + * @param producerId the optional producer ID + * @return a list of filtered active producers */ private List getFilteredActiveProducers(String clientId, Optional producerId) { List producers = producerService.getProducersByClientId(clientId).stream() @@ -158,10 +186,10 @@ private List getFilteredActiveProducers(String clientId, Optional collectDataProviderIds(List producers) { List dataProviderIds = new ArrayList<>(); @@ -180,6 +208,11 @@ private List collectDataProviderIds(List producers) { * * @param producers list of producers to process */ + /** + * Processes consumers for a list of producers. + * + * @param producers the list of producers + */ private void processConsumersForProducers(List producers) { for (ProducerDTO producer : producers) { for (ProductDTO provider : producer.getProducts()) { @@ -193,6 +226,11 @@ private void processConsumersForProducers(List producers) { * * @param provider the provider to process consumers for */ + /** + * Processes consumers for a specific provider. + * + * @param provider the product provider DTO + */ private void processConsumersForProvider(ProductDTO provider) { // Get consumer providers for this data provider @@ -208,6 +246,12 @@ private void processConsumersForProvider(ProductDTO provider) { * @param consumerProviders list of consumer-provider relationships * @param provider the provider to add consumers to */ + /** + * Adds valid consumers to a provider. + * + * @param consumerProviders the list of product consumer DTOs + * @param provider the product provider DTO + */ private void addValidConsumersToProvider(List consumerProviders, ProductDTO provider) { if (provider.getConsumers() == null) { provider.setConsumers(new ArrayList<>()); @@ -218,6 +262,12 @@ private void addValidConsumersToProvider(List consumerProvid }); } + /** + * Checks if a provider (product consumer) is valid based on its granted date and validity period. + * + * @param provider the product consumer DTO + * @return true if valid, false otherwise + */ private boolean isValidProvider(ProductConsumerDTO provider) { if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java new file mode 100644 index 0000000..5dccf99 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java @@ -0,0 +1,105 @@ +/* + * 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.utils.cryptography; + +import java.io.ByteArrayInputStream; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; + +/** + * Utility class for PEM (Privacy-Enhanced Mail) format operations. + * Provides methods for parsing keys and certificates from PEM strings and converting to PEM format. + */ +public class PemUtil { + + private static final String KEY_ALG = "RSA"; + + private PemUtil() {} + /** + * Parses a PKCS#8 encoded private key from a PEM string. + * + * @param pem the PEM encoded private key string + * @return the parsed PrivateKey + * @throws PkiException if the key cannot be parsed or the algorithm is unavailable + */ + public static PrivateKey parsePkcs8PrivateKey(String pem) { + try { + byte[] der = decodePem(pem); + return KeyFactory.getInstance(KEY_ALG).generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new PkiException("Failed to parse private key", e); + } + } + + /** + * Parses an X.509 encoded public key from a PEM string. + * + * @param pem the PEM encoded public key string + * @return the parsed PublicKey + * @throws PkiException if the key cannot be parsed or the algorithm is unavailable + */ + public static PublicKey parsePublicKey(String pem) { + try { + byte[] der = decodePem(pem); + return KeyFactory.getInstance(KEY_ALG).generatePublic(new X509EncodedKeySpec(der)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new PkiException("Failed to parse public key", e); + } + } + + /** + * Decodes a PEM string by removing headers, footers, and whitespace. + * + * @param pem the PEM string to decode + * @return the decoded byte array + */ + private static byte[] decodePem(String pem) { + String cleaned = pem.replaceAll("-----BEGIN ([A-Z ]+)-----", "") + .replaceAll("-----END ([A-Z ]+)-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(cleaned); + } + + /** + * Converts a DER encoded byte array to a PEM string. + * + * @param type the PEM type label (e.g., "PRIVATE KEY", "CERTIFICATE") + * @param der the DER encoded byte array + * @return the PEM formatted string + */ + public static String toPem(String type, byte[] der) { + String b64 = Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(der); + return "-----BEGIN " + type + "-----\n" + b64 + "\n-----END " + type + "-----\n"; + } + + /** + * Parses an X.509 certificate from a PEM string. + * + * @param pem the PEM encoded certificate string + * @return the parsed X509Certificate + * @throws PkiException if the certificate cannot be parsed or the factory is unavailable + */ + public static X509Certificate parseCertificate(String pem) { + try { + byte[] der = decodePem(pem); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(der)); + } catch (CertificateException e) { + throw new PkiException("Failed to parse certificate", e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f8b919b..ae88342 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: application: name: management-node + cloud: + vault: + uri: http://localhost:8200 + token: # access token to vault + authentication: token security: oauth2: resourceserver: @@ -36,13 +41,17 @@ server: key-alias: localhost key-store: keystore.jks #path to ssl keystore key-store-type: PKCS12 - key-store-password: ${CERTPASSWORD:} #keystore password + key-store-password: ${CERTPASSWORD:defaultpassword} #keystore password trust-store: truststore.jks #path to ssl truststore - trust-store-password: ${CERTPASSWORD:} #truststore password + trust-store-password: ${CERTPASSWORD:defaultpassword} #truststore password trust-store-type: PKCS12 client-auth: need enabled: true # disable for local development Only application: + vault: + pki-mount: pki-int # mount point for intermediate CA + default-role: default-role # default role for signing CSRs + default-ttl: 24h # default TTL for signing CSRs client: key-store: keystore.jks # path to MTLS client keystore key-store-password: ${CERTPASSWORD:} # MTLS client keystore password diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java index 381b682..8f30299 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java @@ -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. */ @@ -51,8 +51,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<>(); @@ -93,7 +91,10 @@ void setUp() { void convert_withRestClientException_shouldFallbackToJwtParsing() { // Arrange // Configure RestTemplate to throw a RestClientException - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenThrow(new RestClientException("Connection refused")); // Act @@ -109,46 +110,21 @@ void convert_withRestClientException_shouldFallbackToJwtParsing() { assertEquals("management-node", principal.clientId()); } - @Test - void convert_withMalformedIntrospectionResponse_shouldFallbackToJwtParsing() { - // Arrange - // Create a malformed introspection response that will cause a ResourceAccessParsingException - Map malformedResponse = new HashMap<>(); - malformedResponse.put("active", true); - malformedResponse.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); - malformedResponse.put("client_id", "management-node"); - - // Add malformed resource_access (not a map but a string) - malformedResponse.put("resource_access", "not-a-map"); - - // Configure RestTemplate to return the malformed response - ResponseEntity responseEntity = new ResponseEntity<>(malformedResponse, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) - .thenReturn(responseEntity); - - // Act - AbstractAuthenticationToken token = converter.convert(mockJwt); - - // Assert - assertNotNull(token); - assertTrue(token instanceof CustomJwtAuthenticationToken); - - // Verify the token has the correct principal - EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); - assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); - assertEquals("management-node", principal.clientId()); - } - @Test void convert_withInactiveToken_shouldFallbackToJwtParsing() { // Arrange // Create an introspection response with inactive token - Map inactiveTokenResponse = new HashMap<>(); - inactiveTokenResponse.put("active", false); + uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken inactiveTokenResponse = + new uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken(); + inactiveTokenResponse.setActive(false); // Configure RestTemplate to return the inactive token response - ResponseEntity responseEntity = new ResponseEntity<>(inactiveTokenResponse, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + ResponseEntity responseEntity = + new ResponseEntity<>(inactiveTokenResponse, HttpStatus.OK); + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenReturn(responseEntity); // Act @@ -168,8 +144,12 @@ void convert_withInactiveToken_shouldFallbackToJwtParsing() { void convert_withNullIntrospectionResponse_shouldFallbackToJwtParsing() { // Arrange // Configure RestTemplate to return null response body - ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + ResponseEntity responseEntity = + new ResponseEntity<>(null, HttpStatus.OK); + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenReturn(responseEntity); // Act diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java new file mode 100644 index 0000000..fe51be9 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -0,0 +1,116 @@ +/* + * 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.controller.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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 org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CertificateInfoDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrRequestDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateKeyResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.IntermediateCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; + +@ExtendWith(MockitoExtension.class) +class CertificateControllerTest { + + private MockMvc mockMvc; + + @Mock + private VaultPkiService pkiService; + + @InjectMocks + private CertificateController certificateController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(certificateController).build(); + } + + @Test + void createKeyPair_shouldReturnKeyPair() throws Exception { + CreateKeyResponseDTO response = CreateKeyResponseDTO.builder() + .algorithm("RSA") + .publicKeyPem("PUB") + .privateKeyPem("PRIV") + .build(); + when(pkiService.createKeyPair(anyString(), any())).thenReturn(response); + + mockMvc.perform(get("/api/v1/certificate/keyPair").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.algorithm").value("RSA")); + } + + @Test + void createCsr_shouldReturnCsr() throws Exception { + CreateCsrResponseDTO response = new CreateCsrResponseDTO("id", "CSR_PEM"); + when(pkiService.createCsr(any(CreateCsrRequestDTO.class))).thenReturn(response); + + String jsonRequest = "{\"commonName\":\"test\"}"; + + mockMvc.perform(post("/api/v1/certificate/csr/create") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.csrPem").value("CSR_PEM")); + } + + @Test + void signCsr_shouldReturnSignedCert() throws Exception { + SignCertResponseDTO response = SignCertResponseDTO.builder() + .certificate("CERT") + .serialNumber("123") + .build(); + when(pkiService.signCsr(anyString(), any(), any())).thenReturn(response); + + String jsonRequest = "{\"csr\":\"CSR\"}"; + + mockMvc.perform(post("/api/v1/certificate/csr/sign") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.certificate").value("CERT")); + } + + @Test + void getIntermediateCertificate_shouldReturnCertificateWithInfo() throws Exception { + String mockCert = "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----"; + CertificateInfoDTO info = CertificateInfoDTO.builder() + .subject("CN=Test") + .issuer("CN=Root") + .serialNumber("12345") + .build(); + IntermediateCertResponseDTO responseDTO = IntermediateCertResponseDTO.builder() + .certificate(mockCert) + .caChain(null) + .info(info) + .build(); + when(pkiService.getIntermediateCertificate()).thenReturn(responseDTO); + + mockMvc.perform(get("/api/v1/certificate/intermediate").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.certificate").value(mockCert)) + .andExpect(jsonPath("$.info.subject").value("CN=Test")) + .andExpect(jsonPath("$.info.issuer").value("CN=Root")) + .andExpect(jsonPath("$.info.serialNumber").value("12345")); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java index 22e4789..7566a04 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.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. */ @@ -12,9 +12,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; import uk.gov.dbt.ndtp.ia.node.management.exception.JwtClaimParsingException; @@ -112,4 +114,22 @@ void handleAllExceptions_shouldReturnInternalServerErrorStatus() { assertEquals("An unexpected error occurred", errorResponse.getMessage()); assertNotNull(errorResponse.getErrorId()); } + + @Test + void handleNoResourceFoundException_shouldReturnNotFoundStatus() { + // Arrange + String path = "/api/v1/invalid"; + NoResourceFoundException exception = new NoResourceFoundException(HttpMethod.GET, path); + + // Act + ResponseEntity response = exceptionHandler.handleNoResourceFoundException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.NOT_FOUND.value(), errorResponse.getStatus()); + assertEquals("Resource not found: " + path, errorResponse.getMessage()); + assertNotNull(errorResponse.getErrorId()); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java new file mode 100644 index 0000000..ecf4556 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java @@ -0,0 +1,26 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ProductTypeTest { + + @Test + void testProductTypeSettersAndGetters() { + ProductType productType = new ProductType(); + productType.setId(1L); + productType.setName("Test Product Type"); + productType.setDescription("Test Description"); + + assertThat(productType.getId()).isEqualTo(1L); + assertThat(productType.getName()).isEqualTo("Test Product Type"); + assertThat(productType.getDescription()).isEqualTo("Test Description"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java new file mode 100644 index 0000000..4dfc5be --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java @@ -0,0 +1,262 @@ +/* + * 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.certificate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.math.BigInteger; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.security.auth.x500.X500Principal; +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.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +@ExtendWith(MockitoExtension.class) +class VaultPkiServiceTest { + + @Mock + private VaultTemplate vaultTemplate; + + private VaultPkiService vaultPkiService; + + private static final String PKI_MOUNT = "pki-int"; + + private static final String DEFAULT_ROLE = "default-role"; + + private static final String DEFAULT_TTL = "24h"; + + @BeforeEach + void setUp() { + vaultPkiService = new VaultPkiService(vaultTemplate, PKI_MOUNT, DEFAULT_ROLE, DEFAULT_TTL); + } + + @Test + void createKeyPair_shouldReturnValidKeyPair() { + CreateKeyResponseDTO response = vaultPkiService.createKeyPair("RSA", 2048); + + assertNotNull(response); + assertEquals("RSA", response.getAlgorithm()); + assertTrue(response.getPublicKeyPem().contains("BEGIN PUBLIC KEY")); + assertTrue(response.getPrivateKeyPem().contains("BEGIN PRIVATE KEY")); + } + + @Test + void createKeyPair_withNullAlgorithm_shouldUseDefault() { + CreateKeyResponseDTO response = vaultPkiService.createKeyPair(null, null); + + assertNotNull(response); + assertEquals("RSA", response.getAlgorithm()); + } + + @Test + void createKeyPair_withInvalidAlgorithm_shouldThrowPkiException() { + assertThrows(PkiException.class, () -> vaultPkiService.createKeyPair("INVALID", 1024)); + } + + @Test + void createCsr_shouldReturnValidCsr() { + CreateKeyResponseDTO keyPair = vaultPkiService.createKeyPair("RSA", 2048); + CreateCsrRequestDTO request = CreateCsrRequestDTO.builder() + .commonName("test.example.com") + .organization("Test Org") + .organizationalUnit("Test Unit") + .country("GB") + .publicKeyPem(keyPair.getPublicKeyPem()) + .privateKeyPem(keyPair.getPrivateKeyPem()) + .dnsSans(List.of("alt.example.com")) + .build(); + + CreateCsrResponseDTO response = vaultPkiService.createCsr(request); + + assertNotNull(response); + assertNotNull(response.getCsrId()); + assertTrue(response.getCsrPem().contains("BEGIN CERTIFICATE REQUEST")); + } + + @Test + void createCsr_withInvalidKeys_shouldThrowPkiException() { + CreateCsrRequestDTO request = CreateCsrRequestDTO.builder() + .commonName("test.example.com") + .publicKeyPem("INVALID") + .privateKeyPem("INVALID") + .build(); + + assertThrows(PkiException.class, () -> vaultPkiService.createCsr(request)); + } + + @Test + @SuppressWarnings("unchecked") + void signCsr_shouldReturnSignedCertificate() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String role = "test-role"; + String ttl = "24h"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + role), anyMap())).thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.of(role), Optional.of(ttl)); + + assertNotNull(response); + assertEquals("SIGNED_CERT", response.getCertificate()); + assertEquals("SERIAL_123", response.getSerialNumber()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(vaultTemplate).write(eq(PKI_MOUNT + "/sign/" + role), bodyCaptor.capture()); + Map body = bodyCaptor.getValue(); + assertEquals(csrPem, body.get("csr")); + assertEquals(ttl, body.get("ttl")); + assertEquals("pem", body.get("format")); + } + + @Test + void signCsr_withNullRole_shouldUseDefaultRole() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String ttl = "24h"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + DEFAULT_ROLE), anyMap())) + .thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.of(ttl)); + + assertNotNull(response); + verify(vaultTemplate).write(eq(PKI_MOUNT + "/sign/" + DEFAULT_ROLE), anyMap()); + } + + @Test + void signCsr_withNullTtl_shouldUseDefaultTtl() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String role = "test-role"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + role), anyMap())).thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.of(role), Optional.empty()); + + assertNotNull(response); + verify(vaultTemplate) + .write( + eq(PKI_MOUNT + "/sign/" + role), + argThat((Map m) -> DEFAULT_TTL.equals(m.get("ttl")))); + } + + @Test + void signCsr_withEmptyCsr_shouldThrowPkiException() { + Optional role = Optional.of("role"); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("", role, ttl)); + } + + @Test + void signCsr_withEmptyRole_shouldThrowPkiException() { + Optional role = Optional.of(""); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("csr", role, ttl)); + } + + @Test + void signCsr_whenVaultReturnsNull_shouldThrowPkiException() { + when(vaultTemplate.write(anyString(), anyMap())).thenReturn(null); + Optional role = Optional.of("role"); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("csr", role, ttl)); + } + + @Test + void getIntermediateCertificate_shouldReturnCertAndChain() { + String certPem = "-----BEGIN CERTIFICATE-----\nFAKE_CERT\n-----END CERTIFICATE-----"; + + VaultResponse certResp = mock(VaultResponse.class); + when(certResp.getData()).thenReturn(Map.of("certificate", certPem)); + + VaultResponse chainResp = mock(VaultResponse.class); + when(chainResp.getData()).thenReturn(Map.of("ca_chain", List.of("CHAIN_1"))); + + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca")).thenReturn(certResp); + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca_chain")).thenReturn(chainResp); + + X509Certificate mockX509 = mock(X509Certificate.class); + when(mockX509.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test-cert")); + when(mockX509.getIssuerX500Principal()).thenReturn(new X500Principal("CN=root-ca")); + when(mockX509.getSerialNumber()).thenReturn(new BigInteger("12345")); + when(mockX509.getNotBefore()).thenReturn(new Date()); + when(mockX509.getNotAfter()).thenReturn(new Date()); + when(mockX509.getSigAlgName()).thenReturn("SHA256withRSA"); + when(mockX509.getVersion()).thenReturn(3); + + try (MockedStatic mockedPemUtil = mockStatic(PemUtil.class)) { + mockedPemUtil.when(() -> PemUtil.parseCertificate(certPem)).thenReturn(mockX509); + + IntermediateCertResponseDTO response = vaultPkiService.getIntermediateCertificate(); + + assertNotNull(response); + assertEquals(certPem, response.getCertificate()); + assertNotNull(response.getInfo()); + assertEquals("CN=test-cert", response.getInfo().getSubject()); + } + + verify(vaultTemplate).read(PKI_MOUNT + "/cert/ca"); + verify(vaultTemplate).read(PKI_MOUNT + "/cert/ca_chain"); + } + + @Test + void getIntermediateCertificate_whenParsingFails_shouldThrowPkiException() { + VaultResponse certResp = mock(VaultResponse.class); + when(certResp.getData()).thenReturn(Map.of("certificate", "INVALID_PEM")); + + VaultResponse chainResp = mock(VaultResponse.class); + when(chainResp.getData()).thenReturn(Map.of("ca_chain", List.of("CHAIN_1"))); + + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca")).thenReturn(certResp); + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca_chain")).thenReturn(chainResp); + + assertThrows(PkiException.class, () -> vaultPkiService.getIntermediateCertificate()); + } + + @Test + void getIntermediateCertificate_whenVaultReadFails_shouldThrowPkiException() { + when(vaultTemplate.read(anyString())).thenReturn(null); + assertThrows(PkiException.class, () -> vaultPkiService.getIntermediateCertificate()); + } +} 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 2948fb4..1140d73 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,9 +1,3 @@ -/* - * 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; @@ -206,6 +200,33 @@ void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersA assertThat(pr2.getConsumers()).isEmpty(); } + @Test + void getProducerConfigByClientId_whenNoProducersFound_returnsEmptyConfig() { + String clientId = "nonExistentClient"; + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of()); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg).isNotNull(); + assertThat(cfg.getClientId()).isEqualTo(clientId); + assertThat(cfg.getProducers()).isEmpty(); + } + + @Test + void getConsumerConfigByClientId_whenNoConsumersFound_returnsEmptyConfig() { + String clientId = "nonExistentClient"; + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of()); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg).isNotNull(); + assertThat(cfg.getClientId()).isEqualTo(clientId); + assertThat(cfg.getProducers()).isEmpty(); + assertThat(cfg.getName()).isNull(); + assertThat(cfg.getScheduleType()).isNull(); + assertThat(cfg.getScheduleExpression()).isNull(); + } + @Test void getProducerConfigByClientId_withValidValidity_includesConsumer() { String clientId = "producerClient"; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java new file mode 100644 index 0000000..3963e67 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java @@ -0,0 +1,52 @@ +/* + * 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.utils.cryptography; + +import static org.junit.jupiter.api.Assertions.*; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import org.junit.jupiter.api.Test; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; + +class PemUtilTest { + + @Test + void parsePublicKey_withInvalidPem_shouldThrowPkiException() { + String invalidPem = "-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----\n"; + assertThrows(PkiException.class, () -> PemUtil.parsePublicKey(invalidPem)); + } + + @Test + void parseCertificate_withInvalidPem_shouldThrowPkiException() { + String invalidPem = "-----BEGIN CERTIFICATE-----\nINVALID\n-----END CERTIFICATE-----\n"; + assertThrows(PkiException.class, () -> PemUtil.parseCertificate(invalidPem)); + } + + @Test + void roundTripKeyParsing_shouldSucceed() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + PrivateKey priv = kp.getPrivate(); + PublicKey pub = kp.getPublic(); + + String privPem = PemUtil.toPem("PRIVATE KEY", priv.getEncoded()); + String pubPem = PemUtil.toPem("PUBLIC KEY", pub.getEncoded()); + + PrivateKey parsedPriv = PemUtil.parsePkcs8PrivateKey(privPem); + PublicKey parsedPub = PemUtil.parsePublicKey(pubPem); + + assertEquals(priv.getAlgorithm(), parsedPriv.getAlgorithm()); + assertEquals(pub.getAlgorithm(), parsedPub.getAlgorithm()); + assertArrayEquals(priv.getEncoded(), parsedPriv.getEncoded()); + assertArrayEquals(pub.getEncoded(), parsedPub.getEncoded()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 61b6538..a531ac8 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,9 @@ spring: dialect: org.hibernate.dialect.H2Dialect show_sql: false format_sql: false +application: + vault: + pki-mount: pki-int server: port: 0 logging: From 2234a54d0f786994ceaf718c624ae690c5583b0f Mon Sep 17 00:00:00 2001 From: LukeJonesIS Date: Fri, 6 Mar 2026 16:41:38 +0000 Subject: [PATCH 02/18] [DPAV-2601] Management Node - Resolve CVEs (#43) * [DPAV-2601] update versions * feat(OSPO): synchronise OSPO workflows * [DPAV-2601] update to version 3.5.11 --------- Co-authored-by: LukeJonesIS <182091054+LukeJonesIS@users.noreply.github.com> --- .github/workflows/oss-checker.yml | 2 +- .github/workflows/publish-github-release.yml | 4 ++-- pom.xml | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 6033c7d..68dab5e 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -449,7 +449,7 @@ jobs: - name: Upload OSS result artifacts if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: oss-checks-${{ github.run_id }} retention-days: 30 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9832c9d..4402c47 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -85,7 +85,7 @@ jobs: echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: sbom path: sbom.spdx.json @@ -122,7 +122,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 with: name: sbom diff --git a/pom.xml b/pom.xml index 183b632..d03fecc 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.5 + 3.5.11 uk.gov.dbt.ndtp.ia.management.node @@ -179,7 +179,11 @@ h2 test - + + org.apache.commons + commons-lang3 + 3.18.0 + From 26bb5ea2e17181cdb39b6b5e3fcdf47ee2927bd5 Mon Sep 17 00:00:00 2001 From: JamesRuane-is <108880654+JamesRuane-is@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:55:33 +0000 Subject: [PATCH 03/18] [DPAV_2599] add missing docker attributes (#44) --- docker/Dockerfile | 4 ++++ docker/keycloak/docker-compose.yml | 33 ++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d05736..7d79215 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,12 +38,16 @@ COPY --from=build /build/target/${JAR_NAME}.jar /app/app.jar RUN chown app:app /app/app.jar +RUN apk add --no-cache curl + # Use non-root user from here on USER app:app # Expose HTTPS port EXPOSE 8443 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 CMD curl -f -k https://localhost:8443 || exit 1 + # Helpful defaults for Java in containers ENV JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom -XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/app/tmp" diff --git a/docker/keycloak/docker-compose.yml b/docker/keycloak/docker-compose.yml index a28e706..17fb701 100644 --- a/docker/keycloak/docker-compose.yml +++ b/docker/keycloak/docker-compose.yml @@ -9,6 +9,15 @@ services: image: postgres:16.2 volumes: - postgres_data:/var/lib/postgresql/data + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + - DAC_OVERRIDE + security_opt: + - no-new-privileges:true environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} @@ -16,12 +25,22 @@ services: networks: - keycloak_network ports: - - "5433:5432" + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s keycloak: image: quay.io/keycloak/keycloak:26.3.2 command: start --verbose hostname: localhost container_name: keycloak + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KC_HOSTNAME: localhost KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT} @@ -51,10 +70,16 @@ services: KC_LOG_LEVEL: ${KC_LOG_LEVEL} ports: - - "8080:8080" - - "8443:8443" - - "9000:9000" + - "127.0.0.1:8080:8080" + - "127.0.0.1:8443:8443" + - "127.0.0.1:9000:9000" restart: always + healthcheck: + test: ["CMD-SHELL", "exec 6<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nhost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&6 && timeout 5 cat <&6 > /dev/null"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 30s depends_on: - postgres networks: From 6cca4a0a2851f53d952dea18205c3c58dc269bbc Mon Sep 17 00:00:00 2001 From: KumailKamranIS <131384933+KumailKamranIS@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:09:04 +0000 Subject: [PATCH 04/18] feat(dpav-2609): non root access for dev container and missing doc (#45) --- .gitignore | 6 +++- README.md | 59 +++++++++++++++++++++++++++++++- docker/Dockerfile-dev | 13 +++++-- docker/vault/docker-compose.yaml | 2 +- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 9838bfb..596ed6b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ build/ *.jks *.ext *.pem +*.srl ### Env files ### .env @@ -48,4 +49,7 @@ build/ .terraform/ *.tfstate *.tfstate.backup -*.hcl \ No newline at end of file +*.hcl + +### application local file +**src/main/resources/application-local.yml diff --git a/README.md b/README.md index f1d9996..8db1442 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,63 @@ If you prefer to set up the realm manually (updated for Keycloak 26.x): keyStoreType: JKS ``` +### Setting up vault with Docker compose + +Follow these steps to setup vault using Docker compose: + +1. Create a directory called `config` in the `docker/vault` directory + +```sh +mkdir docker/vault/config +``` + +2. Create a file called `vault.hcl` in the `docker/vault/config` directory with the following content: + +``` +ui = true + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = 1 +} + +storage "file" { + path = "/vault/file" +} + +# Optional but helpful: disables mlock warnings in containers +disable_mlock = true +``` + +3. Start vault using Docker compose: + +```sh +docker compose -f docker/vault/docker-compose.yaml up -d +``` + +4. Verify that vault is running + +```sh +docker exec vault vault status -format=json +``` + +5. initialize vault & generate unseal keys and root token: + +```sh +# copy the Keys and root token to somewhere safe +docker exec vault vault operator init -key-shares=5 -key-threshold=3 -format=json +``` + +6. unseal vault using the unseal key in the previous step: + +```sh +docker exec vault vault operator unseal +docker exec vault vault operator unseal +docker exec vault vault operator unseal +``` + +You can then access vault using the Web UI and the root token at `http://localhost:8200`. Dont forget to add your vault root token to the application file. + ### 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): @@ -816,4 +873,4 @@ This repository has benefited from collaboration with various organisations. For 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 +© 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. diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index 6890770..81ca304 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -6,7 +6,7 @@ # # Build stage -ARG JAR_FILE=management-node-1.0.1.jar +ARG JAR_FILE=management-node-1.1.0.jar FROM maven:3.9.6-eclipse-temurin-21-alpine AS build ARG JAR_FILE WORKDIR /build @@ -22,16 +22,25 @@ RUN mvn clean package -DskipTests FROM eclipse-temurin:23-jdk-alpine ARG JAR_FILE +RUN addgroup -S app && adduser -S -G app -u 10001 app + WORKDIR /app +RUN chown -R app:app /app + # Copy application jar from build stage and certificates COPY --from=build /build/target/${JAR_FILE} /app/app.jar COPY docker/keystore.jks /app/keystore.jks COPY docker/truststore.jks /app/truststore.jks +RUN chown app:app /app/app.jar + +# Use non-root user from here on +USER app:app + # Set default certificate password ENV CERTPASSWORD=changeit EXPOSE 8090 -ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/docker/vault/docker-compose.yaml b/docker/vault/docker-compose.yaml index ad5f94a..ea3899a 100644 --- a/docker/vault/docker-compose.yaml +++ b/docker/vault/docker-compose.yaml @@ -16,7 +16,7 @@ services: volumes: - vault-data:/vault/file - - ./vault/config:/vault/config:ro + - ./config:/vault/config:ro command: vault server -config=/vault/config/vault.hcl From 6868b2d27cf6419d0c2e5d8dfe095e23350dd931 Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Wed, 11 Mar 2026 07:29:00 +0000 Subject: [PATCH 05/18] [DPAV-2627] Organization Certificates (#46) * feat(DPAV-2627): Add persistence for new certificate schema * feat(DPAV-2627): Add org cert validation and config filtering Adds a HandlerInterceptor that validates organisation certificates on all /api/** requests. Using an interceptor rather than a filter so it caninspect controller annotations allowing for the @AllowBootstrapCertificates annotation used for certificate endpoints. Updates producer and consumer config responses to exclude organisations without active certificates. * feat(DPAV-2627): Record certificate details and audit events on CSR signing Adds CertificateSigningProvider to orchestrate the /csr/sign endpoint, updating the org certificate record and creating audit events on renewal. Rejects requests for manual or non-renewable certs with 403. Fixes serial number format mismatch between Vault (colon-separated) and Java X509 (plain hex) in the validation interceptor. * feat(DPAV-2627): Resolve Sonar issues --- .../config/AllowBootstrapCertificates.java | 20 ++ .../CertificateValidationInterceptor.java | 125 ++++++++ .../ia/node/management/config/WebConfig.java | 26 ++ .../controller/v1/CertificateController.java | 20 +- .../OrganisationCertificateConverter.java | 83 +++++ .../CertificateSigningException.java | 23 ++ .../handlers/GlobalExceptionHandler.java | 24 ++ .../model/dto/OrganisationCertificateDTO.java | 50 +++ .../persistency/entity/CertificateEvent.java | 41 +++ .../entity/CertificateEventType.java | 14 + .../persistency/entity/CertificateType.java | 13 + .../persistency/entity/Organisation.java | 3 + .../entity/OrganisationCertificate.java | 55 ++++ .../CertificateEventRepository.java | 24 ++ .../OrganisationCertificateRepository.java | 44 +++ .../service/data/CertificateEventService.java | 27 ++ .../data/OrganisationCertificateService.java | 50 +++ .../impl/CertificateEventServiceImpl.java | 50 +++ .../OrganisationCertificateServiceImpl.java | 52 ++++ .../CertificateSigningProvider.java | 24 ++ .../CertificateSigningProviderImpl.java | 86 ++++++ .../CertificateValidationProvider.java | 43 +++ .../CertificateValidationProviderImpl.java | 63 ++++ .../ConfigurationProviderImpl.java | 123 ++++---- ...20260305120000__add_certificate_tables.sql | 60 ++++ .../CertificateValidationInterceptorTest.java | 291 ++++++++++++++++++ .../v1/CertificateControllerTest.java | 39 ++- .../OrganisationCertificateConverterTest.java | 164 ++++++++++ .../entity/CertificateEventTest.java | 39 +++ .../entity/CertificateEventTypeTest.java | 34 ++ .../entity/CertificateTypeTest.java | 29 ++ .../entity/OrganisationCertificateTest.java | 56 ++++ .../persistency/entity/OrganisationTest.java | 33 ++ .../impl/CertificateEventServiceImplTest.java | 62 ++++ ...rganisationCertificateServiceImplTest.java | 154 +++++++++ .../CertificateSigningProviderImplTest.java | 187 +++++++++++ ...CertificateValidationProviderImplTest.java | 135 ++++++++ .../ConfigurationProviderImplTest.java | 79 ++++- 38 files changed, 2379 insertions(+), 66 deletions(-) create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java create mode 100644 src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java new file mode 100644 index 0000000..801ca6a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.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.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a controller as accessible by bootstrap certificates. + * Controllers without this annotation will reject requests from bootstrap certificate holders. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface AllowBootstrapCertificates {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java new file mode 100644 index 0000000..617d4c5 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java @@ -0,0 +1,125 @@ +/* + * 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.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; + +@Component +@Slf4j +public class CertificateValidationInterceptor implements HandlerInterceptor { + + private static final String X509_CERT_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + + private final CertificateValidationProvider validationProvider; + private final ObjectMapper objectMapper; + + public CertificateValidationInterceptor( + CertificateValidationProvider validationProvider, ObjectMapper objectMapper) { + this.validationProvider = validationProvider; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String clientId = extractClientId(); + if (clientId == null) { + log.warn("No client ID found for request to {}", request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Client ID required"); + return false; + } + + OrganisationCertificateDTO cert = + validationProvider.findByClientId(clientId).orElse(null); + if (cert == null) { + log.warn("No certificate record for client {} on {}", clientId, request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "No organisation certificate found"); + return false; + } + + if (!validationProvider.isActive(cert)) { + log.warn("Inactive certificate for client {} on {}", clientId, request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Organisation certificate is not active"); + return false; + } + + if (cert.getSerialNumber() != null) { + String rejection = validateSerialNumber(request, cert, clientId); + if (rejection != null) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, rejection); + return false; + } + } + + if (cert.getType() == CertificateType.BOOTSTRAP && !allowsBootstrapCertificates(handler)) { + log.warn("Bootstrap certificate denied access to {}", request.getRequestURI()); + writeError( + response, HttpServletResponse.SC_FORBIDDEN, "Bootstrap certificates cannot access this endpoint"); + return false; + } + + log.debug("Certificate validation successful for client {} on {}", clientId, request.getRequestURI()); + return true; + } + + private String extractClientId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof EnhancedPrincipal principal)) { + return null; + } + String clientId = principal.clientId(); + return (clientId == null || clientId.isEmpty()) ? null : clientId; + } + + private boolean allowsBootstrapCertificates(Object handler) { + return handler instanceof HandlerMethod handlerMethod + && handlerMethod.getBeanType().isAnnotationPresent(AllowBootstrapCertificates.class); + } + + private String validateSerialNumber(HttpServletRequest request, OrganisationCertificateDTO cert, String clientId) { + X509Certificate[] certs = (X509Certificate[]) request.getAttribute(X509_CERT_ATTRIBUTE); + if (certs == null || certs.length == 0) { + return "Client certificate required"; + } + String presentedSerial = certs[0].getSerialNumber().toString(16); + String storedSerial = cert.getSerialNumber().replace(":", ""); + if (!presentedSerial.equalsIgnoreCase(storedSerial)) { + log.warn( + "Serial mismatch for client {}: expected={}, presented={}", + clientId, + cert.getSerialNumber(), + presentedSerial); + return "Certificate serial number mismatch"; + } + return null; + } + + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + ErrorResponse errorResponse = + new ErrorResponse(status, message, UUID.randomUUID().toString()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java new file mode 100644 index 0000000..dab9c03 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java @@ -0,0 +1,26 @@ +/* + * 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.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final CertificateValidationInterceptor certificateValidationInterceptor; + + public WebConfig(CertificateValidationInterceptor certificateValidationInterceptor) { + this.certificateValidationInterceptor = certificateValidationInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(certificateValidationInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java index dbfea83..8bffe98 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -7,15 +7,19 @@ package uk.gov.dbt.ndtp.ia.node.management.controller.v1; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import java.util.Optional; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import uk.gov.dbt.ndtp.ia.node.management.config.AllowBootstrapCertificates; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; /** @@ -24,14 +28,17 @@ */ @RestController @RequestMapping("/api/v1/certificate") +@AllowBootstrapCertificates @Slf4j @Tag(name = "Certificate", description = "Endpoints for certificate management.") public class CertificateController { private final VaultPkiService pkiService; + private final CertificateSigningProvider signingProvider; - public CertificateController(VaultPkiService pkiService) { + public CertificateController(VaultPkiService pkiService, CertificateSigningProvider signingProvider) { this.pkiService = pkiService; + this.signingProvider = signingProvider; } /** @@ -87,9 +94,10 @@ public CreateCsrResponseDTO createCsr(@RequestBody CreateCsrRequestDTO req) { } /** - * Signs a CSR using the default role and TTL. + * Signs a CSR and records the result against the caller's organisation. * * @param req the sign request DTO containing the CSR PEM + * @param principal the authenticated caller's identity * @return a DTO containing the signed certificate and its chain */ @PostMapping("/csr/sign") @@ -109,8 +117,10 @@ public CreateCsrResponseDTO createCsr(@RequestBody CreateCsrRequestDTO req) { @ApiResponse(responseCode = "401", description = "Unauthorized") @ApiResponse(responseCode = "403", description = "Forbidden") @ApiResponse(responseCode = "500", description = "Internal server error") - public SignCertResponseDTO signCsr(@RequestBody SignCertRequestDTO req) { - return pkiService.signCsr(req.getCsr(), Optional.empty(), Optional.empty()); + public SignCertResponseDTO signCsr( + @RequestBody SignCertRequestDTO req, + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal) { + return signingProvider.signAndRecord(req.getCsr(), principal.clientId()); } /** diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java new file mode 100644 index 0000000..ca58356 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java @@ -0,0 +1,83 @@ +/* + * 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.impl; + +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +/** + * Converter for OrganisationCertificate entity and OrganisationCertificateDTO. + */ +@Component +public class OrganisationCertificateConverter + implements EntityDtoConverter { + + private final OrganisationRepository organisationRepository; + + public OrganisationCertificateConverter(OrganisationRepository organisationRepository) { + this.organisationRepository = organisationRepository; + } + + @Override + public OrganisationCertificateDTO toDto(OrganisationCertificate entity) { + if (entity == null) { + return null; + } + + return OrganisationCertificateDTO.builder() + .id(entity.getId()) + .organisationId( + entity.getOrganisation() != null + ? entity.getOrganisation().getId() + : null) + .certificateAutomationEnabled( + entity.getOrganisation() != null + ? entity.getOrganisation().getCertificateAutomationEnabled() + : null) + .subjectDn(entity.getSubjectDn()) + .serialNumber(entity.getSerialNumber()) + .isRenewable(entity.getIsRenewable()) + .renewalTtl(entity.getRenewalTtl()) + .type(entity.getType()) + .requestedAt(entity.getRequestedAt()) + .issuedAt(entity.getIssuedAt()) + .expiresAt(entity.getExpiresAt()) + .revokedAt(entity.getRevokedAt()) + .build(); + } + + @Override + public OrganisationCertificate toEntity(OrganisationCertificateDTO dto) { + if (dto == null) { + return null; + } + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(dto.getId()); + entity.setSubjectDn(dto.getSubjectDn()); + entity.setSerialNumber(dto.getSerialNumber()); + entity.setIsRenewable(dto.getIsRenewable()); + entity.setRenewalTtl(dto.getRenewalTtl()); + entity.setType(dto.getType()); + entity.setRequestedAt(dto.getRequestedAt()); + entity.setIssuedAt(dto.getIssuedAt()); + entity.setExpiresAt(dto.getExpiresAt()); + entity.setRevokedAt(dto.getRevokedAt()); + + if (dto.getOrganisationId() != null) { + Organisation organisation = + organisationRepository.findById(dto.getOrganisationId()).orElse(null); + entity.setOrganisation(organisation); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java new file mode 100644 index 0000000..a05658e --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java @@ -0,0 +1,23 @@ +/* + * 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.exception; + +/** + * Exception thrown when a certificate signing request is rejected. + * Mapped to HTTP 403 by the global exception handler. + */ +public class CertificateSigningException extends RuntimeException { + + /** + * Constructs a new CertificateSigningException with the specified detail message. + * + * @param message the detail message + */ + public CertificateSigningException(String message) { + super(message); + } +} 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 5862496..4d239ec 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 @@ -17,6 +17,7 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.resource.NoResourceFoundException; import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; +import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; @@ -87,6 +88,29 @@ public ResponseEntity handleNoResourceFoundException( return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); } + /** + * Handles CertificateSigningException. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 403 error message + */ + @ExceptionHandler(CertificateSigningException.class) + public ResponseEntity handleCertificateSigningException( + CertificateSigningException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.warn( + "Certificate signing rejected, error_id={}, path={}: {}", + errorId, + request.getDescription(false), + ex.getMessage()); + + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + @ExceptionHandler(PkiException.class) public ResponseEntity handlePkiException(PkiException ex, WebRequest request) { diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java new file mode 100644 index 0000000..590e06f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java @@ -0,0 +1,50 @@ +/* + * 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.dto; + +import java.sql.Timestamp; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; + +/** + * DTO for OrganisationCertificate entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class OrganisationCertificateDTO { + + private Long id; + + private Long organisationId; + + private Boolean certificateAutomationEnabled; + + private String subjectDn; + + private String serialNumber; + + private Boolean isRenewable; + + private Long renewalTtl; + + private CertificateType type; + + private Timestamp requestedAt; + + private Timestamp issuedAt; + + private Timestamp expiresAt; + + private Timestamp revokedAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java new file mode 100644 index 0000000..a866a70 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java @@ -0,0 +1,41 @@ +/* + * 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.persistency.entity; + +import jakarta.persistence.*; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "certificate_events") +public class CertificateEvent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_certificate_id", nullable = false) + private OrganisationCertificate organisationCertificate; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private CertificateType type; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 50) + private CertificateEventType eventType; + + @Column(name = "event_time", nullable = false) + private Timestamp eventTime; + + @Column(name = "performed_by", length = 255) + private String performedBy; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java new file mode 100644 index 0000000..2093129 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java @@ -0,0 +1,14 @@ +/* + * 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.persistency.entity; + +public enum CertificateEventType { + ISSUED, + RENEWED, + EXPIRED, + REVOKED +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java new file mode 100644 index 0000000..434d0fa --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java @@ -0,0 +1,13 @@ +/* + * 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.persistency.entity; + +public enum CertificateType { + BOOTSTRAP, + MANUAL, + AUTOMATED +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java index f2ac4dd..f433bfe 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java @@ -22,4 +22,7 @@ public class Organisation { @Column(name = "name", nullable = false, length = 150) private String name; + + @Column(name = "certificate_automation_enabled", nullable = false) + private Boolean certificateAutomationEnabled = true; } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java new file mode 100644 index 0000000..33d47dc --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java @@ -0,0 +1,55 @@ +/* + * 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.persistency.entity; + +import jakarta.persistence.*; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "organisation_certificate") +public class OrganisationCertificate { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id", unique = true) + private Organisation organisation; + + @Column(name = "subject_dn", length = 500) + private String subjectDn; + + @Column(name = "serial_number", length = 150) + private String serialNumber; + + @Column(name = "is_renewable", nullable = false) + private Boolean isRenewable = false; + + @Column(name = "renewal_ttl") + private Long renewalTtl; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private CertificateType type; + + @Column(name = "requested_at") + private Timestamp requestedAt; + + @Column(name = "issued_at") + private Timestamp issuedAt; + + @Column(name = "expires_at") + private Timestamp expiresAt; + + @Column(name = "revoked_at") + private Timestamp revokedAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java new file mode 100644 index 0000000..e731ec9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java @@ -0,0 +1,24 @@ +/* + * 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.persistency.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; + +/** + * Repository interface for managing {@link CertificateEvent} entities. + * + * This interface provides CRUD operations and query methods for interacting with + * the underlying database layer as it extends the {@link JpaRepository} interface. + * It facilitates persistence and retrieval of CertificateEvent data from the related + * database table. + * + * Primary focus is on the {@link CertificateEvent} entity with the identifier type {@link Long}. + */ +@Repository +public interface CertificateEventRepository extends JpaRepository {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java new file mode 100644 index 0000000..17d996d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java @@ -0,0 +1,44 @@ +/* + * 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.persistency.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; + +/** + * Repository interface for managing {@link OrganisationCertificate} entities. + * + * This interface provides CRUD operations and query methods for interacting with + * the underlying database layer as it extends the {@link JpaRepository} interface. + * It facilitates persistence and retrieval of OrganisationCertificate data from the related + * database table. + * + * Primary focus is on the {@link OrganisationCertificate} entity with the identifier type {@link Long}. + */ +@Repository +public interface OrganisationCertificateRepository extends JpaRepository { + + Optional findByOrganisationId(Long organisationId); + + @Query("SELECT oc FROM OrganisationCertificate oc " + + "JOIN FETCH oc.organisation " + + "WHERE oc.organisation.id IN :orgIds") + List findAllWithOrganisationByOrganisationIdIn(@Param("orgIds") Collection orgIds); + + @Query("SELECT oc FROM OrganisationCertificate oc " + + "JOIN FETCH oc.organisation o " + + "LEFT JOIN Consumer c ON c.org = o " + + "LEFT JOIN Producer p ON p.org = o " + + "WHERE c.idpClientId = :clientId OR p.idpClientId = :clientId") + Optional findByClientId(@Param("clientId") String clientId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java new file mode 100644 index 0000000..6970a1a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java @@ -0,0 +1,27 @@ +/* + * 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.data; + +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; + +/** + * Service interface for recording certificate lifecycle events. + */ +public interface CertificateEventService { + + /** + * Records a certificate event for audit purposes. + * + * @param organisationCertificateId the ID of the organisation certificate + * @param type the certificate type at the time of the event + * @param eventType the type of event being recorded + * @param performedBy identifier of the actor that triggered the event + */ + void recordEvent( + Long organisationCertificateId, CertificateType type, CertificateEventType eventType, String performedBy); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java new file mode 100644 index 0000000..7a3d71e --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java @@ -0,0 +1,50 @@ +/* + * 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.data; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; + +/** + * Service interface for managing OrganisationCertificate entities. + */ +public interface OrganisationCertificateService { + + /** + * Find a certificate by its organisation ID. + * + * @param organisationId the organisation ID to search for + * @return the certificate DTO if found + */ + Optional findByOrganisationId(Long organisationId); + + /** + * Find a certificate by the IDP client ID of its organisation's consumer or producer. + * + * @param clientId the IDP client ID to search for + * @return the certificate DTO if found + */ + Optional findByClientId(String clientId); + + /** + * Find all certificates for the given organisation IDs. + * + * @param orgIds the organisation IDs to search for + * @return a list of certificate DTOs + */ + List findAllByOrganisationIds(Collection orgIds); + + /** + * Save or update a certificate record. + * + * @param certificate the certificate DTO to save + * @return the saved certificate DTO + */ + OrganisationCertificateDTO save(OrganisationCertificateDTO certificate); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java new file mode 100644 index 0000000..e88c110 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java @@ -0,0 +1,50 @@ +/* + * 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.data.impl; + +import java.sql.Timestamp; +import java.time.Instant; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.CertificateEventRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; + +/** + * Implementation of {@link CertificateEventService} that persists events + * via {@link CertificateEventRepository}. + */ +@Service +public class CertificateEventServiceImpl implements CertificateEventService { + + private final CertificateEventRepository eventRepository; + private final OrganisationCertificateRepository certificateRepository; + + public CertificateEventServiceImpl( + CertificateEventRepository eventRepository, OrganisationCertificateRepository certificateRepository) { + this.eventRepository = eventRepository; + this.certificateRepository = certificateRepository; + } + + @Override + public void recordEvent( + Long organisationCertificateId, CertificateType type, CertificateEventType eventType, String performedBy) { + OrganisationCertificate certificate = certificateRepository.getReferenceById(organisationCertificateId); + + CertificateEvent event = new CertificateEvent(); + event.setOrganisationCertificate(certificate); + event.setType(type); + event.setEventType(eventType); + event.setEventTime(Timestamp.from(Instant.now())); + event.setPerformedBy(performedBy); + + eventRepository.save(event); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java new file mode 100644 index 0000000..2a1446a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java @@ -0,0 +1,52 @@ +/* + * 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.data.impl; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationCertificateConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +@Service +public class OrganisationCertificateServiceImpl implements OrganisationCertificateService { + + private final OrganisationCertificateRepository repository; + private final OrganisationCertificateConverter converter; + + public OrganisationCertificateServiceImpl( + OrganisationCertificateRepository repository, OrganisationCertificateConverter converter) { + this.repository = repository; + this.converter = converter; + } + + @Override + public Optional findByOrganisationId(Long organisationId) { + return repository.findByOrganisationId(organisationId).map(converter::toDto); + } + + @Override + public Optional findByClientId(String clientId) { + return repository.findByClientId(clientId).map(converter::toDto); + } + + @Override + public List findAllByOrganisationIds(Collection orgIds) { + if (orgIds == null || orgIds.isEmpty()) { + return List.of(); + } + return converter.toDtoList(repository.findAllWithOrganisationByOrganisationIdIn(orgIds)); + } + + @Override + public OrganisationCertificateDTO save(OrganisationCertificateDTO dto) { + return converter.toDto(repository.save(converter.toEntity(dto))); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java new file mode 100644 index 0000000..a4b8839 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java @@ -0,0 +1,24 @@ +/* + * 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.certificate; + +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; + +/** + * Provides certificate signing orchestration, including record updates and audit events. + */ +public interface CertificateSigningProvider { + + /** + * Sign a CSR and record the result against the caller's organisation certificate. + * + * @param csrPem the CSR in PEM format + * @param clientId the IDP client ID of the calling organisation + * @return a DTO containing the signed certificate and its chain + */ + SignCertResponseDTO signAndRecord(String csrPem, String clientId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java new file mode 100644 index 0000000..729833d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java @@ -0,0 +1,86 @@ +/* + * 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.certificate; + +import java.security.cert.X509Certificate; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +/** + * Implementation of {@link CertificateSigningProvider} that delegates to + * {@link VaultPkiService} for signing and {@link CertificateEventService} for audit. + */ +@Service +@Slf4j +public class CertificateSigningProviderImpl implements CertificateSigningProvider { + + private final VaultPkiService vaultPkiService; + private final OrganisationCertificateService certificateService; + private final CertificateEventService eventService; + + public CertificateSigningProviderImpl( + VaultPkiService vaultPkiService, + OrganisationCertificateService certificateService, + CertificateEventService eventService) { + this.vaultPkiService = vaultPkiService; + this.certificateService = certificateService; + this.eventService = eventService; + } + + @Override + @Transactional + public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { + OrganisationCertificateDTO cert = certificateService + .findByClientId(clientId) + .orElseThrow(() -> { + log.warn("No certificate record found for client {}", clientId); + return new CertificateSigningException("No certificate record found for client"); + }); + + if (!Boolean.TRUE.equals(cert.getCertificateAutomationEnabled())) { + log.warn("Certificate automation not enabled for client {}", clientId); + throw new CertificateSigningException("Certificate automation is not enabled for this organisation"); + } + + if (!Boolean.TRUE.equals(cert.getIsRenewable())) { + log.warn("Certificate is not marked as renewable for client {}", clientId); + throw new CertificateSigningException("Certificate is not renewable"); + } + + cert.setRequestedAt(Timestamp.from(Instant.now())); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.empty()); + + X509Certificate x509 = PemUtil.parseCertificate(response.getCertificate()); + cert.setSubjectDn(x509.getSubjectX500Principal().getName()); + cert.setSerialNumber(response.getSerialNumber()); + cert.setIssuedAt(Timestamp.from(x509.getNotBefore().toInstant())); + cert.setExpiresAt( + Timestamp.from(Instant.ofEpochSecond(response.getExpiration().longValue()))); + cert.setType(CertificateType.AUTOMATED); + + certificateService.save(cert); + + eventService.recordEvent(cert.getId(), CertificateType.AUTOMATED, CertificateEventType.RENEWED, clientId); + + log.info("Certificate signed and recorded for client {}, serial {}", clientId, response.getSerialNumber()); + + return response; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java new file mode 100644 index 0000000..cec73e2 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java @@ -0,0 +1,43 @@ +/* + * 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.certificate; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; + +/** + * Provides certificate validation logic for organisation certificates. + */ +public interface CertificateValidationProvider { + + /** + * Find a certificate by the IDP client ID of its organisation's consumer or producer. + * + * @param clientId the IDP client ID + * @return the certificate DTO if found + */ + Optional findByClientId(String clientId); + + /** + * Checks whether the given certificate is active (not revoked and not expired). + * + * @param cert the certificate DTO to check + * @return true if the certificate is active + */ + boolean isActive(OrganisationCertificateDTO cert); + + /** + * Returns the subset of organisation IDs that have an active certificate record. + * Organisations without a certificate record are excluded. + * + * @param organisationIds the organisation IDs to check + * @return the IDs of organisations with active certificates + */ + Set findActiveOrganisationIds(Collection organisationIds); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java new file mode 100644 index 0000000..d556de0 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java @@ -0,0 +1,63 @@ +/* + * 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.certificate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +/** + * Implementation of {@link CertificateValidationProvider} that delegates to + * {@link OrganisationCertificateService} for data access. + */ +@Service +public class CertificateValidationProviderImpl implements CertificateValidationProvider { + + private final OrganisationCertificateService certificateService; + + public CertificateValidationProviderImpl(OrganisationCertificateService certificateService) { + this.certificateService = certificateService; + } + + @Override + public Optional findByClientId(String clientId) { + return certificateService.findByClientId(clientId); + } + + @Override + public boolean isActive(OrganisationCertificateDTO cert) { + return !isRevoked(cert.getRevokedAt()) && !isExpired(cert.getExpiresAt()); + } + + @Override + public Set findActiveOrganisationIds(Collection organisationIds) { + if (organisationIds == null || organisationIds.isEmpty()) { + return Set.of(); + } + + List certs = certificateService.findAllByOrganisationIds(organisationIds); + return certs.stream() + .filter(this::isActive) + .map(OrganisationCertificateDTO::getOrganisationId) + .collect(Collectors.toSet()); + } + + private boolean isRevoked(Timestamp revokedAt) { + return revokedAt != null && revokedAt.toInstant().isBefore(Instant.now()); + } + + private boolean isExpired(Timestamp expiresAt) { + return expiresAt != null && expiresAt.toInstant().isBefore(Instant.now()); + } +} 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 eff22aa..68d790c 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 @@ -8,15 +8,22 @@ import java.math.BigDecimal; import java.sql.Timestamp; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; /** * Implementation of {@link ConfigurationProvider} that retrieves configuration from database services. @@ -30,21 +37,26 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final ProducerService producerService; + private final CertificateValidationProvider certificateValidationProvider; + /** * Constructs a new ConfigurationProviderImpl with required services. * * @param consumerService the consumer service * @param consumerAllowedDataProviders the product consumer service * @param producerService the producer service + * @param certificateValidationProvider the certificate validation provider */ public ConfigurationProviderImpl( ConsumerService consumerService, ProductConsumerService consumerAllowedDataProviders, - ProducerService producerService) { + ProducerService producerService, + CertificateValidationProvider certificateValidationProvider) { this.consumerService = consumerService; this.productConsumerService = consumerAllowedDataProviders; this.producerService = producerService; + this.certificateValidationProvider = certificateValidationProvider; } /** @@ -58,7 +70,7 @@ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity return grantedTs != null && grantedTs .toInstant() - .plus(java.time.Duration.ofDays(validity.longValue())) + .plus(Duration.ofDays(validity.longValue())) .isAfter(Instant.now()); } @@ -76,9 +88,11 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional(validProductIds); + Set validIdsSet = new HashSet<>(validProductIds); producers.forEach( p -> p.getProducts().removeIf(prod -> prod.getId() == null || !validIdsSet.contains(prod.getId()))); } else { @@ -136,8 +150,7 @@ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional collectDataProviderIds(List producers) { } /** - * Processes consumers for each provider in the given producers. + * Resolves consumers for each product and populates them onto the product DTOs, + * filtering out consumers whose organisations have inactive certificates. * - * @param producers list of producers to process + * @param producers the list of producers whose products need consumer resolution */ - /** - * Processes consumers for a list of producers. - * - * @param producers the list of producers - */ - private void processConsumersForProducers(List producers) { + private void populateConsumersForProducers(List producers) { + // Resolve all valid consumers per product + Map> consumersByProduct = new LinkedHashMap<>(); for (ProducerDTO producer : producers) { - for (ProductDTO provider : producer.getProducts()) { - processConsumersForProvider(provider); + for (ProductDTO product : producer.getProducts()) { + List resolved = productConsumerService.findByDataProviderId(product.getId()).stream() + .filter(this::isValidProvider) + .map(cp -> consumerService.findById(cp.getConsumerId())) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + consumersByProduct.put(product, resolved); } } - } - - /** - * Processes consumers for a specific provider. - * - * @param provider the provider to process consumers for - */ - /** - * Processes consumers for a specific provider. - * - * @param provider the product provider DTO - */ - private void processConsumersForProvider(ProductDTO provider) { - // Get consumer providers for this data provider - List consumerProviders = productConsumerService.findByDataProviderId(provider.getId()); - - // Filter valid providers and add their consumers - addValidConsumersToProvider(consumerProviders, provider); - } - - /** - * Adds valid consumers to the given provider. - * - * @param consumerProviders list of consumer-provider relationships - * @param provider the provider to add consumers to - */ - /** - * Adds valid consumers to a provider. - * - * @param consumerProviders the list of product consumer DTOs - * @param provider the product provider DTO - */ - private void addValidConsumersToProvider(List consumerProviders, ProductDTO provider) { - if (provider.getConsumers() == null) { - provider.setConsumers(new ArrayList<>()); + Set allConsumerOrgIds = consumersByProduct.values().stream() + .flatMap(List::stream) + .map(ConsumerDTO::getOrgId) + .collect(Collectors.toSet()); + Set activeOrgIds = certificateValidationProvider.findActiveOrganisationIds(allConsumerOrgIds); + + // Populate each product's consumer list, skipping inactive orgs + for (var entry : consumersByProduct.entrySet()) { + ProductDTO product = entry.getKey(); + if (product.getConsumers() == null) { + product.setConsumers(new ArrayList<>()); + } + for (ConsumerDTO consumer : entry.getValue()) { + if (activeOrgIds.contains(consumer.getOrgId())) { + product.getConsumers().add(consumer); + } + } } - consumerProviders.stream().filter(this::isValidProvider).forEach(consumerProvider -> { - Optional consumer = consumerService.findById(consumerProvider.getConsumerId()); - consumer.ifPresent(provider.getConsumers()::add); - }); } /** @@ -274,4 +269,24 @@ private boolean isValidProvider(ProductConsumerDTO provider) { return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); } + + /** + * Filters producers to only those whose organisations have active certificates. + * + * @param producers the list of producers to filter + * @return producers with active organisation certificates + */ + private List filterProducersByActiveCertificate(List producers) { + Set producerOrgIds = producers.stream().map(ProducerDTO::getOrgId).collect(Collectors.toSet()); + + if (producerOrgIds.isEmpty()) { + return producers; + } + + Set activeOrgIds = certificateValidationProvider.findActiveOrganisationIds(producerOrgIds); + + return producers.stream() + .filter(p -> activeOrgIds.contains(p.getOrgId())) + .toList(); + } } diff --git a/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql new file mode 100644 index 0000000..a0fa405 --- /dev/null +++ b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql @@ -0,0 +1,60 @@ +ALTER TABLE mn.organisation +ADD COLUMN certificate_automation_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE mn.organisation_certificate ( + id BIGSERIAL PRIMARY KEY, + organisation_id BIGINT NOT NULL UNIQUE, + subject_dn VARCHAR(500), + serial_number VARCHAR(150), + is_renewable BOOLEAN NOT NULL DEFAULT FALSE, + renewal_ttl BIGINT, + type VARCHAR(50) NOT NULL, + requested_at TIMESTAMP, + issued_at TIMESTAMP, + expires_at TIMESTAMP, + revoked_at TIMESTAMP, + CONSTRAINT fk_organisation_certificate__organisation_id + FOREIGN KEY (organisation_id) REFERENCES mn.organisation (id) +); +CREATE INDEX idx_organisation_certificate__organisation_id +ON mn.organisation_certificate (organisation_id); + +CREATE TABLE mn.certificate_events ( + id BIGSERIAL PRIMARY KEY, + organisation_certificate_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + event_type VARCHAR(50) NOT NULL, + event_time TIMESTAMP NOT NULL, + performed_by VARCHAR(255), + CONSTRAINT fk_certificate_events__organisation_certificate_id + FOREIGN KEY (organisation_certificate_id) + REFERENCES mn.organisation_certificate (id) +); +CREATE INDEX idx_certificate_events__organisation_certificate_id +ON mn.certificate_events (organisation_certificate_id); + +/* Mark existing orgs as manually configured */ +UPDATE mn.organisation SET certificate_automation_enabled = FALSE; +INSERT INTO mn.organisation_certificate ( + organisation_id, + subject_dn, + serial_number, + is_renewable, + type, + requested_at, + issued_at, + expires_at, + revoked_at +) +SELECT + id AS organisation_id, + NULL AS subject_dn, + NULL AS serial_number, + FALSE AS is_renewable, + 'MANUAL' AS type, + NULL AS requested_at, + NULL AS issued_at, + NULL AS expires_at, + NULL AS revoked_at +FROM mn.organisation +; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java new file mode 100644 index 0000000..045d8e9 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java @@ -0,0 +1,291 @@ +/* + * 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.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.cert.X509Certificate; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.method.HandlerMethod; +import uk.gov.dbt.ndtp.ia.node.management.controller.v1.CertificateController; +import uk.gov.dbt.ndtp.ia.node.management.controller.v1.ConfigurationController; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; + +class CertificateValidationInterceptorTest { + + @Mock + private CertificateValidationProvider validationProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private HandlerMethod handlerMethod; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + private CertificateValidationInterceptor interceptor; + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + interceptor = new CertificateValidationInterceptor(validationProvider, new ObjectMapper()); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() throws Exception { + SecurityContextHolder.clearContext(); + closeable.close(); + } + + private void setupAuthentication(String clientId) { + EnhancedPrincipal principal = new EnhancedPrincipal("subject", clientId); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(principal); + } + + private StringWriter setupResponseWriter() throws Exception { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + when(response.getWriter()).thenReturn(pw); + return sw; + } + + private OrganisationCertificateDTO certDto(CertificateType type, String serial) { + return OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(1L) + .certificateAutomationEnabled(type != CertificateType.MANUAL) + .type(type) + .serialNumber(serial) + .expiresAt(Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS))) + .build(); + } + + @Test + void noAuthentication_returns403() throws Exception { + when(securityContext.getAuthentication()).thenReturn(null); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void nonEnhancedPrincipal_returns403() throws Exception { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("plain-string-principal"); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void emptyClientId_returns403() throws Exception { + setupAuthentication(""); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void noCertRecord_returns403() throws Exception { + setupAuthentication("client-1"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.empty()); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @ParameterizedTest + @EnumSource( + value = CertificateType.class, + names = {"AUTOMATED", "MANUAL"}) + void activeCert_passesThrough(CertificateType type) throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(type, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + } + + @Test + void inactiveCert_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(false); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + static Stream serialNumberFormats() { + return Stream.of( + Arguments.of("abc123", "plain hex match"), + Arguments.of("ABC123", "case-insensitive match"), + Arguments.of("ab:c1:23", "colon-separated match")); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("serialNumberFormats") + void matchingSerialNumber_passesThrough(String storedSerial, String description) throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, storedSerial); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSerialNumber()).thenReturn(new BigInteger("abc123", 16)); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .thenReturn(new X509Certificate[] {mockCert}); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + } + + @Test + void mismatchedSerialNumber_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, "abc123"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSerialNumber()).thenReturn(new BigInteger("def456", 16)); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .thenReturn(new X509Certificate[] {mockCert}); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void noCertificateWhenSerialExpected_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, "abc123"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")).thenReturn(null); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void nullSerialNumber_skipsCheck() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + verify(request, never()).getAttribute("jakarta.servlet.request.X509Certificate"); + } + + @Test + void bootstrapCert_annotatedController_passesThrough() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + when(handlerMethod.getBeanType()).thenReturn((Class) CertificateController.class); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + } + + @Test + void bootstrapCert_unannotatedController_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + when(handlerMethod.getBeanType()).thenReturn((Class) ConfigurationController.class); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void bootstrapCert_nonHandlerMethod_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + when(request.getRequestURI()).thenReturn("/api/v1/something"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, "not-a-handler-method")) + .isFalse(); + verify(response).setStatus(403); + } + + @Test + void errorResponse_containsStatusAndMessage() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(false); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + StringWriter sw = setupResponseWriter(); + + interceptor.preHandle(request, response, handlerMethod); + + verify(response).setStatus(403); + verify(response).setContentType("application/json"); + String body = sw.toString(); + assertThat(body).contains("403").contains("Organisation certificate is not active"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java index fe51be9..e3860db 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -19,15 +19,23 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CertificateInfoDTO; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrRequestDTO; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrResponseDTO; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateKeyResponseDTO; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.IntermediateCertResponseDTO; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; @ExtendWith(MockitoExtension.class) @@ -38,12 +46,33 @@ class CertificateControllerTest { @Mock private VaultPkiService pkiService; + @Mock + private CertificateSigningProvider signingProvider; + @InjectMocks private CertificateController certificateController; + private static final EnhancedPrincipal TEST_PRINCIPAL = new EnhancedPrincipal("subject", "client-1"); + @BeforeEach void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(certificateController).build(); + mockMvc = MockMvcBuilders.standaloneSetup(certificateController) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return TEST_PRINCIPAL; + } + }) + .build(); } @Test @@ -75,18 +104,16 @@ void createCsr_shouldReturnCsr() throws Exception { } @Test - void signCsr_shouldReturnSignedCert() throws Exception { + void signCsr_shouldDelegateToSigningProvider() throws Exception { SignCertResponseDTO response = SignCertResponseDTO.builder() .certificate("CERT") .serialNumber("123") .build(); - when(pkiService.signCsr(anyString(), any(), any())).thenReturn(response); - - String jsonRequest = "{\"csr\":\"CSR\"}"; + when(signingProvider.signAndRecord("CSR", "client-1")).thenReturn(response); mockMvc.perform(post("/api/v1/certificate/csr/sign") .contentType(MediaType.APPLICATION_JSON) - .content(jsonRequest)) + .content("{\"csr\":\"CSR\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.certificate").value("CERT")); } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java new file mode 100644 index 0000000..ad56a64 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java @@ -0,0 +1,164 @@ +/* + * 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.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +class OrganisationCertificateConverterTest { + + @Mock + private OrganisationRepository organisationRepository; + + private OrganisationCertificateConverter converter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + converter = new OrganisationCertificateConverter(organisationRepository); + } + + @Test + void toDto_withNullEntity_returnsNull() { + assertThat(converter.toDto(null)).isNull(); + } + + @Test + void toDto_mapsAllFieldsCorrectly() { + Organisation org = new Organisation(); + org.setId(10L); + org.setName("Test Org"); + org.setCertificateAutomationEnabled(true); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(org); + entity.setSubjectDn("CN=test"); + entity.setSerialNumber("abc123"); + entity.setIsRenewable(true); + entity.setRenewalTtl(3600L); + entity.setType(CertificateType.AUTOMATED); + entity.setRequestedAt(now); + entity.setIssuedAt(now); + entity.setExpiresAt(now); + entity.setRevokedAt(null); + + OrganisationCertificateDTO dto = converter.toDto(entity); + + assertThat(dto.getId()).isEqualTo(1L); + assertThat(dto.getOrganisationId()).isEqualTo(10L); + assertThat(dto.getCertificateAutomationEnabled()).isTrue(); + assertThat(dto.getSubjectDn()).isEqualTo("CN=test"); + assertThat(dto.getSerialNumber()).isEqualTo("abc123"); + assertThat(dto.getIsRenewable()).isTrue(); + assertThat(dto.getRenewalTtl()).isEqualTo(3600L); + assertThat(dto.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(dto.getRequestedAt()).isEqualTo(now); + assertThat(dto.getIssuedAt()).isEqualTo(now); + assertThat(dto.getExpiresAt()).isEqualTo(now); + assertThat(dto.getRevokedAt()).isNull(); + } + + @Test + void toDto_withNullOrganisation_setsNullOrgFields() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(null); + entity.setType(CertificateType.BOOTSTRAP); + + OrganisationCertificateDTO dto = converter.toDto(entity); + + assertThat(dto.getOrganisationId()).isNull(); + assertThat(dto.getCertificateAutomationEnabled()).isNull(); + } + + @Test + void toEntity_withNullDto_returnsNull() { + assertThat(converter.toEntity(null)).isNull(); + } + + @Test + void toEntity_mapsAllFieldsCorrectly() { + Organisation org = new Organisation(); + org.setId(10L); + org.setName("Test Org"); + when(organisationRepository.findById(10L)).thenReturn(Optional.of(org)); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(10L) + .subjectDn("CN=test") + .serialNumber("abc123") + .isRenewable(true) + .renewalTtl(3600L) + .type(CertificateType.AUTOMATED) + .requestedAt(now) + .issuedAt(now) + .expiresAt(now) + .revokedAt(null) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getId()).isEqualTo(1L); + assertThat(entity.getOrganisation()).isEqualTo(org); + assertThat(entity.getSubjectDn()).isEqualTo("CN=test"); + assertThat(entity.getSerialNumber()).isEqualTo("abc123"); + assertThat(entity.getIsRenewable()).isTrue(); + assertThat(entity.getRenewalTtl()).isEqualTo(3600L); + assertThat(entity.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(entity.getRequestedAt()).isEqualTo(now); + assertThat(entity.getIssuedAt()).isEqualTo(now); + assertThat(entity.getExpiresAt()).isEqualTo(now); + assertThat(entity.getRevokedAt()).isNull(); + } + + @Test + void toEntity_withNullOrganisationId_doesNotLookUpOrg() { + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(null) + .type(CertificateType.BOOTSTRAP) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getOrganisation()).isNull(); + } + + @Test + void toEntity_withUnknownOrganisationId_setsNullOrg() { + when(organisationRepository.findById(999L)).thenReturn(Optional.empty()); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(999L) + .type(CertificateType.AUTOMATED) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getOrganisation()).isNull(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java new file mode 100644 index 0000000..7c77cfb --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java @@ -0,0 +1,39 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class CertificateEventTest { + + @Test + void testCertificateEventSettersAndGetters() { + OrganisationCertificate cert = new OrganisationCertificate(); + cert.setId(1L); + + Timestamp now = Timestamp.from(Instant.now()); + + CertificateEvent event = new CertificateEvent(); + event.setId(5L); + event.setOrganisationCertificate(cert); + event.setType(CertificateType.AUTOMATED); + event.setEventType(CertificateEventType.ISSUED); + event.setEventTime(now); + event.setPerformedBy("system"); + + assertThat(event.getId()).isEqualTo(5L); + assertThat(event.getOrganisationCertificate()).isEqualTo(cert); + assertThat(event.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(event.getEventType()).isEqualTo(CertificateEventType.ISSUED); + assertThat(event.getEventTime()).isEqualTo(now); + assertThat(event.getPerformedBy()).isEqualTo("system"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java new file mode 100644 index 0000000..c7a657e --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java @@ -0,0 +1,34 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CertificateEventTypeTest { + + @Test + void testCertificateEventTypeValues() { + CertificateEventType[] values = CertificateEventType.values(); + + assertThat(values) + .containsExactly( + CertificateEventType.ISSUED, + CertificateEventType.RENEWED, + CertificateEventType.EXPIRED, + CertificateEventType.REVOKED); + } + + @Test + void testCertificateEventTypeValueOf() { + assertThat(CertificateEventType.valueOf("ISSUED")).isEqualTo(CertificateEventType.ISSUED); + assertThat(CertificateEventType.valueOf("RENEWED")).isEqualTo(CertificateEventType.RENEWED); + assertThat(CertificateEventType.valueOf("EXPIRED")).isEqualTo(CertificateEventType.EXPIRED); + assertThat(CertificateEventType.valueOf("REVOKED")).isEqualTo(CertificateEventType.REVOKED); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java new file mode 100644 index 0000000..a6fd976 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java @@ -0,0 +1,29 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CertificateTypeTest { + + @Test + void testCertificateTypeValues() { + CertificateType[] values = CertificateType.values(); + + assertThat(values) + .containsExactly(CertificateType.BOOTSTRAP, CertificateType.MANUAL, CertificateType.AUTOMATED); + } + + @Test + void testCertificateTypeValueOf() { + assertThat(CertificateType.valueOf("BOOTSTRAP")).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(CertificateType.valueOf("MANUAL")).isEqualTo(CertificateType.MANUAL); + assertThat(CertificateType.valueOf("AUTOMATED")).isEqualTo(CertificateType.AUTOMATED); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java new file mode 100644 index 0000000..069d4c3 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java @@ -0,0 +1,56 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class OrganisationCertificateTest { + + @Test + void testOrganisationCertificateSettersAndGetters() { + Organisation organisation = new Organisation(); + organisation.setId(1L); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificate cert = new OrganisationCertificate(); + cert.setId(10L); + cert.setOrganisation(organisation); + cert.setSubjectDn("CN=test,O=NDTP"); + cert.setSerialNumber("ABC123"); + cert.setIsRenewable(true); + cert.setRenewalTtl(86400L); + cert.setType(CertificateType.BOOTSTRAP); + cert.setRequestedAt(now); + cert.setIssuedAt(now); + cert.setExpiresAt(now); + cert.setRevokedAt(now); + + assertThat(cert.getId()).isEqualTo(10L); + assertThat(cert.getOrganisation()).isEqualTo(organisation); + assertThat(cert.getSubjectDn()).isEqualTo("CN=test,O=NDTP"); + assertThat(cert.getSerialNumber()).isEqualTo("ABC123"); + assertThat(cert.getIsRenewable()).isTrue(); + assertThat(cert.getRenewalTtl()).isEqualTo(86400L); + assertThat(cert.getType()).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(cert.getRequestedAt()).isEqualTo(now); + assertThat(cert.getIssuedAt()).isEqualTo(now); + assertThat(cert.getExpiresAt()).isEqualTo(now); + assertThat(cert.getRevokedAt()).isEqualTo(now); + } + + @Test + void testIsRenewableDefaultsToFalse() { + OrganisationCertificate cert = new OrganisationCertificate(); + + assertThat(cert.getIsRenewable()).isFalse(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java new file mode 100644 index 0000000..cdeed49 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java @@ -0,0 +1,33 @@ +/* + * 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.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class OrganisationTest { + + @Test + void testOrganisationSettersAndGetters() { + Organisation organisation = new Organisation(); + organisation.setId(1L); + organisation.setName("Test Organisation"); + organisation.setCertificateAutomationEnabled(false); + + assertThat(organisation.getId()).isEqualTo(1L); + assertThat(organisation.getName()).isEqualTo("Test Organisation"); + assertThat(organisation.getCertificateAutomationEnabled()).isFalse(); + } + + @Test + void testCertificateAutomationEnabledDefaultsToTrue() { + Organisation organisation = new Organisation(); + + assertThat(organisation.getCertificateAutomationEnabled()).isTrue(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java new file mode 100644 index 0000000..fb775c5 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java @@ -0,0 +1,62 @@ +/* + * 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.data.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.CertificateEventRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; + +class CertificateEventServiceImplTest { + + @Mock + private CertificateEventRepository eventRepository; + + @Mock + private OrganisationCertificateRepository certificateRepository; + + private CertificateEventServiceImpl service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new CertificateEventServiceImpl(eventRepository, certificateRepository); + } + + @Test + void recordEvent_savesEventWithCorrectFields() { + OrganisationCertificate certRef = new OrganisationCertificate(); + certRef.setId(5L); + when(certificateRepository.getReferenceById(5L)).thenReturn(certRef); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Instant before = Instant.now(); + service.recordEvent(5L, CertificateType.AUTOMATED, CertificateEventType.RENEWED, "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CertificateEvent.class); + verify(eventRepository).save(captor.capture()); + + CertificateEvent saved = captor.getValue(); + assertThat(saved.getOrganisationCertificate()).isEqualTo(certRef); + assertThat(saved.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(saved.getEventType()).isEqualTo(CertificateEventType.RENEWED); + assertThat(saved.getPerformedBy()).isEqualTo("client-1"); + assertThat(saved.getEventTime().toInstant()).isBetween(before, Instant.now()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java new file mode 100644 index 0000000..3a21027 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java @@ -0,0 +1,154 @@ +/* + * 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.data.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationCertificateConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; + +class OrganisationCertificateServiceImplTest { + + @Mock + private OrganisationCertificateRepository repository; + + @Mock + private OrganisationCertificateConverter converter; + + private OrganisationCertificateServiceImpl service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new OrganisationCertificateServiceImpl(repository, converter); + } + + @Test + void findByOrganisationId_whenFound_returnsDto() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + + when(repository.findByOrganisationId(10L)).thenReturn(Optional.of(entity)); + when(converter.toDto(entity)).thenReturn(dto); + + Optional result = service.findByOrganisationId(10L); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(1L); + } + + @Test + void findByOrganisationId_whenNotFound_returnsEmpty() { + when(repository.findByOrganisationId(99L)).thenReturn(Optional.empty()); + + Optional result = service.findByOrganisationId(99L); + + assertThat(result).isEmpty(); + } + + @Test + void findAllByOrganisationIds_delegatesToRepositoryAndConverter() { + Organisation org = new Organisation(); + org.setId(1L); + org.setCertificateAutomationEnabled(true); + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(org); + entity.setType(CertificateType.AUTOMATED); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(1L) + .type(CertificateType.AUTOMATED) + .build(); + + when(repository.findAllWithOrganisationByOrganisationIdIn(Set.of(1L, 2L))) + .thenReturn(List.of(entity)); + when(converter.toDtoList(List.of(entity))).thenReturn(List.of(dto)); + + List result = service.findAllByOrganisationIds(Set.of(1L, 2L)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(1L); + } + + @Test + void findAllByOrganisationIds_withNullInput_returnsEmptyList() { + List result = service.findAllByOrganisationIds(null); + + assertThat(result).isEmpty(); + verifyNoInteractions(repository); + } + + @Test + void findAllByOrganisationIds_withEmptyInput_returnsEmptyList() { + List result = service.findAllByOrganisationIds(List.of()); + + assertThat(result).isEmpty(); + verifyNoInteractions(repository); + } + + @Test + void findByClientId_whenFound_returnsDto() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + + when(repository.findByClientId("client-1")).thenReturn(Optional.of(entity)); + when(converter.toDto(entity)).thenReturn(dto); + + Optional result = service.findByClientId("client-1"); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(1L); + } + + @Test + void findByClientId_whenNotFound_returnsEmpty() { + when(repository.findByClientId("unknown")).thenReturn(Optional.empty()); + + Optional result = service.findByClientId("unknown"); + + assertThat(result).isEmpty(); + } + + @Test + void save_convertsAndPersists() { + OrganisationCertificateDTO inputDto = + OrganisationCertificateDTO.builder().id(1L).serialNumber("abc").build(); + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setSerialNumber("abc"); + OrganisationCertificateDTO outputDto = + OrganisationCertificateDTO.builder().id(1L).serialNumber("abc").build(); + + when(converter.toEntity(inputDto)).thenReturn(entity); + when(repository.save(entity)).thenReturn(entity); + when(converter.toDto(entity)).thenReturn(outputDto); + + OrganisationCertificateDTO result = service.save(inputDto); + + assertThat(result.getSerialNumber()).isEqualTo("abc"); + verify(repository).save(entity); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java new file mode 100644 index 0000000..d9788ce --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java @@ -0,0 +1,187 @@ +/* + * 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.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.security.cert.X509Certificate; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import javax.security.auth.x500.X500Principal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +class CertificateSigningProviderImplTest { + + @Mock + private VaultPkiService vaultPkiService; + + @Mock + private OrganisationCertificateService certificateService; + + @Mock + private CertificateEventService eventService; + + private CertificateSigningProviderImpl provider; + private MockedStatic pemUtilMock; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + provider = new CertificateSigningProviderImpl(vaultPkiService, certificateService, eventService); + pemUtilMock = mockStatic(PemUtil.class); + } + + @AfterEach + void tearDown() { + pemUtilMock.close(); + } + + private OrganisationCertificateDTO buildCert(CertificateType type, boolean automationEnabled, boolean renewable) { + return OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(10L) + .type(type) + .certificateAutomationEnabled(automationEnabled) + .isRenewable(renewable) + .build(); + } + + private SignCertResponseDTO buildSignResponse() { + return SignCertResponseDTO.builder() + .certificate("CERT_PEM") + .serialNumber("abc123") + .expiration(1735689600L) + .build(); + } + + private static final Instant NOT_BEFORE = Instant.parse("2025-01-01T00:00:00Z"); + + private void mockPemParsing(String subjectDn) { + X509Certificate x509 = mock(X509Certificate.class); + when(x509.getSubjectX500Principal()).thenReturn(new X500Principal(subjectDn)); + when(x509.getNotBefore()).thenReturn(Date.from(NOT_BEFORE)); + pemUtilMock.when(() -> PemUtil.parseCertificate("CERT_PEM")).thenReturn(x509); + } + + @Test + void signAndRecord_updatesRecordAndCreatesEvent() { + Instant before = Instant.now(); + OrganisationCertificateDTO cert = buildCert(CertificateType.BOOTSTRAP, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(buildSignResponse()); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test,O=NDTP"); + + SignCertResponseDTO result = provider.signAndRecord("CSR", "client-1"); + + assertThat(result.getSerialNumber()).isEqualTo("abc123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + + OrganisationCertificateDTO saved = captor.getValue(); + assertThat(saved.getSerialNumber()).isEqualTo("abc123"); + assertThat(saved.getSubjectDn()).isEqualTo("CN=test,O=NDTP"); + assertThat(saved.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(saved.getRequestedAt()).isNotNull(); + assertThat(saved.getRequestedAt().toInstant()).isBetween(before, Instant.now()); + assertThat(saved.getIssuedAt()).isEqualTo(Timestamp.from(NOT_BEFORE)); + assertThat(saved.getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); + + verify(eventService).recordEvent(1L, CertificateType.AUTOMATED, CertificateEventType.RENEWED, "client-1"); + } + + @Test + void signAndRecord_noCertRecord_throwsException() { + when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "unknown")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("No certificate record"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_automationDisabled_throwsException() { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, false, false); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "client-1")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("automation is not enabled"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_notRenewable_throwsException() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, false); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "client-1")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("not renewable"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_alreadyAutomated_staysAutomated() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(buildSignResponse()); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test"); + + provider.signAndRecord("CSR", "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo(CertificateType.AUTOMATED); + } + + @Test + void signAndRecord_parsesExpirationAsEpochSeconds() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + SignCertResponseDTO response = SignCertResponseDTO.builder() + .certificate("CERT_PEM") + .serialNumber("ser1") + .expiration(1735689600L) + .build(); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(response); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test"); + + provider.signAndRecord("CSR", "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + assertThat(captor.getValue().getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java new file mode 100644 index 0000000..0eb9a41 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java @@ -0,0 +1,135 @@ +/* + * 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.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +class CertificateValidationProviderImplTest { + + @Mock + private OrganisationCertificateService certificateService; + + private CertificateValidationProviderImpl provider; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + provider = new CertificateValidationProviderImpl(certificateService); + } + + private OrganisationCertificateDTO cert(Long orgId, Timestamp expiresAt, Timestamp revokedAt) { + return OrganisationCertificateDTO.builder() + .id(orgId) + .organisationId(orgId) + .expiresAt(expiresAt) + .revokedAt(revokedAt) + .build(); + } + + @Test + void findByClientId_delegatesToService() { + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(dto)); + + assertThat(provider.findByClientId("client-1")).isPresent(); + } + + @Test + void findByClientId_unknownClient_returnsEmpty() { + when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + + assertThat(provider.findByClientId("unknown")).isEmpty(); + } + + @Test + void isActive_nullExpiryNullRevocation_returnsTrue() { + assertThat(provider.isActive(cert(1L, null, null))).isTrue(); + } + + @Test + void isActive_futureExpiry_returnsTrue() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, future, null))).isTrue(); + } + + @Test + void isActive_pastExpiry_returnsFalse() { + Timestamp past = Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, past, null))).isFalse(); + } + + @Test + void isActive_pastRevocation_returnsFalse() { + Timestamp revokedAt = Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)); + + assertThat(provider.isActive(cert(1L, null, revokedAt))).isFalse(); + } + + @Test + void isActive_futureRevocation_returnsTrue() { + Timestamp futureRevocation = Timestamp.from(Instant.now().plus(7, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, null, futureRevocation))).isTrue(); + } + + @Test + void findActiveOrganisationIds_nullInput_returnsEmptySet() { + assertThat(provider.findActiveOrganisationIds(null)).isEmpty(); + } + + @Test + void findActiveOrganisationIds_emptyInput_returnsEmptySet() { + assertThat(provider.findActiveOrganisationIds(Set.of())).isEmpty(); + } + + @Test + void findActiveOrganisationIds_returnsOnlyActiveOrgs() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + Timestamp past = Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Timestamp revokedAt = Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)); + + OrganisationCertificateDTO active = cert(1L, future, null); + OrganisationCertificateDTO expired = cert(2L, past, null); + OrganisationCertificateDTO revoked = cert(3L, future, revokedAt); + + when(certificateService.findAllByOrganisationIds(Set.of(1L, 2L, 3L))) + .thenReturn(List.of(active, expired, revoked)); + + Set result = provider.findActiveOrganisationIds(Set.of(1L, 2L, 3L)); + + assertThat(result).containsExactly(1L); + } + + @Test + void findActiveOrganisationIds_orgsWithoutCertRecords_excluded() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + OrganisationCertificateDTO active = cert(1L, future, null); + + when(certificateService.findAllByOrganisationIds(Set.of(1L, 2L))).thenReturn(List.of(active)); + + Set result = provider.findActiveOrganisationIds(Set.of(1L, 2L)); + + assertThat(result).containsExactly(1L); + } +} 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 1140d73..27a429e 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; @@ -7,9 +13,12 @@ import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -20,6 +29,7 @@ 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; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; class ConfigurationProviderImplTest { @@ -32,13 +42,23 @@ class ConfigurationProviderImplTest { @Mock private ProducerService producerService; + @Mock + private CertificateValidationProvider certificateValidationProvider; + @InjectMocks private ConfigurationProviderImpl configurationProvider; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - configurationProvider = new ConfigurationProviderImpl(consumerService, productConsumerService, producerService); + configurationProvider = new ConfigurationProviderImpl( + consumerService, productConsumerService, producerService, certificateValidationProvider); + // Default: treat all orgs as having active certificates, override in specific + // tests to simulate inactive/missing certs. + when(certificateValidationProvider.findActiveOrganisationIds(any())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids != null ? new HashSet<>(ids) : Set.of(); + }); } private ConsumerDTO consumer( @@ -46,6 +66,7 @@ private ConsumerDTO consumer( ConsumerDTO dto = ConsumerDTO.builder() .idpClientId(clientId) .name(name) + .orgId(1L) .scheduleType(scheduleType) .scheduleExpression(scheduleExpression) .build(); @@ -57,6 +78,7 @@ private ProducerDTO producer(long id, boolean active, ProductDTO... products) { ProducerDTO p = ProducerDTO.builder() .id(id) .active(active) + .orgId(1L) .idpClientId("cid") .name("p") .build(); @@ -291,4 +313,59 @@ void getProducerConfigByClientId_withInvalidValidity_filtersOutConsumer() { assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) .isEmpty(); } + + @Test + void getConsumerConfig_filtersOutProducersWithInactiveCerts() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); + + ProductConsumerDTO pc = productConsumer(100L, 1L, null, null); + when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of(pc)); + + // Two active producers with different orgIds + ProducerDTO activeOrgProducer = producer(10L, true, product(100L, "dp-100")); + activeOrgProducer.setOrgId(100L); + ProducerDTO inactiveOrgProducer = producer(11L, true, product(100L, "dp-100")); + inactiveOrgProducer.setOrgId(200L); + when(producerService.getProducersByConsumerIds(List.of(1L))) + .thenReturn(List.of(activeOrgProducer, inactiveOrgProducer)); + + // Only org 100 has an active certificate + when(certificateValidationProvider.findActiveOrganisationIds(Set.of(100L, 200L))) + .thenReturn(Set.of(100L)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers()).containsExactly(activeOrgProducer); + } + + @Test + void getProducerConfig_filtersOutConsumersWithInactiveCerts() { + String clientId = "clientP"; + ProductDTO p1 = product(900L, "prov1"); + ProducerDTO pr1 = producer(91L, true, p1); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + + ProductConsumerDTO cp1 = productConsumer(900L, 501L, null, null); + ProductConsumerDTO cp2 = productConsumer(900L, 502L, null, null); + when(productConsumerService.findByDataProviderId(900L)).thenReturn(List.of(cp1, cp2)); + + ConsumerDTO activeOrgConsumer = consumer(501L, "cid501", "c501", "CRON", "@hourly"); + activeOrgConsumer.setOrgId(300L); + ConsumerDTO inactiveOrgConsumer = consumer(502L, "cid502", "c502", "CRON", "@hourly"); + inactiveOrgConsumer.setOrgId(400L); + when(consumerService.findById(501L)).thenReturn(Optional.of(activeOrgConsumer)); + when(consumerService.findById(502L)).thenReturn(Optional.of(inactiveOrgConsumer)); + + // Only org 300 has an active certificate + when(certificateValidationProvider.findActiveOrganisationIds(Set.of(300L, 400L))) + .thenReturn(Set.of(300L)); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) + .containsExactly(activeOrgConsumer); + } } From 8f79769216af631c97c77f5dcd6f92bab08406ad Mon Sep 17 00:00:00 2001 From: JamesRuane-is <108880654+JamesRuane-is@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:59:44 +0000 Subject: [PATCH 06/18] [DPAV-2591] replace version in gh workflows (#47) --- .github/workflows/docker-ghcr.yml | 6 +++--- .github/workflows/maven.yml | 8 ++++---- .github/workflows/publish-mkdocs.yml | 6 +++--- .github/workflows/release.yaml | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index 7ecccc2..ffa211d 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -53,10 +53,10 @@ jobs: fi - name: Checkout repo - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Login to ghcr.io - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -66,7 +66,7 @@ jobs: run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - name: Get server and client jars - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: management-node-*.jar path: target diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d06d31e..76fb7c1 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -41,12 +41,12 @@ jobs: pull-requests: read runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 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 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '21' distribution: 'temurin' @@ -69,9 +69,9 @@ jobs: pull-requests: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '21' distribution: 'temurin' diff --git a/.github/workflows/publish-mkdocs.yml b/.github/workflows/publish-mkdocs.yml index 0cb4072..9151608 100644 --- a/.github/workflows/publish-mkdocs.yml +++ b/.github/workflows/publish-mkdocs.yml @@ -67,12 +67,12 @@ jobs: contents: write pages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 with: python-version: 3.x - name: Configure Git user for mike @@ -80,7 +80,7 @@ jobs: 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@v5 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # 5.0.3 with: key: mkdocs-material-${{ env.cache_id }} path: ~/.cache diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ed437c8..1a6d62d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -48,9 +48,9 @@ jobs: outputs: project_version: ${{ steps.get-version.outputs.project_version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -63,7 +63,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS package - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Persist server id: persist-server with: @@ -84,9 +84,9 @@ jobs: GITHUB_ACTOR: ${{ github.actor }} GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -125,7 +125,7 @@ jobs: - verify if: ${{ needs.verify.result == 'success' }} steps: - - uses: geekyeggo/delete-artifact@v5 + - uses: geekyeggo/delete-artifact@f275313e70c08f6120db482d7a6b98377786765b # v5.1.0 name: Delete server artifact with: name: management-node-${{ needs.verify.outputs.project_version }}.jar From e1df1b1afcbc6d538258385c2dd25e65ebe7fb41 Mon Sep 17 00:00:00 2001 From: JamesRuane-is <108880654+JamesRuane-is@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:29:26 +0000 Subject: [PATCH 07/18] ci(dpav-2566): trigger release workflow from main and trivy check * [DPAV-2566] trigger release from main and trivy check * [DPAV-2566] comment out publish steps for testing * feat(OSPO): synchronise OSPO workflows * Revert "[DPAV-2566] comment out publish steps for testing" This reverts commit 566fdf4727a47735101ae1dbab738cb56735fc30. * [DPAV-2566] fix trigger --- .github/workflows/auto-back-merge.yml | 98 ++++++++++++++++++++ .github/workflows/docker-ghcr.yml | 10 ++ .github/workflows/publish-github-release.yml | 11 ++- .github/workflows/release.yaml | 10 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/auto-back-merge.yml diff --git a/.github/workflows/auto-back-merge.yml b/.github/workflows/auto-back-merge.yml new file mode 100644 index 0000000..f409f69 --- /dev/null +++ b/.github/workflows/auto-back-merge.yml @@ -0,0 +1,98 @@ +# 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. + +# This workflow is triggered when a pull request is merged into the main branch and automatically merges the main branch back into develop to keep it up to date. +# This is currently opt-in via the repository variable MERGE_BACK_OPT_IN but will eventually be enabled by default. +# If the merge fails (e.g., due to conflicts), a manual intervention is required. The workflow generates a Job summary of the merge attempt. +name: Auto Back-merge Main to Develop + +on: + pull_request: + types: + - closed + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + merge-main-to-develop: + + permissions: {} + + name: Back-merge Main to Develop + # Only run if the PR was actually merged (not just closed) and + # if the repository variable MERGE_BACK_OPT_IN is set to 'true'. + # + # https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables#creating-configuration-variables-for-a-repository + if: github.event.pull_request.merged == true && vars.MERGE_BACK_OPT_IN == 'true' + runs-on: ubuntu-latest + + steps: + + - name: Generate Sync Token + id: sync-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_CLIENT_ID }} + private-key: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write + + - name: Merge main into develop and Generate Summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.sync-token.outputs.token }} + script: | + const prNumber = context.payload.pull_request.number; + const mergedBy = context.payload.sender.login; + const prUrl = context.payload.pull_request.html_url; + + try { + // Attempt to merge main into develop, use the api to ensure the commit + // is gpg signed. + await github.rest.repos.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'develop', + head: 'main', + commit_message: `Merge branch 'main' into 'develop' (#${prNumber})` + }); + + let summaryText = + `## Sync Main to Develop ✅ + + Successfully triggered a merge of \`main\` into \`develop\` following the closure of PR #${prNumber}. + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + } catch (error) { + const finalErrorMessage = error.message || error; + // Write failure summary + summaryText = + `## Sync Main to Develop ❌ + + Failed to trigger a merge of \`main\` into \`develop\`! This is usually due to a merge conflict. Please resolve it manually by opening a PR from \`main\` to \`develop\`. + + ### Error Details: + + \`\`\`text + ${finalErrorMessage} + \`\`\` + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + + // Fail the workflow step + core.setFailed(`Merge failed: ${finalErrorMessage}`); + } diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index ffa211d..f34ab2a 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -75,6 +75,16 @@ jobs: - name: Build Server Image run: docker build --no-cache --build-arg JAR_NAME="management-node-${{ inputs.jar_version }}" -t ghcr.io/${REPO}/management-node:staged -f "${{ github.workspace }}/docker/Dockerfile" --target ${{ inputs.docker_target }} . + - name: Run Trivy Scan on Server Docker Image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "ghcr.io/${{ env.REPO }}/management-node:staged" + format: "table" + exit-code: "1" + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: true + - name: Tag Server Image with tag(s) ${{ inputs.image_tag }} run: | ./.github/actions/docker-tags.sh "ghcr.io/${REPO}/management-node" "${{ inputs.image_tag }}" diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 4402c47..5c1b7c8 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -15,6 +15,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: versioning: if: | @@ -22,6 +26,7 @@ jobs: (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) permissions: contents: read + name: Extract Release Version runs-on: ubuntu-latest outputs: @@ -62,6 +67,7 @@ jobs: generate-sbom: permissions: contents: read + name: Generate SPDX SBOM runs-on: ubuntu-latest needs: [versioning] @@ -93,6 +99,7 @@ jobs: create-git-tag: permissions: contents: write + name: Create Git Tag needs: [versioning, generate-sbom] runs-on: ubuntu-latest @@ -117,12 +124,13 @@ jobs: 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@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sbom @@ -136,4 +144,3 @@ jobs: prerelease: false files: | sbom.spdx.json - diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1a6d62d..73ad910 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,10 @@ env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" on: - # Hopefully this will eventually be replaced with the release event instead + pull_request: + types: [closed] + branches: + - main workflow_dispatch: # To eventually be replaced with just using the version from the pom inputs: @@ -45,6 +48,9 @@ jobs: packages: write id-token: write runs-on: ubuntu-latest + if: | + github.event.pull_request.merged == true && + (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) outputs: project_version: ${{ steps.get-version.outputs.project_version }} steps: @@ -109,7 +115,7 @@ jobs: with: image_tag: "${{ inputs.image_tag }},latest" jar_version: ${{ needs.verify.outputs.project_version }} - dry_run: ${{ inputs.dry_run }} + dry_run: false docker_target: management-node cleanup: From b59d8be60e9a90286fae43b3aa7580cd99fb166c Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Wed, 18 Mar 2026 15:45:06 +0000 Subject: [PATCH 08/18] =?UTF-8?q?[DPAV-2612]=20Protect=20certificate=20end?= =?UTF-8?q?points=20with=20role-based=20access=20and=20=E2=80=A6=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DPAV-2612] Protect certificate endpoints with role-based access and add Keycloak TF roles * feat(OSPO): synchronise OSPO workflows --------- Co-authored-by: jsmith-informed <217566155+jsmith-informed@users.noreply.github.com> --- docker/keycloak/tofu/clients.tf | 26 ++- docker/keycloak/tofu/terraform.tfvars | 8 +- .../controller/v1/CertificateController.java | 5 + .../v1/CertificateControllerSecurityTest.java | 165 ++++++++++++++++++ 4 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerSecurityTest.java diff --git a/docker/keycloak/tofu/clients.tf b/docker/keycloak/tofu/clients.tf index 1268c6d..0dad4d2 100644 --- a/docker/keycloak/tofu/clients.tf +++ b/docker/keycloak/tofu/clients.tf @@ -32,6 +32,27 @@ resource "keycloak_role" "access_producer_configurations" { description = "Allows access to producer configuration resources" } +resource "keycloak_role" "sign_certificate" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "sign_certificate" + description = "Allows signing certificate signing requests" +} + +resource "keycloak_role" "access_public_certificates" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "access_public_certificates" + description = "Allows access to public certificate resources" +} + +resource "keycloak_role" "create_keys" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "create_keys" + description = "Allows creating key pairs and certificate signing requests" +} + # Configure clients from federator_clients variable module "federator_client" { source = "./modules/federator_client" @@ -46,6 +67,9 @@ module "federator_client" { keycloak_openid_client.management_node, keycloak_role.access_consumer_configurations, keycloak_role.access_producer_configurations, + keycloak_role.sign_certificate, + keycloak_role.access_public_certificates, + keycloak_role.create_keys, ] # Token lifespan for this client @@ -74,4 +98,4 @@ module "federator_client" { } ] ]) -} \ No newline at end of file +} diff --git a/docker/keycloak/tofu/terraform.tfvars b/docker/keycloak/tofu/terraform.tfvars index c35b1c9..33869b0 100644 --- a/docker/keycloak/tofu/terraform.tfvars +++ b/docker/keycloak/tofu/terraform.tfvars @@ -22,7 +22,7 @@ federator_clients = [ mapped_client_roles = [ { client = "management-node" - roles = ["access_producer_configurations", "access_consumer_configurations"] + roles = ["access_producer_configurations", "access_consumer_configurations", "sign_certificate", "access_public_certificates", "create_keys"] } ] }, @@ -32,7 +32,7 @@ federator_clients = [ mapped_client_roles = [ { client = "management-node" - roles = ["access_producer_configurations", "access_consumer_configurations"] + roles = ["access_producer_configurations", "access_consumer_configurations", "sign_certificate", "access_public_certificates", "create_keys"] } ] }, @@ -42,7 +42,7 @@ federator_clients = [ mapped_client_roles = [ { client = "management-node" - roles = ["access_producer_configurations", "access_consumer_configurations"] + roles = ["access_producer_configurations", "access_consumer_configurations", "sign_certificate", "access_public_certificates", "create_keys"] } ] }, @@ -52,7 +52,7 @@ federator_clients = [ mapped_client_roles = [ { client = "management-node" - roles = ["access_producer_configurations", "access_consumer_configurations"] + roles = ["access_producer_configurations", "access_consumer_configurations", "sign_certificate", "access_public_certificates", "create_keys"] } ] } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java index 8bffe98..0820dde 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import uk.gov.dbt.ndtp.ia.node.management.config.AllowBootstrapCertificates; @@ -47,6 +48,7 @@ public CertificateController(VaultPkiService pkiService, CertificateSigningProvi * @return a DTO containing the generated public and private keys in PEM format */ @GetMapping("/keyPair") + @PreAuthorize("hasAuthority('ROLE_management-node:create_keys')") @Operation( summary = "Create RSA key pair", description = "Creates a new RSA 2048-bit key pair using the configured PKI service.", @@ -74,6 +76,7 @@ public CreateKeyResponseDTO createKeyPair() { * @return a DTO containing the generated CSR PEM */ @PostMapping("/csr/create") + @PreAuthorize("hasAuthority('ROLE_management-node:create_keys')") @Operation( summary = "Create Certificate Signing Request (CSR)", description = "Generates a CSR from the provided public and private keys and subject information.", @@ -101,6 +104,7 @@ public CreateCsrResponseDTO createCsr(@RequestBody CreateCsrRequestDTO req) { * @return a DTO containing the signed certificate and its chain */ @PostMapping("/csr/sign") + @PreAuthorize("hasAuthority('ROLE_management-node:sign_certificate')") @Operation( summary = "Sign CSR", description = @@ -129,6 +133,7 @@ public SignCertResponseDTO signCsr( * @return a DTO containing the PEM certificate, CA chain, and parsed info */ @GetMapping("/intermediate") + @PreAuthorize("hasAuthority('ROLE_management-node:access_public_certificates')") @Operation( summary = "Get intermediate certificate", description = "Retrieves the configured intermediate certificate and its CA chain.", diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerSecurityTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerSecurityTest.java new file mode 100644 index 0000000..3a0b08f --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerSecurityTest.java @@ -0,0 +1,165 @@ +/* + * 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.controller.v1; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrRequestDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateKeyResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.IntermediateCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertRequestDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; + +/** + * Security tests for CertificateController. + * Verifies that @PreAuthorize annotations correctly enforce role-based access. + * + *

Uses a minimal Spring context that only enables method security and creates + * the controller bean, so @PreAuthorize checks are active without needing the + * full application context. + * + * @see + * Spring Security - Testing Method Security + */ +@SpringJUnitConfig(CertificateControllerSecurityTest.Config.class) +class CertificateControllerSecurityTest { + + @Configuration + @EnableMethodSecurity(prePostEnabled = true) + static class Config { + @Bean + CertificateController certificateController( + VaultPkiService pkiService, CertificateSigningProvider signingProvider) { + return new CertificateController(pkiService, signingProvider); + } + } + + @MockitoBean + private VaultPkiService pkiService; + + @MockitoBean + private CertificateSigningProvider signingProvider; + + @Autowired + private CertificateController controller; + + private static final EnhancedPrincipal TEST_PRINCIPAL = new EnhancedPrincipal("sub", "client-1"); + private static final SignCertRequestDTO SIGN_REQUEST = + SignCertRequestDTO.builder().csr("CSR").build(); + private static final CreateCsrRequestDTO CSR_REQUEST = new CreateCsrRequestDTO(); + + @Test + @WithMockUser(authorities = "ROLE_management-node:create_keys") + void createKeyPair_withCorrectRole_shouldSucceed() { + when(pkiService.createKeyPair(anyString(), any())) + .thenReturn(CreateKeyResponseDTO.builder() + .algorithm("RSA") + .publicKeyPem("PUB") + .privateKeyPem("PRIV") + .build()); + + assertDoesNotThrow(() -> controller.createKeyPair()); + } + + @Test + @WithMockUser( + authorities = {"ROLE_management-node:sign_certificate", "ROLE_management-node:access_public_certificates"}) + void createKeyPair_withWrongRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.createKeyPair()); + } + + @Test + @WithMockUser + void createKeyPair_withNoRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.createKeyPair()); + } + + @Test + @WithMockUser(authorities = "ROLE_management-node:create_keys") + void createCsr_withCorrectRole_shouldSucceed() { + when(pkiService.createCsr(any(CreateCsrRequestDTO.class))) + .thenReturn(new CreateCsrResponseDTO("id", "CSR_PEM")); + + assertDoesNotThrow(() -> controller.createCsr(CSR_REQUEST)); + } + + @Test + @WithMockUser(authorities = "ROLE_management-node:access_public_certificates") + void createCsr_withWrongRole_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.createCsr(CSR_REQUEST)); + } + + @Test + @WithMockUser + void createCsr_withNoRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.createCsr(CSR_REQUEST)); + } + + @Test + @WithMockUser(authorities = "ROLE_management-node:sign_certificate") + void signCsr_withCorrectRole_shouldSucceed() { + when(signingProvider.signAndRecord(anyString(), anyString())) + .thenReturn(SignCertResponseDTO.builder() + .certificate("CERT") + .serialNumber("123") + .build()); + + assertDoesNotThrow(() -> controller.signCsr(SIGN_REQUEST, TEST_PRINCIPAL)); + } + + @Test + @WithMockUser(authorities = {"ROLE_management-node:create_keys", "ROLE_management-node:access_public_certificates"}) + void signCsr_withWrongRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.signCsr(SIGN_REQUEST, TEST_PRINCIPAL)); + } + + @Test + @WithMockUser + void signCsr_withNoRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.signCsr(SIGN_REQUEST, TEST_PRINCIPAL)); + } + + @Test + @WithMockUser(authorities = "ROLE_management-node:access_public_certificates") + void getIntermediateCertificate_withCorrectRole_shouldSucceed() { + when(pkiService.getIntermediateCertificate()) + .thenReturn(IntermediateCertResponseDTO.builder() + .certificate("CERT") + .build()); + + assertDoesNotThrow(() -> controller.getIntermediateCertificate()); + } + + @Test + @WithMockUser(authorities = "ROLE_management-node:create_keys") + void getIntermediateCertificate_withWrongRole_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.getIntermediateCertificate()); + } + + @Test + @WithMockUser + void getIntermediateCertificate_withNoRoles_shouldThrowAccessDenied() { + assertThrows(AuthorizationDeniedException.class, () -> controller.getIntermediateCertificate()); + } +} From e9ab7d82d561f77508ed79250d2e8cf7b3965426 Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Wed, 18 Mar 2026 17:04:36 +0000 Subject: [PATCH 09/18] [DPAV-2440] Certificate bootstrap package (#48) * feat(DPAV-2440): Add bootstrap certificate endpoint Adds POST /api/v1/certificate/bootstrap endpoint that generates a short-lived certificate and returns it as a ZIP download containing PKCS#12 keystore, truststore, and password files. * feat(DPAV-2440): Add role-based authorization to bootstrap endpoint * fix(DPAV-2440): Fix serial number mismatch on renewal Vault returns serials with leading-zero hex octets (02:ca:22:2b) but BigInteger.toString(16) strips leading zeros (2ca22b), which caused random 403 with leading zeros. Switched to storing the result from parsed certificate instead so we don't need to perform conversion. * fix(DPAV-2440): Resolve Sonar issues * fix(DPAV-2440): Refactor bootstrap endpoint to accept caller-provided CSR - Accept CSR PEM instead of generating keypair server-side - Return PEM files (certificate.pem, ca-chain.pem) instead of PKCS#12 keystores - Use management-node-level role (request_bootstrap_certificate) instead of per-client hasRole - Rename download to bootstrap_bundle.zip - Fix GlobalExceptionHandler: resolve "No suitable resolver" error by splitting AuthorizationDeniedException into dedicated 403 handler - Remove dead code: KeyStoreBuilder, VaultPkiService.generateKeyPair/createCsr(KeyPair) - Add request_bootstrap_certificate role to Keycloak Terraform * feat(DPAV-2440): Add bootstrap OID marker and exclude bootstrap from cert validation - Add other_sans support to VaultPkiService.signCsr() to embed a configurable bootstrap OID (1.3.6.1.4.1.32473.1.1) in bootstrap certificates. - Exclude the bootstrap endpoint from CertificateValidationInterceptor so admin clients without org cert records can call it. * feat(OSPO): synchronise OSPO workflows --------- Co-authored-by: jsmith-informed <217566155+jsmith-informed@users.noreply.github.com> --- .github/workflows/auto-back-merge.yml | 2 +- .github/workflows/oss-checker.yml | 4 +- .github/workflows/publish-github-release.yml | 2 +- docker/keycloak/tofu/clients.tf | 8 + .../ia/node/management/config/WebConfig.java | 4 +- .../controller/v1/CertificateController.java | 40 +++++ .../CertificateSigningException.java | 10 ++ .../handlers/GlobalExceptionHandler.java | 20 ++- .../dto/certificates/BootstrapRequestDTO.java | 27 ++++ .../CertificateSigningProvider.java | 11 ++ .../CertificateSigningProviderImpl.java | 92 +++++++++-- .../certificate/VaultPkiService.java | 78 ++++++--- src/main/resources/application.yml | 3 + .../node/management/config/WebConfigTest.java | 43 +++++ .../v1/CertificateControllerTest.java | 14 ++ .../handlers/GlobalExceptionHandlerTest.java | 19 +++ .../CertificateSigningProviderImplTest.java | 148 +++++++++++++++++- 17 files changed, 472 insertions(+), 53 deletions(-) create mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java create mode 100644 src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java diff --git a/.github/workflows/auto-back-merge.yml b/.github/workflows/auto-back-merge.yml index f409f69..6917fb9 100644 --- a/.github/workflows/auto-back-merge.yml +++ b/.github/workflows/auto-back-merge.yml @@ -34,7 +34,7 @@ jobs: - name: Generate Sync Token id: sync-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_CLIENT_ID }} private-key: ${{ secrets.NDTP_REPOSITORY_WRITER_APP_PRIVATE_KEY }} diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 68dab5e..c15a4f8 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Fetch GitHub App token for target repo id: target_token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -37,7 +37,7 @@ jobs: - name: Fetch GitHub App token for OSPO source repo (read-only) id: ospo_token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 5c1b7c8..f68b86e 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -135,7 +135,7 @@ jobs: name: sbom - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: "v${{ needs.versioning.outputs.version }}" name: "Release v${{ needs.versioning.outputs.version }}" diff --git a/docker/keycloak/tofu/clients.tf b/docker/keycloak/tofu/clients.tf index 0dad4d2..f8f4f63 100644 --- a/docker/keycloak/tofu/clients.tf +++ b/docker/keycloak/tofu/clients.tf @@ -53,6 +53,13 @@ resource "keycloak_role" "create_keys" { description = "Allows creating key pairs and certificate signing requests" } +resource "keycloak_role" "request_bootstrap_certificate" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "request_bootstrap_certificate" + description = "Allows requesting bootstrap certificate packages" +} + # Configure clients from federator_clients variable module "federator_client" { source = "./modules/federator_client" @@ -70,6 +77,7 @@ module "federator_client" { keycloak_role.sign_certificate, keycloak_role.access_public_certificates, keycloak_role.create_keys, + keycloak_role.request_bootstrap_certificate, ] # Token lifespan for this client diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java index dab9c03..116df20 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java @@ -21,6 +21,8 @@ public WebConfig(CertificateValidationInterceptor certificateValidationIntercept @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(certificateValidationInterceptor).addPathPatterns("/api/**"); + registry.addInterceptor(certificateValidationInterceptor) + .addPathPatterns("/api/**") + .excludePathPatterns("/api/v1/certificate/bootstrap"); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java index 0820dde..9d1a22a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -13,7 +13,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -151,4 +155,40 @@ public SignCertResponseDTO signCsr( public IntermediateCertResponseDTO getIntermediateCertificate() { return pkiService.getIntermediateCertificate(); } + + /** + * Issues a short-lived bootstrap certificate and returns it as a downloadable ZIP package + * containing signed certificate and CA chain PEM files. + * + *

The caller must hold the {@code ROLE_management-node:request_bootstrap_certificate} + * authority in their JWT. The client ID in the request body identifies the target + * organisation, while the JWT identifies and authorizes the caller. + * + * @param request the bootstrap request containing the target client ID and CSR PEM + * @return a ZIP archive containing the bootstrap certificate package + */ + @PostMapping("/bootstrap") + @PreAuthorize("hasAuthority('ROLE_management-node:request_bootstrap_certificate')") + @Operation( + summary = "Issue bootstrap certificate package", + description = + "Signs the provided CSR and returns a ZIP download containing certificate.pem and ca-chain.pem. Requires the ROLE_management-node:request_bootstrap_certificate authority.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "Bootstrap certificate package created successfully", + content = @Content(mediaType = "application/zip")) + @ApiResponse(responseCode = "400", description = "Invalid request — client ID and CSR are required") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse( + responseCode = "403", + description = "Forbidden — insufficient permissions or certificate validation failure") + @ApiResponse(responseCode = "500", description = "Internal server error") + public ResponseEntity issueBootstrapCertificate(@Valid @RequestBody BootstrapRequestDTO request) { + byte[] zip = signingProvider.issueBootstrapPackage(request.getClientId(), request.getCsr()); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/zip")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"bootstrap_bundle.zip\"") + .body(zip); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java index a05658e..87730c0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java @@ -20,4 +20,14 @@ public class CertificateSigningException extends RuntimeException { public CertificateSigningException(String message) { super(message); } + + /** + * Constructs a new CertificateSigningException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of this exception + */ + public CertificateSigningException(String message, Throwable cause) { + super(message, cause); + } } 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 4d239ec..4622403 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 @@ -6,7 +6,6 @@ package uk.gov.dbt.ndtp.ia.node.management.exception.handlers; -import java.nio.file.AccessDeniedException; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -46,11 +45,7 @@ private String generateErrorId() { * @param request the current request * @return a ResponseEntity with an error message */ - @ExceptionHandler({ - AuthenticationProcessingException.class, - AccessDeniedException.class, - AuthorizationDeniedException.class - }) + @ExceptionHandler(AuthenticationProcessingException.class) public ResponseEntity handleAuthenticationProcessingException( AuthenticationProcessingException ex, WebRequest request) { @@ -68,6 +63,19 @@ public ResponseEntity handleAuthenticationProcessingException( return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); } + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity handleAuthorizationDeniedException( + AuthorizationDeniedException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug("Access denied, error_id={}, path={}: {}", errorId, request.getDescription(false), ex.getMessage()); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.FORBIDDEN.value(), "Access denied: insufficient permissions for this operation", errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + /** * Handles NoResourceFoundException. * diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java new file mode 100644 index 0000000..d7d2358 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java @@ -0,0 +1,27 @@ +/* + * 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.dto.certificates; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BootstrapRequestDTO { + @NotBlank + private String clientId; + + @NotBlank + private String csr; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java index a4b8839..e65c8c7 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java @@ -21,4 +21,15 @@ public interface CertificateSigningProvider { * @return a DTO containing the signed certificate and its chain */ SignCertResponseDTO signAndRecord(String csrPem, String clientId); + + /** + * Issue a short-lived bootstrap certificate package for the given organisation. + * Signs the caller-provided CSR and packages the signed certificate and CA chain + * as PEM files in a ZIP archive for deployment to Vault. + * + * @param clientId the IDP client ID of the calling organisation + * @param csrPem the Certificate Signing Request in PEM format + * @return a ZIP archive as a byte array containing certificate.pem and ca-chain.pem + */ + byte[] issueBootstrapPackage(String clientId, String csrPem); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java index 729833d..cbe10f1 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java @@ -6,11 +6,17 @@ package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.sql.Timestamp; import java.time.Instant; import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; @@ -33,25 +39,26 @@ public class CertificateSigningProviderImpl implements CertificateSigningProvide private final VaultPkiService vaultPkiService; private final OrganisationCertificateService certificateService; private final CertificateEventService eventService; + private final String bootstrapTtl; + private final String bootstrapOid; public CertificateSigningProviderImpl( VaultPkiService vaultPkiService, OrganisationCertificateService certificateService, - CertificateEventService eventService) { + CertificateEventService eventService, + @Value("${application.bootstrap.ttl:2h}") String bootstrapTtl, + @Value("${application.bootstrap.oid:1.3.6.1.4.1.32473.1.1}") String bootstrapOid) { this.vaultPkiService = vaultPkiService; this.certificateService = certificateService; this.eventService = eventService; + this.bootstrapTtl = bootstrapTtl; + this.bootstrapOid = bootstrapOid; } @Override @Transactional public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { - OrganisationCertificateDTO cert = certificateService - .findByClientId(clientId) - .orElseThrow(() -> { - log.warn("No certificate record found for client {}", clientId); - return new CertificateSigningException("No certificate record found for client"); - }); + OrganisationCertificateDTO cert = lookupCertificate(clientId); if (!Boolean.TRUE.equals(cert.getCertificateAutomationEnabled())) { log.warn("Certificate automation not enabled for client {}", clientId); @@ -67,20 +74,75 @@ public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.empty()); + updateCertificateRecord(cert, response, CertificateType.AUTOMATED); + certificateService.save(cert); + eventService.recordEvent(cert.getId(), CertificateType.AUTOMATED, CertificateEventType.RENEWED, clientId); + + log.info("Certificate signed and recorded for client {}, serial {}", clientId, response.getSerialNumber()); + + return response; + } + + @Override + @Transactional + public byte[] issueBootstrapPackage(String clientId, String csrPem) { + OrganisationCertificateDTO cert = lookupCertificate(clientId); + + if (cert.getType() == CertificateType.AUTOMATED) { + log.warn("Overwriting active automated certificate for client {}", clientId); + } + + String otherSans = bootstrapOid + ";UTF8:bootstrap"; + SignCertResponseDTO signResponse = + vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.of(bootstrapTtl), Optional.of(otherSans)); + + byte[] zipBytes = assembleBootstrapBundle(signResponse); + + cert.setRequestedAt(Timestamp.from(Instant.now())); + updateCertificateRecord(cert, signResponse, CertificateType.BOOTSTRAP); + cert.setIsRenewable(true); + certificateService.save(cert); + eventService.recordEvent(cert.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, clientId); + + log.info("Bootstrap certificate issued for client {}, serial {}", clientId, signResponse.getSerialNumber()); + + return zipBytes; + } + + private OrganisationCertificateDTO lookupCertificate(String clientId) { + return certificateService.findByClientId(clientId).orElseThrow(() -> { + log.warn("No certificate record found for client {}", clientId); + return new CertificateSigningException("No certificate record found for client"); + }); + } + + private void updateCertificateRecord( + OrganisationCertificateDTO cert, SignCertResponseDTO response, CertificateType type) { X509Certificate x509 = PemUtil.parseCertificate(response.getCertificate()); cert.setSubjectDn(x509.getSubjectX500Principal().getName()); - cert.setSerialNumber(response.getSerialNumber()); + cert.setSerialNumber(x509.getSerialNumber().toString(16)); cert.setIssuedAt(Timestamp.from(x509.getNotBefore().toInstant())); cert.setExpiresAt( Timestamp.from(Instant.ofEpochSecond(response.getExpiration().longValue()))); - cert.setType(CertificateType.AUTOMATED); - - certificateService.save(cert); - - eventService.recordEvent(cert.getId(), CertificateType.AUTOMATED, CertificateEventType.RENEWED, clientId); + cert.setType(type); + } - log.info("Certificate signed and recorded for client {}, serial {}", clientId, response.getSerialNumber()); + private static byte[] assembleBootstrapBundle(SignCertResponseDTO signResponse) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + addZipEntry(zos, "certificate.pem", signResponse.getCertificate().getBytes(StandardCharsets.UTF_8)); + String caChainPem = signResponse.getCaChain() != null ? String.join("", signResponse.getCaChain()) : ""; + addZipEntry(zos, "ca-chain.pem", caChainPem.getBytes(StandardCharsets.UTF_8)); + zos.finish(); + return baos.toByteArray(); + } catch (IOException e) { + throw new CertificateSigningException("Failed to assemble bootstrap certificate package", e); + } + } - return response; + private static void addZipEntry(ZipOutputStream zos, String name, byte[] data) throws IOException { + zos.putNextEntry(new ZipEntry(name)); + zos.write(data); + zos.closeEntry(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java index e9924eb..eda0e43 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java @@ -61,6 +61,7 @@ public class VaultPkiService { private static final String PARAM_CSR = "csr"; private static final String PARAM_TTL = "ttl"; private static final String PARAM_FORMAT = "format"; + private static final String PARAM_OTHER_SANS = "other_sans"; public VaultPkiService( VaultTemplate vault, @@ -76,7 +77,7 @@ public VaultPkiService( } /** - * Creates a new RSA or specified algorithm key pair. + * Creates a new RSA or specified algorithm key pair and returns it as a PEM-encoded DTO. * * @param algorithm the key algorithm (defaults to RSA if null or blank) * @param keySize the size of the key (defaults to 2048 if null) @@ -120,48 +121,57 @@ public CreateKeyResponseDTO createKeyPair(String algorithm, Integer keySize) { public CreateCsrResponseDTO createCsr(CreateCsrRequestDTO req) { log.info("Creating CSR for common name: {}", req.getCommonName()); try { - String privateKeyPem = req.getPrivateKeyPem(); - String publicKeyPem = req.getPublicKeyPem(); + PrivateKey privateKey = PemUtil.parsePkcs8PrivateKey(req.getPrivateKeyPem()); + var publicKey = PemUtil.parsePublicKey(req.getPublicKeyPem()); + KeyPair keyPair = new KeyPair(publicKey, privateKey); - PrivateKey privateKey = PemUtil.parsePkcs8PrivateKey(privateKeyPem); - var publicKey = PemUtil.parsePublicKey(publicKeyPem); - - // Build subject String subject = String.format( "CN=%s, OU=%s, O=%s, C=%s", safe(req.getCommonName()), safe(req.getOrganizationalUnit()), safe(req.getOrganization()), safe(req.getCountry())); - X500Name x500 = new X500Name(subject); - - // CSR builder - JcaPKCS10CertificationRequestBuilder csrBuilder = new JcaPKCS10CertificationRequestBuilder(x500, publicKey); - - // SANs - if (req.getDnsSans() != null && !req.getDnsSans().isEmpty()) { - log.debug("Adding DNS SANs to CSR: {}", req.getDnsSans()); - GeneralNames sans = new GeneralNames(req.getDnsSans().stream() - .map(d -> new GeneralName(GeneralName.dNSName, d)) - .toArray(GeneralName[]::new)); - csrBuilder.addAttribute( - PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, - new Extensions(new Extension(Extension.subjectAlternativeName, false, sans.getEncoded()))); - } - - ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(privateKey); - PKCS10CertificationRequest csr = csrBuilder.build(signer); - String csrPem = PemUtil.toPem("CERTIFICATE REQUEST", csr.getEncoded()); + String csrPem = buildCsr(keyPair, new X500Name(subject), req.getDnsSans()); String csrId = UUID.randomUUID().toString(); return new CreateCsrResponseDTO(csrId, csrPem); + } catch (PkiException e) { + throw e; } catch (Exception e) { log.error("Failed to create CSR for subject common name: {}", req.getCommonName(), e); throw new PkiException("CSR creation failed", e); } } + /** + * Builds a PKCS#10 CSR from the given key pair, subject, and optional DNS SANs. + * + * @param keyPair the key pair to sign the CSR with + * @param subject the X.500 distinguished name for the subject + * @param dnsSans optional list of DNS Subject Alternative Names + * @return the CSR in PEM format + * @throws Exception if CSR building or signing fails + */ + private String buildCsr(KeyPair keyPair, X500Name subject, List dnsSans) throws Exception { + JcaPKCS10CertificationRequestBuilder csrBuilder = + new JcaPKCS10CertificationRequestBuilder(subject, keyPair.getPublic()); + + if (dnsSans != null && !dnsSans.isEmpty()) { + log.debug("Adding DNS SANs to CSR: {}", dnsSans); + GeneralNames sans = new GeneralNames(dnsSans.stream() + .map(d -> new GeneralName(GeneralName.dNSName, d)) + .toArray(GeneralName[]::new)); + csrBuilder.addAttribute( + PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, + new Extensions(new Extension(Extension.subjectAlternativeName, false, sans.getEncoded()))); + } + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + PKCS10CertificationRequest csr = csrBuilder.build(signer); + return PemUtil.toPem("CERTIFICATE REQUEST", csr.getEncoded()); + } + /** * Signs a CSR using the specified role in Vault. * @@ -172,6 +182,21 @@ public CreateCsrResponseDTO createCsr(CreateCsrRequestDTO req) { * @throws PkiException if signing fails or Vault returns an empty response */ public SignCertResponseDTO signCsr(String csrPem, Optional role, Optional ttl) { + return signCsr(csrPem, role, ttl, Optional.empty()); + } + + /** + * Signs a CSR using the specified role in Vault, with optional custom SANs. + * + * @param csrPem the CSR in PEM format + * @param role the Vault PKI role to use for signing + * @param ttl the requested Time To Live for the certificate + * @param otherSans optional other SANs to include (e.g. "1.3.6.1.4.1.32473.1.1;UTF8:bootstrap") + * @return a DTO containing the signed certificate and its chain + * @throws PkiException if signing fails or Vault returns an empty response + */ + public SignCertResponseDTO signCsr( + String csrPem, Optional role, Optional ttl, Optional otherSans) { String effectiveRole = role.filter(StringUtils::isNotBlank).orElse(defaultRole); String effectiveTtl = ttl.filter(StringUtils::isNotBlank).orElse(defaultTtl); log.info("Signing CSR with role: {} and TTL: {}", effectiveRole, effectiveTtl); @@ -193,6 +218,7 @@ public SignCertResponseDTO signCsr(String csrPem, Optional role, Optiona body.put(PARAM_TTL, effectiveTtl); } body.put(PARAM_FORMAT, "pem"); + otherSans.filter(StringUtils::isNotBlank).ifPresent(sans -> body.put(PARAM_OTHER_SANS, sans)); try { VaultResponse resp = vault.write(path, body); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ae88342..7799fba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,9 @@ application: pki-mount: pki-int # mount point for intermediate CA default-role: default-role # default role for signing CSRs default-ttl: 24h # default TTL for signing CSRs + bootstrap: + ttl: ${BOOTSTRAP_CERT_TTL:2h} # TTL for bootstrap certificates + oid: ${BOOTSTRAP_OID:1.3.6.1.4.1.32473.1.1} # OID for bootstrap cert marker (RFC 5612 example PEN) client: key-store: keystore.jks # path to MTLS client keystore key-store-password: ${CERTPASSWORD:} # MTLS client keystore password diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java new file mode 100644 index 0000000..9973bad --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java @@ -0,0 +1,43 @@ +/* + * 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.config; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +@ExtendWith(MockitoExtension.class) +class WebConfigTest { + + @Mock + private CertificateValidationInterceptor interceptor; + + @Mock + private InterceptorRegistry registry; + + @Mock + private InterceptorRegistration registration; + + @Test + void bootstrapEndpoint_excludedFromCertificateValidation() { + when(registry.addInterceptor(any())).thenReturn(registration); + when(registration.addPathPatterns(any(String.class))).thenReturn(registration); + + WebConfig config = new WebConfig(interceptor); + config.addInterceptors(registry); + + verify(registration).addPathPatterns("/api/**"); + verify(registration).excludePathPatterns("/api/v1/certificate/bootstrap"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java index e3860db..029040c 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -140,4 +140,18 @@ void getIntermediateCertificate_shouldReturnCertificateWithInfo() throws Excepti .andExpect(jsonPath("$.info.issuer").value("CN=Root")) .andExpect(jsonPath("$.info.serialNumber").value("12345")); } + + @Test + void issueBootstrapCertificate_shouldReturnZipDownload() throws Exception { + byte[] zipBytes = new byte[] {0x50, 0x4B, 0x03, 0x04}; + when(signingProvider.issueBootstrapPackage("cert-manager", "CSR_PEM")).thenReturn(zipBytes); + + mockMvc.perform(post("/api/v1/certificate/bootstrap") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"clientId\":\"cert-manager\",\"csr\":\"CSR_PEM\"}")) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/zip")) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"bootstrap_bundle.zip\"")) + .andExpect(content().bytes(zipBytes)); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java index 7566a04..be749c8 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java @@ -15,6 +15,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.resource.NoResourceFoundException; import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; @@ -79,6 +80,24 @@ void handleAuthenticationProcessingException_withSubclass_shouldReturnUnauthoriz assertNotNull(errorResponse.getErrorId()); } + @Test + void handleAuthorizationDeniedException_shouldReturnForbiddenStatus() { + // Arrange + AuthorizationDeniedException exception = new AuthorizationDeniedException("Access Denied"); + + // Act + ResponseEntity response = + exceptionHandler.handleAuthorizationDeniedException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.FORBIDDEN.value(), errorResponse.getStatus()); + assertTrue(errorResponse.getMessage().contains("insufficient permissions")); + assertNotNull(errorResponse.getErrorId()); + } + @Test void handleRuntimeException_shouldReturnInternalServerErrorStatus() { // Arrange diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java index d9788ce..870f63f 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java @@ -11,11 +11,16 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import java.io.ByteArrayInputStream; +import java.math.BigInteger; import java.security.cert.X509Certificate; import java.sql.Timestamp; import java.time.Instant; import java.util.Date; +import java.util.List; import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import javax.security.auth.x500.X500Principal; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -47,10 +52,17 @@ class CertificateSigningProviderImplTest { private CertificateSigningProviderImpl provider; private MockedStatic pemUtilMock; + private static final String BOOTSTRAP_TTL = "2h"; + private static final String BOOTSTRAP_OID = "1.3.6.1.4.1.32473.1.1"; + private static final String BOOTSTRAP_OTHER_SANS = BOOTSTRAP_OID + ";UTF8:bootstrap"; + private static final String BOOTSTRAP_CSR = + "-----BEGIN CERTIFICATE REQUEST-----\nMIIBtest\n-----END CERTIFICATE REQUEST-----"; + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - provider = new CertificateSigningProviderImpl(vaultPkiService, certificateService, eventService); + provider = new CertificateSigningProviderImpl( + vaultPkiService, certificateService, eventService, BOOTSTRAP_TTL, BOOTSTRAP_OID); pemUtilMock = mockStatic(PemUtil.class); } @@ -74,6 +86,8 @@ private SignCertResponseDTO buildSignResponse() { .certificate("CERT_PEM") .serialNumber("abc123") .expiration(1735689600L) + .caChain(List.of("CA_PEM")) + .issuingCa("ISSUING_CA_PEM") .build(); } @@ -83,6 +97,7 @@ private void mockPemParsing(String subjectDn) { X509Certificate x509 = mock(X509Certificate.class); when(x509.getSubjectX500Principal()).thenReturn(new X500Principal(subjectDn)); when(x509.getNotBefore()).thenReturn(Date.from(NOT_BEFORE)); + when(x509.getSerialNumber()).thenReturn(new BigInteger("abc123", 16)); pemUtilMock.when(() -> PemUtil.parseCertificate("CERT_PEM")).thenReturn(x509); } @@ -184,4 +199,135 @@ void signAndRecord_parsesExpirationAsEpochSeconds() { verify(certificateService).save(captor.capture()); assertThat(captor.getValue().getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); } + + private void setupBootstrapMocks(OrganisationCertificateDTO cert) { + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(vaultPkiService.signCsr( + BOOTSTRAP_CSR, Optional.empty(), Optional.of(BOOTSTRAP_TTL), Optional.of(BOOTSTRAP_OTHER_SANS))) + .thenReturn(buildSignResponse()); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + mockPemParsing("CN=api.acme-digital.co.uk"); + } + + @Test + void issueBootstrapPackage_success_returnsZipWithTwoEntries() throws Exception { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + setupBootstrapMocks(cert); + + byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + assertThat(zip).isNotNull().hasSizeGreaterThan(0); + + List entryNames = new java.util.ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entryNames.add(entry.getName()); + } + } + assertThat(entryNames).containsExactly("certificate.pem", "ca-chain.pem"); + } + + @Test + void issueBootstrapPackage_success_nullCaChain_returnsEmptyCaChainPem() throws Exception { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + SignCertResponseDTO responseWithNullChain = SignCertResponseDTO.builder() + .certificate("CERT_PEM") + .serialNumber("abc123") + .expiration(1735689600L) + .caChain(null) + .issuingCa("ISSUING_CA_PEM") + .build(); + when(vaultPkiService.signCsr( + BOOTSTRAP_CSR, Optional.empty(), Optional.of(BOOTSTRAP_TTL), Optional.of(BOOTSTRAP_OTHER_SANS))) + .thenReturn(responseWithNullChain); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=api.acme-digital.co.uk"); + + byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + assertThat(zip).isNotNull(); + + java.util.Map entries = new java.util.LinkedHashMap<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.put(entry.getName(), zis.readAllBytes()); + } + } + assertThat(entries).containsKeys("certificate.pem", "ca-chain.pem"); + assertThat(entries.get("ca-chain.pem")).isEmpty(); + } + + @Test + void issueBootstrapPackage_noCertRecord_throwsException() { + when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> provider.issueBootstrapPackage("unknown", BOOTSTRAP_CSR)) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("No certificate record"); + + verify(vaultPkiService, never()).signCsr(any(), any(), any(), any()); + } + + @Test + void issueBootstrapPackage_recordUpdatedWithBootstrapType() { + Instant before = Instant.now(); + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + setupBootstrapMocks(cert); + + provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + + OrganisationCertificateDTO saved = captor.getValue(); + assertThat(saved.getType()).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(saved.getIsRenewable()).isTrue(); + assertThat(saved.getSerialNumber()).isEqualTo("abc123"); + assertThat(saved.getSubjectDn()).isEqualTo("CN=api.acme-digital.co.uk"); + assertThat(saved.getRequestedAt()).isNotNull(); + assertThat(saved.getRequestedAt().toInstant()).isBetween(before, Instant.now()); + assertThat(saved.getIssuedAt()).isEqualTo(Timestamp.from(NOT_BEFORE)); + assertThat(saved.getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); + } + + @Test + void issueBootstrapPackage_auditEventRecorded() { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + setupBootstrapMocks(cert); + + provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "client-1"); + } + + @Test + void issueBootstrapPackage_usesConfiguredTtl() { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + setupBootstrapMocks(cert); + + provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + verify(vaultPkiService) + .signCsr( + BOOTSTRAP_CSR, Optional.empty(), Optional.of(BOOTSTRAP_TTL), Optional.of(BOOTSTRAP_OTHER_SANS)); + } + + @Test + void issueBootstrapPackage_existingAutomatedCert_succeeds() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); + setupBootstrapMocks(cert); + + byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + + assertThat(zip).isNotNull(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo(CertificateType.BOOTSTRAP); + } } From d73be7eefbe3d4d2b31339d655ded10903538dab Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Fri, 20 Mar 2026 16:52:53 +0000 Subject: [PATCH 10/18] feat(DPAV-2440): Bootstrap endpoint changes and interceptor exemption (#52) * feat(DPAV-2440): Bootstrap endpoint changes and interceptor exemption - Exclude all /api/v1/certificate/** from CertificateValidationInterceptor as caller may be an internal service without an organisation - Bootstrap endpoint now takes organisationId instead of clientId so orgs without producers/consumers can be bootstrapped - Create organisation_certificate record automatically if one does not exist - Update migration, remove schema prefix and add missing SPDX header * feat(DPAV-2440): Remove dead @AllowBootstrapCertificates annotation --- .../config/AllowBootstrapCertificates.java | 20 ----- .../CertificateValidationInterceptor.java | 8 +- .../ia/node/management/config/WebConfig.java | 2 +- .../controller/v1/CertificateController.java | 4 +- .../dto/certificates/BootstrapRequestDTO.java | 5 +- .../CertificateSigningProvider.java | 9 ++- .../CertificateSigningProviderImpl.java | 35 ++++++-- ...20260305120000__add_certificate_tables.sql | 27 ++++--- .../CertificateValidationInterceptorTest.java | 30 +------ .../node/management/config/WebConfigTest.java | 4 +- .../v1/CertificateControllerTest.java | 4 +- .../CertificateSigningProviderImplTest.java | 81 ++++++++++++++----- 12 files changed, 124 insertions(+), 105 deletions(-) delete mode 100644 src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java deleted file mode 100644 index 801ca6a..0000000 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/AllowBootstrapCertificates.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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.config; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a controller as accessible by bootstrap certificates. - * Controllers without this annotation will reject requests from bootstrap certificate holders. - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface AllowBootstrapCertificates {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java index 617d4c5..9f6ebc9 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java @@ -17,7 +17,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; -import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; @@ -72,7 +71,7 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } } - if (cert.getType() == CertificateType.BOOTSTRAP && !allowsBootstrapCertificates(handler)) { + if (cert.getType() == CertificateType.BOOTSTRAP) { log.warn("Bootstrap certificate denied access to {}", request.getRequestURI()); writeError( response, HttpServletResponse.SC_FORBIDDEN, "Bootstrap certificates cannot access this endpoint"); @@ -92,11 +91,6 @@ private String extractClientId() { return (clientId == null || clientId.isEmpty()) ? null : clientId; } - private boolean allowsBootstrapCertificates(Object handler) { - return handler instanceof HandlerMethod handlerMethod - && handlerMethod.getBeanType().isAnnotationPresent(AllowBootstrapCertificates.class); - } - private String validateSerialNumber(HttpServletRequest request, OrganisationCertificateDTO cert, String clientId) { X509Certificate[] certs = (X509Certificate[]) request.getAttribute(X509_CERT_ATTRIBUTE); if (certs == null || certs.length == 0) { diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java index 116df20..57fdc1d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java @@ -23,6 +23,6 @@ public WebConfig(CertificateValidationInterceptor certificateValidationIntercept public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(certificateValidationInterceptor) .addPathPatterns("/api/**") - .excludePathPatterns("/api/v1/certificate/bootstrap"); + .excludePathPatterns("/api/v1/certificate/**"); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java index 9d1a22a..1e314bb 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -21,7 +21,6 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import uk.gov.dbt.ndtp.ia.node.management.config.AllowBootstrapCertificates; import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; @@ -33,7 +32,6 @@ */ @RestController @RequestMapping("/api/v1/certificate") -@AllowBootstrapCertificates @Slf4j @Tag(name = "Certificate", description = "Endpoints for certificate management.") public class CertificateController { @@ -185,7 +183,7 @@ public IntermediateCertResponseDTO getIntermediateCertificate() { description = "Forbidden — insufficient permissions or certificate validation failure") @ApiResponse(responseCode = "500", description = "Internal server error") public ResponseEntity issueBootstrapCertificate(@Valid @RequestBody BootstrapRequestDTO request) { - byte[] zip = signingProvider.issueBootstrapPackage(request.getClientId(), request.getCsr()); + byte[] zip = signingProvider.issueBootstrapPackage(request.getOrganisationId(), request.getCsr()); return ResponseEntity.ok() .contentType(MediaType.parseMediaType("application/zip")) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"bootstrap_bundle.zip\"") diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java index d7d2358..f981482 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java @@ -7,6 +7,7 @@ package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,8 +20,8 @@ @NoArgsConstructor @AllArgsConstructor public class BootstrapRequestDTO { - @NotBlank - private String clientId; + @NotNull + private Long organisationId; @NotBlank private String csr; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java index e65c8c7..35a98e8 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java @@ -24,12 +24,13 @@ public interface CertificateSigningProvider { /** * Issue a short-lived bootstrap certificate package for the given organisation. - * Signs the caller-provided CSR and packages the signed certificate and CA chain - * as PEM files in a ZIP archive for deployment to Vault. + * Signs the caller-provided CSR with a short TTL and bootstrap OID marker, + * sets the organisation's certificate record to BOOTSTRAP type with renewal enabled, + * and packages the signed certificate and CA chain as PEM files in a ZIP archive. * - * @param clientId the IDP client ID of the calling organisation + * @param organisationId the ID of the target organisation * @param csrPem the Certificate Signing Request in PEM format * @return a ZIP archive as a byte array containing certificate.pem and ca-chain.pem */ - byte[] issueBootstrapPackage(String clientId, String csrPem); + byte[] issueBootstrapPackage(Long organisationId, String csrPem); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java index cbe10f1..3213527 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java @@ -24,6 +24,7 @@ import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; @@ -39,6 +40,7 @@ public class CertificateSigningProviderImpl implements CertificateSigningProvide private final VaultPkiService vaultPkiService; private final OrganisationCertificateService certificateService; private final CertificateEventService eventService; + private final OrganisationRepository organisationRepository; private final String bootstrapTtl; private final String bootstrapOid; @@ -46,11 +48,13 @@ public CertificateSigningProviderImpl( VaultPkiService vaultPkiService, OrganisationCertificateService certificateService, CertificateEventService eventService, + OrganisationRepository organisationRepository, @Value("${application.bootstrap.ttl:2h}") String bootstrapTtl, @Value("${application.bootstrap.oid:1.3.6.1.4.1.32473.1.1}") String bootstrapOid) { this.vaultPkiService = vaultPkiService; this.certificateService = certificateService; this.eventService = eventService; + this.organisationRepository = organisationRepository; this.bootstrapTtl = bootstrapTtl; this.bootstrapOid = bootstrapOid; } @@ -85,11 +89,13 @@ public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { @Override @Transactional - public byte[] issueBootstrapPackage(String clientId, String csrPem) { - OrganisationCertificateDTO cert = lookupCertificate(clientId); + public byte[] issueBootstrapPackage(Long organisationId, String csrPem) { + OrganisationCertificateDTO cert = certificateService + .findByOrganisationId(organisationId) + .orElseGet(() -> createCertificateRecord(organisationId)); if (cert.getType() == CertificateType.AUTOMATED) { - log.warn("Overwriting active automated certificate for client {}", clientId); + log.warn("Overwriting active automated certificate for organisation {}", organisationId); } String otherSans = bootstrapOid + ";UTF8:bootstrap"; @@ -101,14 +107,31 @@ public byte[] issueBootstrapPackage(String clientId, String csrPem) { cert.setRequestedAt(Timestamp.from(Instant.now())); updateCertificateRecord(cert, signResponse, CertificateType.BOOTSTRAP); cert.setIsRenewable(true); - certificateService.save(cert); - eventService.recordEvent(cert.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, clientId); + OrganisationCertificateDTO saved = certificateService.save(cert); + eventService.recordEvent( + saved.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, organisationId.toString()); - log.info("Bootstrap certificate issued for client {}, serial {}", clientId, signResponse.getSerialNumber()); + log.info( + "Bootstrap certificate issued for organisation {}, serial {}", + organisationId, + signResponse.getSerialNumber()); return zipBytes; } + private OrganisationCertificateDTO createCertificateRecord(Long organisationId) { + if (!organisationRepository.existsById(organisationId)) { + throw new CertificateSigningException("Organisation not found: " + organisationId); + } + log.info("No certificate record found for organisation {}. Creating one.", organisationId); + OrganisationCertificateDTO newCert = OrganisationCertificateDTO.builder() + .organisationId(organisationId) + .type(CertificateType.MANUAL) + .isRenewable(false) + .build(); + return certificateService.save(newCert); + } + private OrganisationCertificateDTO lookupCertificate(String clientId) { return certificateService.findByClientId(clientId).orElseThrow(() -> { log.warn("No certificate record found for client {}", clientId); diff --git a/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql index a0fa405..39b0a95 100644 --- a/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql +++ b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql @@ -1,7 +1,13 @@ -ALTER TABLE mn.organisation +/* + * 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. + */ + +ALTER TABLE organisation ADD COLUMN certificate_automation_enabled BOOLEAN NOT NULL DEFAULT TRUE; -CREATE TABLE mn.organisation_certificate ( +CREATE TABLE organisation_certificate ( id BIGSERIAL PRIMARY KEY, organisation_id BIGINT NOT NULL UNIQUE, subject_dn VARCHAR(500), @@ -14,12 +20,12 @@ CREATE TABLE mn.organisation_certificate ( expires_at TIMESTAMP, revoked_at TIMESTAMP, CONSTRAINT fk_organisation_certificate__organisation_id - FOREIGN KEY (organisation_id) REFERENCES mn.organisation (id) + FOREIGN KEY (organisation_id) REFERENCES organisation (id) ); CREATE INDEX idx_organisation_certificate__organisation_id -ON mn.organisation_certificate (organisation_id); +ON organisation_certificate (organisation_id); -CREATE TABLE mn.certificate_events ( +CREATE TABLE certificate_events ( id BIGSERIAL PRIMARY KEY, organisation_certificate_id BIGINT NOT NULL, type VARCHAR(50) NOT NULL, @@ -28,14 +34,14 @@ CREATE TABLE mn.certificate_events ( performed_by VARCHAR(255), CONSTRAINT fk_certificate_events__organisation_certificate_id FOREIGN KEY (organisation_certificate_id) - REFERENCES mn.organisation_certificate (id) + REFERENCES organisation_certificate (id) ); CREATE INDEX idx_certificate_events__organisation_certificate_id -ON mn.certificate_events (organisation_certificate_id); +ON certificate_events (organisation_certificate_id); /* Mark existing orgs as manually configured */ -UPDATE mn.organisation SET certificate_automation_enabled = FALSE; -INSERT INTO mn.organisation_certificate ( +UPDATE organisation SET certificate_automation_enabled = FALSE; +INSERT INTO organisation_certificate ( organisation_id, subject_dn, serial_number, @@ -56,5 +62,4 @@ SELECT NULL AS issued_at, NULL AS expires_at, NULL AS revoked_at -FROM mn.organisation -; +FROM organisation; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java index 045d8e9..1b6c190 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java @@ -34,8 +34,6 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.method.HandlerMethod; -import uk.gov.dbt.ndtp.ia.node.management.controller.v1.CertificateController; -import uk.gov.dbt.ndtp.ia.node.management.controller.v1.ConfigurationController; import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; @@ -234,23 +232,11 @@ void nullSerialNumber_skipsCheck() throws Exception { } @Test - void bootstrapCert_annotatedController_passesThrough() throws Exception { + void bootstrapCert_alwaysReturns403() throws Exception { setupAuthentication("client-1"); OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); when(validationProvider.isActive(cert)).thenReturn(true); - when(handlerMethod.getBeanType()).thenReturn((Class) CertificateController.class); - - assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); - } - - @Test - void bootstrapCert_unannotatedController_returns403() throws Exception { - setupAuthentication("client-1"); - OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); - when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); - when(validationProvider.isActive(cert)).thenReturn(true); - when(handlerMethod.getBeanType()).thenReturn((Class) ConfigurationController.class); when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); setupResponseWriter(); @@ -258,20 +244,6 @@ void bootstrapCert_unannotatedController_returns403() throws Exception { verify(response).setStatus(403); } - @Test - void bootstrapCert_nonHandlerMethod_returns403() throws Exception { - setupAuthentication("client-1"); - OrganisationCertificateDTO cert = certDto(CertificateType.BOOTSTRAP, null); - when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); - when(validationProvider.isActive(cert)).thenReturn(true); - when(request.getRequestURI()).thenReturn("/api/v1/something"); - setupResponseWriter(); - - assertThat(interceptor.preHandle(request, response, "not-a-handler-method")) - .isFalse(); - verify(response).setStatus(403); - } - @Test void errorResponse_containsStatusAndMessage() throws Exception { setupAuthentication("client-1"); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java index 9973bad..60b1304 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java @@ -30,7 +30,7 @@ class WebConfigTest { private InterceptorRegistration registration; @Test - void bootstrapEndpoint_excludedFromCertificateValidation() { + void certificateEndpoints_excludedFromCertificateValidation() { when(registry.addInterceptor(any())).thenReturn(registration); when(registration.addPathPatterns(any(String.class))).thenReturn(registration); @@ -38,6 +38,6 @@ void bootstrapEndpoint_excludedFromCertificateValidation() { config.addInterceptors(registry); verify(registration).addPathPatterns("/api/**"); - verify(registration).excludePathPatterns("/api/v1/certificate/bootstrap"); + verify(registration).excludePathPatterns("/api/v1/certificate/**"); } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java index 029040c..a0d1d88 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -144,11 +144,11 @@ void getIntermediateCertificate_shouldReturnCertificateWithInfo() throws Excepti @Test void issueBootstrapCertificate_shouldReturnZipDownload() throws Exception { byte[] zipBytes = new byte[] {0x50, 0x4B, 0x03, 0x04}; - when(signingProvider.issueBootstrapPackage("cert-manager", "CSR_PEM")).thenReturn(zipBytes); + when(signingProvider.issueBootstrapPackage(10L, "CSR_PEM")).thenReturn(zipBytes); mockMvc.perform(post("/api/v1/certificate/bootstrap") .contentType(MediaType.APPLICATION_JSON) - .content("{\"clientId\":\"cert-manager\",\"csr\":\"CSR_PEM\"}")) + .content("{\"organisationId\":10,\"csr\":\"CSR_PEM\"}")) .andExpect(status().isOk()) .andExpect(content().contentType("application/zip")) .andExpect(header().string("Content-Disposition", "attachment; filename=\"bootstrap_bundle.zip\"")) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java index 870f63f..64f6de1 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java @@ -34,6 +34,7 @@ import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; @@ -49,6 +50,9 @@ class CertificateSigningProviderImplTest { @Mock private CertificateEventService eventService; + @Mock + private OrganisationRepository organisationRepository; + private CertificateSigningProviderImpl provider; private MockedStatic pemUtilMock; @@ -62,7 +66,12 @@ class CertificateSigningProviderImplTest { void setUp() { MockitoAnnotations.openMocks(this); provider = new CertificateSigningProviderImpl( - vaultPkiService, certificateService, eventService, BOOTSTRAP_TTL, BOOTSTRAP_OID); + vaultPkiService, + certificateService, + eventService, + organisationRepository, + BOOTSTRAP_TTL, + BOOTSTRAP_OID); pemUtilMock = mockStatic(PemUtil.class); } @@ -201,7 +210,7 @@ void signAndRecord_parsesExpirationAsEpochSeconds() { } private void setupBootstrapMocks(OrganisationCertificateDTO cert) { - when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(certificateService.findByOrganisationId(10L)).thenReturn(Optional.of(cert)); when(vaultPkiService.signCsr( BOOTSTRAP_CSR, Optional.empty(), Optional.of(BOOTSTRAP_TTL), Optional.of(BOOTSTRAP_OTHER_SANS))) .thenReturn(buildSignResponse()); @@ -215,7 +224,7 @@ void issueBootstrapPackage_success_returnsZipWithTwoEntries() throws Exception { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); assertThat(zip).isNotNull().hasSizeGreaterThan(0); @@ -232,7 +241,7 @@ void issueBootstrapPackage_success_returnsZipWithTwoEntries() throws Exception { @Test void issueBootstrapPackage_success_nullCaChain_returnsEmptyCaChainPem() throws Exception { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); - when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(certificateService.findByOrganisationId(10L)).thenReturn(Optional.of(cert)); SignCertResponseDTO responseWithNullChain = SignCertResponseDTO.builder() .certificate("CERT_PEM") @@ -247,7 +256,7 @@ void issueBootstrapPackage_success_nullCaChain_returnsEmptyCaChainPem() throws E when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockPemParsing("CN=api.acme-digital.co.uk"); - byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); assertThat(zip).isNotNull(); @@ -263,12 +272,46 @@ void issueBootstrapPackage_success_nullCaChain_returnsEmptyCaChainPem() throws E } @Test - void issueBootstrapPackage_noCertRecord_throwsException() { - when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + void issueBootstrapPackage_noCertRecord_createsOne() { + when(certificateService.findByOrganisationId(99L)).thenReturn(Optional.empty()); + when(organisationRepository.existsById(99L)).thenReturn(true); + + OrganisationCertificateDTO created = OrganisationCertificateDTO.builder() + .id(5L) + .organisationId(99L) + .type(CertificateType.MANUAL) + .isRenewable(false) + .build(); + when(certificateService.save(any())).thenReturn(created).thenAnswer(inv -> inv.getArgument(0)); + when(vaultPkiService.signCsr( + BOOTSTRAP_CSR, Optional.empty(), Optional.of(BOOTSTRAP_TTL), Optional.of(BOOTSTRAP_OTHER_SANS))) + .thenReturn(buildSignResponse()); + mockPemParsing("CN=api.acme-digital.co.uk"); + + byte[] zip = provider.issueBootstrapPackage(99L, BOOTSTRAP_CSR); + + assertThat(zip).isNotNull(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService, atLeastOnce()).save(captor.capture()); + + OrganisationCertificateDTO finalSave = + captor.getAllValues().get(captor.getAllValues().size() - 1); + assertThat(finalSave.getType()).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(finalSave.getIsRenewable()).isTrue(); + assertThat(finalSave.getSerialNumber()).isEqualTo("abc123"); + + verify(eventService).recordEvent(5L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "99"); + } - assertThatThrownBy(() -> provider.issueBootstrapPackage("unknown", BOOTSTRAP_CSR)) + @Test + void issueBootstrapPackage_organisationNotFound_throwsException() { + when(certificateService.findByOrganisationId(999L)).thenReturn(Optional.empty()); + when(organisationRepository.existsById(999L)).thenReturn(false); + + assertThatThrownBy(() -> provider.issueBootstrapPackage(999L, BOOTSTRAP_CSR)) .isInstanceOf(CertificateSigningException.class) - .hasMessageContaining("No certificate record"); + .hasMessageContaining("Organisation not found"); verify(vaultPkiService, never()).signCsr(any(), any(), any(), any()); } @@ -279,12 +322,13 @@ void issueBootstrapPackage_recordUpdatedWithBootstrapType() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); - verify(certificateService).save(captor.capture()); + verify(certificateService, atLeastOnce()).save(captor.capture()); - OrganisationCertificateDTO saved = captor.getValue(); + OrganisationCertificateDTO saved = + captor.getAllValues().get(captor.getAllValues().size() - 1); assertThat(saved.getType()).isEqualTo(CertificateType.BOOTSTRAP); assertThat(saved.getIsRenewable()).isTrue(); assertThat(saved.getSerialNumber()).isEqualTo("abc123"); @@ -300,9 +344,9 @@ void issueBootstrapPackage_auditEventRecorded() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); - verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "client-1"); + verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "10"); } @Test @@ -310,7 +354,7 @@ void issueBootstrapPackage_usesConfiguredTtl() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); verify(vaultPkiService) .signCsr( @@ -322,12 +366,13 @@ void issueBootstrapPackage_existingAutomatedCert_succeeds() { OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); setupBootstrapMocks(cert); - byte[] zip = provider.issueBootstrapPackage("client-1", BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); assertThat(zip).isNotNull(); ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); - verify(certificateService).save(captor.capture()); - assertThat(captor.getValue().getType()).isEqualTo(CertificateType.BOOTSTRAP); + verify(certificateService, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(captor.getAllValues().size() - 1).getType()) + .isEqualTo(CertificateType.BOOTSTRAP); } } From f68da721a27ee8de51dbce0937e20a27e4061633 Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Tue, 24 Mar 2026 10:20:53 +0000 Subject: [PATCH 11/18] feat(DPAV-2440): Set bootstrap event performed by to requester clientId (#54) --- .../controller/v1/CertificateController.java | 7 +++++-- .../CertificateSigningProvider.java | 3 ++- .../CertificateSigningProviderImpl.java | 5 ++--- .../v1/CertificateControllerTest.java | 3 ++- .../CertificateSigningProviderImplTest.java | 20 +++++++++---------- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java index 1e314bb..9f28b92 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -182,8 +182,11 @@ public IntermediateCertResponseDTO getIntermediateCertificate() { responseCode = "403", description = "Forbidden — insufficient permissions or certificate validation failure") @ApiResponse(responseCode = "500", description = "Internal server error") - public ResponseEntity issueBootstrapCertificate(@Valid @RequestBody BootstrapRequestDTO request) { - byte[] zip = signingProvider.issueBootstrapPackage(request.getOrganisationId(), request.getCsr()); + public ResponseEntity issueBootstrapCertificate( + @Valid @RequestBody BootstrapRequestDTO request, + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal) { + byte[] zip = signingProvider.issueBootstrapPackage( + request.getOrganisationId(), request.getCsr(), principal.clientId()); return ResponseEntity.ok() .contentType(MediaType.parseMediaType("application/zip")) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"bootstrap_bundle.zip\"") diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java index 35a98e8..d888368 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java @@ -30,7 +30,8 @@ public interface CertificateSigningProvider { * * @param organisationId the ID of the target organisation * @param csrPem the Certificate Signing Request in PEM format + * @param performedBy the client ID of the caller requesting the bootstrap * @return a ZIP archive as a byte array containing certificate.pem and ca-chain.pem */ - byte[] issueBootstrapPackage(Long organisationId, String csrPem); + byte[] issueBootstrapPackage(Long organisationId, String csrPem, String performedBy); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java index 3213527..3121559 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java @@ -89,7 +89,7 @@ public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { @Override @Transactional - public byte[] issueBootstrapPackage(Long organisationId, String csrPem) { + public byte[] issueBootstrapPackage(Long organisationId, String csrPem, String performedBy) { OrganisationCertificateDTO cert = certificateService .findByOrganisationId(organisationId) .orElseGet(() -> createCertificateRecord(organisationId)); @@ -108,8 +108,7 @@ public byte[] issueBootstrapPackage(Long organisationId, String csrPem) { updateCertificateRecord(cert, signResponse, CertificateType.BOOTSTRAP); cert.setIsRenewable(true); OrganisationCertificateDTO saved = certificateService.save(cert); - eventService.recordEvent( - saved.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, organisationId.toString()); + eventService.recordEvent(saved.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, performedBy); log.info( "Bootstrap certificate issued for organisation {}, serial {}", diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java index a0d1d88..1d03468 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -144,7 +144,8 @@ void getIntermediateCertificate_shouldReturnCertificateWithInfo() throws Excepti @Test void issueBootstrapCertificate_shouldReturnZipDownload() throws Exception { byte[] zipBytes = new byte[] {0x50, 0x4B, 0x03, 0x04}; - when(signingProvider.issueBootstrapPackage(10L, "CSR_PEM")).thenReturn(zipBytes); + when(signingProvider.issueBootstrapPackage(any(), anyString(), anyString())) + .thenReturn(zipBytes); mockMvc.perform(post("/api/v1/certificate/bootstrap") .contentType(MediaType.APPLICATION_JSON) diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java index 64f6de1..03c6cee 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java @@ -224,7 +224,7 @@ void issueBootstrapPackage_success_returnsZipWithTwoEntries() throws Exception { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); assertThat(zip).isNotNull().hasSizeGreaterThan(0); @@ -256,7 +256,7 @@ void issueBootstrapPackage_success_nullCaChain_returnsEmptyCaChainPem() throws E when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); mockPemParsing("CN=api.acme-digital.co.uk"); - byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); assertThat(zip).isNotNull(); @@ -288,7 +288,7 @@ void issueBootstrapPackage_noCertRecord_createsOne() { .thenReturn(buildSignResponse()); mockPemParsing("CN=api.acme-digital.co.uk"); - byte[] zip = provider.issueBootstrapPackage(99L, BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(99L, BOOTSTRAP_CSR, "test-client"); assertThat(zip).isNotNull(); @@ -301,7 +301,7 @@ void issueBootstrapPackage_noCertRecord_createsOne() { assertThat(finalSave.getIsRenewable()).isTrue(); assertThat(finalSave.getSerialNumber()).isEqualTo("abc123"); - verify(eventService).recordEvent(5L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "99"); + verify(eventService).recordEvent(5L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "test-client"); } @Test @@ -309,7 +309,7 @@ void issueBootstrapPackage_organisationNotFound_throwsException() { when(certificateService.findByOrganisationId(999L)).thenReturn(Optional.empty()); when(organisationRepository.existsById(999L)).thenReturn(false); - assertThatThrownBy(() -> provider.issueBootstrapPackage(999L, BOOTSTRAP_CSR)) + assertThatThrownBy(() -> provider.issueBootstrapPackage(999L, BOOTSTRAP_CSR, "test-client")) .isInstanceOf(CertificateSigningException.class) .hasMessageContaining("Organisation not found"); @@ -322,7 +322,7 @@ void issueBootstrapPackage_recordUpdatedWithBootstrapType() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); verify(certificateService, atLeastOnce()).save(captor.capture()); @@ -344,9 +344,9 @@ void issueBootstrapPackage_auditEventRecorded() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); - verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "10"); + verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "test-client"); } @Test @@ -354,7 +354,7 @@ void issueBootstrapPackage_usesConfiguredTtl() { OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); setupBootstrapMocks(cert); - provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); verify(vaultPkiService) .signCsr( @@ -366,7 +366,7 @@ void issueBootstrapPackage_existingAutomatedCert_succeeds() { OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); setupBootstrapMocks(cert); - byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR); + byte[] zip = provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); assertThat(zip).isNotNull(); From 2e2f12d478680cdb2525abecb5bfac05dea56cbd Mon Sep 17 00:00:00 2001 From: ethiggins <86731618+ethiggins@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:00:06 +0000 Subject: [PATCH 12/18] feat(dpav-2563): add trivy vulnerability check to management-node pipeline (#49) * feat(dpav-2563): add trivy vulnerability check to management-node pipeline * feat(OSPO): synchronise OSPO workflows * feat(dpav-2563): use correct trivy-action commit hash * feat(dpav-2563): build and scan management node image * feat(dpav-2563): Build management node image correctly * feat(dpav-2563): add scan permissions * feat(dpav-2563): set up jdk prior to get_version step * feat(dpav-2563): tag docker image prior to scan * feat(dpav-2563): test use of repo environment variable * feat(dpav-2563): remove trailing quote * test repo variable * test permissions * Use env.REPO in with block * use github event context for repository name * test setting repository name * feat(dpav-2563): use correct env names * feat(dpav-2563): use correct env reference * use env.repo outside of shell context * Correct syntax for env reference * [DPAV-2563] Update to more recent images to resolve vulnerabilities * feat(OSPO): synchronise OSPO workflows * feat(dpav-2563): change used image and rename step * feat(dpav-2563): upgrade dev dockerfile maven image --------- Co-authored-by: Nikan Negaresh <84400913+nikan-negaresh-informed@users.noreply.github.com> --- .github/workflows/maven.yml | 59 ++++++++++++++++++++++++++++++++++++- docker/Dockerfile | 2 +- docker/Dockerfile-dev | 2 +- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 76fb7c1..8a6ebb6 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -33,6 +33,8 @@ on: env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" + DOCKER_TARGET: management-node + GITHUB_REPOSITORY: ${{ github.repository }} jobs: build: @@ -80,4 +82,59 @@ jobs: - name: Lint env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS spotless:check \ No newline at end of file + run: ./mvnw $MAVEN_CLI_OPTS spotless:check + security-scanning: + permissions: + contents: read + pull-requests: read + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - name: Set up JDK 21 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + server-password: 'GH_PACKAGES_PAT' + + - name: Get version + id: get_version + run: echo project_version=$(./mvnw $MAVEN_CLI_OPTS help:evaluate -Dexpression=project.version -q -DforceStdout) >> $GITHUB_OUTPUT + + - name: Checkout repo + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Login to ghcr.io + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Format repo name + run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} + + - name: Get server jar + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + pattern: management-node-*.jar + path: target + merge-multiple: true + + - name: Build Server Image + run: docker build --no-cache --build-arg JAR_NAME="management-node-${{ steps.get_version.outputs.project_version}}" -t ghcr.io/${REPO}/management-node:staged -f "${{ github.workspace }}/docker/Dockerfile" --target ${{ env.DOCKER_TARGET }} . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ghcr.io/${{ env.REPO }}/management-node:staged + format: table + exit-code: 1 + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: true + + - name: Clean up docker image + run: docker rmi ghcr.io/${REPO}/management-node:staged \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d79215..86a8fcf 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,7 +7,7 @@ # Build stage ARG JAR_FILE=management-node-1.0.1.jar -FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +FROM maven:3.9.14-eclipse-temurin-21-alpine AS build ARG JAR_FILE WORKDIR /build diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index 81ca304..71908ee 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -7,7 +7,7 @@ # Build stage ARG JAR_FILE=management-node-1.1.0.jar -FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +FROM maven:3.9.14-eclipse-temurin-21-alpine AS build ARG JAR_FILE WORKDIR /build From 90b104482c0e788b0860fc8ccca4e90fd8b830ec Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Wed, 25 Mar 2026 17:16:53 +0000 Subject: [PATCH 13/18] feat(DPAV-2601): Resolve CVE-2026-22732 (#56) * feat(DPAV-2601): Resolve CVE-2026-22732 Upgrade spring-security to 6.5.9 https://spring.io/security/cve-2026-22732 * feat(OSPO): synchronise OSPO workflows --------- Co-authored-by: jsmith-informed <217566155+jsmith-informed@users.noreply.github.com> --- .github/workflows/oss-checker.yml | 23 ++++++++++++++++++++--- pom.xml | 1 + 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index c15a4f8..bda3994 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -103,10 +103,27 @@ jobs: core.info('Generated repository-metadata.json for policy context.'); - name: Install Conftest + env: + FALLBACK_VERSION: '0.37.0' 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/ + set -euo pipefail + + install_conftest() { + local version="$1" + curl --proto "=https" --fail -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${version}/conftest_${version}_Linux_x86_64.tar.gz" -o conftest.tar.gz + tar -xzf conftest.tar.gz + sudo mv conftest /usr/local/bin/ + rm -f conftest.tar.gz + } + + LATEST_VERSION="$(curl --proto "=https" --fail -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+' || true)" + + if [[ -n "${LATEST_VERSION}" ]] && install_conftest "${LATEST_VERSION}"; then + echo "Installed latest Conftest version: ${LATEST_VERSION}" + else + echo "Failed to install latest Conftest. Falling back to version ${FALLBACK_VERSION}." + install_conftest "${FALLBACK_VERSION}" + fi - name: Run Policy Checks id: run_conftest diff --git a/pom.xml b/pom.xml index d03fecc..60df3c5 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,7 @@ 5.10.0 2.8.13 1.83 + 6.5.9 **/config/**, **/dto/**, **/entity/**, From e8dc8c69ac540dd78fc62e2cc43446af6e0dc232 Mon Sep 17 00:00:00 2001 From: JamesRuane-is <108880654+JamesRuane-is@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:16:18 +0000 Subject: [PATCH 14/18] ci(dpav-2566): get image tag from branch name --- .github/workflows/release.yaml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 73ad910..c449e4d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -83,6 +83,8 @@ jobs: contents: read packages: write id-token: write + outputs: + image_tag: ${{ steps.get_version.outputs.version }} name: Publish to github packages needs: verify runs-on: ubuntu-latest @@ -102,18 +104,26 @@ jobs: run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests - name: Publish package run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests + - name: get image tag from branch + id: get_version + run: | + BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + VERSION="${BRANCH#release/}" + echo "version=$VERSION" >> $GITHUB_OUTPUT 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 + name: "Build and release docker images to GHCR with tags '${{ needs.publish.outputs.image_tag }} latest'" + needs: + - verify + - publish uses: ./.github/workflows/docker-ghcr.yml secrets: inherit with: - image_tag: "${{ inputs.image_tag }},latest" + image_tag: "${{ needs.publish.outputs.image_tag }},latest" jar_version: ${{ needs.verify.outputs.project_version }} dry_run: false docker_target: management-node From 47ea4722dc67d20f23701af9c9836894a94f214d Mon Sep 17 00:00:00 2001 From: ethiggins <86731618+ethiggins@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:35:55 +0000 Subject: [PATCH 15/18] feat(dpav-2563): fix to vulnerability and update to trivy scan config (#55) * feat(dpav-2563): fix to vulnerability and update to trivy scan config * feat(OSPO): synchronise OSPO workflows * feat(dpav-2563): update to ignore file * feat(dpav:2563): update ignore file * feat(dpav-2563): update trivy ignore file * feat(dpav-2563): update spring boot starter parent version to 3.5.12 * feat(dpav-2563): reduce spring back to 3.5.11 --- .github/workflows/maven.yml | 3 ++- .trivyignore | 8 ++++++++ pom.xml | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .trivyignore diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 8a6ebb6..135f8ff 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -134,7 +134,8 @@ jobs: exit-code: 1 ignore-unfixed: true severity: "CRITICAL,HIGH" - continue-on-error: true + trivyignores: .trivyignore + continue-on-error: false - name: Clean up docker image run: docker rmi ghcr.io/${REPO}/management-node:staged \ No newline at end of file diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..b068e71 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# Lack of information on this CVE, with many pages on it leading to a 404 or claiming there's no mitigations. Worth keeping an eye on but can be ignored from scan. +CVE-2026-1584 +# SiYuan: Authorization Bypass Allows Arbitrary SQL Execution +CVE-2026-32767 +# libpng: LIBPNG has a heap buffer. +CVE-2026-25646 +# zlib: Arbitrary code execution via buffer overflow +CVE-2026-22184 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 60df3c5..213138d 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ 21 UTF-8 2025.0.0 + 2.21.1 42.7.7 11.10.4 2.46.1 From 30749a5e993222da7f53f2e5657b8cae852ce57a Mon Sep 17 00:00:00 2001 From: jsmith-informed Date: Thu, 26 Mar 2026 13:33:35 +0000 Subject: [PATCH 16/18] feat(dpav-2608): update documentation and development setup guide (#53) * feat(DPAV-2608): Update documentation and development setup guide * fix(dpav-2608): addressed review comments --------- Co-authored-by: KumailKamranIS --- README.md | 429 +++++++++++++++++---------- docker/keycloak/management-node.json | 57 +++- docs/AUTHENTICATION_REQUIREMENTS.md | 34 ++- docs/BOOTSTRAP_ONBOARDING.md | 97 ++++++ docs/DATABASE_SCHEMA.md | 79 ++++- docs/MTLS_CONFIGURATION.md | 2 +- 6 files changed, 529 insertions(+), 169 deletions(-) create mode 100644 docs/BOOTSTRAP_ONBOARDING.md diff --git a/README.md b/README.md index 8db1442..18ef406 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # README -**Repository:** `management-node` -**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**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. +The Management Node is a Spring Boot application that provides REST APIs for federator configuration management and certificate lifecycle operations (key generation, CSR signing, and bootstrap onboarding). It implements secure communication using Mutual TLS (mTLS) and zero-trust authentication via Keycloak. ---- +--- ## 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 @@ -28,7 +28,8 @@ For a full description of the database tables, relationships, and constraints, s --- ## Quick Start -Note. see lower for setting up prerequisites for local deployment certs, keycloak etc. + +> **Note:** see [lower](#prerequisites-setup) for setting up prerequisites for local deployment certs, keycloak etc. ### Run the Spring Boot application @@ -74,7 +75,7 @@ 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). +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). @@ -97,26 +98,26 @@ The Management Node Module implements a zero-trust security architecture using M The system requires several certificate files: -1. **Private Key (`localhost.key`)**: +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`)**: +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`)**: +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`)**: +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`)**: +5. **Java Truststore (`truststore.jks`)**: - Contains certificates that the server trusts - Used to validate client certificates during MTLS @@ -157,7 +158,7 @@ cd docker 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. @@ -175,7 +176,7 @@ cd docker 6. **Add the Root CA to the Trust Store**: ```bash - keytool -importcert -file rootCA.crt -alias clientca -keystore localhost.p12 -storetype PKCS12 -storepass changeit + keytool -importcert -noprompt -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. @@ -217,9 +218,13 @@ cd docker ### Certificate Placement and Configuration -After generating the certificates, place them in the appropriate locations: +After generating the certificates, ensure they are readable by Docker containers (Keycloak runs as a non-root user): -if you've followed the above then follow with +```bash +chmod 644 localhost.key localhost.crt localhost.p12 keystore.jks truststore.jks rootCA.crt rootCA.key +``` + +Then copy them to the management-node root directory: ```bash cp keystore.jks ../keystore.jks cp truststore.jks ../truststore.jks @@ -322,7 +327,7 @@ The application uses Keycloak for authentication and authorization. Follow these cd docker ``` -2. Make sure you have the required certificates in the `docker` directory: see lower for local certificate setup +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 @@ -344,9 +349,15 @@ The application uses Keycloak for authentication and authorization. Follow these 5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: - Username: `admin` - - Password: `password` + - Password: `password` + + You must import your `client.p12` certificate into your browser before accessing the admin console, otherwise the request will be rejected by mTLS. + + **Chrome:** + 1. Navigate to `chrome://certificate-manager/clientcerts/platformclientcerts` + 2. Click **Import** and select the `client.p12` file from the `docker/` directory + 3. When prompted for a password, enter `changeit` - 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. @@ -364,8 +375,8 @@ After starting Keycloak, you need to set up a realm for the Management Node. You 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=*************``` +8. After the import is complete, verify that the `mng-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_CLIENT_SECRET=*************``` ### Option 2: Manual Configuration @@ -373,22 +384,22 @@ 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" +2. Create a new realm named `mng-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** - + - In the `mng-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: `+` @@ -400,41 +411,93 @@ If you prefer to set up the realm manually (updated for Keycloak 26.x): 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` + - `access_producer_configurations` — access producer configuration endpoint + - `access_consumer_configurations` — access consumer configuration endpoint + - `create_keys` — generate key pairs and create CSRs + - `sign_certificate` — sign CSRs via the PKI engine + - `access_public_certificates` — retrieve the intermediate CA certificate + - `request_bootstrap_certificate` — issue bootstrap certificate packages (website service account only) + + See [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md) for full details on which roles map to which endpoints. 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`) + - Check the roles needed for this client: + - `access_producer_configurations` + - `access_consumer_configurations` + - `create_keys` + - `sign_certificate` + - `access_public_certificates` + - `request_bootstrap_certificate` - Click **Assign** -7. Update your `application.yml` with the client configuration, if needed (or do ```export KEYCLOAK_CLIENTID=*************```): + > **Note:** For local development, assign all 6 roles if you will be testing all endpoints with the single `management-node` client credentials. In production, roles should be split across separate clients (e.g. `request_bootstrap_certificate` only for the website/onboarding service). + +7. Create `src/main/resources/application-local.yml` with all local development overrides: ```yaml spring: + cloud: + vault: + token: not_setup # replace with Vault root token if using certificate endpoints 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 - + client-secret: + datasource: + password: keycloak_db_user_password + + server: + ssl: + key-store-password: changeit + trust-store-password: changeit + application: client: - key-store: keystore.jks key-store-password: changeit - keyStoreType: JKS + keyStoreType: PKCS12 ``` -### Setting up vault with Docker compose + > **Note:** The `audiences` is set to `account` because Keycloak includes `account` as the default audience in service account tokens. Without this override, JWT validation will reject tokens with an audience mismatch. In production, configure a client scope audience mapper in Keycloak to use a custom audience instead. + +### 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_CLIENT_SECRET=`YOUR_CLIENT_SECRET` +cd docker # or where your certificates are stored +``` + +```bash +curl -k --location 'https://localhost:8443/realms/mng-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_CLIENT_SECRET}" \ + --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 + +## Vault Setup (Optional — required for Certificate Manager) + +This section is only needed if you plan to use the certificate endpoints (`/api/v1/certificate/*`) or run the federator-certificate-manager. If you only need the configuration endpoints, skip to [Building and Running with Maven](#building-and-running-with-maven). -Follow these steps to setup vault using Docker compose: +### Setting up Vault with Docker Compose 1. Create a directory called `config` in the `docker/vault` directory @@ -472,51 +535,129 @@ docker compose -f docker/vault/docker-compose.yaml up -d docker exec vault vault status -format=json ``` -5. initialize vault & generate unseal keys and root token: +5. Initialise vault & generate unseal keys and root token: ```sh # copy the Keys and root token to somewhere safe -docker exec vault vault operator init -key-shares=5 -key-threshold=3 -format=json +docker exec vault vault operator init -key-shares=1 -key-threshold=1 -format=json ``` -6. unseal vault using the unseal key in the previous step: +> **Note:** A single key share is used here for convenience. In production, use multiple key shares (e.g. `-key-shares=5 -key-threshold=3`) to distribute unseal keys across different operators via [Shamir's secret sharing](https://developer.hashicorp.com/vault/docs/concepts/seal). + +6. Unseal vault using the unseal key from the previous step: ```sh -docker exec vault vault operator unseal -docker exec vault vault operator unseal -docker exec vault vault operator unseal +docker exec vault vault operator unseal ``` -You can then access vault using the Web UI and the root token at `http://localhost:8200`. Dont forget to add your vault root token to the application file. +> **Note:** Vault seals itself whenever the container is stopped or restarted. You will need to run the unseal command again each time you bring the container back up. The init and PKI setup steps do not need to be repeated — only the unseal. -### Testing mTLS connectivity: +You can then access vault using the Web UI and the root token at [http://localhost:8200](http://localhost:8200). Add the Vault root token to your `application-local.yml`: -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): + ```yaml + spring: + cloud: + vault: + token: + ``` -```bash -export KEYCLOAK_CLIENTID=`YOUR_CLIENT_SECRET` -cd docker # or where your certificates are stored +### Setting up the Vault PKI Secrets Engine + +The Management Node uses Vault's PKI secrets engine to sign certificate requests. After initialising and unsealing Vault, you need to enable the PKI engine, import the root CA, and create a signing role. + +All commands below assume you are using the root token for authentication. Replace `` with the token from the init step above. + +7. Set the Vault address and token for the CLI: + +```sh +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN= +``` + +8. Enable the PKI secrets engine at the `pki-int` mount path. This path must match the `application.vault.pki-mount` property in `application.yml` (default: `pki-int`): + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault secrets enable -path=pki-int pki +``` + +9. Set the maximum TTL for the PKI engine. This controls how long certificates issued by this CA can be valid: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault secrets tune -max-lease-ttl=87600h pki-int +``` + +10. Import the existing root CA into Vault's PKI engine. This reuses the same root CA generated during [Certificate Setup](#certificate-setup), so certificates signed by Vault are trusted by the same truststore used for mTLS: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault write pki-int/config/ca \ + pem_bundle="$(openssl rsa -in docker/rootCA.key -passin pass:changeit 2>/dev/null && cat docker/rootCA.crt)" +``` + +11. Configure the PKI issuing certificate and CRL distribution URLs. These are embedded in certificates issued by the CA: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault write pki-int/config/urls \ + issuing_certificates="http://localhost:8200/v1/pki-int/ca" \ + crl_distribution_points="http://localhost:8200/v1/pki-int/crl" +``` + +12. Create a signing role. The role name must match `application.vault.default-role` in `application.yml` (default: `default-role`). This role defines what the CA is allowed to sign: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault write pki-int/roles/default-role \ + allow_any_name=true \ + allow_subdomains=true \ + max_ttl=8760h \ + key_type=any \ + allow_ip_sans=true \ + allow_glob_domains=true \ + server_flag=true \ + client_flag=true \ + allowed_other_sans="1.3.6.1.4.1.32473.1.1;utf8:*" ``` +- `allow_any_name=true` permits signing CSRs with any common name — appropriate for development. In production, restrict this to specific domains. +- `key_type=any` allows both RSA and EC keys. +- `allowed_other_sans` whitelists the bootstrap OID so the bootstrap flow can embed it in signed certificates. This matches the default `application.bootstrap.oid` value — if you override `BOOTSTRAP_OID`, update this Vault role to match. + +13. Verify the PKI engine is working by reading back the CA certificate: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault read pki-int/cert/ca +``` + +You should see the root CA certificate in PEM format. + +14. Test that the PKI engine can sign a certificate: + +```sh +docker exec -e VAULT_TOKEN=$VAULT_TOKEN vault vault write -format=json pki-int/issue/default-role \ + common_name="test.example.com" \ + ttl=1h | jq -r '.data.certificate' +``` + +You should see a signed PEM certificate. This confirms the Vault PKI engine is working. + +14. To verify the full integration with the Management Node, restart the app (with the real Vault token in `application-local.yml`) and test the certificate endpoints: + ```bash -curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ +# Get a token +TOKEN=$(curl -k https://localhost:8443/realms/mng-node/protocol/openid-connect/token \ --cert client.crt --key client.key \ - --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=client_credentials' \ --data-urlencode 'client_id=management-node' \ - --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ - --data-urlencode 'grant_type=client_credentials' + --data-urlencode "client_secret=${KEYCLOAK_CLIENT_SECRET}" \ + -s | jq -r '.access_token') + +# Generate a key pair via the Management Node +curl -k https://localhost:8090/api/v1/certificate/keyPair \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . ``` -**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 this returns 200 with a JSON response containing the key pair, the Management Node is fully connected to Vault's PKI engine. -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 +> **Note:** This setup imports the development root CA directly at the `pki-int` mount point. For production, you would typically create a separate root CA and then generate an intermediate CA signed by it. Both configurations work with the Management Node — the only difference is whether `pki-int/cert/ca_chain` returns a chain or is empty. ## Building and Running with Maven @@ -547,9 +688,9 @@ 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 +Note: if using `application-local.yml` (see [Keycloak Realm Setup](#keycloak-realm-setup) step 7), passwords are already configured. Otherwise, export them first: ``` export POSTGRES_PASSWORD=keycloak_db_user_password export CERTPASSWORD=changeit @@ -567,9 +708,9 @@ cp docker/client.key client.key java -jar target/management-node-1.0.1.jar ``` -2. Using the Maven Spring Boot plugin: +2. Using the Maven Spring Boot plugin (with the local profile if using `application-local.yml`): ```bash - mvn spring-boot:run + mvn spring-boot:run -Dspring-boot.run.profiles=local ``` The application will be available at https://localhost:8090 @@ -581,12 +722,12 @@ 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 +2. Navigate to: **mng-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 +export KEYCLOAK_CLIENT_SECRET=your_actual_client_secret_here ``` **Step 2: Get a JWT token and test the endpoints** @@ -596,25 +737,25 @@ export KEYCLOAK_CLIENTID=your_actual_client_secret_here 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 \ +curl -k https://localhost:8443/realms/mng-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}" \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENT_SECRET}" \ -s | jq . # Get a token and save it -TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ +TOKEN=$(curl -k https://localhost:8443/realms/mng-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}" \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENT_SECRET}" \ -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 +# If TOKEN is "null", check that KEYCLOAK_CLIENT_SECRET is set correctly # Test the producer endpoint curl -k https://localhost:8090/api/v1/configuration/producer \ @@ -627,85 +768,65 @@ curl -k https://localhost:8090/api/v1/configuration/consumer \ -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: +The configuration endpoints will return a **403 Forbidden** with `"No organisation certificate found"`. This is expected — the `management-node` client is not mapped to a Producer or Consumer organisation in the database. At this point the setup is confirmed working: - ✅ mTLS authentication is working (client certificates validated) -- ✅ Client credentials are correct +- ✅ JWT token was issued with correct roles - ✅ Keycloak is properly configured +- ✅ The 403 is the interceptor correctly rejecting an unmapped client -### 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 +**Testing further with a federator client (optional):** -#### Creating a Profile-Specific YAML File +To test the full configuration response, create a Keycloak client that matches one of the seeded organisations: -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) +1. In the Keycloak admin console, navigate to **mng-node realm** → **Clients** → **Create client** +2. Set Client ID to `FEDERATOR_ENV` (matches the sample data for Environment Agency) +3. Enable **Client authentication** and **Service accounts roles** +4. Save, then go to the **Service accounts roles** tab +5. Assign the `management-node` roles: `access_producer_configurations` and `access_consumer_configurations` +6. Copy the client secret from the **Credentials** tab -2. Add your environment-specific configuration to this file. For example: +Then test with the new client: - ```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 - ``` +```bash +export FEDERATOR_ENV_SECRET= -3. Make sure not to commit this file to version control by adding it to your `.gitignore` file: - ``` - src/main/resources/application-local.yml - ``` +# Get a token for the FEDERATOR_ENV client +TOKEN=$(curl -k https://localhost:8443/realms/mng-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=FEDERATOR_ENV' \ + --data-urlencode "client_secret=${FEDERATOR_ENV_SECRET}" \ + -s | jq -r '.access_token') -#### Running the Application with a Specific Profile +# This should now return the configuration for the Environment Agency organisation +curl -k https://localhost:8090/api/v1/configuration/producer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . +``` -To run the application with your profile, use one of these methods: +### Using Profile-Specific Configuration Files -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 - ``` +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. -2. Using the Maven Spring Boot plugin: - ```bash - mvn spring-boot:run -Dspring-boot.run.profiles=local - ``` +- **Security**: Keep sensitive information like passwords and API keys out of version control +- **Environment-Specific Settings**: Configure different settings for development, testing, and production +- **Local Development**: Each developer can have their own configuration without affecting others -3. Using environment variables: - ```bash - export SPRING_PROFILES_ACTIVE=local - java -jar target/management-node-0.0.1.jar - ``` +For local development, create `src/main/resources/application-local.yml` to override secrets and passwords. See [Keycloak Realm Setup](#keycloak-realm-setup) step 7 for the full example. -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 - ``` +Make sure to add this file to `.gitignore`: +``` +src/main/resources/application-local.yml +``` -The application will load both the default `application.yml` and your profile-specific `application-local.yml`, with the latter overriding any duplicate properties. +Then run with the `local` 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 +``` ## Code Coverage with JaCoCo @@ -751,7 +872,7 @@ The current configuration aims for 80% code coverage across instructions, branch - **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 + - Check that `KEYCLOAK_CLIENT_SECRET` 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 @@ -792,7 +913,7 @@ For production deployments, consider: 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 +- Swagger UI: https://localhost:8090/swagger-ui.html - OpenAPI JSON: https://localhost:8090/v3/api-docs @@ -808,14 +929,14 @@ How Springdoc OpenAPI works in this project - @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. @@ -827,8 +948,12 @@ All protected endpoints require JWT bearer tokens. Tokens must: - 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 +- `access_producer_configurations` — access `/api/v1/configuration/producer` +- `access_consumer_configurations` — access `/api/v1/configuration/consumer` +- `create_keys` — `GET /api/v1/certificate/keyPair`, `POST /api/v1/certificate/csr/create` +- `sign_certificate` — `POST /api/v1/certificate/csr/sign` +- `access_public_certificates` — `GET /api/v1/certificate/intermediate` +- `request_bootstrap_certificate` — `POST /api/v1/certificate/bootstrap` **Token Structure Example:** ```json @@ -838,7 +963,9 @@ All protected endpoints require JWT bearer tokens. Tokens must: "management-node": { "roles": [ "access_producer_configurations", - "access_consumer_configurations" + "access_consumer_configurations", + "sign_certificate", + "access_public_certificates" ] } }, @@ -848,7 +975,7 @@ All protected endpoints require JWT bearer tokens. Tokens must: 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 +2. Assigned to the appropriate service accounts Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). @@ -857,7 +984,7 @@ This repository has been developed with public funding as part of the National D ## 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. +- **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`. @@ -870,7 +997,7 @@ We welcome contributions that align with the Programme’s objectives. Please re ## 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. +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).** +**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. diff --git a/docker/keycloak/management-node.json b/docker/keycloak/management-node.json index 74ab02f..80bfe55 100644 --- a/docker/keycloak/management-node.json +++ b/docker/keycloak/management-node.json @@ -1,6 +1,6 @@ { "id": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", - "realm": "management-node", + "realm": "mng-node", "notBefore": 0, "defaultSignatureAlgorithm": "RS256", "revokeRefreshToken": false, @@ -49,7 +49,7 @@ "realm": [ { "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", - "name": "default-roles-management-node", + "name": "default-roles-mng-node", "description": "${role_default-roles}", "composite": true, "composites": { @@ -321,6 +321,42 @@ "clientRole": true, "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", "attributes": {} + }, + { + "id": "a1b2c3d4-1111-4000-a000-000000000001", + "name": "create_keys", + "description": "Generate key pairs and create CSRs", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + }, + { + "id": "a1b2c3d4-2222-4000-a000-000000000002", + "name": "sign_certificate", + "description": "Sign CSRs via the PKI engine", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + }, + { + "id": "a1b2c3d4-3333-4000-a000-000000000003", + "name": "access_public_certificates", + "description": "Retrieve the intermediate CA certificate", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + }, + { + "id": "a1b2c3d4-4444-4000-a000-000000000004", + "name": "request_bootstrap_certificate", + "description": "Issue bootstrap certificate packages", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} } ], "broker": [ @@ -427,7 +463,7 @@ "groups": [], "defaultRole": { "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", - "name": "default-roles-management-node", + "name": "default-roles-mng-node", "description": "${role_default-roles}", "composite": true, "clientRole": false, @@ -489,12 +525,15 @@ "disableableCredentialTypes": [], "requiredActions": [], "realmRoles": [ - "default-roles-management-node" + "default-roles-mng-node" ], "clientRoles": { "management-node": [ "access_producer_configurations", - "access_consumer_configurations" + "access_consumer_configurations", + "create_keys", + "sign_certificate", + "access_public_certificates" ] }, "notBefore": 0, @@ -526,13 +565,13 @@ "clientId": "account", "name": "${client_account}", "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/management-node/account/", + "baseUrl": "/realms/mng-node/account/", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "redirectUris": [ - "/realms/management-node/account/*" + "/realms/mng-node/account/*" ], "webOrigins": [], "notBefore": 0, @@ -573,13 +612,13 @@ "clientId": "account-console", "name": "${client_account-console}", "rootUrl": "${authBaseUrl}", - "baseUrl": "/realms/management-node/account/", + "baseUrl": "/realms/mng-node/account/", "surrogateAuthRequired": false, "enabled": true, "alwaysDisplayInConsole": false, "clientAuthenticatorType": "client-secret", "redirectUris": [ - "/realms/management-node/account/*" + "/realms/mng-node/account/*" ], "webOrigins": [], "notBefore": 0, diff --git a/docs/AUTHENTICATION_REQUIREMENTS.md b/docs/AUTHENTICATION_REQUIREMENTS.md index 9bef0fa..b775d5a 100644 --- a/docs/AUTHENTICATION_REQUIREMENTS.md +++ b/docs/AUTHENTICATION_REQUIREMENTS.md @@ -41,6 +41,10 @@ Sample JWT payload (use this structure when testing locally): "roles": [ "access_producer_configurations", "access_consumer_configurations", + "create_keys", + "sign_certificate", + "access_public_certificates", + "request_bootstrap_certificate", "BrownfieldLandAvailability", "PendingPlanningApplications" ] @@ -57,10 +61,22 @@ Notes: ## Role requirements per API - Producer API: Federator clients may access Producer configuration only when their token contains the role `access_producer_configurations` under the `resource_access` for the audience/client `management-node`. - - Enforcement in code: `@PreAuthorize("hasRole('ROLE_management-node:access_producer_configurations')")` on `/api/v1/configuration/producer`. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:access_producer_configurations')")` on `/api/v1/configuration/producer`. - Consumer API: Federator clients may access Consumer configuration only when their token contains the role `access_consumer_configurations` under the `resource_access` for the audience/client `management-node`. - - Enforcement in code: `@PreAuthorize("hasRole('ROLE_management-node:access_consumer_configurations')")` on `/api/v1/configuration/consumer`. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:access_consumer_configurations')")` on `/api/v1/configuration/consumer`. + +- Key Pair / CSR Creation API: Clients may create RSA key pairs and certificate signing requests when their token contains the role `create_keys`. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:create_keys')")` on `GET /api/v1/certificate/keyPair` and `POST /api/v1/certificate/csr/create`. + +- CSR Signing API: Clients may sign certificate signing requests when their token contains the role `sign_certificate`. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:sign_certificate')")` on `POST /api/v1/certificate/csr/sign`. + +- Intermediate Certificate API: Clients may retrieve the intermediate CA certificate when their token contains the role `access_public_certificates`. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:access_public_certificates')")` on `GET /api/v1/certificate/intermediate`. + +- Bootstrap Certificate API: The onboarding service account may request bootstrap certificate packages when its token contains the role `request_bootstrap_certificate`. The request body contains the target `organisationId` and a CSR. If no certificate record exists for the organisation, one is created automatically. This role is typically assigned only to the website backend service account, not to individual federator clients. + - Enforcement in code: `@PreAuthorize("hasAuthority('ROLE_management-node:request_bootstrap_certificate')")` on `POST /api/v1/certificate/bootstrap`. ## How this maps to Keycloak @@ -71,13 +87,19 @@ Notes: - Create and assign the following client roles on the `management-node` client: - `access_producer_configurations` - `access_consumer_configurations` -- Assign these roles to the appropriate Producer or Consumer Federator clients or service accounts. + - `create_keys` + - `sign_certificate` + - `access_public_certificates` + - `request_bootstrap_certificate` +- Assign configuration roles to the appropriate Producer or Consumer Federator clients or service accounts. +- Assign certificate roles (`create_keys`, `sign_certificate`, `access_public_certificates`) to federator service accounts that manage their own certificates. +- Assign `request_bootstrap_certificate` only to the website/onboarding backend service account. ## Requesting tokens (example) Using client credentials with mTLS (as per the project’s Keycloak setup): ``` -curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ +curl --location 'https://localhost:8443/realms/mng-node/protocol/openid-connect/token' \ --cert client.crt --key client.key \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id=' \ @@ -97,4 +119,8 @@ curl -k 'https://localhost:8090/api/v1/configuration/producer' \ - Authorization: - Producer API requires role: `access_producer_configurations`. - Consumer API requires role: `access_consumer_configurations`. + - Key Pair / CSR Creation API requires role: `create_keys`. + - CSR Signing API requires role: `sign_certificate`. + - Intermediate Certificate API requires role: `access_public_certificates`. + - Bootstrap Certificate API requires role: `request_bootstrap_certificate`. - Swagger/OpenAPI: Use Swagger UI at `/swagger-ui.html` to explore and test with a valid token. \ No newline at end of file diff --git a/docs/BOOTSTRAP_ONBOARDING.md b/docs/BOOTSTRAP_ONBOARDING.md new file mode 100644 index 0000000..61dbb6a --- /dev/null +++ b/docs/BOOTSTRAP_ONBOARDING.md @@ -0,0 +1,97 @@ +# Bootstrap Onboarding Flow + +**Repository:** `management-node` +**Description:** `End-to-end bootstrap flow for onboarding new organisations with initial certificates` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0` + +--- + +## Overview + +Bootstrap onboarding enables new organisations to obtain their initial certificates through a website-mediated flow, without requiring pre-existing mTLS credentials for the target organisation. The organisation's administrator generates a private key and CSR locally, then submits the CSR via the website. The website backend requests a short-lived bootstrap certificate from the Management Node on behalf of the organisation, which is then automatically replaced with a full certificate by the Certificate Manager. + +--- + +## Sequence Diagram + +```mermaid +sequenceDiagram + participant Admin as Org Administrator + participant UI as Website UI + participant WB as Website Backend + participant KC as Keycloak (IdP) + participant MN as Management Node + participant V as Vault (KV v2) + participant CM as Certificate Manager + participant F as Federator + + Note over Admin: Step 1 — Generate credentials locally + Admin->>Admin: Generate RSA private key + CSR + Admin->>UI: Upload CSR + + Note over UI: Step 2 — Submit onboarding request + UI->>WB: Send CSR (target org: HMRC) + + Note over WB: Step 3 — Authenticate as website backend + WB->>KC: client_credentials grant (WEBSITE_CLIENT_ID, mTLS) + KC-->>WB: JWT (role: request_bootstrap_certificate) + + Note over WB: Step 4 — Request bootstrap package + WB->>MN: POST /api/v1/certificate/bootstrap + Note over WB,MN: { organisationId: 1, csr: "" }
Authorization: Bearer
Organisation must already exist in Management Node. + MN->>MN: Sign CSR (short TTL, OID marker in otherName SAN) + MN-->>WB: ZIP (certificate.pem + ca-chain.pem) + WB-->>Admin: Return ZIP + + Note over Admin: Step 5 — Deploy to organisation's Vault + Admin->>V: Store private key, certificate, ca-chain + Note over V: Organisation's own Vault instance.
Certificate contains bootstrap OID marker.
Secrets must be stored at the paths expected
by the Certificate Manager (see its vault-integration docs). + + Note over CM: Step 6 — Automatic renewal + CM->>V: Read certificate (next renewal cycle) + CM->>CM: Detect bootstrap OID in otherName SAN + CM->>CM: Generate new RSA key pair + CSR + CM->>KC: client_credentials grant (HMRC_CLIENT_ID, mTLS) + KC-->>CM: JWT (roles: sign_certificate, access_public_certificates) + CM->>MN: POST /api/v1/certificate/csr/sign (CSR, standard TTL) + MN-->>CM: Signed certificate (no OID marker) + CM->>V: Persist new key pair + signed certificate + CM->>CM: Generate PKCS#12 keystore + truststore + CM-->>F: Write keystores + credentials to shared filesystem + + Note over F: Step 7 — Operational + F->>MN: GET /api/v1/configuration/consumer + MN-->>F: Configuration response (200 OK) +``` + +--- + +## Bootstrap Certificate Properties + +| Property | Value | Description | +|----------|-------|-------------| +| TTL | Short (e.g., 2 hours) | Limits the window of exposure before automatic renewal | +| OID marker | `1.3.6.1.4.1.32473.1.1` (configurable) | Embedded in an `otherName` SAN entry, used by Certificate Manager to detect bootstrap certificates | +| Certificate type | `BOOTSTRAP` | Recorded in `organisation_certificate.type`; changes to `AUTOMATED` after renewal | +| `other_sans` format | `;UTF8:bootstrap` | Vault PKI parameter used when signing the bootstrap CSR | + +The OID `1.3.6.1.4.1.32473.1.1` uses the reserved Private Enterprise Number 32473 (RFC 5612, for documentation use). In production, NDTP would register a PEN with IANA and replace this value. + +--- + +## Keycloak Clients + +| Client ID | Purpose | Authentication | Roles | +|-----------|---------|----------------|-------| +| `WEBSITE_CLIENT_ID` | Website backend that initiates bootstrap | X.509 client certificate (`client_credentials` grant) | `request_bootstrap_certificate` | +| `HMRC_CLIENT_ID` | Target organisation's federator | X.509 client certificate (`client_credentials` grant) | `sign_certificate`, `access_public_certificates`, `access_consumer_configurations` | + +--- + +## Security Considerations + +- The **website backend** is the only actor with the `request_bootstrap_certificate` role. Individual organisations cannot self-bootstrap. +- The bootstrap certificate's **short TTL** limits the window during which the initial certificate is valid. +- The **OID marker** ensures Certificate Manager can distinguish bootstrap certificates from production certificates and trigger immediate renewal. Without it, the certificate would still be renewed but only when it approaches expiry based on the configured renewal threshold. +- All certificate endpoints (`/api/v1/certificate/**`) are **excluded from the CertificateValidationInterceptor**. These endpoints are called by service accounts (e.g. the website backend, Certificate Manager) that may not have an associated organisation certificate record, so organisation certificate validation is not applicable. Access is still secured by JWT authentication and role-based authorization (`@PreAuthorize`). +- After renewal, the replacement certificate is a standard certificate with no OID marker and a normal TTL. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 94ca1a5..9c58ed6 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,7 +1,7 @@ # Database Schema -**Repository:** `management-node` -**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**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 ` --- @@ -18,6 +18,8 @@ The database is designed to model Organisations, their Producers and Consumers, ## Overview of Entities and Relationships - Organisation has many Producers and Consumers +- Organisation has one optional OrganisationCertificate +- OrganisationCertificate has many CertificateEvents - Producer belongs to an Organisation - Consumer belongs to an Organisation - Product belongs to a Producer @@ -30,6 +32,8 @@ A simple ER diagram (Mermaid): erDiagram ORGANISATION ||--o{ PRODUCER : has ORGANISATION ||--o{ CONSUMER : has + ORGANISATION ||--o| ORGANISATION_CERTIFICATE : has + ORGANISATION_CERTIFICATE ||--o{ CERTIFICATE_EVENTS : logs PRODUCER ||--o{ PRODUCT : offers PRODUCT ||--o{ PRODUCT_CONSUMER : grants CONSUMER ||--o{ PRODUCT_CONSUMER : consumes @@ -39,6 +43,7 @@ erDiagram ORGANISATION { BIGSERIAL id PK VARCHAR name + BOOLEAN certificate_automation_enabled } PRODUCER { BIGSERIAL id PK @@ -90,6 +95,27 @@ erDiagram VARCHAR value BIGINT product_consumer_id FK } + ORGANISATION_CERTIFICATE { + BIGSERIAL id PK + BIGINT organisation_id FK + VARCHAR subject_dn + VARCHAR serial_number + BOOLEAN is_renewable + BIGINT renewal_ttl + VARCHAR type + TIMESTAMP requested_at + TIMESTAMP issued_at + TIMESTAMP expires_at + TIMESTAMP revoked_at + } + CERTIFICATE_EVENTS { + BIGSERIAL id PK + BIGINT organisation_certificate_id FK + VARCHAR type + VARCHAR event_type + TIMESTAMP event_time + VARCHAR performed_by + } ``` --- @@ -102,9 +128,10 @@ Represents an organisation that owns Producers and Consumers. Columns: - `id` BIGSERIAL, primary key - `name` VARCHAR(150), not null +- `certificate_automation_enabled` BOOLEAN, not null, default TRUE Usage: -- Parent entity for `producer` and `consumer`. +- Parent entity for `producer`, `consumer`, and `organisation_certificate`. --- @@ -213,6 +240,50 @@ Usage: --- +### organisation_certificate +Tracks the current certificate state for each organisation. Each organisation has at most one certificate record. + +Columns: +- `id` BIGSERIAL, primary key +- `organisation_id` BIGINT, not null, unique, foreign key → `organisation(id)` +- `subject_dn` VARCHAR(500), nullable — X.500 distinguished name of the certificate subject +- `serial_number` VARCHAR(150), nullable — certificate serial number +- `is_renewable` BOOLEAN, not null, default FALSE — whether automatic renewal is enabled +- `renewal_ttl` BIGINT, nullable — renewal time-to-live value +- `type` VARCHAR(50), not null — certificate type (e.g., `MANUAL`, `BOOTSTRAP`, `AUTOMATED`) +- `requested_at` TIMESTAMP, nullable — when the certificate was requested +- `issued_at` TIMESTAMP, nullable — when the certificate was issued +- `expires_at` TIMESTAMP, nullable — certificate expiry time +- `revoked_at` TIMESTAMP, nullable — when the certificate was revoked (if applicable) + +Constraints: +- UNIQUE on `organisation_id` — one certificate record per organisation +- Index on `organisation_id` + +Usage: +- Records the lifecycle state of each organisation's certificate. The `type` field tracks whether the certificate was manually provisioned, issued via the bootstrap flow, or issued via automated renewal. + +--- + +### certificate_events +Audit trail of certificate lifecycle events for each organisation certificate. + +Columns: +- `id` BIGSERIAL, primary key +- `organisation_certificate_id` BIGINT, not null, foreign key → `organisation_certificate(id)` +- `type` VARCHAR(50), not null — certificate type at the time of the event +- `event_type` VARCHAR(50), not null — the event that occurred (e.g., `ISSUED`, `RENEWED`, `EXPIRED`, `REVOKED`) +- `event_time` TIMESTAMP, not null — when the event occurred +- `performed_by` VARCHAR(255), nullable — identifier of the actor who triggered the event + +Constraints: +- Index on `organisation_certificate_id` + +Usage: +- Provides an audit log of all certificate lifecycle transitions. Each event captures what happened, when, and who performed the action. + +--- + ## Migration Notes - Schema is versioned and applied with Flyway on application startup. - Foreign keys enforce referential integrity among core entities. @@ -220,4 +291,4 @@ Usage: ## 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. -- Ensure that any PII or sensitive metadata stored in attributes follows your organization’s data handling policies. \ No newline at end of file +- Ensure that any PII or sensitive metadata stored in attributes follows your organization’s data handling policies. diff --git a/docs/MTLS_CONFIGURATION.md b/docs/MTLS_CONFIGURATION.md index 190279e..8df0ee6 100644 --- a/docs/MTLS_CONFIGURATION.md +++ b/docs/MTLS_CONFIGURATION.md @@ -32,7 +32,7 @@ For instructions on generating these files, refer to the [Certificate Setup](#ce ## Configuring MTLS for Keycloak -Keycloak's MTLS configuration is defined in the `docker/docker-compose.yml` file. The following environment variables control MTLS behavior: +Keycloak's MTLS configuration is defined in the `docker/keycloak/docker-compose.yml` file. The following environment variables control MTLS behavior: ```yaml KC_HTTPS_CLIENT_AUTH: ${KC_HTTPS_CLIENT_AUTH} # Set to 'required' to enforce MTLS From 7a8c1f3e4e7895553d5e46d38db158ab4afca637 Mon Sep 17 00:00:00 2001 From: KumailKamranIS <131384933+KumailKamranIS@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:44:52 +0000 Subject: [PATCH 17/18] docs: fix formatting issues with doc identifiers (#58) --- README.md | 6 +++--- docs/DATABASE_SCHEMA.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 18ef406..4d2e420 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # 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 ` +**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 ` --- diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 9c58ed6..440c889 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,8 +1,8 @@ # 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 ` +**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 ` --- From abab8c9eae7f257da01fcabe22ace7bd12b51c70 Mon Sep 17 00:00:00 2001 From: KumailKamranIS <131384933+KumailKamranIS@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:35:23 +0000 Subject: [PATCH 18/18] chore: pre release 1.2.0 (#59) * chore: pre release 1.2.0 * feat(OSPO): synchronise OSPO workflows --- .github/workflows/auto-back-merge.yml | 7 +---- .github/workflows/oss-checker.yml | 10 ++++---- CHANGELOG.md | 37 +++++++++++++++++++++++++++ pom.xml | 2 +- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/auto-back-merge.yml b/.github/workflows/auto-back-merge.yml index 6917fb9..37a2b99 100644 --- a/.github/workflows/auto-back-merge.yml +++ b/.github/workflows/auto-back-merge.yml @@ -2,7 +2,6 @@ # © 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. # This workflow is triggered when a pull request is merged into the main branch and automatically merges the main branch back into develop to keep it up to date. -# This is currently opt-in via the repository variable MERGE_BACK_OPT_IN but will eventually be enabled by default. # If the merge fails (e.g., due to conflicts), a manual intervention is required. The workflow generates a Job summary of the merge attempt. name: Auto Back-merge Main to Develop @@ -23,11 +22,7 @@ jobs: permissions: {} name: Back-merge Main to Develop - # Only run if the PR was actually merged (not just closed) and - # if the repository variable MERGE_BACK_OPT_IN is set to 'true'. - # - # https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables#creating-configuration-variables-for-a-repository - if: github.event.pull_request.merged == true && vars.MERGE_BACK_OPT_IN == 'true' + if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index bda3994..80ec1a2 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -104,16 +104,16 @@ jobs: - name: Install Conftest env: - FALLBACK_VERSION: '0.37.0' + FALLBACK_VERSION: '0.67.1' run: | set -euo pipefail install_conftest() { local version="$1" - curl --proto "=https" --fail -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${version}/conftest_${version}_Linux_x86_64.tar.gz" -o conftest.tar.gz - tar -xzf conftest.tar.gz - sudo mv conftest /usr/local/bin/ - rm -f conftest.tar.gz + local file_name="conftest_${version}_Linux_x86_64.deb" + curl --proto "=https" --fail -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${version}/${file_name}" -o "${file_name}" + sudo dpkg -i "${file_name}" + rm -f "${file_name}" } LATEST_VERSION="$(curl --proto "=https" --fail -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+' || true)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 680bf05..5373003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,43 @@ This project follows **Semantic Versioning (SemVer)** ([semver.org](https://semv --- +## [1.2.0] - 2026-03-26 + +### Added + +- Vault PKI service with enhancements and tests +- Missing docker attributes +- Missing documentation regarding vault setup +- Organization certificates +- Trigger release workflow from main and trivy check +- Protect certificate endpoints with role-based access and add Keycloak TF roles +- Bootstrap certificate endpoint +- Bootstrap endpoint changes and interceptor exemption +- Set bootstrap event performed by to requester clientId +- Trivy vulnerability check to management-node pipeline +- Get image tag from branch name + +### Fixed + +- Fix to vulnerability and update to trivy scan config + +### Changed + +- Management Node - Resolve CVEs +- Non root access for dev container +- Replace version in GitHub workflows +- Update documentation and development setup guide + +### Dependencies + +- Added `org.springframework.cloud.spring-cloud-starter-vault-config` version `` +- Added `org.bouncycastle.bcpkix-jdk18on` version `1.83` +- Added `org.bouncycastle.bcprov-jdk18on` version `1.83` +- Bumped `spring-boot-starter` to version `3.5.11` +- Bumped `org.apache.commons.commons-lang3` to version `3.18.0` +- Bumped `org.springframework.security.spring-security` to version `6.5.9` +- Bumped `com.fasterxml.jackson` to version `2.21.1` + ## [1.1.0] - 2026-02-20 ### Added diff --git a/pom.xml b/pom.xml index 213138d..18fb886 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ uk.gov.dbt.ndtp.ia.management.node management-node - 1.1.0 + 1.2.0 jar management-node Provides Management capabilities over IA Node Net