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