diff --git a/.github/workflows/auto-back-merge.yml b/.github/workflows/auto-back-merge.yml new file mode 100644 index 0000000..37a2b99 --- /dev/null +++ b/.github/workflows/auto-back-merge.yml @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# This workflow is triggered when a pull request is merged into the main branch and automatically merges the main branch back into develop to keep it up to date. +# If the merge fails (e.g., due to conflicts), a manual intervention is required. The workflow generates a Job summary of the merge attempt. +name: Auto Back-merge Main to Develop + +on: + pull_request: + types: + - closed + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + merge-main-to-develop: + + permissions: {} + + name: Back-merge Main to Develop + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + + - name: Generate Sync Token + id: sync-token + 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 }} + permission-contents: write + permission-workflows: write + + - name: Merge main into develop and Generate Summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.sync-token.outputs.token }} + script: | + const prNumber = context.payload.pull_request.number; + const mergedBy = context.payload.sender.login; + const prUrl = context.payload.pull_request.html_url; + + try { + // Attempt to merge main into develop, use the api to ensure the commit + // is gpg signed. + await github.rest.repos.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + base: 'develop', + head: 'main', + commit_message: `Merge branch 'main' into 'develop' (#${prNumber})` + }); + + let summaryText = + `## Sync Main to Develop ✅ + + Successfully triggered a merge of \`main\` into \`develop\` following the closure of PR #${prNumber}. + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + } catch (error) { + const finalErrorMessage = error.message || error; + // Write failure summary + summaryText = + `## Sync Main to Develop ❌ + + Failed to trigger a merge of \`main\` into \`develop\`! This is usually due to a merge conflict. Please resolve it manually by opening a PR from \`main\` to \`develop\`. + + ### Error Details: + + \`\`\`text + ${finalErrorMessage} + \`\`\` + + **Original PR Merged by**: @${mergedBy} + + [View Original PR](${prUrl}) + `; + + await core.summary.addRaw(summaryText).write(); + + // Fail the workflow step + core.setFailed(`Merge failed: ${finalErrorMessage}`); + } diff --git a/.github/workflows/docker-ghcr.yml b/.github/workflows/docker-ghcr.yml index 7ecccc2..f34ab2a 100644 --- a/.github/workflows/docker-ghcr.yml +++ b/.github/workflows/docker-ghcr.yml @@ -53,10 +53,10 @@ jobs: fi - name: Checkout repo - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Login to ghcr.io - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -66,7 +66,7 @@ jobs: run: echo "REPO=${GITHUB_REPOSITORY,,}" >>${GITHUB_ENV} - name: Get server and client jars - uses: actions/download-artifact@v5 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: pattern: management-node-*.jar path: target @@ -75,6 +75,16 @@ jobs: - name: Build Server Image run: docker build --no-cache --build-arg JAR_NAME="management-node-${{ inputs.jar_version }}" -t ghcr.io/${REPO}/management-node:staged -f "${{ github.workspace }}/docker/Dockerfile" --target ${{ inputs.docker_target }} . + - name: Run Trivy Scan on Server Docker Image + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "ghcr.io/${{ env.REPO }}/management-node:staged" + format: "table" + exit-code: "1" + ignore-unfixed: true + severity: "CRITICAL,HIGH" + continue-on-error: true + - name: Tag Server Image with tag(s) ${{ inputs.image_tag }} run: | ./.github/actions/docker-tags.sh "ghcr.io/${REPO}/management-node" "${{ inputs.image_tag }}" diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d06d31e..135f8ff 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: @@ -41,12 +43,12 @@ jobs: pull-requests: read runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: # Full history is recommended for accurate Sonar analysis and PR decoration fetch-depth: 0 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '21' distribution: 'temurin' @@ -69,9 +71,9 @@ jobs: pull-requests: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: '21' distribution: 'temurin' @@ -80,4 +82,60 @@ 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" + 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/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 6033c7d..80ec1a2 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 }} @@ -103,10 +103,27 @@ jobs: core.info('Generated repository-metadata.json for policy context.'); - name: Install Conftest + env: + FALLBACK_VERSION: '0.67.1' 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" + 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)" + + 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 @@ -449,7 +466,7 @@ jobs: - name: Upload OSS result artifacts if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: oss-checks-${{ github.run_id }} retention-days: 30 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9832c9d..f68b86e 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -15,6 +15,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: versioning: if: | @@ -22,6 +26,7 @@ jobs: (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) permissions: contents: read + name: Extract Release Version runs-on: ubuntu-latest outputs: @@ -62,6 +67,7 @@ jobs: generate-sbom: permissions: contents: read + name: Generate SPDX SBOM runs-on: ubuntu-latest needs: [versioning] @@ -85,7 +91,7 @@ jobs: echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: sbom path: sbom.spdx.json @@ -93,6 +99,7 @@ jobs: create-git-tag: permissions: contents: write + name: Create Git Tag needs: [versioning, generate-sbom] runs-on: ubuntu-latest @@ -117,17 +124,18 @@ jobs: create-git-release: permissions: contents: write + name: Create GitHub Release needs: [versioning, generate-sbom, create-git-tag] runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: 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 }}" @@ -136,4 +144,3 @@ jobs: prerelease: false files: | sbom.spdx.json - diff --git a/.github/workflows/publish-mkdocs.yml b/.github/workflows/publish-mkdocs.yml index 03ef20a..9151608 100644 --- a/.github/workflows/publish-mkdocs.yml +++ b/.github/workflows/publish-mkdocs.yml @@ -67,12 +67,12 @@ jobs: contents: write pages: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 with: python-version: 3.x - name: Configure Git user for mike @@ -80,7 +80,7 @@ jobs: git config --global user.name "github-actions[bot]" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # 5.0.3 with: key: mkdocs-material-${{ env.cache_id }} path: ~/.cache diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ed437c8..c449e4d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,10 @@ env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" on: - # Hopefully this will eventually be replaced with the release event instead + pull_request: + types: [closed] + branches: + - main workflow_dispatch: # To eventually be replaced with just using the version from the pom inputs: @@ -45,12 +48,15 @@ jobs: packages: write id-token: write runs-on: ubuntu-latest + if: | + github.event.pull_request.merged == true && + (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) outputs: project_version: ${{ steps.get-version.outputs.project_version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -63,7 +69,7 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS package - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 name: Persist server id: persist-server with: @@ -77,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 @@ -84,9 +92,9 @@ jobs: GITHUB_ACTOR: ${{ github.actor }} GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Setup Java/Maven - uses: actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: java-version: 21 distribution: "temurin" @@ -96,20 +104,28 @@ 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: ${{ inputs.dry_run }} + dry_run: false docker_target: management-node cleanup: @@ -125,7 +141,7 @@ jobs: - verify if: ${{ needs.verify.result == 'success' }} steps: - - uses: geekyeggo/delete-artifact@v5 + - uses: geekyeggo/delete-artifact@f275313e70c08f6120db482d7a6b98377786765b # v5.1.0 name: Delete server artifact with: name: management-node-${{ needs.verify.outputs.project_version }}.jar diff --git a/.gitignore b/.gitignore index 9838bfb..596ed6b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ build/ *.jks *.ext *.pem +*.srl ### Env files ### .env @@ -48,4 +49,7 @@ build/ .terraform/ *.tfstate *.tfstate.backup -*.hcl \ No newline at end of file +*.hcl + +### application local file +**src/main/resources/application-local.yml diff --git a/.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/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/README.md b/README.md index f1d9996..4d2e420 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ **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 ` +**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,53 +411,74 @@ 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 ``` + > **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_CLIENTID=`YOUR_CLIENT_SECRET` +export KEYCLOAK_CLIENT_SECRET=`YOUR_CLIENT_SECRET` cd docker # or where your certificates are stored ``` ```bash -curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ +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_CLIENTID}" \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENT_SECRET}" \ --data-urlencode 'grant_type=client_credentials' ``` @@ -461,6 +493,172 @@ If successful, you will receive a JSON response containing an `access_token` wit - ✅ 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). + +### Setting up Vault with Docker Compose + +1. Create a directory called `config` in the `docker/vault` directory + +```sh +mkdir docker/vault/config +``` + +2. Create a file called `vault.hcl` in the `docker/vault/config` directory with the following content: + +``` +ui = true + +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = 1 +} + +storage "file" { + path = "/vault/file" +} + +# Optional but helpful: disables mlock warnings in containers +disable_mlock = true +``` + +3. Start vault using Docker compose: + +```sh +docker compose -f docker/vault/docker-compose.yaml up -d +``` + +4. Verify that vault is running + +```sh +docker exec vault vault status -format=json +``` + +5. 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=1 -key-threshold=1 -format=json +``` + +> **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 +``` + +> **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. + +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`: + + ```yaml + spring: + cloud: + vault: + token: + ``` + +### 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 +# Get a 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_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 . +``` + +If this returns 200 with a JSON response containing the key pair, the Management Node is fully connected to Vault's PKI engine. + +> **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 ### Building the Application @@ -490,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 @@ -510,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 @@ -524,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** @@ -539,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 \ @@ -570,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. +**Testing further with a federator client (optional):** -#### Why Use Profile-Specific Configuration? +To test the full configuration response, create a Keycloak client that matches one of the seeded organisations: -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 +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 -#### Creating a Profile-Specific YAML File +Then test with the new client: -1. Create a file named `application-{profile}.yml` in the `src/main/resources` directory, where `{profile}` is the name of your profile (e.g., `application-local.yml` for a "local" profile) - -2. Add your environment-specific configuration to this file. For example: - - ```yaml - spring: - security: - oauth2: - resourceserver: - opaquetoken: - client-secret: your-client-secret-here - client-id: ztf-client - datasource: - password: your-database-password-here - - server: - ssl: - key-store-password: your-keystore-password-here - trust-store-password: your-truststore-password-here - key-store: /path/to/your/local/keystore.jks - trust-store: /path/to/your/local/truststore.jks - ``` +```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 @@ -694,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 @@ -735,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 @@ -751,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. @@ -770,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 @@ -781,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" ] } }, @@ -791,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). @@ -800,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`. @@ -813,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).** -© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entityright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. \ No newline at end of file +**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/Dockerfile b/docker/Dockerfile index 7d05736..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 @@ -38,12 +38,16 @@ COPY --from=build /build/target/${JAR_NAME}.jar /app/app.jar RUN chown app:app /app/app.jar +RUN apk add --no-cache curl + # Use non-root user from here on USER app:app # Expose HTTPS port EXPOSE 8443 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 CMD curl -f -k https://localhost:8443 || exit 1 + # Helpful defaults for Java in containers ENV JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom -XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/app/tmp" diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index 6890770..71908ee 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -6,8 +6,8 @@ # # Build stage -ARG JAR_FILE=management-node-1.0.1.jar -FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +ARG JAR_FILE=management-node-1.1.0.jar +FROM maven:3.9.14-eclipse-temurin-21-alpine AS build ARG JAR_FILE WORKDIR /build @@ -22,16 +22,25 @@ RUN mvn clean package -DskipTests FROM eclipse-temurin:23-jdk-alpine ARG JAR_FILE +RUN addgroup -S app && adduser -S -G app -u 10001 app + WORKDIR /app +RUN chown -R app:app /app + # Copy application jar from build stage and certificates COPY --from=build /build/target/${JAR_FILE} /app/app.jar COPY docker/keystore.jks /app/keystore.jks COPY docker/truststore.jks /app/truststore.jks +RUN chown app:app /app/app.jar + +# Use non-root user from here on +USER app:app + # Set default certificate password ENV CERTPASSWORD=changeit EXPOSE 8090 -ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/docker/keycloak/docker-compose.yml b/docker/keycloak/docker-compose.yml index a28e706..17fb701 100644 --- a/docker/keycloak/docker-compose.yml +++ b/docker/keycloak/docker-compose.yml @@ -9,6 +9,15 @@ services: image: postgres:16.2 volumes: - postgres_data:/var/lib/postgresql/data + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + - DAC_OVERRIDE + security_opt: + - no-new-privileges:true environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} @@ -16,12 +25,22 @@ services: networks: - keycloak_network ports: - - "5433:5432" + - "127.0.0.1:5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s keycloak: image: quay.io/keycloak/keycloak:26.3.2 command: start --verbose hostname: localhost container_name: keycloak + cap_drop: + - ALL + security_opt: + - no-new-privileges:true environment: KC_HOSTNAME: localhost KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT} @@ -51,10 +70,16 @@ services: KC_LOG_LEVEL: ${KC_LOG_LEVEL} ports: - - "8080:8080" - - "8443:8443" - - "9000:9000" + - "127.0.0.1:8080:8080" + - "127.0.0.1:8443:8443" + - "127.0.0.1:9000:9000" restart: always + healthcheck: + test: ["CMD-SHELL", "exec 6<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nhost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&6 && timeout 5 cat <&6 > /dev/null"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 30s depends_on: - postgres networks: 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/docker/keycloak/tofu/clients.tf b/docker/keycloak/tofu/clients.tf index 1268c6d..f8f4f63 100644 --- a/docker/keycloak/tofu/clients.tf +++ b/docker/keycloak/tofu/clients.tf @@ -32,6 +32,34 @@ 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" +} + +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" @@ -46,6 +74,10 @@ 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, + keycloak_role.request_bootstrap_certificate, ] # Token lifespan for this client @@ -74,4 +106,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/docker/vault/docker-compose.yaml b/docker/vault/docker-compose.yaml new file mode 100644 index 0000000..ea3899a --- /dev/null +++ b/docker/vault/docker-compose.yaml @@ -0,0 +1,24 @@ +services: + vault: + image: hashicorp/vault:1.16 + container_name: vault + restart: unless-stopped + + cap_add: + - IPC_LOCK + + environment: + VAULT_ADDR: "http://127.0.0.1:8200" + VAULT_API_ADDR: "http://0.0.0.0:8200" + + ports: + - "8200:8200" + + volumes: + - vault-data:/vault/file + - ./config:/vault/config:ro + + command: vault server -config=/vault/config/vault.hcl + +volumes: + vault-data: 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..440c889 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -2,7 +2,7 @@ **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 ` +**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 diff --git a/pom.xml b/pom.xml index 6c6e18b..18fb886 100644 --- a/pom.xml +++ b/pom.xml @@ -9,12 +9,12 @@ org.springframework.boot spring-boot-starter-parent - 3.5.5 + 3.5.11 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 @@ -45,6 +45,7 @@ 21 UTF-8 2025.0.0 + 2.21.1 42.7.7 11.10.4 2.46.1 @@ -55,11 +56,12 @@ 3.2.0 5.10.0 2.8.13 + 1.83 + 6.5.9 **/config/**, - **/exception/**, - **/dto/**, - **/entity/**, - **/ManagementNodeApplication.java + **/dto/**, + **/entity/**, + **/ManagementNodeApplication.java @@ -99,6 +101,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.cloud + spring-cloud-starter-vault-config + org.springframework.boot spring-boot-starter-web @@ -122,6 +128,16 @@ flyway-database-postgresql ${flyway.version} + + org.bouncycastle + bcpkix-jdk18on + ${bouncycastle.version} + + + org.bouncycastle + bcprov-jdk18on + ${bouncycastle.version} + org.springframework.boot spring-boot-devtools @@ -165,7 +181,11 @@ h2 test - + + org.apache.commons + commons-lang3 + 3.18.0 + @@ -197,10 +217,7 @@ org.apache.maven.plugins maven-surefire-plugin - - - true - + com.diffplug.spotless @@ -258,7 +275,6 @@ uk/gov/dbt/ndtp/ia/node/management/model/dto/** uk/gov/dbt/ndtp/ia/node/management/entity/** uk/gov/dbt/ndtp/ia/node/management/config/** - uk/gov/dbt/ndtp/ia/node/management/exception/** uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplication.java @@ -311,7 +327,7 @@ CLASS COVEREDRATIO - 0.50 + 0.80 diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java new file mode 100644 index 0000000..9f6ebc9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptor.java @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; + +@Component +@Slf4j +public class CertificateValidationInterceptor implements HandlerInterceptor { + + private static final String X509_CERT_ATTRIBUTE = "jakarta.servlet.request.X509Certificate"; + + private final CertificateValidationProvider validationProvider; + private final ObjectMapper objectMapper; + + public CertificateValidationInterceptor( + CertificateValidationProvider validationProvider, ObjectMapper objectMapper) { + this.validationProvider = validationProvider; + this.objectMapper = objectMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String clientId = extractClientId(); + if (clientId == null) { + log.warn("No client ID found for request to {}", request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Client ID required"); + return false; + } + + OrganisationCertificateDTO cert = + validationProvider.findByClientId(clientId).orElse(null); + if (cert == null) { + log.warn("No certificate record for client {} on {}", clientId, request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "No organisation certificate found"); + return false; + } + + if (!validationProvider.isActive(cert)) { + log.warn("Inactive certificate for client {} on {}", clientId, request.getRequestURI()); + writeError(response, HttpServletResponse.SC_FORBIDDEN, "Organisation certificate is not active"); + return false; + } + + if (cert.getSerialNumber() != null) { + String rejection = validateSerialNumber(request, cert, clientId); + if (rejection != null) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, rejection); + return false; + } + } + + if (cert.getType() == CertificateType.BOOTSTRAP) { + log.warn("Bootstrap certificate denied access to {}", request.getRequestURI()); + writeError( + response, HttpServletResponse.SC_FORBIDDEN, "Bootstrap certificates cannot access this endpoint"); + return false; + } + + log.debug("Certificate validation successful for client {} on {}", clientId, request.getRequestURI()); + return true; + } + + private String extractClientId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof EnhancedPrincipal principal)) { + return null; + } + String clientId = principal.clientId(); + return (clientId == null || clientId.isEmpty()) ? null : clientId; + } + + private String validateSerialNumber(HttpServletRequest request, OrganisationCertificateDTO cert, String clientId) { + X509Certificate[] certs = (X509Certificate[]) request.getAttribute(X509_CERT_ATTRIBUTE); + if (certs == null || certs.length == 0) { + return "Client certificate required"; + } + String presentedSerial = certs[0].getSerialNumber().toString(16); + String storedSerial = cert.getSerialNumber().replace(":", ""); + if (!presentedSerial.equalsIgnoreCase(storedSerial)) { + log.warn( + "Serial mismatch for client {}: expected={}, presented={}", + clientId, + cert.getSerialNumber(), + presentedSerial); + return "Certificate serial number mismatch"; + } + return null; + } + + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + ErrorResponse errorResponse = + new ErrorResponse(status, message, UUID.randomUUID().toString()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java new file mode 100644 index 0000000..57fdc1d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfig.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final CertificateValidationInterceptor certificateValidationInterceptor; + + public WebConfig(CertificateValidationInterceptor certificateValidationInterceptor) { + this.certificateValidationInterceptor = certificateValidationInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(certificateValidationInterceptor) + .addPathPatterns("/api/**") + .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 new file mode 100644 index 0000000..9f28b92 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateController.java @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.controller.v1; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import 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.*; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; + +/** + * Controller for certificate and PKI management. + * Provides endpoints for creating key pairs, CSRs, and signing certificates. + */ +@RestController +@RequestMapping("/api/v1/certificate") +@Slf4j +@Tag(name = "Certificate", description = "Endpoints for certificate management.") +public class CertificateController { + + private final VaultPkiService pkiService; + private final CertificateSigningProvider signingProvider; + + public CertificateController(VaultPkiService pkiService, CertificateSigningProvider signingProvider) { + this.pkiService = pkiService; + this.signingProvider = signingProvider; + } + + /** + * Creates RSA key pair using configured PKI service. + * + * @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.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "RSA key pair created successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = CreateKeyResponseDTO.class))) + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public CreateKeyResponseDTO createKeyPair() { + CreateKeyRequestDTO rsaRequest = + CreateKeyRequestDTO.builder().keySize(2048).algorithm("RSA").build(); + return pkiService.createKeyPair(rsaRequest.getAlgorithm(), rsaRequest.getKeySize()); + } + + /** + * Creates a Certificate Signing Request (CSR) from provided public and private keys. + * + * @param req the CSR request DTO containing keys and subject details + * @return a DTO containing the generated CSR PEM + */ + @PostMapping("/csr/create") + @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.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "CSR created successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = CreateCsrResponseDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public CreateCsrResponseDTO createCsr(@RequestBody CreateCsrRequestDTO req) { + return pkiService.createCsr(req); + } + + /** + * Signs a CSR and records the result against the caller's organisation. + * + * @param req the sign request DTO containing the CSR PEM + * @param principal the authenticated caller's identity + * @return a DTO containing the signed certificate and its chain + */ + @PostMapping("/csr/sign") + @PreAuthorize("hasAuthority('ROLE_management-node:sign_certificate')") + @Operation( + summary = "Sign CSR", + description = + "Signs the provided CSR using the PKI service. Uses the provided role or default if not specified. Uses default TTL if not specified.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "CSR signed successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = SignCertResponseDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid CSR or parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public SignCertResponseDTO signCsr( + @RequestBody SignCertRequestDTO req, + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal) { + return signingProvider.signAndRecord(req.getCsr(), principal.clientId()); + } + + /** + * Retrieves the intermediate certificate and its chain. + * + * @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.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponse( + responseCode = "200", + description = "Intermediate certificate retrieved successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = IntermediateCertResponseDTO.class))) + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "500", description = "Internal server error") + public IntermediateCertResponseDTO getIntermediateCertificate() { + return pkiService.getIntermediateCertificate(); + } + + /** + * 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, + @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\"") + .body(zip); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java new file mode 100644 index 0000000..ca58356 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverter.java @@ -0,0 +1,83 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +/** + * Converter for OrganisationCertificate entity and OrganisationCertificateDTO. + */ +@Component +public class OrganisationCertificateConverter + implements EntityDtoConverter { + + private final OrganisationRepository organisationRepository; + + public OrganisationCertificateConverter(OrganisationRepository organisationRepository) { + this.organisationRepository = organisationRepository; + } + + @Override + public OrganisationCertificateDTO toDto(OrganisationCertificate entity) { + if (entity == null) { + return null; + } + + return OrganisationCertificateDTO.builder() + .id(entity.getId()) + .organisationId( + entity.getOrganisation() != null + ? entity.getOrganisation().getId() + : null) + .certificateAutomationEnabled( + entity.getOrganisation() != null + ? entity.getOrganisation().getCertificateAutomationEnabled() + : null) + .subjectDn(entity.getSubjectDn()) + .serialNumber(entity.getSerialNumber()) + .isRenewable(entity.getIsRenewable()) + .renewalTtl(entity.getRenewalTtl()) + .type(entity.getType()) + .requestedAt(entity.getRequestedAt()) + .issuedAt(entity.getIssuedAt()) + .expiresAt(entity.getExpiresAt()) + .revokedAt(entity.getRevokedAt()) + .build(); + } + + @Override + public OrganisationCertificate toEntity(OrganisationCertificateDTO dto) { + if (dto == null) { + return null; + } + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(dto.getId()); + entity.setSubjectDn(dto.getSubjectDn()); + entity.setSerialNumber(dto.getSerialNumber()); + entity.setIsRenewable(dto.getIsRenewable()); + entity.setRenewalTtl(dto.getRenewalTtl()); + entity.setType(dto.getType()); + entity.setRequestedAt(dto.getRequestedAt()); + entity.setIssuedAt(dto.getIssuedAt()); + entity.setExpiresAt(dto.getExpiresAt()); + entity.setRevokedAt(dto.getRevokedAt()); + + if (dto.getOrganisationId() != null) { + Organisation organisation = + organisationRepository.findById(dto.getOrganisationId()).orElse(null); + entity.setOrganisation(organisation); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java new file mode 100644 index 0000000..87730c0 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/CertificateSigningException.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +/** + * Exception thrown when a certificate signing request is rejected. + * Mapped to HTTP 403 by the global exception handler. + */ +public class CertificateSigningException extends RuntimeException { + + /** + * Constructs a new CertificateSigningException with the specified detail message. + * + * @param message the detail message + */ + public CertificateSigningException(String message) { + super(message); + } + + /** + * 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/PkiException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java new file mode 100644 index 0000000..56a7ab7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/PkiException.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +/** + * Exception thrown for errors during PKI and certificate management operations. + */ +public class PkiException extends RuntimeException { + + /** + * Constructs a new PkiException with the specified detail message. + * + * @param message the detail message + */ + public PkiException(String message) { + super(message); + } + + /** + * Constructs a new PkiException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + */ + public PkiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java index cd29bbe..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 @@ -1,12 +1,11 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ package uk.gov.dbt.ndtp.ia.node.management.exception.handlers; -import java.nio.file.AccessDeniedException; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -15,8 +14,11 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.resource.NoResourceFoundException; import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; +import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; /** * Global exception handler for the application. @@ -43,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) { @@ -65,6 +63,79 @@ 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. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 404 error message + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException( + NoResourceFoundException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug("Resource not found, error_id={}, path={}: ", errorId, request.getContextPath(), ex); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.NOT_FOUND.value(), "Resource not found: " + ex.getResourcePath(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + /** + * Handles CertificateSigningException. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with a 403 error message + */ + @ExceptionHandler(CertificateSigningException.class) + public ResponseEntity handleCertificateSigningException( + CertificateSigningException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.warn( + "Certificate signing rejected, error_id={}, path={}: {}", + errorId, + request.getDescription(false), + ex.getMessage()); + + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.FORBIDDEN.value(), ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN); + } + + @ExceptionHandler(PkiException.class) + public ResponseEntity handlePkiException(PkiException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.error( + "PKI exception occurred, error_id={}, path={}: {}", + errorId, + request.getDescription(false), + ex.getMessage(), + ex); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "PKI/Certificate error: " + ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + /** * Handles RuntimeException. * diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java index 77f55a1..5ba2214 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java @@ -1,12 +1,16 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ package uk.gov.dbt.ndtp.ia.node.management.model.dto; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Builder @Getter diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java index bcf3d6a..6ec60ff 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -9,7 +9,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for consumerId entity. diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java new file mode 100644 index 0000000..590e06f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/OrganisationCertificateDTO.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import java.sql.Timestamp; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; + +/** + * DTO for OrganisationCertificate entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class OrganisationCertificateDTO { + + private Long id; + + private Long organisationId; + + private Boolean certificateAutomationEnabled; + + private String subjectDn; + + private String serialNumber; + + private Boolean isRenewable; + + private Long renewalTtl; + + private CertificateType type; + + private Timestamp requestedAt; + + private Timestamp issuedAt; + + private Timestamp expiresAt; + + private Timestamp revokedAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java index dcbdac7..aaa858f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -10,7 +10,11 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for OrganisationProducer entity. diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java index 26baef0..bf1ac6f 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -11,7 +11,11 @@ import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * DTO for ConsumerAllowedDataProvider entity. diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java new file mode 100644 index 0000000..f981482 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/BootstrapRequestDTO.java @@ -0,0 +1,28 @@ +/* + * 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 jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BootstrapRequestDTO { + @NotNull + private Long organisationId; + + @NotBlank + private String csr; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java new file mode 100644 index 0000000..fdaa800 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CertificateInfoDTO.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CertificateInfoDTO { + private String subject; + private String issuer; + private String serialNumber; + private Instant notBefore; + private Instant notAfter; + private String signatureAlgorithm; + private Integer version; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java new file mode 100644 index 0000000..060a771 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrRequestDTO.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateCsrRequestDTO { + private String publicKeyPem; + private String privateKeyPem; + private String commonName; + private String organization; + private String organizationalUnit; + private String country; + private List dnsSans; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java new file mode 100644 index 0000000..27c230d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateCsrResponseDTO.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateCsrResponseDTO { + private String csrId; + private String csrPem; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java new file mode 100644 index 0000000..7578bb7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyRequestDTO.java @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateKeyRequestDTO { + private String algorithm; + private Integer keySize; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java new file mode 100644 index 0000000..8fb02c7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/CreateKeyResponseDTO.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class CreateKeyResponseDTO { + private String privateKeyPem; + private String publicKeyPem; + private String algorithm; + private String createdAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java new file mode 100644 index 0000000..7016b09 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/IntermediateCertResponseDTO.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class IntermediateCertResponseDTO { + private String certificate; + private Object caChain; + private CertificateInfoDTO info; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java new file mode 100644 index 0000000..5b111a8 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertRequestDTO.java @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SignCertRequestDTO { + private String csr; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java new file mode 100644 index 0000000..7ddda17 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/certificates/SignCertResponseDTO.java @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SignCertResponseDTO { + private String certificate; + private List caChain; + private String issuingCa; + private String serialNumber; + private Number expiration; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java new file mode 100644 index 0000000..a866a70 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEvent.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "certificate_events") +public class CertificateEvent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_certificate_id", nullable = false) + private OrganisationCertificate organisationCertificate; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private CertificateType type; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 50) + private CertificateEventType eventType; + + @Column(name = "event_time", nullable = false) + private Timestamp eventTime; + + @Column(name = "performed_by", length = 255) + private String performedBy; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java new file mode 100644 index 0000000..2093129 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventType.java @@ -0,0 +1,14 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +public enum CertificateEventType { + ISSUED, + RENEWED, + EXPIRED, + REVOKED +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java new file mode 100644 index 0000000..434d0fa --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateType.java @@ -0,0 +1,13 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +public enum CertificateType { + BOOTSTRAP, + MANUAL, + AUTOMATED +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java index f2ac4dd..f433bfe 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java @@ -22,4 +22,7 @@ public class Organisation { @Column(name = "name", nullable = false, length = 150) private String name; + + @Column(name = "certificate_automation_enabled", nullable = false) + private Boolean certificateAutomationEnabled = true; } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java new file mode 100644 index 0000000..33d47dc --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificate.java @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.sql.Timestamp; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "organisation_certificate") +public class OrganisationCertificate { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation_id", unique = true) + private Organisation organisation; + + @Column(name = "subject_dn", length = 500) + private String subjectDn; + + @Column(name = "serial_number", length = 150) + private String serialNumber; + + @Column(name = "is_renewable", nullable = false) + private Boolean isRenewable = false; + + @Column(name = "renewal_ttl") + private Long renewalTtl; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private CertificateType type; + + @Column(name = "requested_at") + private Timestamp requestedAt; + + @Column(name = "issued_at") + private Timestamp issuedAt; + + @Column(name = "expires_at") + private Timestamp expiresAt; + + @Column(name = "revoked_at") + private Timestamp revokedAt; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java new file mode 100644 index 0000000..e731ec9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/CertificateEventRepository.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; + +/** + * Repository interface for managing {@link CertificateEvent} entities. + * + * This interface provides CRUD operations and query methods for interacting with + * the underlying database layer as it extends the {@link JpaRepository} interface. + * It facilitates persistence and retrieval of CertificateEvent data from the related + * database table. + * + * Primary focus is on the {@link CertificateEvent} entity with the identifier type {@link Long}. + */ +@Repository +public interface CertificateEventRepository extends JpaRepository {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java new file mode 100644 index 0000000..17d996d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationCertificateRepository.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; + +/** + * Repository interface for managing {@link OrganisationCertificate} entities. + * + * This interface provides CRUD operations and query methods for interacting with + * the underlying database layer as it extends the {@link JpaRepository} interface. + * It facilitates persistence and retrieval of OrganisationCertificate data from the related + * database table. + * + * Primary focus is on the {@link OrganisationCertificate} entity with the identifier type {@link Long}. + */ +@Repository +public interface OrganisationCertificateRepository extends JpaRepository { + + Optional findByOrganisationId(Long organisationId); + + @Query("SELECT oc FROM OrganisationCertificate oc " + + "JOIN FETCH oc.organisation " + + "WHERE oc.organisation.id IN :orgIds") + List findAllWithOrganisationByOrganisationIdIn(@Param("orgIds") Collection orgIds); + + @Query("SELECT oc FROM OrganisationCertificate oc " + + "JOIN FETCH oc.organisation o " + + "LEFT JOIN Consumer c ON c.org = o " + + "LEFT JOIN Producer p ON p.org = o " + + "WHERE c.idpClientId = :clientId OR p.idpClientId = :clientId") + Optional findByClientId(@Param("clientId") String clientId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java new file mode 100644 index 0000000..6970a1a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/CertificateEventService.java @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; + +/** + * Service interface for recording certificate lifecycle events. + */ +public interface CertificateEventService { + + /** + * Records a certificate event for audit purposes. + * + * @param organisationCertificateId the ID of the organisation certificate + * @param type the certificate type at the time of the event + * @param eventType the type of event being recorded + * @param performedBy identifier of the actor that triggered the event + */ + void recordEvent( + Long organisationCertificateId, CertificateType type, CertificateEventType eventType, String performedBy); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java new file mode 100644 index 0000000..7a3d71e --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationCertificateService.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; + +/** + * Service interface for managing OrganisationCertificate entities. + */ +public interface OrganisationCertificateService { + + /** + * Find a certificate by its organisation ID. + * + * @param organisationId the organisation ID to search for + * @return the certificate DTO if found + */ + Optional findByOrganisationId(Long organisationId); + + /** + * Find a certificate by the IDP client ID of its organisation's consumer or producer. + * + * @param clientId the IDP client ID to search for + * @return the certificate DTO if found + */ + Optional findByClientId(String clientId); + + /** + * Find all certificates for the given organisation IDs. + * + * @param orgIds the organisation IDs to search for + * @return a list of certificate DTOs + */ + List findAllByOrganisationIds(Collection orgIds); + + /** + * Save or update a certificate record. + * + * @param certificate the certificate DTO to save + * @return the saved certificate DTO + */ + OrganisationCertificateDTO save(OrganisationCertificateDTO certificate); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java new file mode 100644 index 0000000..e88c110 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImpl.java @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.sql.Timestamp; +import java.time.Instant; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.CertificateEventRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.CertificateEventService; + +/** + * Implementation of {@link CertificateEventService} that persists events + * via {@link CertificateEventRepository}. + */ +@Service +public class CertificateEventServiceImpl implements CertificateEventService { + + private final CertificateEventRepository eventRepository; + private final OrganisationCertificateRepository certificateRepository; + + public CertificateEventServiceImpl( + CertificateEventRepository eventRepository, OrganisationCertificateRepository certificateRepository) { + this.eventRepository = eventRepository; + this.certificateRepository = certificateRepository; + } + + @Override + public void recordEvent( + Long organisationCertificateId, CertificateType type, CertificateEventType eventType, String performedBy) { + OrganisationCertificate certificate = certificateRepository.getReferenceById(organisationCertificateId); + + CertificateEvent event = new CertificateEvent(); + event.setOrganisationCertificate(certificate); + event.setType(type); + event.setEventType(eventType); + event.setEventTime(Timestamp.from(Instant.now())); + event.setPerformedBy(performedBy); + + eventRepository.save(event); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java new file mode 100644 index 0000000..2a1446a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImpl.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationCertificateConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +@Service +public class OrganisationCertificateServiceImpl implements OrganisationCertificateService { + + private final OrganisationCertificateRepository repository; + private final OrganisationCertificateConverter converter; + + public OrganisationCertificateServiceImpl( + OrganisationCertificateRepository repository, OrganisationCertificateConverter converter) { + this.repository = repository; + this.converter = converter; + } + + @Override + public Optional findByOrganisationId(Long organisationId) { + return repository.findByOrganisationId(organisationId).map(converter::toDto); + } + + @Override + public Optional findByClientId(String clientId) { + return repository.findByClientId(clientId).map(converter::toDto); + } + + @Override + public List findAllByOrganisationIds(Collection orgIds) { + if (orgIds == null || orgIds.isEmpty()) { + return List.of(); + } + return converter.toDtoList(repository.findAllWithOrganisationByOrganisationIdIn(orgIds)); + } + + @Override + public OrganisationCertificateDTO save(OrganisationCertificateDTO dto) { + return converter.toDto(repository.save(converter.toEntity(dto))); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java new file mode 100644 index 0000000..d888368 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProvider.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; + +/** + * Provides certificate signing orchestration, including record updates and audit events. + */ +public interface CertificateSigningProvider { + + /** + * Sign a CSR and record the result against the caller's organisation certificate. + * + * @param csrPem the CSR in PEM format + * @param clientId the IDP client ID of the calling organisation + * @return a DTO containing the signed certificate and its chain + */ + SignCertResponseDTO signAndRecord(String csrPem, String clientId); + + /** + * Issue a short-lived bootstrap certificate package for the given organisation. + * 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 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, 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 new file mode 100644 index 0000000..3121559 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImpl.java @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import java.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; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.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; + +/** + * Implementation of {@link CertificateSigningProvider} that delegates to + * {@link VaultPkiService} for signing and {@link CertificateEventService} for audit. + */ +@Service +@Slf4j +public class CertificateSigningProviderImpl implements CertificateSigningProvider { + + private final VaultPkiService vaultPkiService; + private final OrganisationCertificateService certificateService; + private final CertificateEventService eventService; + private final OrganisationRepository organisationRepository; + private final String bootstrapTtl; + private final String bootstrapOid; + + 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; + } + + @Override + @Transactional + public SignCertResponseDTO signAndRecord(String csrPem, String clientId) { + OrganisationCertificateDTO cert = lookupCertificate(clientId); + + if (!Boolean.TRUE.equals(cert.getCertificateAutomationEnabled())) { + log.warn("Certificate automation not enabled for client {}", clientId); + throw new CertificateSigningException("Certificate automation is not enabled for this organisation"); + } + + if (!Boolean.TRUE.equals(cert.getIsRenewable())) { + log.warn("Certificate is not marked as renewable for client {}", clientId); + throw new CertificateSigningException("Certificate is not renewable"); + } + + cert.setRequestedAt(Timestamp.from(Instant.now())); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.empty()); + + 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(Long organisationId, String csrPem, String performedBy) { + OrganisationCertificateDTO cert = certificateService + .findByOrganisationId(organisationId) + .orElseGet(() -> createCertificateRecord(organisationId)); + + if (cert.getType() == CertificateType.AUTOMATED) { + log.warn("Overwriting active automated certificate for organisation {}", organisationId); + } + + 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); + OrganisationCertificateDTO saved = certificateService.save(cert); + eventService.recordEvent(saved.getId(), CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, performedBy); + + 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); + 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(x509.getSerialNumber().toString(16)); + cert.setIssuedAt(Timestamp.from(x509.getNotBefore().toInstant())); + cert.setExpiresAt( + Timestamp.from(Instant.ofEpochSecond(response.getExpiration().longValue()))); + cert.setType(type); + } + + 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); + } + } + + 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/CertificateValidationProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java new file mode 100644 index 0000000..cec73e2 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProvider.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; + +/** + * Provides certificate validation logic for organisation certificates. + */ +public interface CertificateValidationProvider { + + /** + * Find a certificate by the IDP client ID of its organisation's consumer or producer. + * + * @param clientId the IDP client ID + * @return the certificate DTO if found + */ + Optional findByClientId(String clientId); + + /** + * Checks whether the given certificate is active (not revoked and not expired). + * + * @param cert the certificate DTO to check + * @return true if the certificate is active + */ + boolean isActive(OrganisationCertificateDTO cert); + + /** + * Returns the subset of organisation IDs that have an active certificate record. + * Organisations without a certificate record are excluded. + * + * @param organisationIds the organisation IDs to check + * @return the IDs of organisations with active certificates + */ + Set findActiveOrganisationIds(Collection organisationIds); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java new file mode 100644 index 0000000..d556de0 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImpl.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +/** + * Implementation of {@link CertificateValidationProvider} that delegates to + * {@link OrganisationCertificateService} for data access. + */ +@Service +public class CertificateValidationProviderImpl implements CertificateValidationProvider { + + private final OrganisationCertificateService certificateService; + + public CertificateValidationProviderImpl(OrganisationCertificateService certificateService) { + this.certificateService = certificateService; + } + + @Override + public Optional findByClientId(String clientId) { + return certificateService.findByClientId(clientId); + } + + @Override + public boolean isActive(OrganisationCertificateDTO cert) { + return !isRevoked(cert.getRevokedAt()) && !isExpired(cert.getExpiresAt()); + } + + @Override + public Set findActiveOrganisationIds(Collection organisationIds) { + if (organisationIds == null || organisationIds.isEmpty()) { + return Set.of(); + } + + List certs = certificateService.findAllByOrganisationIds(organisationIds); + return certs.stream() + .filter(this::isActive) + .map(OrganisationCertificateDTO::getOrganisationId) + .collect(Collectors.toSet()); + } + + private boolean isRevoked(Timestamp revokedAt) { + return revokedAt != null && revokedAt.toInstant().isBefore(Instant.now()); + } + + private boolean isExpired(Timestamp expiresAt) { + return expiresAt != null && expiresAt.toInstant().isBefore(Instant.now()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java new file mode 100644 index 0000000..eda0e43 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiService.java @@ -0,0 +1,313 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +/** + * Service for managing PKI operations using HashiCorp Vault. + * Provides functionality for creating key pairs, CSRs, signing CSRs, and retrieving intermediate certificates. + */ +@Service +@Slf4j +public class VaultPkiService { + + private final VaultTemplate vault; + private final String defaultRole; + private final String defaultTtl; + + // Vault paths + private final String pathSign; + private final String pathCertCa; + private final String pathCertCaChain; + + // Vault response keys + private static final String KEY_CERTIFICATE = "certificate"; + private static final String KEY_CA_CHAIN = "ca_chain"; + private static final String KEY_ISSUING_CA = "issuing_ca"; + private static final String KEY_SERIAL_NUMBER = "serial_number"; + private static final String KEY_EXPIRATION = "expiration"; + + // Request parameter keys + private static final String PARAM_CSR = "csr"; + private static final String PARAM_TTL = "ttl"; + private static final String PARAM_FORMAT = "format"; + private static final String PARAM_OTHER_SANS = "other_sans"; + + public VaultPkiService( + VaultTemplate vault, + @Value("${application.vault.pki-mount:pki-int}") String pkiMount, + @Value("${application.vault.default-role:default-role}") String defaultRole, + @Value("${application.vault.default-ttl:24h}") String defaultTtl) { + this.vault = vault; + this.defaultRole = defaultRole; + this.defaultTtl = defaultTtl; + this.pathSign = pkiMount + "/sign/"; + this.pathCertCa = pkiMount + "/cert/ca"; + this.pathCertCaChain = pkiMount + "/cert/ca_chain"; + } + + /** + * Creates a new RSA or specified algorithm key pair 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) + * @return a DTO containing the public and private key in PEM format + * @throws PkiException if key pair generation fails + */ + public CreateKeyResponseDTO createKeyPair(String algorithm, Integer keySize) { + String alg = (algorithm == null || algorithm.isBlank()) ? "RSA" : algorithm; + int size = (keySize == null) ? 2048 : keySize; + + log.info("Creating key pair with algorithm: {} and size: {}", alg, size); + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(alg); + if ("RSA".equalsIgnoreCase(alg)) { + kpg.initialize(size); + } + KeyPair kp = kpg.generateKeyPair(); + + String privateKeyPem = PemUtil.toPem("PRIVATE KEY", kp.getPrivate().getEncoded()); + String publicKeyPem = PemUtil.toPem("PUBLIC KEY", kp.getPublic().getEncoded()); + + return CreateKeyResponseDTO.builder() + .createdAt(Instant.now().toString()) + .algorithm(alg) + .publicKeyPem(publicKeyPem) + .privateKeyPem(privateKeyPem) + .build(); + } catch (NoSuchAlgorithmException e) { + log.error("Failed to create key pair: algorithm {} not found", alg, e); + throw new PkiException("Key pair generation failed: " + alg, e); + } + } + + /** + * Creates a Certificate Signing Request (CSR) from provided public and private keys and subject information. + * + * @param req the CSR request DTO containing keys and subject details + * @return a DTO containing the generated CSR PEM and a unique ID + * @throws PkiException if CSR creation or signing fails + */ + public CreateCsrResponseDTO createCsr(CreateCsrRequestDTO req) { + log.info("Creating CSR for common name: {}", req.getCommonName()); + try { + PrivateKey privateKey = PemUtil.parsePkcs8PrivateKey(req.getPrivateKeyPem()); + var publicKey = PemUtil.parsePublicKey(req.getPublicKeyPem()); + KeyPair keyPair = new KeyPair(publicKey, privateKey); + + String subject = String.format( + "CN=%s, OU=%s, O=%s, C=%s", + safe(req.getCommonName()), + safe(req.getOrganizationalUnit()), + safe(req.getOrganization()), + safe(req.getCountry())); + + 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. + * + * @param csrPem the CSR in PEM format + * @param role the Vault PKI role to use for signing + * @param ttl the requested Time To Live for the certificate + * @return a DTO containing the signed certificate and its chain + * @throws PkiException if signing fails or Vault returns an empty response + */ + public SignCertResponseDTO signCsr(String csrPem, Optional role, Optional ttl) { + 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); + + if (StringUtils.isEmpty(csrPem)) { + log.warn("Sign CSR request failed: CSR PEM is empty"); + throw new PkiException("CSR PEM cannot be empty"); + } + if (StringUtils.isEmpty(effectiveRole)) { + log.warn("Sign CSR request failed: Role is empty"); + throw new PkiException("Role cannot be empty"); + } + + String path = pathSign + effectiveRole; + + Map body = new HashMap<>(); + body.put(PARAM_CSR, csrPem); + if (effectiveTtl != null && !effectiveTtl.isBlank()) { + body.put(PARAM_TTL, effectiveTtl); + } + body.put(PARAM_FORMAT, "pem"); + otherSans.filter(StringUtils::isNotBlank).ifPresent(sans -> body.put(PARAM_OTHER_SANS, sans)); + + try { + VaultResponse resp = vault.write(path, body); + if (Optional.ofNullable(resp).map(VaultResponse::getData).isEmpty()) { + log.error("Vault returned empty response for CSR signing at path: {}", path); + throw new PkiException("No response from Vault PKI sign endpoint"); + } + + log.info("Successfully signed CSR with role: {}", effectiveRole); + Object caChainObj = resp.getData().get(KEY_CA_CHAIN); + List caChain = Collections.emptyList(); + if (caChainObj instanceof List list) { + caChain = list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + + return new SignCertResponseDTO( + resp.getData().get(KEY_CERTIFICATE).toString(), + caChain, + resp.getData().get(KEY_ISSUING_CA).toString(), + resp.getData().get(KEY_SERIAL_NUMBER).toString(), + (Number) resp.getData().get(KEY_EXPIRATION)); + } catch (Exception e) { + if (e instanceof PkiException) { + throw e; + } + log.error("Error occurred while signing CSR with role: {}", effectiveRole, e); + throw new PkiException("Failed to sign CSR via Vault", e); + } + } + + /** + * Retrieves the intermediate certificate and its chain from Vault. + * Parses the certificate to provide metadata in the response. + * + * @return a DTO containing the PEM certificate, CA chain, and parsed info + * @throws PkiException if certificate retrieval or parsing fails + */ + public IntermediateCertResponseDTO getIntermediateCertificate() { + log.info("Retrieving intermediate certificate from Vault"); + String path = pathCertCa; + VaultResponse resp = vault.read(path); + if (Optional.ofNullable(resp).map(VaultResponse::getData).isEmpty()) { + log.error("Failed to retrieve intermediate certificate from path: {}", path); + throw new PkiException("Could not retrieve intermediate certificate from Vault"); + } + + String pathToCaChain = pathCertCaChain; + VaultResponse caChainResp = vault.read(pathToCaChain); + if (Optional.ofNullable(caChainResp).map(VaultResponse::getData).isEmpty()) { + log.error("Failed to retrieve CA chain from path: {}", pathToCaChain); + throw new PkiException("Could not retrieve CA Chain certificate from Vault"); + } + + String certificatePem = (String) resp.getData().get(KEY_CERTIFICATE); + CertificateInfoDTO info; + try { + X509Certificate x509 = PemUtil.parseCertificate(certificatePem); + info = CertificateInfoDTO.builder() + .subject(x509.getSubjectX500Principal().getName()) + .issuer(x509.getIssuerX500Principal().getName()) + .serialNumber(x509.getSerialNumber().toString()) + .notBefore(x509.getNotBefore().toInstant()) + .notAfter(x509.getNotAfter().toInstant()) + .signatureAlgorithm(x509.getSigAlgName()) + .version(x509.getVersion()) + .build(); + log.debug("Successfully parsed intermediate certificate: {}", info.getSubject()); + } catch (Exception e) { + log.error("Failed to parse intermediate certificate PEM", e); + throw new PkiException("Error parsing intermediate certificate", e); + } + + return IntermediateCertResponseDTO.builder() + .certificate(certificatePem) + .caChain(caChainResp.getData().get(KEY_CA_CHAIN)) + .info(info) + .build(); + } + + /** + * Safely formats a subject component by replacing commas with spaces. + * + * @param s the string to safe format + * @return the safe string or empty string if null + */ + private static String safe(String s) { + return (s == null) ? "" : s.replace(",", " "); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java index 68e2880..68d790c 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -8,16 +8,26 @@ import java.math.BigDecimal; import java.sql.Timestamp; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; +/** + * Implementation of {@link ConfigurationProvider} that retrieves configuration from database services. + */ @Service public class ConfigurationProviderImpl implements ConfigurationProvider { @@ -27,21 +37,40 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final ProducerService producerService; + private final CertificateValidationProvider certificateValidationProvider; + + /** + * Constructs a new ConfigurationProviderImpl with required services. + * + * @param consumerService the consumer service + * @param consumerAllowedDataProviders the product consumer service + * @param producerService the producer service + * @param certificateValidationProvider the certificate validation provider + */ public ConfigurationProviderImpl( ConsumerService consumerService, ProductConsumerService consumerAllowedDataProviders, - ProducerService producerService) { + ProducerService producerService, + CertificateValidationProvider certificateValidationProvider) { this.consumerService = consumerService; this.productConsumerService = consumerAllowedDataProviders; this.producerService = producerService; + this.certificateValidationProvider = certificateValidationProvider; } + /** + * Checks if a granted timestamp is still valid based on the validity period. + * + * @param grantedTs the timestamp when access was granted + * @param validity the validity period in days + * @return true if valid, false otherwise + */ private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity) { return grantedTs != null && grantedTs .toInstant() - .plus(java.time.Duration.ofDays(validity.longValue())) + .plus(Duration.ofDays(validity.longValue())) .isAfter(Instant.now()); } @@ -59,9 +88,11 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional(validProductIds); + Set validIdsSet = new HashSet<>(validProductIds); producers.forEach( p -> p.getProducts().removeIf(prod -> prod.getId() == null || !validIdsSet.contains(prod.getId()))); } else { @@ -76,7 +107,12 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional getValidProductConsumers(List consumers) { List validProductIds = new ArrayList<>(); @@ -108,8 +150,7 @@ public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional getFilteredConsumers(String clientId, Optional consumerId) { List consumers = consumerService.findByIdpClientId(clientId); @@ -139,9 +180,9 @@ private List getFilteredConsumers(String clientId, Optional c /** * Filters active producers by client ID and optional producer ID. * - * @param clientId the client ID to filter by - * @param producerId optional producer ID for additional filtering - * @return filtered list of active producers + * @param clientId the client ID + * @param producerId the optional producer ID + * @return a list of filtered active producers */ private List getFilteredActiveProducers(String clientId, Optional producerId) { List producers = producerService.getProducersByClientId(clientId).stream() @@ -158,10 +199,10 @@ private List getFilteredActiveProducers(String clientId, Optional collectDataProviderIds(List producers) { List dataProviderIds = new ArrayList<>(); @@ -176,52 +217,76 @@ private List collectDataProviderIds(List producers) { } /** - * Processes consumers for each provider in the given producers. + * Resolves consumers for each product and populates them onto the product DTOs, + * filtering out consumers whose organisations have inactive certificates. * - * @param producers list of producers to process + * @param producers the list of producers whose products need consumer resolution */ - private void processConsumersForProducers(List producers) { + private void populateConsumersForProducers(List producers) { + // Resolve all valid consumers per product + Map> consumersByProduct = new LinkedHashMap<>(); for (ProducerDTO producer : producers) { - for (ProductDTO provider : producer.getProducts()) { - processConsumersForProvider(provider); + for (ProductDTO product : producer.getProducts()) { + List resolved = productConsumerService.findByDataProviderId(product.getId()).stream() + .filter(this::isValidProvider) + .map(cp -> consumerService.findById(cp.getConsumerId())) + .filter(Optional::isPresent) + .map(Optional::get) + .toList(); + consumersByProduct.put(product, resolved); + } + } + + Set allConsumerOrgIds = consumersByProduct.values().stream() + .flatMap(List::stream) + .map(ConsumerDTO::getOrgId) + .collect(Collectors.toSet()); + Set activeOrgIds = certificateValidationProvider.findActiveOrganisationIds(allConsumerOrgIds); + + // Populate each product's consumer list, skipping inactive orgs + for (var entry : consumersByProduct.entrySet()) { + ProductDTO product = entry.getKey(); + if (product.getConsumers() == null) { + product.setConsumers(new ArrayList<>()); + } + for (ConsumerDTO consumer : entry.getValue()) { + if (activeOrgIds.contains(consumer.getOrgId())) { + product.getConsumers().add(consumer); + } } } } /** - * Processes consumers for a specific provider. + * Checks if a provider (product consumer) is valid based on its granted date and validity period. * - * @param provider the provider to process consumers for + * @param provider the product consumer DTO + * @return true if valid, false otherwise */ - private void processConsumersForProvider(ProductDTO provider) { + private boolean isValidProvider(ProductConsumerDTO provider) { - // Get consumer providers for this data provider - List consumerProviders = productConsumerService.findByDataProviderId(provider.getId()); + if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; - // Filter valid providers and add their consumers - addValidConsumersToProvider(consumerProviders, provider); + return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); } /** - * Adds valid consumers to the given provider. + * Filters producers to only those whose organisations have active certificates. * - * @param consumerProviders list of consumer-provider relationships - * @param provider the provider to add consumers to + * @param producers the list of producers to filter + * @return producers with active organisation certificates */ - private void addValidConsumersToProvider(List consumerProviders, ProductDTO provider) { - if (provider.getConsumers() == null) { - provider.setConsumers(new ArrayList<>()); - } - consumerProviders.stream().filter(this::isValidProvider).forEach(consumerProvider -> { - Optional consumer = consumerService.findById(consumerProvider.getConsumerId()); - consumer.ifPresent(provider.getConsumers()::add); - }); - } + private List filterProducersByActiveCertificate(List producers) { + Set producerOrgIds = producers.stream().map(ProducerDTO::getOrgId).collect(Collectors.toSet()); - private boolean isValidProvider(ProductConsumerDTO provider) { + if (producerOrgIds.isEmpty()) { + return producers; + } - if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; + Set activeOrgIds = certificateValidationProvider.findActiveOrganisationIds(producerOrgIds); - return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); + return producers.stream() + .filter(p -> activeOrgIds.contains(p.getOrgId())) + .toList(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java new file mode 100644 index 0000000..5dccf99 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtil.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.utils.cryptography; + +import java.io.ByteArrayInputStream; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; + +/** + * Utility class for PEM (Privacy-Enhanced Mail) format operations. + * Provides methods for parsing keys and certificates from PEM strings and converting to PEM format. + */ +public class PemUtil { + + private static final String KEY_ALG = "RSA"; + + private PemUtil() {} + /** + * Parses a PKCS#8 encoded private key from a PEM string. + * + * @param pem the PEM encoded private key string + * @return the parsed PrivateKey + * @throws PkiException if the key cannot be parsed or the algorithm is unavailable + */ + public static PrivateKey parsePkcs8PrivateKey(String pem) { + try { + byte[] der = decodePem(pem); + return KeyFactory.getInstance(KEY_ALG).generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new PkiException("Failed to parse private key", e); + } + } + + /** + * Parses an X.509 encoded public key from a PEM string. + * + * @param pem the PEM encoded public key string + * @return the parsed PublicKey + * @throws PkiException if the key cannot be parsed or the algorithm is unavailable + */ + public static PublicKey parsePublicKey(String pem) { + try { + byte[] der = decodePem(pem); + return KeyFactory.getInstance(KEY_ALG).generatePublic(new X509EncodedKeySpec(der)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new PkiException("Failed to parse public key", e); + } + } + + /** + * Decodes a PEM string by removing headers, footers, and whitespace. + * + * @param pem the PEM string to decode + * @return the decoded byte array + */ + private static byte[] decodePem(String pem) { + String cleaned = pem.replaceAll("-----BEGIN ([A-Z ]+)-----", "") + .replaceAll("-----END ([A-Z ]+)-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(cleaned); + } + + /** + * Converts a DER encoded byte array to a PEM string. + * + * @param type the PEM type label (e.g., "PRIVATE KEY", "CERTIFICATE") + * @param der the DER encoded byte array + * @return the PEM formatted string + */ + public static String toPem(String type, byte[] der) { + String b64 = Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(der); + return "-----BEGIN " + type + "-----\n" + b64 + "\n-----END " + type + "-----\n"; + } + + /** + * Parses an X.509 certificate from a PEM string. + * + * @param pem the PEM encoded certificate string + * @return the parsed X509Certificate + * @throws PkiException if the certificate cannot be parsed or the factory is unavailable + */ + public static X509Certificate parseCertificate(String pem) { + try { + byte[] der = decodePem(pem); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(der)); + } catch (CertificateException e) { + throw new PkiException("Failed to parse certificate", e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f8b919b..7799fba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: application: name: management-node + cloud: + vault: + uri: http://localhost:8200 + token: # access token to vault + authentication: token security: oauth2: resourceserver: @@ -36,13 +41,20 @@ server: key-alias: localhost key-store: keystore.jks #path to ssl keystore key-store-type: PKCS12 - key-store-password: ${CERTPASSWORD:} #keystore password + key-store-password: ${CERTPASSWORD:defaultpassword} #keystore password trust-store: truststore.jks #path to ssl truststore - trust-store-password: ${CERTPASSWORD:} #truststore password + trust-store-password: ${CERTPASSWORD:defaultpassword} #truststore password trust-store-type: PKCS12 client-auth: need enabled: true # disable for local development Only application: + vault: + pki-mount: pki-int # mount point for intermediate CA + default-role: default-role # default role for signing CSRs + default-ttl: 24h # default TTL for signing CSRs + 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/main/resources/db/migration/V20260305120000__add_certificate_tables.sql b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql new file mode 100644 index 0000000..39b0a95 --- /dev/null +++ b/src/main/resources/db/migration/V20260305120000__add_certificate_tables.sql @@ -0,0 +1,65 @@ +/* + * 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 organisation_certificate ( + id BIGSERIAL PRIMARY KEY, + organisation_id BIGINT NOT NULL UNIQUE, + subject_dn VARCHAR(500), + serial_number VARCHAR(150), + is_renewable BOOLEAN NOT NULL DEFAULT FALSE, + renewal_ttl BIGINT, + type VARCHAR(50) NOT NULL, + requested_at TIMESTAMP, + issued_at TIMESTAMP, + expires_at TIMESTAMP, + revoked_at TIMESTAMP, + CONSTRAINT fk_organisation_certificate__organisation_id + FOREIGN KEY (organisation_id) REFERENCES organisation (id) +); +CREATE INDEX idx_organisation_certificate__organisation_id +ON organisation_certificate (organisation_id); + +CREATE TABLE certificate_events ( + id BIGSERIAL PRIMARY KEY, + organisation_certificate_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + event_type VARCHAR(50) NOT NULL, + event_time TIMESTAMP NOT NULL, + performed_by VARCHAR(255), + CONSTRAINT fk_certificate_events__organisation_certificate_id + FOREIGN KEY (organisation_certificate_id) + REFERENCES organisation_certificate (id) +); +CREATE INDEX idx_certificate_events__organisation_certificate_id +ON certificate_events (organisation_certificate_id); + +/* Mark existing orgs as manually configured */ +UPDATE organisation SET certificate_automation_enabled = FALSE; +INSERT INTO organisation_certificate ( + organisation_id, + subject_dn, + serial_number, + is_renewable, + type, + requested_at, + issued_at, + expires_at, + revoked_at +) +SELECT + id AS organisation_id, + NULL AS subject_dn, + NULL AS serial_number, + FALSE AS is_renewable, + 'MANUAL' AS type, + NULL AS requested_at, + NULL AS issued_at, + NULL AS expires_at, + NULL AS revoked_at +FROM organisation; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java new file mode 100644 index 0000000..1b6c190 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/CertificateValidationInterceptorTest.java @@ -0,0 +1,263 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.math.BigInteger; +import java.security.cert.X509Certificate; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.method.HandlerMethod; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; + +class CertificateValidationInterceptorTest { + + @Mock + private CertificateValidationProvider validationProvider; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private HandlerMethod handlerMethod; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + private CertificateValidationInterceptor interceptor; + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + interceptor = new CertificateValidationInterceptor(validationProvider, new ObjectMapper()); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() throws Exception { + SecurityContextHolder.clearContext(); + closeable.close(); + } + + private void setupAuthentication(String clientId) { + EnhancedPrincipal principal = new EnhancedPrincipal("subject", clientId); + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn(principal); + } + + private StringWriter setupResponseWriter() throws Exception { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + when(response.getWriter()).thenReturn(pw); + return sw; + } + + private OrganisationCertificateDTO certDto(CertificateType type, String serial) { + return OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(1L) + .certificateAutomationEnabled(type != CertificateType.MANUAL) + .type(type) + .serialNumber(serial) + .expiresAt(Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS))) + .build(); + } + + @Test + void noAuthentication_returns403() throws Exception { + when(securityContext.getAuthentication()).thenReturn(null); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void nonEnhancedPrincipal_returns403() throws Exception { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.getPrincipal()).thenReturn("plain-string-principal"); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void emptyClientId_returns403() throws Exception { + setupAuthentication(""); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void noCertRecord_returns403() throws Exception { + setupAuthentication("client-1"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.empty()); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @ParameterizedTest + @EnumSource( + value = CertificateType.class, + names = {"AUTOMATED", "MANUAL"}) + void activeCert_passesThrough(CertificateType type) throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(type, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + } + + @Test + void inactiveCert_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(false); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + static Stream serialNumberFormats() { + return Stream.of( + Arguments.of("abc123", "plain hex match"), + Arguments.of("ABC123", "case-insensitive match"), + Arguments.of("ab:c1:23", "colon-separated match")); + } + + @ParameterizedTest(name = "{1}") + @MethodSource("serialNumberFormats") + void matchingSerialNumber_passesThrough(String storedSerial, String description) throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, storedSerial); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSerialNumber()).thenReturn(new BigInteger("abc123", 16)); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .thenReturn(new X509Certificate[] {mockCert}); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + } + + @Test + void mismatchedSerialNumber_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, "abc123"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSerialNumber()).thenReturn(new BigInteger("def456", 16)); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")) + .thenReturn(new X509Certificate[] {mockCert}); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void noCertificateWhenSerialExpected_returns403() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, "abc123"); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + when(request.getAttribute("jakarta.servlet.request.X509Certificate")).thenReturn(null); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void nullSerialNumber_skipsCheck() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(true); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isTrue(); + verify(request, never()).getAttribute("jakarta.servlet.request.X509Certificate"); + } + + @Test + void bootstrapCert_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(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + setupResponseWriter(); + + assertThat(interceptor.preHandle(request, response, handlerMethod)).isFalse(); + verify(response).setStatus(403); + } + + @Test + void errorResponse_containsStatusAndMessage() throws Exception { + setupAuthentication("client-1"); + OrganisationCertificateDTO cert = certDto(CertificateType.AUTOMATED, null); + when(validationProvider.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(validationProvider.isActive(cert)).thenReturn(false); + when(request.getRequestURI()).thenReturn("/api/v1/configuration/consumer"); + StringWriter sw = setupResponseWriter(); + + interceptor.preHandle(request, response, handlerMethod); + + verify(response).setStatus(403); + verify(response).setContentType("application/json"); + String body = sw.toString(); + assertThat(body).contains("403").contains("Organisation certificate is not active"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java index 381b682..8f30299 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -51,8 +51,6 @@ void setUp() { converter, "introspectionUri", "http://localhost:8080/realms/management-node/protocol/openid-connect/token/introspect"); - ReflectionTestUtils.setField(converter, "clientId", "management-node"); - ReflectionTestUtils.setField(converter, "clientSecret", "0T5S4wNAPaaOUzFVFQyenorSEC6zxcb0"); // Create a mock JWT with the sample token data Map headers = new HashMap<>(); @@ -93,7 +91,10 @@ void setUp() { void convert_withRestClientException_shouldFallbackToJwtParsing() { // Arrange // Configure RestTemplate to throw a RestClientException - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenThrow(new RestClientException("Connection refused")); // Act @@ -109,46 +110,21 @@ void convert_withRestClientException_shouldFallbackToJwtParsing() { assertEquals("management-node", principal.clientId()); } - @Test - void convert_withMalformedIntrospectionResponse_shouldFallbackToJwtParsing() { - // Arrange - // Create a malformed introspection response that will cause a ResourceAccessParsingException - Map malformedResponse = new HashMap<>(); - malformedResponse.put("active", true); - malformedResponse.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); - malformedResponse.put("client_id", "management-node"); - - // Add malformed resource_access (not a map but a string) - malformedResponse.put("resource_access", "not-a-map"); - - // Configure RestTemplate to return the malformed response - ResponseEntity responseEntity = new ResponseEntity<>(malformedResponse, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) - .thenReturn(responseEntity); - - // Act - AbstractAuthenticationToken token = converter.convert(mockJwt); - - // Assert - assertNotNull(token); - assertTrue(token instanceof CustomJwtAuthenticationToken); - - // Verify the token has the correct principal - EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); - assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); - assertEquals("management-node", principal.clientId()); - } - @Test void convert_withInactiveToken_shouldFallbackToJwtParsing() { // Arrange // Create an introspection response with inactive token - Map inactiveTokenResponse = new HashMap<>(); - inactiveTokenResponse.put("active", false); + uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken inactiveTokenResponse = + new uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken(); + inactiveTokenResponse.setActive(false); // Configure RestTemplate to return the inactive token response - ResponseEntity responseEntity = new ResponseEntity<>(inactiveTokenResponse, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + ResponseEntity responseEntity = + new ResponseEntity<>(inactiveTokenResponse, HttpStatus.OK); + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenReturn(responseEntity); // Act @@ -168,8 +144,12 @@ void convert_withInactiveToken_shouldFallbackToJwtParsing() { void convert_withNullIntrospectionResponse_shouldFallbackToJwtParsing() { // Arrange // Configure RestTemplate to return null response body - ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + ResponseEntity responseEntity = + new ResponseEntity<>(null, HttpStatus.OK); + when(restTemplate.postForEntity( + anyString(), + any(HttpEntity.class), + Mockito.>any())) .thenReturn(responseEntity); // Act diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/WebConfigTest.java new file mode 100644 index 0000000..60b1304 --- /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 certificateEndpoints_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/**"); + } +} 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()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java new file mode 100644 index 0000000..1d03468 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/CertificateControllerTest.java @@ -0,0 +1,158 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.controller.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CertificateInfoDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrRequestDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateCsrResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.CreateKeyResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.IntermediateCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateSigningProvider; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.VaultPkiService; + +@ExtendWith(MockitoExtension.class) +class CertificateControllerTest { + + private MockMvc mockMvc; + + @Mock + private VaultPkiService pkiService; + + @Mock + private CertificateSigningProvider signingProvider; + + @InjectMocks + private CertificateController certificateController; + + private static final EnhancedPrincipal TEST_PRINCIPAL = new EnhancedPrincipal("subject", "client-1"); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(certificateController) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthenticationPrincipal.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + return TEST_PRINCIPAL; + } + }) + .build(); + } + + @Test + void createKeyPair_shouldReturnKeyPair() throws Exception { + CreateKeyResponseDTO response = CreateKeyResponseDTO.builder() + .algorithm("RSA") + .publicKeyPem("PUB") + .privateKeyPem("PRIV") + .build(); + when(pkiService.createKeyPair(anyString(), any())).thenReturn(response); + + mockMvc.perform(get("/api/v1/certificate/keyPair").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.algorithm").value("RSA")); + } + + @Test + void createCsr_shouldReturnCsr() throws Exception { + CreateCsrResponseDTO response = new CreateCsrResponseDTO("id", "CSR_PEM"); + when(pkiService.createCsr(any(CreateCsrRequestDTO.class))).thenReturn(response); + + String jsonRequest = "{\"commonName\":\"test\"}"; + + mockMvc.perform(post("/api/v1/certificate/csr/create") + .contentType(MediaType.APPLICATION_JSON) + .content(jsonRequest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.csrPem").value("CSR_PEM")); + } + + @Test + void signCsr_shouldDelegateToSigningProvider() throws Exception { + SignCertResponseDTO response = SignCertResponseDTO.builder() + .certificate("CERT") + .serialNumber("123") + .build(); + when(signingProvider.signAndRecord("CSR", "client-1")).thenReturn(response); + + mockMvc.perform(post("/api/v1/certificate/csr/sign") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"csr\":\"CSR\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.certificate").value("CERT")); + } + + @Test + void getIntermediateCertificate_shouldReturnCertificateWithInfo() throws Exception { + String mockCert = "-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----"; + CertificateInfoDTO info = CertificateInfoDTO.builder() + .subject("CN=Test") + .issuer("CN=Root") + .serialNumber("12345") + .build(); + IntermediateCertResponseDTO responseDTO = IntermediateCertResponseDTO.builder() + .certificate(mockCert) + .caChain(null) + .info(info) + .build(); + when(pkiService.getIntermediateCertificate()).thenReturn(responseDTO); + + mockMvc.perform(get("/api/v1/certificate/intermediate").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.certificate").value(mockCert)) + .andExpect(jsonPath("$.info.subject").value("CN=Test")) + .andExpect(jsonPath("$.info.issuer").value("CN=Root")) + .andExpect(jsonPath("$.info.serialNumber").value("12345")); + } + + @Test + void issueBootstrapCertificate_shouldReturnZipDownload() throws Exception { + byte[] zipBytes = new byte[] {0x50, 0x4B, 0x03, 0x04}; + when(signingProvider.issueBootstrapPackage(any(), anyString(), anyString())) + .thenReturn(zipBytes); + + mockMvc.perform(post("/api/v1/certificate/bootstrap") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"organisationId\":10,\"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/converter/impl/OrganisationCertificateConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java new file mode 100644 index 0000000..ad56a64 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationCertificateConverterTest.java @@ -0,0 +1,164 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +class OrganisationCertificateConverterTest { + + @Mock + private OrganisationRepository organisationRepository; + + private OrganisationCertificateConverter converter; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + converter = new OrganisationCertificateConverter(organisationRepository); + } + + @Test + void toDto_withNullEntity_returnsNull() { + assertThat(converter.toDto(null)).isNull(); + } + + @Test + void toDto_mapsAllFieldsCorrectly() { + Organisation org = new Organisation(); + org.setId(10L); + org.setName("Test Org"); + org.setCertificateAutomationEnabled(true); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(org); + entity.setSubjectDn("CN=test"); + entity.setSerialNumber("abc123"); + entity.setIsRenewable(true); + entity.setRenewalTtl(3600L); + entity.setType(CertificateType.AUTOMATED); + entity.setRequestedAt(now); + entity.setIssuedAt(now); + entity.setExpiresAt(now); + entity.setRevokedAt(null); + + OrganisationCertificateDTO dto = converter.toDto(entity); + + assertThat(dto.getId()).isEqualTo(1L); + assertThat(dto.getOrganisationId()).isEqualTo(10L); + assertThat(dto.getCertificateAutomationEnabled()).isTrue(); + assertThat(dto.getSubjectDn()).isEqualTo("CN=test"); + assertThat(dto.getSerialNumber()).isEqualTo("abc123"); + assertThat(dto.getIsRenewable()).isTrue(); + assertThat(dto.getRenewalTtl()).isEqualTo(3600L); + assertThat(dto.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(dto.getRequestedAt()).isEqualTo(now); + assertThat(dto.getIssuedAt()).isEqualTo(now); + assertThat(dto.getExpiresAt()).isEqualTo(now); + assertThat(dto.getRevokedAt()).isNull(); + } + + @Test + void toDto_withNullOrganisation_setsNullOrgFields() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(null); + entity.setType(CertificateType.BOOTSTRAP); + + OrganisationCertificateDTO dto = converter.toDto(entity); + + assertThat(dto.getOrganisationId()).isNull(); + assertThat(dto.getCertificateAutomationEnabled()).isNull(); + } + + @Test + void toEntity_withNullDto_returnsNull() { + assertThat(converter.toEntity(null)).isNull(); + } + + @Test + void toEntity_mapsAllFieldsCorrectly() { + Organisation org = new Organisation(); + org.setId(10L); + org.setName("Test Org"); + when(organisationRepository.findById(10L)).thenReturn(Optional.of(org)); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(10L) + .subjectDn("CN=test") + .serialNumber("abc123") + .isRenewable(true) + .renewalTtl(3600L) + .type(CertificateType.AUTOMATED) + .requestedAt(now) + .issuedAt(now) + .expiresAt(now) + .revokedAt(null) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getId()).isEqualTo(1L); + assertThat(entity.getOrganisation()).isEqualTo(org); + assertThat(entity.getSubjectDn()).isEqualTo("CN=test"); + assertThat(entity.getSerialNumber()).isEqualTo("abc123"); + assertThat(entity.getIsRenewable()).isTrue(); + assertThat(entity.getRenewalTtl()).isEqualTo(3600L); + assertThat(entity.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(entity.getRequestedAt()).isEqualTo(now); + assertThat(entity.getIssuedAt()).isEqualTo(now); + assertThat(entity.getExpiresAt()).isEqualTo(now); + assertThat(entity.getRevokedAt()).isNull(); + } + + @Test + void toEntity_withNullOrganisationId_doesNotLookUpOrg() { + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(null) + .type(CertificateType.BOOTSTRAP) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getOrganisation()).isNull(); + } + + @Test + void toEntity_withUnknownOrganisationId_setsNullOrg() { + when(organisationRepository.findById(999L)).thenReturn(Optional.empty()); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(999L) + .type(CertificateType.AUTOMATED) + .build(); + + OrganisationCertificate entity = converter.toEntity(dto); + + assertThat(entity.getOrganisation()).isNull(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java index 22e4789..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 @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: Apache-2.0 - * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally * attributed to the Department for Business and Trade (UK) as the governing entity. */ @@ -12,9 +12,12 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.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; import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; import uk.gov.dbt.ndtp.ia.node.management.exception.JwtClaimParsingException; @@ -77,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 @@ -112,4 +133,22 @@ void handleAllExceptions_shouldReturnInternalServerErrorStatus() { assertEquals("An unexpected error occurred", errorResponse.getMessage()); assertNotNull(errorResponse.getErrorId()); } + + @Test + void handleNoResourceFoundException_shouldReturnNotFoundStatus() { + // Arrange + String path = "/api/v1/invalid"; + NoResourceFoundException exception = new NoResourceFoundException(HttpMethod.GET, path); + + // Act + ResponseEntity response = exceptionHandler.handleNoResourceFoundException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.NOT_FOUND.value(), errorResponse.getStatus()); + assertEquals("Resource not found: " + path, errorResponse.getMessage()); + assertNotNull(errorResponse.getErrorId()); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java new file mode 100644 index 0000000..7c77cfb --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTest.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class CertificateEventTest { + + @Test + void testCertificateEventSettersAndGetters() { + OrganisationCertificate cert = new OrganisationCertificate(); + cert.setId(1L); + + Timestamp now = Timestamp.from(Instant.now()); + + CertificateEvent event = new CertificateEvent(); + event.setId(5L); + event.setOrganisationCertificate(cert); + event.setType(CertificateType.AUTOMATED); + event.setEventType(CertificateEventType.ISSUED); + event.setEventTime(now); + event.setPerformedBy("system"); + + assertThat(event.getId()).isEqualTo(5L); + assertThat(event.getOrganisationCertificate()).isEqualTo(cert); + assertThat(event.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(event.getEventType()).isEqualTo(CertificateEventType.ISSUED); + assertThat(event.getEventTime()).isEqualTo(now); + assertThat(event.getPerformedBy()).isEqualTo("system"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java new file mode 100644 index 0000000..c7a657e --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateEventTypeTest.java @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CertificateEventTypeTest { + + @Test + void testCertificateEventTypeValues() { + CertificateEventType[] values = CertificateEventType.values(); + + assertThat(values) + .containsExactly( + CertificateEventType.ISSUED, + CertificateEventType.RENEWED, + CertificateEventType.EXPIRED, + CertificateEventType.REVOKED); + } + + @Test + void testCertificateEventTypeValueOf() { + assertThat(CertificateEventType.valueOf("ISSUED")).isEqualTo(CertificateEventType.ISSUED); + assertThat(CertificateEventType.valueOf("RENEWED")).isEqualTo(CertificateEventType.RENEWED); + assertThat(CertificateEventType.valueOf("EXPIRED")).isEqualTo(CertificateEventType.EXPIRED); + assertThat(CertificateEventType.valueOf("REVOKED")).isEqualTo(CertificateEventType.REVOKED); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java new file mode 100644 index 0000000..a6fd976 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/CertificateTypeTest.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class CertificateTypeTest { + + @Test + void testCertificateTypeValues() { + CertificateType[] values = CertificateType.values(); + + assertThat(values) + .containsExactly(CertificateType.BOOTSTRAP, CertificateType.MANUAL, CertificateType.AUTOMATED); + } + + @Test + void testCertificateTypeValueOf() { + assertThat(CertificateType.valueOf("BOOTSTRAP")).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(CertificateType.valueOf("MANUAL")).isEqualTo(CertificateType.MANUAL); + assertThat(CertificateType.valueOf("AUTOMATED")).isEqualTo(CertificateType.AUTOMATED); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java new file mode 100644 index 0000000..069d4c3 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationCertificateTest.java @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class OrganisationCertificateTest { + + @Test + void testOrganisationCertificateSettersAndGetters() { + Organisation organisation = new Organisation(); + organisation.setId(1L); + + Timestamp now = Timestamp.from(Instant.now()); + + OrganisationCertificate cert = new OrganisationCertificate(); + cert.setId(10L); + cert.setOrganisation(organisation); + cert.setSubjectDn("CN=test,O=NDTP"); + cert.setSerialNumber("ABC123"); + cert.setIsRenewable(true); + cert.setRenewalTtl(86400L); + cert.setType(CertificateType.BOOTSTRAP); + cert.setRequestedAt(now); + cert.setIssuedAt(now); + cert.setExpiresAt(now); + cert.setRevokedAt(now); + + assertThat(cert.getId()).isEqualTo(10L); + assertThat(cert.getOrganisation()).isEqualTo(organisation); + assertThat(cert.getSubjectDn()).isEqualTo("CN=test,O=NDTP"); + assertThat(cert.getSerialNumber()).isEqualTo("ABC123"); + assertThat(cert.getIsRenewable()).isTrue(); + assertThat(cert.getRenewalTtl()).isEqualTo(86400L); + assertThat(cert.getType()).isEqualTo(CertificateType.BOOTSTRAP); + assertThat(cert.getRequestedAt()).isEqualTo(now); + assertThat(cert.getIssuedAt()).isEqualTo(now); + assertThat(cert.getExpiresAt()).isEqualTo(now); + assertThat(cert.getRevokedAt()).isEqualTo(now); + } + + @Test + void testIsRenewableDefaultsToFalse() { + OrganisationCertificate cert = new OrganisationCertificate(); + + assertThat(cert.getIsRenewable()).isFalse(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java new file mode 100644 index 0000000..cdeed49 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/OrganisationTest.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class OrganisationTest { + + @Test + void testOrganisationSettersAndGetters() { + Organisation organisation = new Organisation(); + organisation.setId(1L); + organisation.setName("Test Organisation"); + organisation.setCertificateAutomationEnabled(false); + + assertThat(organisation.getId()).isEqualTo(1L); + assertThat(organisation.getName()).isEqualTo("Test Organisation"); + assertThat(organisation.getCertificateAutomationEnabled()).isFalse(); + } + + @Test + void testCertificateAutomationEnabledDefaultsToTrue() { + Organisation organisation = new Organisation(); + + assertThat(organisation.getCertificateAutomationEnabled()).isTrue(); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java new file mode 100644 index 0000000..ecf4556 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductTypeTest.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ProductTypeTest { + + @Test + void testProductTypeSettersAndGetters() { + ProductType productType = new ProductType(); + productType.setId(1L); + productType.setName("Test Product Type"); + productType.setDescription("Test Description"); + + assertThat(productType.getId()).isEqualTo(1L); + assertThat(productType.getName()).isEqualTo("Test Product Type"); + assertThat(productType.getDescription()).isEqualTo("Test Description"); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java new file mode 100644 index 0000000..fb775c5 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/CertificateEventServiceImplTest.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEvent; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.CertificateEventRepository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; + +class CertificateEventServiceImplTest { + + @Mock + private CertificateEventRepository eventRepository; + + @Mock + private OrganisationCertificateRepository certificateRepository; + + private CertificateEventServiceImpl service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new CertificateEventServiceImpl(eventRepository, certificateRepository); + } + + @Test + void recordEvent_savesEventWithCorrectFields() { + OrganisationCertificate certRef = new OrganisationCertificate(); + certRef.setId(5L); + when(certificateRepository.getReferenceById(5L)).thenReturn(certRef); + when(eventRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Instant before = Instant.now(); + service.recordEvent(5L, CertificateType.AUTOMATED, CertificateEventType.RENEWED, "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CertificateEvent.class); + verify(eventRepository).save(captor.capture()); + + CertificateEvent saved = captor.getValue(); + assertThat(saved.getOrganisationCertificate()).isEqualTo(certRef); + assertThat(saved.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(saved.getEventType()).isEqualTo(CertificateEventType.RENEWED); + assertThat(saved.getPerformedBy()).isEqualTo("client-1"); + assertThat(saved.getEventTime().toInstant()).isBetween(before, Instant.now()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java new file mode 100644 index 0000000..3a21027 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationCertificateServiceImplTest.java @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationCertificateConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.OrganisationCertificate; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationCertificateRepository; + +class OrganisationCertificateServiceImplTest { + + @Mock + private OrganisationCertificateRepository repository; + + @Mock + private OrganisationCertificateConverter converter; + + private OrganisationCertificateServiceImpl service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new OrganisationCertificateServiceImpl(repository, converter); + } + + @Test + void findByOrganisationId_whenFound_returnsDto() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + + when(repository.findByOrganisationId(10L)).thenReturn(Optional.of(entity)); + when(converter.toDto(entity)).thenReturn(dto); + + Optional result = service.findByOrganisationId(10L); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(1L); + } + + @Test + void findByOrganisationId_whenNotFound_returnsEmpty() { + when(repository.findByOrganisationId(99L)).thenReturn(Optional.empty()); + + Optional result = service.findByOrganisationId(99L); + + assertThat(result).isEmpty(); + } + + @Test + void findAllByOrganisationIds_delegatesToRepositoryAndConverter() { + Organisation org = new Organisation(); + org.setId(1L); + org.setCertificateAutomationEnabled(true); + + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setOrganisation(org); + entity.setType(CertificateType.AUTOMATED); + + OrganisationCertificateDTO dto = OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(1L) + .type(CertificateType.AUTOMATED) + .build(); + + when(repository.findAllWithOrganisationByOrganisationIdIn(Set.of(1L, 2L))) + .thenReturn(List.of(entity)); + when(converter.toDtoList(List.of(entity))).thenReturn(List.of(dto)); + + List result = service.findAllByOrganisationIds(Set.of(1L, 2L)); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getId()).isEqualTo(1L); + } + + @Test + void findAllByOrganisationIds_withNullInput_returnsEmptyList() { + List result = service.findAllByOrganisationIds(null); + + assertThat(result).isEmpty(); + verifyNoInteractions(repository); + } + + @Test + void findAllByOrganisationIds_withEmptyInput_returnsEmptyList() { + List result = service.findAllByOrganisationIds(List.of()); + + assertThat(result).isEmpty(); + verifyNoInteractions(repository); + } + + @Test + void findByClientId_whenFound_returnsDto() { + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + + when(repository.findByClientId("client-1")).thenReturn(Optional.of(entity)); + when(converter.toDto(entity)).thenReturn(dto); + + Optional result = service.findByClientId("client-1"); + + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(1L); + } + + @Test + void findByClientId_whenNotFound_returnsEmpty() { + when(repository.findByClientId("unknown")).thenReturn(Optional.empty()); + + Optional result = service.findByClientId("unknown"); + + assertThat(result).isEmpty(); + } + + @Test + void save_convertsAndPersists() { + OrganisationCertificateDTO inputDto = + OrganisationCertificateDTO.builder().id(1L).serialNumber("abc").build(); + OrganisationCertificate entity = new OrganisationCertificate(); + entity.setId(1L); + entity.setSerialNumber("abc"); + OrganisationCertificateDTO outputDto = + OrganisationCertificateDTO.builder().id(1L).serialNumber("abc").build(); + + when(converter.toEntity(inputDto)).thenReturn(entity); + when(repository.save(entity)).thenReturn(entity); + when(converter.toDto(entity)).thenReturn(outputDto); + + OrganisationCertificateDTO result = service.save(inputDto); + + assertThat(result.getSerialNumber()).isEqualTo("abc"); + verify(repository).save(entity); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java new file mode 100644 index 0000000..03c6cee --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateSigningProviderImplTest.java @@ -0,0 +1,378 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.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; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.exception.CertificateSigningException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.SignCertResponseDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateEventType; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.CertificateType; +import uk.gov.dbt.ndtp.ia.node.management.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; + +class CertificateSigningProviderImplTest { + + @Mock + private VaultPkiService vaultPkiService; + + @Mock + private OrganisationCertificateService certificateService; + + @Mock + private CertificateEventService eventService; + + @Mock + private OrganisationRepository organisationRepository; + + 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, + organisationRepository, + BOOTSTRAP_TTL, + BOOTSTRAP_OID); + pemUtilMock = mockStatic(PemUtil.class); + } + + @AfterEach + void tearDown() { + pemUtilMock.close(); + } + + private OrganisationCertificateDTO buildCert(CertificateType type, boolean automationEnabled, boolean renewable) { + return OrganisationCertificateDTO.builder() + .id(1L) + .organisationId(10L) + .type(type) + .certificateAutomationEnabled(automationEnabled) + .isRenewable(renewable) + .build(); + } + + private SignCertResponseDTO buildSignResponse() { + return SignCertResponseDTO.builder() + .certificate("CERT_PEM") + .serialNumber("abc123") + .expiration(1735689600L) + .caChain(List.of("CA_PEM")) + .issuingCa("ISSUING_CA_PEM") + .build(); + } + + private static final Instant NOT_BEFORE = Instant.parse("2025-01-01T00:00:00Z"); + + private void mockPemParsing(String subjectDn) { + X509Certificate x509 = mock(X509Certificate.class); + when(x509.getSubjectX500Principal()).thenReturn(new X500Principal(subjectDn)); + when(x509.getNotBefore()).thenReturn(Date.from(NOT_BEFORE)); + when(x509.getSerialNumber()).thenReturn(new BigInteger("abc123", 16)); + pemUtilMock.when(() -> PemUtil.parseCertificate("CERT_PEM")).thenReturn(x509); + } + + @Test + void signAndRecord_updatesRecordAndCreatesEvent() { + Instant before = Instant.now(); + OrganisationCertificateDTO cert = buildCert(CertificateType.BOOTSTRAP, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(buildSignResponse()); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test,O=NDTP"); + + SignCertResponseDTO result = provider.signAndRecord("CSR", "client-1"); + + assertThat(result.getSerialNumber()).isEqualTo("abc123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + + OrganisationCertificateDTO saved = captor.getValue(); + assertThat(saved.getSerialNumber()).isEqualTo("abc123"); + assertThat(saved.getSubjectDn()).isEqualTo("CN=test,O=NDTP"); + assertThat(saved.getType()).isEqualTo(CertificateType.AUTOMATED); + assertThat(saved.getRequestedAt()).isNotNull(); + assertThat(saved.getRequestedAt().toInstant()).isBetween(before, Instant.now()); + assertThat(saved.getIssuedAt()).isEqualTo(Timestamp.from(NOT_BEFORE)); + assertThat(saved.getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); + + verify(eventService).recordEvent(1L, CertificateType.AUTOMATED, CertificateEventType.RENEWED, "client-1"); + } + + @Test + void signAndRecord_noCertRecord_throwsException() { + when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "unknown")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("No certificate record"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_automationDisabled_throwsException() { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, false, false); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "client-1")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("automation is not enabled"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_notRenewable_throwsException() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, false); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + assertThatThrownBy(() -> provider.signAndRecord("CSR", "client-1")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("not renewable"); + + verifyNoInteractions(vaultPkiService); + } + + @Test + void signAndRecord_alreadyAutomated_staysAutomated() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(buildSignResponse()); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test"); + + provider.signAndRecord("CSR", "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo(CertificateType.AUTOMATED); + } + + @Test + void signAndRecord_parsesExpirationAsEpochSeconds() { + OrganisationCertificateDTO cert = buildCert(CertificateType.AUTOMATED, true, true); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(cert)); + + SignCertResponseDTO response = SignCertResponseDTO.builder() + .certificate("CERT_PEM") + .serialNumber("ser1") + .expiration(1735689600L) + .build(); + when(vaultPkiService.signCsr(eq("CSR"), any(), any())).thenReturn(response); + when(certificateService.save(any())).thenAnswer(inv -> inv.getArgument(0)); + mockPemParsing("CN=test"); + + provider.signAndRecord("CSR", "client-1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService).save(captor.capture()); + assertThat(captor.getValue().getExpiresAt()).isEqualTo(Timestamp.from(Instant.ofEpochSecond(1735689600L))); + } + + private void setupBootstrapMocks(OrganisationCertificateDTO 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()); + 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(10L, BOOTSTRAP_CSR, "test-client"); + + 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.findByOrganisationId(10L)).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(10L, BOOTSTRAP_CSR, "test-client"); + + 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_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, "test-client"); + + 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, "test-client"); + } + + @Test + void issueBootstrapPackage_organisationNotFound_throwsException() { + when(certificateService.findByOrganisationId(999L)).thenReturn(Optional.empty()); + when(organisationRepository.existsById(999L)).thenReturn(false); + + assertThatThrownBy(() -> provider.issueBootstrapPackage(999L, BOOTSTRAP_CSR, "test-client")) + .isInstanceOf(CertificateSigningException.class) + .hasMessageContaining("Organisation not found"); + + 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(10L, BOOTSTRAP_CSR, "test-client"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService, atLeastOnce()).save(captor.capture()); + + OrganisationCertificateDTO saved = + captor.getAllValues().get(captor.getAllValues().size() - 1); + 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(10L, BOOTSTRAP_CSR, "test-client"); + + verify(eventService).recordEvent(1L, CertificateType.BOOTSTRAP, CertificateEventType.ISSUED, "test-client"); + } + + @Test + void issueBootstrapPackage_usesConfiguredTtl() { + OrganisationCertificateDTO cert = buildCert(CertificateType.MANUAL, true, false); + setupBootstrapMocks(cert); + + provider.issueBootstrapPackage(10L, BOOTSTRAP_CSR, "test-client"); + + 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(10L, BOOTSTRAP_CSR, "test-client"); + + assertThat(zip).isNotNull(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OrganisationCertificateDTO.class); + verify(certificateService, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(captor.getAllValues().size() - 1).getType()) + .isEqualTo(CertificateType.BOOTSTRAP); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java new file mode 100644 index 0000000..0eb9a41 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/CertificateValidationProviderImplTest.java @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.OrganisationCertificateDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationCertificateService; + +class CertificateValidationProviderImplTest { + + @Mock + private OrganisationCertificateService certificateService; + + private CertificateValidationProviderImpl provider; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + provider = new CertificateValidationProviderImpl(certificateService); + } + + private OrganisationCertificateDTO cert(Long orgId, Timestamp expiresAt, Timestamp revokedAt) { + return OrganisationCertificateDTO.builder() + .id(orgId) + .organisationId(orgId) + .expiresAt(expiresAt) + .revokedAt(revokedAt) + .build(); + } + + @Test + void findByClientId_delegatesToService() { + OrganisationCertificateDTO dto = + OrganisationCertificateDTO.builder().id(1L).build(); + when(certificateService.findByClientId("client-1")).thenReturn(Optional.of(dto)); + + assertThat(provider.findByClientId("client-1")).isPresent(); + } + + @Test + void findByClientId_unknownClient_returnsEmpty() { + when(certificateService.findByClientId("unknown")).thenReturn(Optional.empty()); + + assertThat(provider.findByClientId("unknown")).isEmpty(); + } + + @Test + void isActive_nullExpiryNullRevocation_returnsTrue() { + assertThat(provider.isActive(cert(1L, null, null))).isTrue(); + } + + @Test + void isActive_futureExpiry_returnsTrue() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, future, null))).isTrue(); + } + + @Test + void isActive_pastExpiry_returnsFalse() { + Timestamp past = Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, past, null))).isFalse(); + } + + @Test + void isActive_pastRevocation_returnsFalse() { + Timestamp revokedAt = Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)); + + assertThat(provider.isActive(cert(1L, null, revokedAt))).isFalse(); + } + + @Test + void isActive_futureRevocation_returnsTrue() { + Timestamp futureRevocation = Timestamp.from(Instant.now().plus(7, ChronoUnit.DAYS)); + + assertThat(provider.isActive(cert(1L, null, futureRevocation))).isTrue(); + } + + @Test + void findActiveOrganisationIds_nullInput_returnsEmptySet() { + assertThat(provider.findActiveOrganisationIds(null)).isEmpty(); + } + + @Test + void findActiveOrganisationIds_emptyInput_returnsEmptySet() { + assertThat(provider.findActiveOrganisationIds(Set.of())).isEmpty(); + } + + @Test + void findActiveOrganisationIds_returnsOnlyActiveOrgs() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + Timestamp past = Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Timestamp revokedAt = Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)); + + OrganisationCertificateDTO active = cert(1L, future, null); + OrganisationCertificateDTO expired = cert(2L, past, null); + OrganisationCertificateDTO revoked = cert(3L, future, revokedAt); + + when(certificateService.findAllByOrganisationIds(Set.of(1L, 2L, 3L))) + .thenReturn(List.of(active, expired, revoked)); + + Set result = provider.findActiveOrganisationIds(Set.of(1L, 2L, 3L)); + + assertThat(result).containsExactly(1L); + } + + @Test + void findActiveOrganisationIds_orgsWithoutCertRecords_excluded() { + Timestamp future = Timestamp.from(Instant.now().plus(30, ChronoUnit.DAYS)); + OrganisationCertificateDTO active = cert(1L, future, null); + + when(certificateService.findAllByOrganisationIds(Set.of(1L, 2L))).thenReturn(List.of(active)); + + Set result = provider.findActiveOrganisationIds(Set.of(1L, 2L)); + + assertThat(result).containsExactly(1L); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java new file mode 100644 index 0000000..4dfc5be --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/certificate/VaultPkiServiceTest.java @@ -0,0 +1,262 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.math.BigInteger; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.security.auth.x500.X500Principal; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.certificates.*; +import uk.gov.dbt.ndtp.ia.node.management.utils.cryptography.PemUtil; + +@ExtendWith(MockitoExtension.class) +class VaultPkiServiceTest { + + @Mock + private VaultTemplate vaultTemplate; + + private VaultPkiService vaultPkiService; + + private static final String PKI_MOUNT = "pki-int"; + + private static final String DEFAULT_ROLE = "default-role"; + + private static final String DEFAULT_TTL = "24h"; + + @BeforeEach + void setUp() { + vaultPkiService = new VaultPkiService(vaultTemplate, PKI_MOUNT, DEFAULT_ROLE, DEFAULT_TTL); + } + + @Test + void createKeyPair_shouldReturnValidKeyPair() { + CreateKeyResponseDTO response = vaultPkiService.createKeyPair("RSA", 2048); + + assertNotNull(response); + assertEquals("RSA", response.getAlgorithm()); + assertTrue(response.getPublicKeyPem().contains("BEGIN PUBLIC KEY")); + assertTrue(response.getPrivateKeyPem().contains("BEGIN PRIVATE KEY")); + } + + @Test + void createKeyPair_withNullAlgorithm_shouldUseDefault() { + CreateKeyResponseDTO response = vaultPkiService.createKeyPair(null, null); + + assertNotNull(response); + assertEquals("RSA", response.getAlgorithm()); + } + + @Test + void createKeyPair_withInvalidAlgorithm_shouldThrowPkiException() { + assertThrows(PkiException.class, () -> vaultPkiService.createKeyPair("INVALID", 1024)); + } + + @Test + void createCsr_shouldReturnValidCsr() { + CreateKeyResponseDTO keyPair = vaultPkiService.createKeyPair("RSA", 2048); + CreateCsrRequestDTO request = CreateCsrRequestDTO.builder() + .commonName("test.example.com") + .organization("Test Org") + .organizationalUnit("Test Unit") + .country("GB") + .publicKeyPem(keyPair.getPublicKeyPem()) + .privateKeyPem(keyPair.getPrivateKeyPem()) + .dnsSans(List.of("alt.example.com")) + .build(); + + CreateCsrResponseDTO response = vaultPkiService.createCsr(request); + + assertNotNull(response); + assertNotNull(response.getCsrId()); + assertTrue(response.getCsrPem().contains("BEGIN CERTIFICATE REQUEST")); + } + + @Test + void createCsr_withInvalidKeys_shouldThrowPkiException() { + CreateCsrRequestDTO request = CreateCsrRequestDTO.builder() + .commonName("test.example.com") + .publicKeyPem("INVALID") + .privateKeyPem("INVALID") + .build(); + + assertThrows(PkiException.class, () -> vaultPkiService.createCsr(request)); + } + + @Test + @SuppressWarnings("unchecked") + void signCsr_shouldReturnSignedCertificate() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String role = "test-role"; + String ttl = "24h"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + role), anyMap())).thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.of(role), Optional.of(ttl)); + + assertNotNull(response); + assertEquals("SIGNED_CERT", response.getCertificate()); + assertEquals("SERIAL_123", response.getSerialNumber()); + + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(Map.class); + verify(vaultTemplate).write(eq(PKI_MOUNT + "/sign/" + role), bodyCaptor.capture()); + Map body = bodyCaptor.getValue(); + assertEquals(csrPem, body.get("csr")); + assertEquals(ttl, body.get("ttl")); + assertEquals("pem", body.get("format")); + } + + @Test + void signCsr_withNullRole_shouldUseDefaultRole() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String ttl = "24h"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + DEFAULT_ROLE), anyMap())) + .thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.empty(), Optional.of(ttl)); + + assertNotNull(response); + verify(vaultTemplate).write(eq(PKI_MOUNT + "/sign/" + DEFAULT_ROLE), anyMap()); + } + + @Test + void signCsr_withNullTtl_shouldUseDefaultTtl() { + String csrPem = "-----BEGIN CERTIFICATE REQUEST-----\n...\n-----END CERTIFICATE REQUEST-----"; + String role = "test-role"; + + VaultResponse vaultResponse = mock(VaultResponse.class); + Map data = Map.of( + "certificate", "SIGNED_CERT", + "ca_chain", List.of("CA1", "CA2"), + "issuing_ca", "ISSUING_CA", + "serial_number", "SERIAL_123", + "expiration", 123456789); + when(vaultResponse.getData()).thenReturn(data); + when(vaultTemplate.write(eq(PKI_MOUNT + "/sign/" + role), anyMap())).thenReturn(vaultResponse); + + SignCertResponseDTO response = vaultPkiService.signCsr(csrPem, Optional.of(role), Optional.empty()); + + assertNotNull(response); + verify(vaultTemplate) + .write( + eq(PKI_MOUNT + "/sign/" + role), + argThat((Map m) -> DEFAULT_TTL.equals(m.get("ttl")))); + } + + @Test + void signCsr_withEmptyCsr_shouldThrowPkiException() { + Optional role = Optional.of("role"); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("", role, ttl)); + } + + @Test + void signCsr_withEmptyRole_shouldThrowPkiException() { + Optional role = Optional.of(""); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("csr", role, ttl)); + } + + @Test + void signCsr_whenVaultReturnsNull_shouldThrowPkiException() { + when(vaultTemplate.write(anyString(), anyMap())).thenReturn(null); + Optional role = Optional.of("role"); + Optional ttl = Optional.of("24h"); + assertThrows(PkiException.class, () -> vaultPkiService.signCsr("csr", role, ttl)); + } + + @Test + void getIntermediateCertificate_shouldReturnCertAndChain() { + String certPem = "-----BEGIN CERTIFICATE-----\nFAKE_CERT\n-----END CERTIFICATE-----"; + + VaultResponse certResp = mock(VaultResponse.class); + when(certResp.getData()).thenReturn(Map.of("certificate", certPem)); + + VaultResponse chainResp = mock(VaultResponse.class); + when(chainResp.getData()).thenReturn(Map.of("ca_chain", List.of("CHAIN_1"))); + + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca")).thenReturn(certResp); + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca_chain")).thenReturn(chainResp); + + X509Certificate mockX509 = mock(X509Certificate.class); + when(mockX509.getSubjectX500Principal()).thenReturn(new X500Principal("CN=test-cert")); + when(mockX509.getIssuerX500Principal()).thenReturn(new X500Principal("CN=root-ca")); + when(mockX509.getSerialNumber()).thenReturn(new BigInteger("12345")); + when(mockX509.getNotBefore()).thenReturn(new Date()); + when(mockX509.getNotAfter()).thenReturn(new Date()); + when(mockX509.getSigAlgName()).thenReturn("SHA256withRSA"); + when(mockX509.getVersion()).thenReturn(3); + + try (MockedStatic mockedPemUtil = mockStatic(PemUtil.class)) { + mockedPemUtil.when(() -> PemUtil.parseCertificate(certPem)).thenReturn(mockX509); + + IntermediateCertResponseDTO response = vaultPkiService.getIntermediateCertificate(); + + assertNotNull(response); + assertEquals(certPem, response.getCertificate()); + assertNotNull(response.getInfo()); + assertEquals("CN=test-cert", response.getInfo().getSubject()); + } + + verify(vaultTemplate).read(PKI_MOUNT + "/cert/ca"); + verify(vaultTemplate).read(PKI_MOUNT + "/cert/ca_chain"); + } + + @Test + void getIntermediateCertificate_whenParsingFails_shouldThrowPkiException() { + VaultResponse certResp = mock(VaultResponse.class); + when(certResp.getData()).thenReturn(Map.of("certificate", "INVALID_PEM")); + + VaultResponse chainResp = mock(VaultResponse.class); + when(chainResp.getData()).thenReturn(Map.of("ca_chain", List.of("CHAIN_1"))); + + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca")).thenReturn(certResp); + when(vaultTemplate.read(PKI_MOUNT + "/cert/ca_chain")).thenReturn(chainResp); + + assertThrows(PkiException.class, () -> vaultPkiService.getIntermediateCertificate()); + } + + @Test + void getIntermediateCertificate_whenVaultReadFails_shouldThrowPkiException() { + when(vaultTemplate.read(anyString())).thenReturn(null); + assertThrows(PkiException.class, () -> vaultPkiService.getIntermediateCertificate()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index 2948fb4..27a429e 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -13,9 +13,12 @@ import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -26,6 +29,7 @@ import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.certificate.CertificateValidationProvider; class ConfigurationProviderImplTest { @@ -38,13 +42,23 @@ class ConfigurationProviderImplTest { @Mock private ProducerService producerService; + @Mock + private CertificateValidationProvider certificateValidationProvider; + @InjectMocks private ConfigurationProviderImpl configurationProvider; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - configurationProvider = new ConfigurationProviderImpl(consumerService, productConsumerService, producerService); + configurationProvider = new ConfigurationProviderImpl( + consumerService, productConsumerService, producerService, certificateValidationProvider); + // Default: treat all orgs as having active certificates, override in specific + // tests to simulate inactive/missing certs. + when(certificateValidationProvider.findActiveOrganisationIds(any())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids != null ? new HashSet<>(ids) : Set.of(); + }); } private ConsumerDTO consumer( @@ -52,6 +66,7 @@ private ConsumerDTO consumer( ConsumerDTO dto = ConsumerDTO.builder() .idpClientId(clientId) .name(name) + .orgId(1L) .scheduleType(scheduleType) .scheduleExpression(scheduleExpression) .build(); @@ -63,6 +78,7 @@ private ProducerDTO producer(long id, boolean active, ProductDTO... products) { ProducerDTO p = ProducerDTO.builder() .id(id) .active(active) + .orgId(1L) .idpClientId("cid") .name("p") .build(); @@ -206,6 +222,33 @@ void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersA assertThat(pr2.getConsumers()).isEmpty(); } + @Test + void getProducerConfigByClientId_whenNoProducersFound_returnsEmptyConfig() { + String clientId = "nonExistentClient"; + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of()); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg).isNotNull(); + assertThat(cfg.getClientId()).isEqualTo(clientId); + assertThat(cfg.getProducers()).isEmpty(); + } + + @Test + void getConsumerConfigByClientId_whenNoConsumersFound_returnsEmptyConfig() { + String clientId = "nonExistentClient"; + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of()); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg).isNotNull(); + assertThat(cfg.getClientId()).isEqualTo(clientId); + assertThat(cfg.getProducers()).isEmpty(); + assertThat(cfg.getName()).isNull(); + assertThat(cfg.getScheduleType()).isNull(); + assertThat(cfg.getScheduleExpression()).isNull(); + } + @Test void getProducerConfigByClientId_withValidValidity_includesConsumer() { String clientId = "producerClient"; @@ -270,4 +313,59 @@ void getProducerConfigByClientId_withInvalidValidity_filtersOutConsumer() { assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) .isEmpty(); } + + @Test + void getConsumerConfig_filtersOutProducersWithInactiveCerts() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); + + ProductConsumerDTO pc = productConsumer(100L, 1L, null, null); + when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of(pc)); + + // Two active producers with different orgIds + ProducerDTO activeOrgProducer = producer(10L, true, product(100L, "dp-100")); + activeOrgProducer.setOrgId(100L); + ProducerDTO inactiveOrgProducer = producer(11L, true, product(100L, "dp-100")); + inactiveOrgProducer.setOrgId(200L); + when(producerService.getProducersByConsumerIds(List.of(1L))) + .thenReturn(List.of(activeOrgProducer, inactiveOrgProducer)); + + // Only org 100 has an active certificate + when(certificateValidationProvider.findActiveOrganisationIds(Set.of(100L, 200L))) + .thenReturn(Set.of(100L)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers()).containsExactly(activeOrgProducer); + } + + @Test + void getProducerConfig_filtersOutConsumersWithInactiveCerts() { + String clientId = "clientP"; + ProductDTO p1 = product(900L, "prov1"); + ProducerDTO pr1 = producer(91L, true, p1); + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(pr1)); + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + + ProductConsumerDTO cp1 = productConsumer(900L, 501L, null, null); + ProductConsumerDTO cp2 = productConsumer(900L, 502L, null, null); + when(productConsumerService.findByDataProviderId(900L)).thenReturn(List.of(cp1, cp2)); + + ConsumerDTO activeOrgConsumer = consumer(501L, "cid501", "c501", "CRON", "@hourly"); + activeOrgConsumer.setOrgId(300L); + ConsumerDTO inactiveOrgConsumer = consumer(502L, "cid502", "c502", "CRON", "@hourly"); + inactiveOrgConsumer.setOrgId(400L); + when(consumerService.findById(501L)).thenReturn(Optional.of(activeOrgConsumer)); + when(consumerService.findById(502L)).thenReturn(Optional.of(inactiveOrgConsumer)); + + // Only org 300 has an active certificate + when(certificateValidationProvider.findActiveOrganisationIds(Set.of(300L, 400L))) + .thenReturn(Set.of(300L)); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConsumers()) + .containsExactly(activeOrgConsumer); + } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java new file mode 100644 index 0000000..3963e67 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/utils/cryptography/PemUtilTest.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2026. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.utils.cryptography; + +import static org.junit.jupiter.api.Assertions.*; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import org.junit.jupiter.api.Test; +import uk.gov.dbt.ndtp.ia.node.management.exception.PkiException; + +class PemUtilTest { + + @Test + void parsePublicKey_withInvalidPem_shouldThrowPkiException() { + String invalidPem = "-----BEGIN PUBLIC KEY-----\nINVALID\n-----END PUBLIC KEY-----\n"; + assertThrows(PkiException.class, () -> PemUtil.parsePublicKey(invalidPem)); + } + + @Test + void parseCertificate_withInvalidPem_shouldThrowPkiException() { + String invalidPem = "-----BEGIN CERTIFICATE-----\nINVALID\n-----END CERTIFICATE-----\n"; + assertThrows(PkiException.class, () -> PemUtil.parseCertificate(invalidPem)); + } + + @Test + void roundTripKeyParsing_shouldSucceed() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + PrivateKey priv = kp.getPrivate(); + PublicKey pub = kp.getPublic(); + + String privPem = PemUtil.toPem("PRIVATE KEY", priv.getEncoded()); + String pubPem = PemUtil.toPem("PUBLIC KEY", pub.getEncoded()); + + PrivateKey parsedPriv = PemUtil.parsePkcs8PrivateKey(privPem); + PublicKey parsedPub = PemUtil.parsePublicKey(pubPem); + + assertEquals(priv.getAlgorithm(), parsedPriv.getAlgorithm()); + assertEquals(pub.getAlgorithm(), parsedPub.getAlgorithm()); + assertArrayEquals(priv.getEncoded(), parsedPriv.getEncoded()); + assertArrayEquals(pub.getEncoded(), parsedPub.getEncoded()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 61b6538..a531ac8 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,9 @@ spring: dialect: org.hibernate.dialect.H2Dialect show_sql: false format_sql: false +application: + vault: + pki-mount: pki-int server: port: 0 logging: