diff --git a/.github/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/pull_request_template.md rename to .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 5a1c2c5..d06d31e 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -29,17 +29,22 @@ on: - 'main' workflow_call: -permissions: - contents: read + env: MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" jobs: build: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v5 + with: + # Full history is recommended for accurate Sonar analysis and PR decoration + fetch-depth: 0 - name: Set up JDK 21 uses: actions/setup-java@v5 with: @@ -51,14 +56,17 @@ jobs: env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} run: ./mvnw $MAVEN_CLI_OPTS verify - - name: Upload JaCoCo HTML report - if: always() - uses: actions/upload-artifact@v4 - with: - name: jacoco-report - path: target/site/jacoco - if-no-files-found: warn + - name: Code Coverage + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./mvnw $MAVEN_CLI_OPTS verify -Dsonar.projectKey=National-Digital-Twin_management-node -Dsonar.organization=national-digital-twin -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml org.sonarsource.scanner.maven:sonar-maven-plugin:sonar + - name: Verify JaCoCo XML exists + run: ls -l target/site/jacoco/jacoco.xml lint: + permissions: + contents: read + pull-requests: read runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -72,5 +80,4 @@ jobs: - name: Lint env: GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} - run: ./mvnw $MAVEN_CLI_OPTS spotless:check - + run: ./mvnw $MAVEN_CLI_OPTS spotless:check \ No newline at end of file diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml index 65b5f1b..6033c7d 100644 --- a/.github/workflows/oss-checker.yml +++ b/.github/workflows/oss-checker.yml @@ -4,19 +4,32 @@ name: Run OSS check helper on: + pull_request: + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled workflow_dispatch: -permissions: - contents: read - jobs: oss-checks: + permissions: + contents: read + if: github.actor != 'dependabot[bot]' && + (github.event.repository.private == false || + (github.event.repository.private == true && + contains(join(github.event.pull_request.labels.*.name), 'oss-preparation'))) runs-on: ubuntu-latest + outputs: + summary-table: ${{ steps.summarise_results.outputs.summaryTable }} + has-results: ${{ steps.summarise_results.outputs.hasResults }} steps: - name: Fetch GitHub App token for target repo id: target_token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -24,7 +37,7 @@ jobs: - name: Fetch GitHub App token for OSPO source repo (read-only) id: ospo_token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} @@ -33,95 +46,502 @@ jobs: permission-contents: read - name: Checkout target repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.target_token.outputs.token }} - name: Checkout OSPO source repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: National-Digital-Twin/ospo-resources path: ospo-resources token: ${{ steps.ospo_token.outputs.token }} - name: Checkout archetypes source repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: National-Digital-Twin/archetypes path: archetypes - - name: Test for presence of OSS files and variation from templated content + - name: Fetch Repository Metadata + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + with: + script: | + const { owner, repo } = context.repo; + const { writeFileSync } = require('fs'); + + // Check specifically for 'develop' branch existence + let hasDevelopBranch = false; + try { + await github.rest.repos.getBranch({ + owner, + repo, + branch: 'develop', + }); + hasDevelopBranch = true; + } catch (error) { + if (error.status !== 404) { + core.warning(`Error checking for develop branch: ${error.message}`); + } + } + + const metadata = { + repository: { + defaultBranch: process.env.DEFAULT_BRANCH, + hasDevelopBranch: hasDevelopBranch + } + }; + + const rawMetadata = JSON.stringify(metadata, null, 2); + + core.info('Content for repository-metadata.json:'); + core.info(rawMetadata); + + writeFileSync('repository-metadata.json', rawMetadata); + core.info('Generated repository-metadata.json for policy context.'); + + - name: Install Conftest run: | - missing_files=() - unchanged_files=() - - while IFS= read -r file || [ -n "$file" ]; do - # Skip comments and empty lines - if [[ -z "$file" || "$file" == \#* ]]; then - continue - fi - - target_path="$file" - archetypes_path="archetypes/$file" - - if [ ! -f "$target_path" ]; then - echo "Missing OSS file in target repository: $target_path" - missing_files+=("$file") - elif cmp -s "$target_path" "$archetypes_path"; then - echo "OSS file unchanged from archetypes template: $target_path" - unchanged_files+=("$file") - else - echo "OSS file present and different from the archetypes template: $target_path" - fi - done < ospo-resources/oss-checklist-files.txt - - echo "" - if [ ${#missing_files[@]} -ne 0 ]; then - echo "The following OSS required files are missing:" - printf '%s\n' "${missing_files[@]}" - fi - - if [ ${#unchanged_files[@]} -ne 0 ]; then - echo "The following OSS required files are unchanged from the archetypes template:" - printf '%s\n' "${unchanged_files[@]}" - fi - - if [ ${#missing_files[@]} -ne 0 ] || [ ${#unchanged_files[@]} -ne 0 ]; then - echo "OSS required file check failed." - exit 1 - else - echo "All OSS files are present and have been updated from their original templated content." - fi + LATEST_VERSION=$(curl --proto "=https" -s "https://api.github.com/repos/open-policy-agent/conftest/releases/latest" | grep -Po '"tag_name": "v\K[0-9.]+') + curl --proto "=https" -L "https://github.com/open-policy-agent/conftest/releases/download/v${LATEST_VERSION}/conftest_${LATEST_VERSION}_Linux_x86_64.tar.gz" | tar -xz + sudo mv conftest /usr/local/bin/ - - name: Check GitHub template files are present + - name: Run Policy Checks + id: run_conftest run: | - echo "Checking for pull request and issue template files" - - missing_templates=() - - files_to_check=( - ".github/PULL_REQUEST_TEMPLATE.md" - ".github/ISSUE_TEMPLATE/bug_report.md" - ".github/ISSUE_TEMPLATE/feature_request.md" - ) - - for file in "${files_to_check[@]}"; do - if [ ! -f "$file" ]; then - missing_templates+=("$file") - fi - done - - if [ ${#missing_templates[@]} -ne 0 ]; then - echo "" - echo "Required GitHub template files not found:" - printf ' - %s\n' "${missing_templates[@]}" - echo "" - echo "These files help improve project collaboration and are considered best practice." - echo "These need to be included in repository contents to improve the developer and repository consumer experience." - - # Fail the job - echo "Missing required GitHub template files." - exit 1 - else - echo "Required pull request and issue template files present." - fi + conftest test .github/dependabot.yml \ + -p ospo-resources/tools/policy-as-code/policy \ + --data repository-metadata.json \ + --namespace github.dependabot \ + --output json > policy-report.json || true + + - name: Process Policy Results + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { existsSync, readFileSync, writeFileSync } = require('fs'); + + let resultJson = []; + if (existsSync('policy-report.json')) { + try { + const rawContent = readFileSync('policy-report.json', 'utf8'); + if (rawContent.trim()) { + resultJson = JSON.parse(rawContent); + } + } catch(e) { + core.error(`Failed to parse policy-report.json: ${e.message}`); + core.setFailed(`Failed to parse policy-report.json: ${e.message}`); + return; + } + } + + const results = resultJson.map(r => { + const failureReasons = (r.failures || []).map(f => f.msg); + return { + path: r.filename, + status: failureReasons.length > 0 ? 'failed' : 'passed', + failureReasons: failureReasons, + checks: { + namespace: r.namespace, + successes: r.successes + } + }; + }); + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const commitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown'; + + const report = { + runMetadata: { + timestamp: new Date().toISOString(), + repo: repoFullName, + commit: commitSha, + checkType: 'policy', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'policy-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote policy summary to ${reportPath}`); + + if (failed > 0) { + core.setFailed('Policy checks failed for one or more files.'); + } else { + core.info('All policy checks passed.'); + } + + - name: Test for presence of OSS files and variation from templated content + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + if: success() || failure() + with: + script: | + const { existsSync, readFileSync, writeFileSync } = require('fs'); + + const checklistPath = 'ospo-resources/oss-checklist-files.txt'; + const checklist = readFileSync(checklistPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + const results = []; + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const commitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown'; + + for (const relativePath of checklist) { + const record = { + path: relativePath, + status: 'passed', + checks: { + exists: false, + differsFromTemplate: null, + }, + failureReasons: [], + }; + + const targetPath = relativePath; + const archetypePath = `archetypes/${relativePath}`; + + const fileExists = existsSync(targetPath); + record.checks.exists = fileExists; + + if (!fileExists) { + record.status = 'failed'; + record.failureReasons.push('missing or misnamed'); + core.info(`Missing or misnamed OSS file in target repository: ${targetPath}`); + results.push(record); + continue; + } + + const targetContent = readFileSync(targetPath, 'utf8'); + + if (existsSync(archetypePath)) { + const archetypeContent = readFileSync(archetypePath, 'utf8'); + const differsFromTemplate = targetContent !== archetypeContent; + record.checks.differsFromTemplate = differsFromTemplate; + + if (!differsFromTemplate) { + record.failureReasons.push('unchanged from archetype template'); + core.info(`OSS file unchanged from archetypes template: ${targetPath}`); + } else { + core.info(`OSS file present and different from the archetypes template: ${targetPath}`); + } + } else { + record.checks.differsFromTemplate = null; + core.info(`Template file missing for ${relativePath}; skipping template comparison.`); + } + + record.status = record.failureReasons.length > 0 ? 'failed' : 'passed'; + results.push(record); + } + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const report = { + runMetadata: { + checklistFile: checklistPath, + timestamp: new Date().toISOString(), + repo: repoFullName, + commit: commitSha, + checkType: 'OSS', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'oss-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote checklist summary to ${reportPath}`); + + if (failed > 0) { + const failedFiles = results + .filter((result) => result.status === 'failed') + .map((result) => result.path); + core.setFailed(`The following files failed checks:\n${failedFiles.join('\n')}`); + } else { + core.info('All OSS files are present and have been updated from their original templated content.'); + } + + - name: Check GitHub template files are present + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + if: success() || failure() + with: + script: | + const { existsSync, writeFileSync } = require('fs'); + + core.info('Checking for pull request and issue template files'); + + const filesToCheck = [ + '.github/PULL_REQUEST_TEMPLATE.md', + '.github/ISSUE_TEMPLATE/bug_report.md', + '.github/ISSUE_TEMPLATE/feature_request.md', + ]; + + const results = filesToCheck.map((filePath) => { + const exists = existsSync(filePath); + return { + path: filePath, + status: exists ? 'passed' : 'failed', + checks: { + exists, + differsFromTemplate: null, + }, + failureReasons: exists ? [] : ['missing or misnamed'], + }; + }); + + const passed = results.filter((result) => result.status === 'passed').length; + const failed = results.length - passed; + const score = results.length > 0 ? Number((passed / results.length).toFixed(2)) : 0; + + const prHead = context.payload.pull_request?.head; + const report = { + runMetadata: { + timestamp: new Date().toISOString(), + repo: prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown', + commit: prHead?.sha ?? process.env.GITHUB_SHA ?? 'unknown', + checkType: 'template', + }, + files: results, + summary: { + total: results.length, + passed, + failed, + score, + }, + }; + + const reportPath = 'template-results.json'; + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + core.info(`Wrote template checklist summary to ${reportPath}`); + + if (failed > 0) { + const missingTemplates = results + .filter((result) => result.status === 'failed') + .map((result) => result.path); + + core.info(''); + core.info('Required GitHub template files were not found or did not match expected casing:'); + missingTemplates.forEach((file) => core.info(` - ${file}`)); + core.info(''); + core.info('These files help improve project collaboration and are considered best practice.'); + core.info('These need to be included in repository contents to improve the developer and repository consumer experience.'); + core.setFailed('Missing or misnamed GitHub template files.'); + } else { + core.info('Required pull request and issue template files present.'); + } + + - name: Generate summary + id: summarise_results + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { existsSync, readFileSync } = require('fs'); + + const reportFiles = [ + 'oss-results.json', + 'template-results.json', + 'policy-results.json', + ]; + + const reports = reportFiles + .filter((reportPath) => { + const present = existsSync(reportPath); + if (!present) { + core.info(`Summary step skipping missing report: ${reportPath}`); + } + return present; + }) + .map((reportPath) => JSON.parse(readFileSync(reportPath, 'utf8'))); + + if (reports.length === 0) { + core.info('No report files found; skipping combined summary.'); + core.setOutput('hasResults', 'false'); + return; + } + + const allResults = reports.flatMap((report) => + report.files.map((file) => ({ + ...file, + category: report.runMetadata?.checkType ?? 'unknown', + repo: report.runMetadata?.repo ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown', + commit: report.runMetadata?.commit ?? process.env.GITHUB_SHA ?? 'unknown', + })), + ); + + const combinedTableMarkdown = [ + '| ๐Ÿ“„ File | โœ… Result | ๐Ÿงพ Details |', + '| :--- | :---: | :--- |', + ...allResults.map((result) => { + const href = `https://github.com/${result.repo}/blob/${result.commit}/${result.path}`; + const details = result.failureReasons.length > 0 + ? result.failureReasons.join('; ') + : 'Compliant'; + const statusLabel = result.status === 'passed' ? '๐ŸŸข Pass' : '๐Ÿ”ด Fail'; + return `| [${result.path}](${href}) | ${statusLabel} | ${details} |`; + }), + ].join('\n'); + + const total = allResults.length; + const passed = allResults.filter((result) => result.status === 'passed').length; + const failed = total - passed; + const score = total > 0 ? (passed / total) * 100 : 0; + const summary = { total, passed, failed, score }; + + const overallStatus = summary.failed === 0 + ? '๐ŸŽ‰ Overall status: PASS (all files compliant).' + : 'โš ๏ธ Overall status: FAIL (see table below for details).'; + + const summaryMarkdown = [ + '| ๐Ÿ“Š Total Files | ๐ŸŸข Passed | ๐Ÿ”ด Failed | ๐Ÿงฎ Score |', + '| ---: | ---: | ---: | ---: |', + `| ${summary.total} | ${summary.passed} | ${summary.failed} | ${summary.score.toFixed(0)}% |` + ].join('\n'); + + const prHead = context.payload.pull_request?.head; + const repoFullName = prHead?.repo?.full_name ?? process.env.GITHUB_REPOSITORY ?? 'unknown/unknown'; + const fullSha = prHead?.sha ?? process.env.GITHUB_SHA ?? ''; + const shortSha = fullSha?.slice(0, 7) ?? 'unknown'; + const commitUrl = fullSha + ? `https://github.com/${repoFullName}/commit/${fullSha}` + : null; + const commitLine = commitUrl + ? `Results from commit [\`${shortSha}\`](${commitUrl}).` + : `Results from commit \`${shortSha}\`.`; + + await core.summary + .addRaw('# OSS Check Results โš™๏ธ\n', true) + .addRaw(`\n${combinedTableMarkdown}\n`, true) + .addRaw('\n# Summary ๐Ÿ\n', true) + .addRaw(`\n${overallStatus}\n`, true) + .addRaw(`\n${summaryMarkdown}\n`, true) + .addRaw(`\n${commitLine}\n`, true) + .write(); + + core.setOutput('hasResults', 'true'); + core.setOutput('summaryTable', summaryMarkdown); + + if (summary.failed > 0) { + core.setFailed('OSS checks detected one or more failing files.'); + } + + - name: Upload OSS result artifacts + if: ${{ steps.summarise_results.outputs.hasResults == 'true' }} + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: oss-checks-${{ github.run_id }} + retention-days: 30 + path: | + oss-results.json + template-results.json + policy-results.json + + comment-on-results: + needs: oss-checks + if: >- + always() && + github.event_name == 'pull_request' && + needs.oss-checks.outputs.has-results == 'true' + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + steps: + - name: Comment with OSS summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + SUMMARY_TABLE: ${{ needs.oss-checks.outputs.summary-table }} + JOB_RESULT: ${{ needs.oss-checks.result }} + with: + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request?.number; + + if (!prNumber) { + core.info('No pull request context; skipping comment step.'); + return; + } + + const jobSummaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`; + const prHead = context.payload.pull_request?.head; + const headCommitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? ''; + const shortSha = headCommitSha ? headCommitSha.slice(0, 7) : 'unknown'; + const runResult = process.env.JOB_RESULT?.toLowerCase() ?? ''; + const isFailure = runResult === 'failure'; + + const marker = ''; + const heading = isFailure + ? '## โš ๏ธ OSS Checks Failed' + : '## โœ… OSS Checks Passed'; + const narration = isFailure + ? 'One or more OSS checks failed in this run.' + : 'All tracked OSS checks passed in this run.'; + + const bodySections = [ + heading, + narration, + process.env.SUMMARY_TABLE, + `Results from commit ${shortSha}, view the full [job summaryโ†—๏ธ](${jobSummaryUrl}) for detailed results.` + ]; + + const existingComments = await github.paginate( + github.rest.issues.listComments, + { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }, + ); + + const previous = existingComments.find((comment) => + comment.body?.includes(marker), + ); + + if(previous) { + bodySections.push(':recycle: This comment has been updated with latest results.'); + } + + const body = `${marker}\n${bodySections.join('\n\n')}\n${marker}`; + + if (previous) { + core.info(`Updating existing OSS summary comment (${previous.id}).`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + } else { + core.info('Creating new OSS summary comment.'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 9449b6c..9832c9d 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -2,7 +2,7 @@ # ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. # This workflow is triggered when a pull request is merged into the main branch -# from a release/* branch. It extracts the release version from the source branch, +# from a release/* or hotfix/* branch. It extracts the release version from the source branch, # generates a Software Bill of Materials (SBOM) using the GitHub API, # creates a Git tag with the version, and publishes a GitHub release including the SBOM file. @@ -15,12 +15,13 @@ on: branches: - main -permissions: - contents: write - jobs: versioning: - if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') + if: | + github.event.pull_request.merged == true && + (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) + permissions: + contents: read name: Extract Release Version runs-on: ubuntu-latest outputs: @@ -28,8 +29,10 @@ jobs: steps: - name: Extract Version from Source Branch Name id: extract_version + env: + HEAD_REF: ${{ github.head_ref }} run: | - SOURCE_BRANCH="${{ github.head_ref }}" + SOURCE_BRANCH="$HEAD_REF" VERSION=$(echo "$SOURCE_BRANCH" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+') if [ -z "$VERSION" ]; then @@ -41,71 +44,90 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Validate Version Format (Semantic Versioning) + env: + VERSION: ${{ env.VERSION }} run: | - if [[ ! "${{ env.VERSION }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Invalid version format found. Expected semantic version in release branch name (e.g., release/0.9.0)" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format found. Expected semantic version in release or hotfix branch name (e.g., release/0.9.0 or hotfix/0.9.1)" exit 1 fi - name: Print Tag Version + id: print_tag + env: + EXTRACTED_VERSION: ${{ steps.extract_version.outputs.version }} run: | - echo "Identified release semantic version: ${{ steps.extract_version.outputs.version }}" + echo "Identified release semantic version: $EXTRACTED_VERSION" generate-sbom: + permissions: + contents: read name: Generate SPDX SBOM runs-on: ubuntu-latest needs: [versioning] steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate SPDX SBOM + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} run: | # Call GitHub API to generate SBOM api_response=$(curl -sSL \ -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "$GITHUB_API_URL/repos/${{ github.repository }}/dependency-graph/sbom") + "$GITHUB_API_URL/repos/$REPO/dependency-graph/sbom") # Extract nested "sbom" object into a valid SPDX file echo "$api_response" | jq '.sbom' > sbom.spdx.json - name: Upload SBOM Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: sbom path: sbom.spdx.json create-git-tag: + permissions: + contents: write name: Create Git Tag needs: [versioning, generate-sbom] runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Create Git Tag - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1.7.2 with: tag: "v${{ needs.versioning.outputs.version }}" message: "Release v${{ needs.versioning.outputs.version }}" force_push_tag: true + # Tag the HEAD commit from the merged release branch not the merge commit to + # ensure the tag points to the correct source code state for the release. + # This ensures that the release tag is also visible on any branch which does + # not contain the merge commit such as develop. + commit_sha: ${{ github.event.pull_request.head.sha }} create-git-release: + permissions: + contents: write name: Create GitHub Release needs: [versioning, generate-sbom, create-git-tag] runs-on: ubuntu-latest steps: - name: Download SBOM Artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: sbom - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: tag_name: "v${{ needs.versioning.outputs.version }}" name: "Release v${{ needs.versioning.outputs.version }}" diff --git a/.github/workflows/publish-mkdocs.yml b/.github/workflows/publish-mkdocs.yml new file mode 100644 index 0000000..6f4c014 --- /dev/null +++ b/.github/workflows/publish-mkdocs.yml @@ -0,0 +1,106 @@ +name: publish mkdocs +on: + pull_request: + types: [closed] + branches: + - develop + - main + workflow_dispatch: +permissions: + contents: write + pages: write +jobs: + versioning: + if: ${{ github.event.pull_request.merged == true && (github.event.pull_request.base.ref == 'develop' || github.event.pull_request.base.ref == 'main') }} + name: Extract Release Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract_version.outputs.version }} + is_release: ${{ steps.extract_version.outputs.is_release }} + steps: + - name: Extract Version from Source/Target Branch + id: extract_version + run: | + SOURCE_BRANCH="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + TARGET_BRANCH="${GITHUB_BASE_REF}" + + if [[ "$TARGET_BRANCH" == "develop" ]]; then + # When targeting develop, version should be the target branch name itself + VERSION="$TARGET_BRANCH" + else + # Target is main (per job condition). Keep existing behavior based on the source branch + # If branch contains a slash, take the second segment; otherwise use the whole branch name + if [[ "$SOURCE_BRANCH" == *"/"* ]]; then + VERSION="$(echo "$SOURCE_BRANCH" | cut -d'/' -f2)" + else + VERSION="$SOURCE_BRANCH" + fi + + # If this is a release branch, trim to major.minor (e.g., 1.2.3 -> 1.2) + if [[ "$SOURCE_BRANCH" == release/* ]]; then + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + if [[ -n "$MAJOR" && -n "$MINOR" ]]; then + VERSION="${MAJOR}.${MINOR}.x" + fi + fi + fi + + if [ -z "$VERSION" ]; then + echo "Error: No semantic release version found. Source: $SOURCE_BRANCH, Target: $TARGET_BRANCH" + exit 1 + fi + + # Determine if this is a release branch (source starts with release/) + IS_RELEASE="false" + if [[ "$SOURCE_BRANCH" == release/* ]]; then + IS_RELEASE="true" + fi + + # Expose outputs for downstream steps/jobs + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "is_release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Print Tag Version + run: | + echo "Identified release semantic version: ${{ steps.extract_version.outputs.version }}" + + deploy: + runs-on: ubuntu-latest + needs: [ versioning ] + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.merge_commit_sha }} + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + - name: Configure Git user for mike + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + - uses: actions/cache@v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: ~/.cache + restore-keys: | + mkdocs-material- + - run: pip install mkdocs-material + - run: pip install mkdocs-git-revision-date-localized-plugin + - run: pip install mkdocs-git-committers-plugin-2 + - run: pip install Pygments + - run: pip install mkdocs-include-markdown-plugin + - run: pip install pymdown-extensions + - run: pip install mike + - run: pip install mkdocs-open-in-new-tab==1.0.8 + - name: Deploy docs without latest alias + if: ${{ needs.versioning.outputs.is_release != 'true' }} + run: mike deploy ${{ needs.versioning.outputs.version }} --push + - name: Deploy docs with latest alias + if: ${{ needs.versioning.outputs.is_release == 'true' }} + run: mike deploy ${{ needs.versioning.outputs.version }} latest --push --update-aliases + - name: Set default latest + if: ${{ needs.versioning.outputs.is_release == 'true' }} + run: mike set-default --push latest + diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fc39819..ed437c8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -37,14 +37,13 @@ on: default: false type: boolean -permissions: - contents: read - packages: write - id-token: write - jobs: # Make sure that the current code runs verify: + permissions: + contents: read + packages: write + id-token: write runs-on: ubuntu-latest outputs: project_version: ${{ steps.get-version.outputs.project_version }} @@ -74,6 +73,10 @@ jobs: publish: + permissions: + contents: read + packages: write + id-token: write name: Publish to github packages needs: verify runs-on: ubuntu-latest @@ -95,6 +98,10 @@ jobs: run: ./mvnw $MAVEN_CLI_OPTS package -DskipTests release-ghcr: + permissions: + contents: read + packages: write + id-token: write name: "Build and release docker images to GHCR with tags '${{ inputs.image_tag }}, latest'" needs: verify uses: ./.github/workflows/docker-ghcr.yml @@ -106,6 +113,11 @@ jobs: docker_target: management-node cleanup: + permissions: + contents: read + packages: write + id-token: write + name: Artifact cleanup runs-on: ubuntu-latest needs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1040ff1..680bf05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,29 @@ This project follows **Semantic Versioning (SemVer)** ([semver.org](https://semv --- +## [1.1.0] - 2026-02-20 +### Added +- Support for job scheduling with `schedule_type` and `schedule_expression` fields in configurations. +- `ProductType` domain entity and expanded `Product`, `Consumer`, and `ProductConsumer` models. +- Comprehensive documentation site using MkDocs, including setup guides, architecture overview, and API documentation. +- GitHub Actions workflows for: + - SonarCloud static code analysis and quality gate verification. + - Automated Docker image builds and deployment to GitHub Container Registry (GHCR). + - MkDocs documentation publishing. + - Automated release processes and tagging. +- Keycloak realm configuration for local development and testing. + +### Changed +- Refactored `KeycloakJwtAuthenticationConverter` to remove client secret dependency and improve security. +- Updated Maven workflow to include SonarCloud analysis and optimized JaCoCo reporting phases. +- Enhanced GitHub workflows with job-level permission definitions for improved security. +- Standardized pull request templates and repository metadata. +- Improved local development setup documentation and scripts. + +### Removed +- `OrganisationServiceImpl` and related tests, streamlining the service layer. +- Redundant Maven settings references in CI workflows. ## [1.0.1] - 2025-10-1 diff --git a/README.md b/README.md index fb2bd14..f1d9996 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,33 @@ For a full description of the database tables, relationships, and constraints, s - Docker and Docker Compose - OpenSSL (for certificate generation) - Keycloak (for authentication and authorization) - +- The below assumes your running in Linux - bash, it has been tested under WSL2. --- ## Quick Start +Note. see lower for setting up prerequisites for local deployment certs, keycloak etc. ### Run the Spring Boot application This project is a Spring Boot application. You can run it by supplying configuration via either: -- A default application.yml (or application.yaml) file, or +- A default application.yml file, or - A profile-specific file application-{profile}.yml and passing the profile argument at startup. Quick options: 1. Provide a default config: - - Create src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. - - Run: + - Modify src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. + - Run: (change to suit your local if different) + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` ```bash mvn spring-boot:run ``` or: ```bash - java -jar target/management-node-0.0.1.jar + java -jar target/management-node-1.0.1.jar ``` 2. Use a profile-specific config: @@ -56,7 +61,7 @@ Quick options: ``` or: ```bash - java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + java -jar target/management-node-1.0.1.jar --spring.profiles.active=local ``` - You can also set the environment variable: ```bash @@ -69,70 +74,11 @@ Notes: ```bash java -jar target/management-node-0.0.1.jar --spring.config.location=/path/to/your.yml ``` -- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). - -### Setting up Keycloak with Docker Compose +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). -The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: -1. Navigate to the docker directory: - ```bash - cd docker - ``` - -2. Make sure you have the required certificates in the `docker` directory: - - `keystore.jks` - Java keystore containing the server certificate - - `truststore.jks` - Java truststore containing trusted certificates - - `localhost.p12` - PKCS12 keystore for client authentication - - `localhost.crt` - Certificate file - - `localhost.key` - Private key file - - If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. - -3. Start Keycloak and PostgreSQL using Docker Compose: - ```bash - docker compose -f keycloak/docker-compose.yml up -d - ``` - -4. Verify that Keycloak is running: - ```bash - curl -k https://localhost:8443/health - ``` - -5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: - - Username: `admin` - - Password: `password` - -### Configuration - -For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: - -``` -POSTGRES_DB=keycloak_db -POSTGRES_USER=keycloak_db_user -POSTGRES_PASSWORD=keycloak_db_user_password -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=password -KC_HOSTNAME_STRICT_BACKCHANNEL=false -SERVER_SSL_KEY_STORE_PASSWORD=changeit -SERVER_SSL_TRUST_STORE_PASSWORD=changeit -KC_HTTPS_KEY_STORE_PASSWORD=changeit -KC_HTTPS_TRUST_STORE_PASSWORD=changeit -KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit -KC_HOSTNAME=keycloak -KC_HOSTNAME_PORT=8080 -KC_HTTP_ENABLED=false -KC_HOSTNAME_STRICT_HTTPS=false -KC_HEALTH_ENABLED=true -KC_DB=postgres -KC_HTTPS_CLIENT_AUTH=required -KC_HTTPS_ENABLED=true -KC_HTTPS_PORT=8443 -KC_LOG_LEVEL=INFO -``` - -This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. +# Prerequisites setup ## Certificate Setup The Management Node Module implements a zero-trust security architecture using Mutual TLS (MTLS) for secure communication between all components. This section explains why certificates are needed, how to generate them, and where they are used in the system. @@ -178,6 +124,11 @@ The system requires several certificate files: For development purposes, follow these steps to generate certificates for mTLS. All passwords used are `changeit`. When generating these certficates, for the `Country Name`, you can use the value of 'UK'. All remaining certificate fields can be left to their default values. +move to the docker folder +```bash +cd docker +``` + 1. **Generate a Root CA certificate**: ```bash openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt @@ -191,12 +142,8 @@ For development purposes, follow these steps to generate certificates for mTLS. This creates a private key and certificate signing request (CSR) for the host. 3. **Sign the host certificate with the Root CA**: - ```bash - openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext - ``` - This signs the host CSR with the Root CA, creating a certificate valid for 365 days. - - The content of the `localhost.ext` file should be: + + Create a file called `localhost.ext` file should contain: ``` authorityKeyIdentifier=keyid,issuer basicConstraints=CA:FALSE @@ -205,6 +152,13 @@ For development purposes, follow these steps to generate certificates for mTLS. DNS.1 = localhost DNS.2 = keycloak ``` + + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + ``` + This signs the host CSR with the Root CA, creating a certificate valid for 365 days. + + This configuration specifies that the certificate is valid for both `localhost` and `keycloak` hostnames. 4. **Create a PKCS12 keystore for the server**: @@ -243,30 +197,43 @@ For development purposes, follow these steps to generate certificates for mTLS. ``` This bundles the client certificate and private key into a PKCS12 format for use in browsers or client applications. -10. **Create a Java keystore using keytool**: +10. **Create a Java keystore using keytool** (PKCS12 format, compatible with modern Java): ```bash - keytool -importkeystore -destkeystore keystore.jks -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + keytool -importkeystore -destkeystore keystore.jks -deststoretype PKCS12 -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" ``` - This converts the PKCS12 keystore to a Java KeyStore (JKS) format used by Java applications. + This converts the PKCS12 keystore. Note: Despite the `.jks` extension, modern keytool creates PKCS12 format by default, which is more secure and standard. -11. **Create a Java truststore using keytool**: +11. **Create a Java truststore using keytool** (PKCS12 format): ```bash - keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks -storetype PKCS12 ``` - This creates a truststore containing the Root CA certificate, which will be used to validate client certificates. + This creates a truststore containing the Root CA certificate in PKCS12 format, which will be used to validate client certificates. -12. **Import the Root CA into the truststore**: +12. **Verify the truststore** (optional but recommended): ```bash - keytool -importcert -file rootCA.crt -alias rootCA -keystore truststore.jks -storetype JKS + keytool -list -keystore truststore.jks -storetype PKCS12 -storepass changeit ``` - This ensures the Root CA is properly imported into the Java truststore. + This verifies that the Root CA is properly imported into the truststore. ### Certificate Placement and Configuration After generating the certificates, place them in the appropriate locations: +if you've followed the above then follow with +```bash +cp keystore.jks ../keystore.jks +cp truststore.jks ../truststore.jks +cp client.crt ../client.crt +cp client.key ../client.key +``` + +This copies the necessary files to the management-node root directory: +- `keystore.jks` - Used by the Management Node application for its SSL server configuration +- `truststore.jks` - Used by the Management Node to validate client certificates +- `client.crt` and `client.key` - Used for testing API endpoints with mTLS authentication + 1. **For Keycloak**: - - Place all certificate files in the `docker` directory + - All the certificate files should now be in the `docker` directory - The docker-compose.yml maps these files into the Keycloak container: ```yaml volumes: @@ -315,48 +282,184 @@ KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit For production environments, use strong, unique passwords and secure storage solutions for managing these credentials. + +### Setting up Keycloak with Docker Compose + +#### Configuration + +For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: + +``` +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. + +The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: + +1. Navigate to the docker directory: + ```bash + cd docker + ``` + +2. Make sure you have the required certificates in the `docker` directory: see lower for local certificate setup + - `keystore.jks` - Java keystore containing the server certificate + - `truststore.jks` - Java truststore containing trusted certificates + - `localhost.p12` - PKCS12 keystore for client authentication + - `localhost.crt` - Certificate file + - `localhost.key` - Private key file + + If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. + +3. Start Keycloak and PostgreSQL using Docker Compose: + ```bash + docker compose -f keycloak/docker-compose.yml up -d + ``` + +4. Verify that Keycloak is running: + ```bash + curl -k https://localhost:8443/realms/master --cert client.crt --key client.key + ``` + Note: Keycloak takes about 30 seconds before its ready and the client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. + +5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: + - Username: `admin` + - Password: `password` + + you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. for chrome got to. settings - privacy and security - security - manage certificate - manage imported certificates from windows, then import and follow the wizard. + + + + ## Keycloak Realm Setup -After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin, you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. +After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin. ### Option 1: Import the Realm Configuration (Recommended) 1. Log in to the Keycloak admin console at https://localhost:8443/admin 2. Click on the dropdown menu in the top-left corner (it may show "master" if you haven't created any realms yet) -3. Click on "Create Realm" or "Add realm" button -4. Click on the "Browse" or "Select file" button -5. Navigate to and select the `docker/keycloak/management-node-realm.json` file from your project directory -6. Click "Create" or "Import" -7. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations -8. Note the client secret for the `ztf-client` from the Credentials tab (Clients โ†’ ztf-client โ†’ Credentials) and update it in your application.yml if needed +3. Click on "Manage realms" +4. Click on "Create Realm" or "Add realm" button +5. Click on the "Browse" or "Select file" button +6. Navigate to and select the `docker/keycloak/management-node.json` file from your project directory +7. Click "Create" or "Import" +8. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations +9. Note the client secret for the `management-node` client from the Credentials tab (Clients โ†’ management-node โ†’ Credentials) click regenerate, view it and do ```export KEYCLOAK_CLIENTID=*************``` ### Option 2: Manual Configuration -If you prefer to set up the realm manually: +If you prefer to set up the realm manually (updated for Keycloak 26.x): -1. Log in to the Keycloak admin console at https://localhost:8443/admin -2. Create a new realm named `management-node` -3. Create a client with the following settings: - - Client ID: `ztf-client` - - Client Protocol: `openid-connect` - - Access Type: `confidential` - - Valid Redirect URIs: `https://localhost:8090/*` - - Web Origins: `+` -4. Note the client secret from the Credentials tab and update it in your application.yml if needed +1. Log in to the Keycloak admin console at https://localhost:8443/admin (you must first import your `client.p12` certificate into your browser) + +2. Create a new realm named `management-node` by clicking the dropdown in the top-left and selecting "Create Realm" + +3. Create the **management-node** client: + - In the `management-node` realm, navigate to **Clients** and click **Create client** + + **General Settings:** + - Client type: `OpenID Connect` + - Client ID: `management-node` + - Click **Next** + + **Capability config:** + - Client authentication: **ON** (this enables the Credentials tab) + - Authorization: **OFF** + - Authentication flow: Enable **Service accounts roles** + - Click **Next** + + **Login settings:** + - Valid redirect URIs: `https://localhost:8090/*` + - Valid post logout redirect URIs: `+` + - Web origins: `+` + - Click **Save** + +4. After saving, click on the **Credentials** tab to view the **Client Secret**. Copy this secret. + +5. Add required roles to the client: + - Go to **Clients** โ†’ **management-node** โ†’ **Roles** tab + - Click **Create role** and add the following roles: + - `access_producer_configurations` + - `access_consumer_configurations` + +6. Assign roles to the service account: + - Go to **Clients** โ†’ **management-node** โ†’ **Service accounts roles** tab + - Click **Assign role** + - Filter by **Filter by clients** and select **management-node** + - Check both roles (`access_producer_configurations` and `access_consumer_configurations`) + - Click **Assign** + +7. Update your `application.yml` with the client configuration, if needed (or do ```export KEYCLOAK_CLIENTID=*************```): + ```yaml + spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://localhost:8443/realms/management-node + jwk-set-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/certs + audiences: account + opaquetoken: + introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect + client-secret: "client_secret=${KEYCLOAK_CLIENTID}" + client-id: management-node + + application: + client: + key-store: keystore.jks + key-store-password: changeit + keyStoreType: JKS + ``` ### Testing mTLS connectivity: -Once KeyCloak is running and configured, you can test mTLS connectivity using the below command: +Once Keycloak is running and configured, you can test mTLS connectivity using the command below. Replace `YOUR_CLIENT_SECRET` with the actual client secret obtained from the Keycloak Credentials tab (step 4 in the manual configuration above): - ```bash - curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ - --cert client.crt --key client.key \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'client_id=ztf-client' \ - --data-urlencode 'grant_type=client_credentials' - ``` +```bash +export KEYCLOAK_CLIENTID=`YOUR_CLIENT_SECRET` +cd docker # or where your certificates are stored +``` + +```bash +curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + --data-urlencode 'grant_type=client_credentials' +``` + +**Note:** The `client_secret` parameter is required for confidential clients. Make sure to: +1. Copy the client secret from Keycloak admin console: **Clients** โ†’ **management-node** โ†’ **Credentials** tab +2. Replace `YOUR_CLIENT_SECRET` in the command above with your actual client secret +3. The `-k` flag is used to allow insecure connections (self-signed certificates) for development -This tests the mTLS setup by attempting to obtain a token from Keycloak using client certificate authentication. +If successful, you will receive a JSON response containing an `access_token` with the assigned roles in the `resource_access.management-node.roles` claim. This confirms that: +- โœ… mTLS authentication is working (client certificates validated) +- โœ… Client credentials are correct +- โœ… Keycloak is properly configured +- โœ… Service account has the required roles assigned ## Building and Running with Maven @@ -371,6 +474,7 @@ The Management Node Module uses Maven for dependency management and build automa 2. Build the application: ```bash + cd management-node # change to suit, if following along do cd ../ (from the docker folder) mvn clean package ``` This command will: @@ -386,11 +490,24 @@ The Management Node Module uses Maven for dependency management and build automa ### Running the Application -After building, you can run the application using one of these methods: +After building, you can run the application using one of these methods: + +Note: if running with defaults export your passwords first.eg + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` +Ensure certificate files are in the management-node root directory (if not already there from certificate setup): +```sh +cp docker/keystore.jks keystore.jks +cp docker/truststore.jks truststore.jks +cp docker/client.crt client.crt +cp docker/client.key client.key +``` 1. Using the Java command: ```bash - java -jar target/management-node-0.0.1.jar + java -jar target/management-node-1.0.1.jar ``` 2. Using the Maven Spring Boot plugin: @@ -398,14 +515,74 @@ After building, you can run the application using one of these methods: mvn spring-boot:run ``` -3. Using Docker: - ```bash - docker build -t management-node -f docker/Dockerfile . - docker run -p 8090:8090 management-node - ``` - The application will be available at https://localhost:8090 +### Testing API Endpoints: + +Once you have a valid token, you can test the protected API endpoints: + +**Step 1: Get your Keycloak Client Secret** + +1. Log in to Keycloak admin console at https://localhost:8443/admin +2. Navigate to: **management-node realm** โ†’ **Clients** โ†’ **management-node** โ†’ **Credentials** tab +3. Copy the **Client Secret** value (you can regenerate if needed) +4. Export it as an environment variable: + +```bash +export KEYCLOAK_CLIENTID=your_actual_client_secret_here +``` + +**Step 2: Get a JWT token and test the endpoints** + +```bash +# Navigate to the root directory where client certificates are located +cd /path/to/management-node + +# First, verify you can get a token (view the full response) +curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq . + +# Get a token and save it +TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq -r '.access_token') + +# Verify the token was retrieved successfully +echo "Token (first 50 chars): ${TOKEN:0:50}..." + +# If TOKEN is "null", check that KEYCLOAK_CLIENTID is set correctly + +# Test the producer endpoint +curl -k https://localhost:8090/api/v1/configuration/producer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Test the consumer endpoint +curl -k https://localhost:8090/api/v1/configuration/consumer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +Expected response (if no configuration data exists yet): +```json +{ + "clientId": "management-node", + "producers": [] +} +``` + +If successful, you will receive a JSON response containing an `access_token`. This confirms that: +- โœ… mTLS authentication is working (client certificates validated) +- โœ… Client credentials are correct +- โœ… Keycloak is properly configured + ### Using Profile-Specific Configuration Files Spring Boot supports profile-specific property files, which are essential for local development environments where you need to configure sensitive information like passwords and URLs without committing them to version control. @@ -497,16 +674,48 @@ The current configuration aims for 80% code coverage across instructions, branch ### Common Issues 1. **Certificate Issues**: + - **Error**: `SSL routines::sslv3 alert certificate unknown` + - The server doesn't trust your client certificate + - **Solution**: Regenerate the truststore with the current rootCA: + ```bash + cd docker + mv truststore.jks truststore.jks.old + keytool -import -trustcacerts -noprompt -alias ca -file rootCA.crt -keystore truststore.jks -storepass changeit + # Rebuild the Docker image + cd .. + docker build -t management-node -f docker/Dockerfile-dev . + ``` - Ensure that the paths to the keystore and truststore files in application.yml are correct - Verify that the certificate passwords match those in the .env file + - If certificates were regenerated, ensure the truststore contains the new rootCA 2. **Keycloak Connection Issues**: - - Check that Keycloak is running and accessible at https://localhost:8443 - - Verify that the client secret in application.yml matches the one in Keycloak + - **Error**: `Connection refused` when trying to reach Keycloak + - **From Docker container**: Use `--network keycloak_keycloak_network` and connect to `keycloak:8443` + - **From host machine**: Use `localhost:8443` or `host.docker.internal:8443` + - **Error**: Token validation fails with 401 Unauthorized + - Check that `KEYCLOAK_CLIENTID` environment variable is set correctly + - Verify the token contains required roles using: `echo $TOKEN | cut -d. -f2 | base64 -d | jq .` + - Check that Keycloak is running: `docker ps | grep keycloak` + - Verify that the client secret matches the one in Keycloak admin console 3. **Database Connection Issues**: - - Ensure PostgreSQL is running and accessible - - Check the database credentials in the .env file + - **Error**: `Connection to localhost:5433 refused` from Docker container + - Docker containers can't reach `localhost` on the host + - **Solution**: Use `--network keycloak_keycloak_network` and `jdbc:postgresql://keycloak-postgres-1:5432/keycloak_db` + - Or use `--add-host=host.docker.internal:host-gateway` and `jdbc:postgresql://host.docker.internal:5433/keycloak_db` + - Ensure PostgreSQL is running: `docker ps | grep postgres` + - Check the database credentials match those in the .env file + - Verify you can connect manually: `docker exec -it keycloak-postgres-1 psql -U keycloak_db_user -d keycloak_db` + +4. **Docker-Specific Issues**: + - **Issue**: Management Node can't fetch JWKs from Keycloak (SSL trust issues between containers) + - **Symptom**: Application starts but JWT validation fails silently + - **Workaround**: Run the application directly using Maven instead of Docker for local development + - **Alternative**: Use docker-compose to set up all services with proper SSL configuration + - **Issue**: Environment variables not being passed to container + - Ensure you use `-e` flag for each environment variable + - Verify with: `docker exec env | grep VARIABLE_NAME` ## Security Considerations @@ -557,12 +766,32 @@ How Springdoc OpenAPI works in this project ## Authentication Requirements All protected endpoints require JWT bearer tokens. Tokens must: -- Include the audience (aud) "management-node". -- Contain a `resource_access` claim with client roles used for authorization. +- Include the audience (aud) claim with value `account` (default Keycloak audience for service accounts). +- Contain a `resource_access` claim with client-specific roles under `resource_access.management-node.roles`. + +**Required Client Roles:** +- `access_producer_configurations` - Required to access `/api/v1/configuration/producer` endpoint +- `access_consumer_configurations` - Required to access `/api/v1/configuration/consumer` endpoint + +**Token Structure Example:** +```json +{ + "aud": "account", + "resource_access": { + "management-node": { + "roles": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + } + }, + "client_id": "management-node" +} +``` -Role-specific access: -- Producer Federator: must have role `access_producer_configurations` to access `/api/v1/configuration/producer`. -- Consumer Federator: must have role `access_consumer_configurations` to access `/api/v1/configuration/consumer`. +These roles must be: +1. Created as client roles in the Keycloak `management-node` client +2. Assigned to the service account of the `management-node` client Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). diff --git a/docker/Dockerfile b/docker/Dockerfile index 522ea9b..7d05736 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -6,7 +6,9 @@ # # Build stage +ARG JAR_FILE=management-node-1.0.1.jar FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +ARG JAR_FILE WORKDIR /build # Copy the project files @@ -20,6 +22,7 @@ RUN mvn -B clean package -DskipTests # Name this stage so CI can target it (matches --target management-node in workflows) FROM eclipse-temurin:21-jdk-alpine AS management-node + # Create non-root user and group RUN addgroup -S app && adduser -S -G app -u 10001 app @@ -32,6 +35,7 @@ RUN mkdir -p /app/docker /app/logs /app/tmp && chown -R app:app /app # Use the jar name provided by CI via --build-arg JAR_NAME="management-node-${version}" ARG JAR_NAME COPY --from=build /build/target/${JAR_NAME}.jar /app/app.jar + RUN chown app:app /app/app.jar # Use non-root user from here on diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev index cc4073a..6890770 100644 --- a/docker/Dockerfile-dev +++ b/docker/Dockerfile-dev @@ -6,7 +6,9 @@ # # Build stage +ARG JAR_FILE=management-node-1.0.1.jar FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +ARG JAR_FILE WORKDIR /build # Copy the project files @@ -18,17 +20,18 @@ RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:23-jdk-alpine +ARG JAR_FILE WORKDIR /app -# Create directory for certificates -RUN mkdir -p /app/docker - # Copy application jar from build stage and certificates -COPY --from=build /build/target/management-node-0.90.0.jar /app/app.jar -COPY docker/keystore.jks /app/docker/keystore.jks -COPY docker/truststore.jks /app/docker/truststore.jks +COPY --from=build /build/target/${JAR_FILE} /app/app.jar +COPY docker/keystore.jks /app/keystore.jks +COPY docker/truststore.jks /app/truststore.jks + +# Set default certificate password +ENV CERTPASSWORD=changeit -EXPOSE 8443 +EXPOSE 8090 ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/docker/keycloak/README.md b/docker/keycloak/README.md index 6625d1c..4dd8c7e 100644 --- a/docker/keycloak/README.md +++ b/docker/keycloak/README.md @@ -77,7 +77,7 @@ Import client key and crt in keystore to create the "certificate" to be used in curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ --cert client.crt --key client.key \ --header 'Content-Type: application/x-www-form-urlencoded' \ ---data-urlencode 'client_id=ztf-client' \ +--data-urlencode 'client_id=management-node' \ --data-urlencode 'grant_type=client_credentials' --- diff --git a/docker/keycloak/management-node.json b/docker/keycloak/management-node.json new file mode 100644 index 0000000..74ab02f --- /dev/null +++ b/docker/keycloak/management-node.json @@ -0,0 +1,2563 @@ +{ + "id": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "realm": "management-node", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", + "name": "default-roles-management-node", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "manage-account", + "view-profile" + ] + } + }, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + }, + { + "id": "15d3f8ba-a0d8-4b4a-9e18-77a621db3e81", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + }, + { + "id": "58d30605-273e-4508-8441-2f0c3cda905c", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "84684efe-c21d-4eb7-b8ce-30c707c3d369", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "8f8d1c6e-b2ad-4dbd-804e-0107e85de363", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "a943f735-711f-45c0-9e9a-0f58e766a011", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "da22339c-bd2f-446a-869b-57d7c8fa9b94", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "93aad473-812c-424a-a4bc-5c54752f033d", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "d0f1cc46-4277-43a1-8933-614580e823f5", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "e29aceb4-0833-4115-a4ff-bd00b9cc7d31", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "f0fe073e-3113-4fc5-bdfb-f827dbfaf2c6", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-events", + "view-identity-providers", + "query-realms", + "view-authorization", + "manage-authorization", + "query-clients", + "query-groups", + "manage-realm", + "create-client", + "manage-clients", + "impersonation", + "view-events", + "query-users", + "manage-identity-providers", + "manage-users", + "view-realm", + "view-users", + "view-clients" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "ec4d3a28-6146-44c2-b47c-a9a7d4718fe0", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "7a3951cb-f2e1-42af-b52b-265b1f2ccc90", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "caff9476-f77f-47ac-9370-44301f57b1ff", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "6fb26b30-2525-4a37-9df1-9da873b52f77", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "63517e42-0c33-4f41-9040-ec6b5b0531c1", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "3131211e-b69d-46b0-bfbb-f63ab3dd633a", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "082137ad-03cf-40e4-9e0b-89bc7b91048f", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "6ecc7af4-b24c-40e5-8344-2d1cb836ff97", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "0dd8bf55-e548-4ba0-ae12-581eca4e4b4f", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "754d0d70-23de-46aa-9269-6c88fda2a477", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + }, + { + "id": "63e09485-7240-41fc-93e2-b8206af47a1e", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-users", + "query-groups" + ] + } + }, + "clientRole": true, + "containerId": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "management-node": [ + { + "id": "6d8b6df9-bcf2-4f36-a39f-2a07ccd22e3b", + "name": "access_producer_configurations", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + }, + { + "id": "26909837-6df2-4802-9622-0f9eed78f3fb", + "name": "access_consumer_configurations", + "description": "", + "composite": false, + "clientRole": true, + "containerId": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "attributes": {} + } + ], + "broker": [ + { + "id": "d7c37fdb-8298-4db6-8536-adc3bb6e73ae", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "cde19f67-0386-469c-8c74-9831e13be86f", + "attributes": {} + } + ], + "account": [ + { + "id": "79d4c2c1-7a8a-47e1-9da1-a2f4180d7e9a", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "dc2e806b-5ad2-469a-b7d2-314e0e10cff7", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "6c85fa9c-7972-4a3c-9840-0ddfb465ab7d", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "2eae4a32-aa35-4ec1-9f90-bcf5c92ace4d", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "fe0a910e-b0cf-4261-b743-e6a2c8d1b4cc", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "e71206b1-5c1e-4331-95c9-ad67eb3e29ca", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "528badc3-8b60-4951-ba30-ab0fa6dd2590", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + }, + { + "id": "1a959cc8-f7e7-47e2-916b-67e9453eee4d", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "2349ea4a-fe69-4c32-b0c2-fc539293d1ad", + "name": "default-roles-management-node", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "11e002d3-ea8f-4680-8f2b-e513e3d909b6" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "users": [ + { + "id": "8e8067b1-545e-483e-868d-fbb8e3089cc2", + "username": "service-account-management-node", + "emailVerified": false, + "enabled": true, + "createdTimestamp": 1761124708028, + "totp": false, + "serviceAccountClientId": "management-node", + "disableableCredentialTypes": [], + "requiredActions": [], + "realmRoles": [ + "default-roles-management-node" + ], + "clientRoles": { + "management-node": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "9ee2a961-e8ce-44eb-ac66-3ca99e671062", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/management-node/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/management-node/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "9776bc0a-9a4d-43cf-af0e-8c71bc18bb8b", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/management-node/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/management-node/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "e6d1cfd0-f5e9-4d97-92b0-55203f27da3c", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d263346e-e131-4499-81a5-69260556d460", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "cde19f67-0386-469c-8c74-9831e13be86f", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e6362bd1-4515-42f4-be71-3434e3e0d6da", + "clientId": "management-node", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "https://localhost:8090/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1761124708", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "service_account", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "746cd42d-cdf9-4f11-a1ef-f95c89a60294", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "5ca67f36-bceb-4177-9fb1-80056174ef74", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/management-node/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/management-node/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "f7639ee5-9130-4dd2-bd81-8ede2caa1efd", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "6580612b-4596-4ac4-b1c9-6f5e9991acb4", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f94e282e-fa71-4ed0-a0c0-4165c51a3790", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "19a947e9-4653-4265-966e-d1d4f1b6a1ff", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "c65991c8-1fd8-4a1f-a228-1887a5d5b4ce", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "29e7dde8-6ff7-49e6-b66b-7396ed2802c0", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "109642e5-5424-4e69-b0b6-6840a5f13c81", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "9aa57e69-40cb-41c0-9630-64941bf75218", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "08509e70-25a3-4ad5-a4a5-74e65a722fd2", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "fafdabcc-4b58-48d3-b9f7-731048a7e87e", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "3d31c53e-3cad-462e-9a7d-65a10ed07a97", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "bb83c5a3-23c5-4643-a4d0-2e2c6d2414e9", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "45a4c177-7504-4b13-b090-9dd90d8e2980", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "7c29bb5b-d247-4e0f-87d4-d92eb0857d7f", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "bdb02fe5-552b-487d-9701-8158b56b623d", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "c98adf31-f223-455a-b636-7701707aa54c", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "18e97211-2e01-434d-a9b3-3eec6e10c5e2", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "e65477a4-c6bc-4663-8d3e-eb3b2f63da38", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9e0ae40d-5f69-4002-b631-f27cb25c169e", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "e782994a-8c93-4a44-b776-6e5774e3906c", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "93c4cc79-c25d-4626-9d22-1f96a5135c51", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "16342e52-fc66-428b-96a6-e30c366fc6f9", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "f487705e-8a12-4c7f-aef9-e91e9348a31a", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "aabcbbad-d6ee-4300-b3d9-b6be652b82ae", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "31660997-c4c2-4ea6-a2d0-9d17ba0f32b4", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "78791873-e71b-4e73-9145-3d66897ef5f0", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ac046319-00b3-4afa-884c-a6fb8c312ab3", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "6edeb2fb-933a-4780-9e31-054807807881", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "2fc61305-b5a4-4ef2-8a17-5e626b9ae8c7", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "e70b6969-a64c-47bb-938d-999507f3c58b", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0d408c4c-8782-4e59-80bc-9880bb183f81", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0d94acc9-832d-4000-a70a-e897c9754429", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "100c3c5c-84af-4e17-a83e-829a234e1608", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "2b80a380-d57a-4287-b859-c64afc9a99f8", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "903e03d0-d136-4613-885e-7d1f3595e780", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "1148319d-003b-4b7b-b8f7-6cebff6c4f90", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "6850a449-f816-4394-b286-fe345af4d9bf", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "b6a9b41f-61fb-4ff2-930e-b7c21b658959", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "67e1860b-60a8-40b4-8743-03d80a2a6842", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "0206ca5a-3d98-42d7-90d8-df22bf93beed", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "159cac93-247b-4217-935a-2e30e42759ba", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "8e22e1b6-f57c-4819-a5e0-f785e1d6b01f", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0dd6163c-7bfc-4e36-b370-cc91dead391c", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "db1effbb-c5d8-43ba-bf73-784cfd517eb8", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "57754a78-720d-4fb5-bb0d-8a9c050747b1", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "08264050-785f-46fd-8789-1b319575f223", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "55a2f1ed-297f-4b3b-a624-38ca83c5e74e", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "7bb3656a-31b6-4306-ba00-270f445b659f", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "8a2c23b2-801b-42d2-a5e9-e8a1f8f8a5d4", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "saml_organization", + "profile", + "email", + "roles", + "web-origins", + "acr", + "basic" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt", + "organization" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "3ac3a49c-3e37-4773-95f8-4b1ecb57164a", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-attribute-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "865d6500-9b8f-498a-aa90-e31bf845bf21", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "226606fc-5198-467f-b577-1a69383e7ceb", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "1e7b13ca-3dd3-480b-af93-94f54bcd3306", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "3306d643-bd5b-48ed-8d50-c20d7fed5d22", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "27294c2a-3d43-4c4a-81e8-d0f0e5080b3c", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "eb92e86a-c365-4319-82c9-8e6a69935386", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "0356b6bf-37e6-4fac-9191-57db508900dd", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-full-name-mapper", + "oidc-sha256-pairwise-sub-mapper", + "saml-user-property-mapper" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "3d456641-36b1-4465-be74-952ef91bbf95", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "f02b1302-cd56-4187-a3ca-12039963f357", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "e711f578-053f-4feb-8b71-821457d12606", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "95be6872-65a3-499a-aa62-cc5c16690e2b", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "authenticationFlows": [ + { + "id": "34e99d27-4d0d-462e-a409-e5c8f127ef69", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "25cb3a1a-38ef-4783-831c-9e99a99eb729", + "alias": "Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "d3f0a418-1fbd-4e60-aba2-47f919cfce8d", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "94a2c6fa-b339-420f-935e-87be463c3365", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "bdfb13c2-d548-4f9f-a9f7-d14d5e9db17e", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a557bad3-e44f-42b3-8bd5-0c0a89c52906", + "alias": "First broker login - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a1bba2a0-f37e-420b-98cc-9b4a9f3db2d2", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "0d7fdf57-ea7e-4071-9335-7fd2f83320ed", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "c81ad6ec-f28b-4863-b02b-1569b13b3d97", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "8d2c5a14-ce1f-4cb7-bf35-f56897d9c4ec", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "ea254a4a-d14e-457f-ba67-38a3544ea80e", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "982f63af-52e7-4516-9cb2-5ace5c5617c4", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "1cbc6444-d817-4df3-9da2-3f99e6b7f0cb", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "cb73cfbd-5395-4e87-b9af-9c501e4a9f3e", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "391915b7-33a8-4d0c-9ab1-67ed8537bec1", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f4c7ab68-c48d-4fe5-a8c1-f2519e959ee2", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 50, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "37896020-5c13-4400-b67f-415a54a72788", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "9e558209-b106-4811-9f5f-e71a4b5014f0", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "e3428f93-bfc6-4485-bc0f-df92d3ddadb7", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "aad14193-222f-46e5-a3e7-68024ab2351b", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "05167100-d696-4634-a589-1801e0ccdf70", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "568604a7-c191-4926-9277-533befe7a52e", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "f9f4534b-a6a6-4e0d-9019-331fec99d5fb", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.3.2", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8e9d303..94ca1a5 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -1,12 +1,17 @@ # Database Schema +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- + + + This document describes the relational database schema used by the Management Node. The schema is applied via Flyway migrations located at: - `src/main/resources/db/migration/` -The current schema is based on the following migrations: -- `V20250728142253__intial_database_tables.sql` -- `V20250914182403__productConsumersAttributesTable.sql` The database is designed to model Organisations, their Producers and Consumers, the Products offered by Producers, and the access grants that allow specific Consumers to access specific Products. Additional attributes can be attached to each grant. @@ -29,6 +34,7 @@ erDiagram PRODUCT ||--o{ PRODUCT_CONSUMER : grants CONSUMER ||--o{ PRODUCT_CONSUMER : consumes PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has + PRODUCT_TYPE ||--o{ PRODUCT : categorizes ORGANISATION { BIGSERIAL id PK @@ -51,12 +57,21 @@ erDiagram VARCHAR name BIGINT org_id FK VARCHAR idp_client_id + VARCHAR schedule_type + VARCHAR schedule_expression + } + PRODUCT_TYPE { + BIGSERIAL id PK + VARCHAR name + VARCHAR description } PRODUCT { BIGSERIAL id PK VARCHAR name VARCHAR topic BIGINT producer_id FK + BIGINT product_type_id FK + VARCHAR source } PRODUCT_CONSUMER { BIGSERIAL id PK @@ -64,7 +79,9 @@ erDiagram BIGINT consumer_id FK TIMESTAMP granted_ts NUMERIC validity - + VARCHAR schedule_type + VARCHAR schedule_expression + VARCHAR destination } PRODUCT_CONSUMER_ATTRIBUTE { BIGSERIAL id PK @@ -119,9 +136,25 @@ Columns: - `name` VARCHAR(50), not null - `org_id` BIGINT, not null, foreign key โ†’ `organisation(id)` - `idp_client_id` VARCHAR(50), not null โ€” identity provider client id (e.g., Keycloak). Informational; not an FK +- `schedule_type` VARCHAR(100), nullable โ€” type of schedule, e.g., `cron`, `interval` +- `schedule_expression` VARCHAR(255), nullable โ€” schedule expression matching the chosen schedule_type Usage: - Participates in access grants via `product_consumer`. +- Optional scheduling metadata for consumer-driven jobs. + +--- + +### product_type +Represents a category/type of Product (e.g., topic-based, file-based). + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(150), not null +- `description` VARCHAR(255), nullable โ€” brief description of the product type + +Usage: +- Lookup table used to categorize products. Initial values seeded by migration: `topic` and `file`. --- @@ -133,21 +166,27 @@ Columns: - `name` VARCHAR(50), not null - `topic` VARCHAR(150), not null โ€” logical topic or channel for the product - `producer_id` BIGINT, not null, foreign key โ†’ `producer(id)` +- `product_type_id` BIGINT, nullable, foreign key โ†’ `product_type(id)` โ€” categorization of the product +- `source` VARCHAR(500), nullable โ€” optional source identifier/URI for the product Usage: - The resource being granted to Consumers via `product_consumer`. +- Migration defaults existing rows to the `topic` product type. --- ### product_consumer Join table representing an access grant that allows a Consumer to access a Product. -Columns (after migration `V20250914182403`): +Columns: - `id` BIGSERIAL, primary key - `product_id` BIGINT, not null, foreign key โ†’ `product(id)` - `consumer_id` BIGINT, not null, foreign key โ†’ `consumer(id)` - `granted_ts` TIMESTAMP, not null โ€” timestamp when access was granted - `validity` NUMERIC, not null โ€” validity period/units are application-defined +- `schedule_type` VARCHAR(100), nullable โ€” e.g., `cron`, `interval` +- `schedule_expression` VARCHAR(255), nullable โ€” expression matching the schedule_type +- `destination` VARCHAR(500), nullable โ€” optional destination identifier/URI for scheduled deliveries - `uq_product_consumer_pair` UNIQUE (`product_id`, `consumer_id`) โ€” ensures one grant per pair Notes: @@ -155,6 +194,7 @@ Notes: Usage: - Central record for authorization decisions: which Consumer can access which Product and since when. +- Optional scheduling metadata for grant-level processing/delivery. --- @@ -176,7 +216,7 @@ Usage: ## Migration Notes - Schema is versioned and applied with Flyway on application startup. - Foreign keys enforce referential integrity among core entities. -- Consider adding database indexes on foreign key columns (`producer.producer_id`, `consumer.org_id`, `product.producer_id`, `product_consumer.product_id`, `product_consumer.consumer_id`, `product_consumer_attribute.product_consumer_id`) to optimize query performance, if not already present in future migrations. +- Consider adding database indexes on foreign key columns (`producer.producer_id`, `consumer.org_id`, `product.producer_id`, `product_consumer.product_id`, `product_consumer.consumer_id`, `product_consumer_attribute.product_consumer_id`) to optimize query performance. ## Data Protection and Security - Identity fields like `idp_client_id` are not foreign keys; they link to external IdP configuration (e.g., Keycloak) at the application layer. diff --git a/docs/JACOCO_COVERAGE.md b/docs/JACOCO_COVERAGE.md index 345c33a..7dcb69b 100644 --- a/docs/JACOCO_COVERAGE.md +++ b/docs/JACOCO_COVERAGE.md @@ -1,5 +1,10 @@ # JaCoCo Code Coverage Setup +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- ## Overview This document describes the JaCoCo code coverage setup for the Management Node application. JaCoCo has been configured to measure code coverage and ensure that it meets the specified thresholds. diff --git a/docs/MOCKITO_USAGE.md b/docs/MOCKITO_USAGE.md index 888864d..e2b7de0 100644 --- a/docs/MOCKITO_USAGE.md +++ b/docs/MOCKITO_USAGE.md @@ -1,5 +1,9 @@ # Mockito Testing Tool Usage Guide +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` +--- ## Overview Mockito is a popular mocking framework for Java that allows you to create and configure mock objects. Using Mockito, you can verify that certain methods are called with certain parameters, stub method calls to return specific values, and more. diff --git a/docs/MTLS_CONFIGURATION.md b/docs/MTLS_CONFIGURATION.md index 7cdcdb5..190279e 100644 --- a/docs/MTLS_CONFIGURATION.md +++ b/docs/MTLS_CONFIGURATION.md @@ -1,5 +1,10 @@ # MTLS Configuration Guide +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- This guide provides detailed instructions on how to configure Mutual TLS (MTLS) for both the Keycloak authentication server and the Management Node Spring Boot application. ## What is MTLS and Why It's Needed diff --git a/docs/assets/android-chrome-512x512-1-150x150.png b/docs/assets/android-chrome-512x512-1-150x150.png new file mode 100644 index 0000000..72b9b2c Binary files /dev/null and b/docs/assets/android-chrome-512x512-1-150x150.png differ diff --git a/docs/assets/light-page_footer_logo.png b/docs/assets/light-page_footer_logo.png new file mode 100644 index 0000000..5dc6732 Binary files /dev/null and b/docs/assets/light-page_footer_logo.png differ diff --git a/docs/assets/light-page_header_logo.png b/docs/assets/light-page_header_logo.png new file mode 100644 index 0000000..d62a452 Binary files /dev/null and b/docs/assets/light-page_header_logo.png differ diff --git a/docs/entity-dto-converter-pattern.md b/docs/entity-dto-converter-pattern.md index 62b4998..2c3d1cc 100644 --- a/docs/entity-dto-converter-pattern.md +++ b/docs/entity-dto-converter-pattern.md @@ -1,5 +1,11 @@ # Entity-DTO Converter Pattern +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + + + ## Overview This document describes the Entity-DTO converter pattern implemented in the project to handle conversions between entity objects and DTOs (Data Transfer Objects). This pattern replaces the previous approach of using ModelMapper for these conversions. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f1d9996 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,819 @@ +# README + +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- + +## Overview + +The Management Node Module is a Spring Boot application that provides APIs to be accessed by Consumer and Producer Federators. It implements a secure communication architecture using Mutual TLS (MTLS) connectivity between Federator instances and itself, as well as establishing zero trust connectivity with Keycloak for authentication and authorization. + +--- + +## Database Schema + +For a full description of the database tables, relationships, and constraints, see the Database Schema documentation: [docs/DATABASE_SCHEMA.md](docs/DATABASE_SCHEMA.md). + +--- + +## Prerequisites +- Java 21 +- Maven 3.9+ +- Docker and Docker Compose +- OpenSSL (for certificate generation) +- Keycloak (for authentication and authorization) +- The below assumes your running in Linux - bash, it has been tested under WSL2. +--- + +## Quick Start +Note. see lower for setting up prerequisites for local deployment certs, keycloak etc. + +### Run the Spring Boot application + +This project is a Spring Boot application. You can run it by supplying configuration via either: +- A default application.yml file, or +- A profile-specific file application-{profile}.yml and passing the profile argument at startup. + +Quick options: + +1. Provide a default config: + - Modify src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. + - Run: (change to suit your local if different) + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` + ```bash + mvn spring-boot:run + ``` + or: + ```bash + java -jar target/management-node-1.0.1.jar + ``` + +2. Use a profile-specific config: + - Create src/main/resources/application-local.yml (replace "local" with your profile name) with your settings. + - Run with the profile: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + or: + ```bash + java -jar target/management-node-1.0.1.jar --spring.profiles.active=local + ``` + - You can also set the environment variable: + ```bash + export SPRING_PROFILES_ACTIVE=local + ``` + +Notes: +- Spring Boot will load application.yml and then override with application-{profile}.yml if a profile is active. +- You may also point to an external YAML using: + ```bash + java -jar target/management-node-0.0.1.jar --spring.config.location=/path/to/your.yml + ``` +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). + + + +# Prerequisites setup +## Certificate Setup + +The Management Node Module implements a zero-trust security architecture using Mutual TLS (MTLS) for secure communication between all components. This section explains why certificates are needed, how to generate them, and where they are used in the system. + +> **Note:** For detailed instructions on configuring MTLS for both Keycloak and the Management Node, see the [MTLS Configuration Guide](docs/MTLS_CONFIGURATION.md). + +### Why Certificates Are Needed + +1. **Zero-Trust Security Model**: The system follows a zero-trust approach where all communications must be authenticated and encrypted, regardless of whether they occur inside or outside the network perimeter. + +2. **Mutual TLS (MTLS)**: Unlike standard TLS where only the server authenticates itself to the client, MTLS requires both parties to authenticate each other using X.509 certificates. + +3. **Service-to-Service Authentication**: Certificates provide a secure way for services to verify each other's identity without relying on passwords or API keys. + +### Certificate Types and Their Purpose + +The system requires several certificate files: + +1. **Private Key (`localhost.key`)**: + - The private key used to sign and decrypt data + - Must be kept secure and never shared + - Used by both Keycloak and the Management Node + +2. **Certificate (`localhost.crt`)**: + - The public certificate containing the public key + - Shared with other services to verify the identity + - Used in both server and client authentication + +3. **PKCS12 Keystore (`localhost.p12`)**: + - A container format that stores the private key and certificate + - Used primarily for client authentication + - Imported by Keycloak for client certificate validation + +4. **Java Keystore (`keystore.jks`)**: + - Java-specific format for storing the server's private key and certificate + - Used by both Keycloak and the Management Node for their TLS endpoints + +5. **Java Truststore (`truststore.jks`)**: + - Contains certificates that the server trusts + - Used to validate client certificates during MTLS + +### Step-by-Step Certificate Generation + +For development purposes, follow these steps to generate certificates for mTLS. All passwords used are `changeit`. When generating these certficates, for the `Country Name`, you can use the value of 'UK'. All remaining certificate fields can be left to their default values. + +move to the docker folder +```bash +cd docker +``` + +1. **Generate a Root CA certificate**: + ```bash + openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt + ``` + This creates a Root Certificate Authority (CA) that will be used to sign other certificates. The certificate is valid for 10 years (3650 days). + +2. **Generate a host certificate**: + ```bash + openssl req -new -newkey rsa:4096 -keyout localhost.key -out localhost.csr -nodes + ``` + This creates a private key and certificate signing request (CSR) for the host. + +3. **Sign the host certificate with the Root CA**: + + Create a file called `localhost.ext` file should contain: + ``` + authorityKeyIdentifier=keyid,issuer + basicConstraints=CA:FALSE + subjectAltName = @alt_names + [alt_names] + DNS.1 = localhost + DNS.2 = keycloak + ``` + + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + ``` + This signs the host CSR with the Root CA, creating a certificate valid for 365 days. + + + This configuration specifies that the certificate is valid for both `localhost` and `keycloak` hostnames. + +4. **Create a PKCS12 keystore for the server**: + ```bash + openssl pkcs12 -export -out localhost.p12 -name "localhost" -inkey localhost.key -in localhost.crt + ``` + This bundles the host certificate and private key into a PKCS12 format. + +5. **Create a PEM file for Linux keystore**: + ```bash + openssl pkcs12 -in localhost.p12 -clcerts -nokeys -out localhost.pem + ``` + This extracts the certificate (without the private key) in PEM format. + +6. **Add the Root CA to the Trust Store**: + ```bash + keytool -importcert -file rootCA.crt -alias clientca -keystore localhost.p12 -storetype PKCS12 -storepass changeit + ``` + This adds the Root CA to the trust store so that clients signed by this CA will be trusted. + +7. **Generate a client certificate**: + ```bash + openssl req -new -newkey rsa:4096 -nodes -keyout client.key -out client.csr + ``` + This creates a private key and CSR for the client. + +8. **Sign the client certificate with the Root CA**: + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in client.csr -out client.crt -days 365 -CAcreateserial + ``` + This signs the client CSR with the Root CA, creating a certificate valid for 365 days. + +9. **Create a PKCS12 keystore for the client**: + ```bash + openssl pkcs12 -export -out client.p12 -name "client" -inkey client.key -in client.crt + ``` + This bundles the client certificate and private key into a PKCS12 format for use in browsers or client applications. + +10. **Create a Java keystore using keytool** (PKCS12 format, compatible with modern Java): + ```bash + keytool -importkeystore -destkeystore keystore.jks -deststoretype PKCS12 -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + ``` + This converts the PKCS12 keystore. Note: Despite the `.jks` extension, modern keytool creates PKCS12 format by default, which is more secure and standard. + +11. **Create a Java truststore using keytool** (PKCS12 format): + ```bash + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks -storetype PKCS12 + ``` + This creates a truststore containing the Root CA certificate in PKCS12 format, which will be used to validate client certificates. + +12. **Verify the truststore** (optional but recommended): + ```bash + keytool -list -keystore truststore.jks -storetype PKCS12 -storepass changeit + ``` + This verifies that the Root CA is properly imported into the truststore. + +### Certificate Placement and Configuration + +After generating the certificates, place them in the appropriate locations: + +if you've followed the above then follow with +```bash +cp keystore.jks ../keystore.jks +cp truststore.jks ../truststore.jks +cp client.crt ../client.crt +cp client.key ../client.key +``` + +This copies the necessary files to the management-node root directory: +- `keystore.jks` - Used by the Management Node application for its SSL server configuration +- `truststore.jks` - Used by the Management Node to validate client certificates +- `client.crt` and `client.key` - Used for testing API endpoints with mTLS authentication + +1. **For Keycloak**: + - All the certificate files should now be in the `docker` directory + - The docker-compose.yml maps these files into the Keycloak container: + ```yaml + volumes: + - ./localhost.p12:/keystores/localhost.p12 + - ./localhost.crt:/cert/localhost.crt + - ./localhost.key:/key/localhost.key + - ./keystore.jks:/cert/keystore.jks + - ./truststore.jks:/cert/truststore.jks + ``` + - Keycloak uses these certificates for: + - Securing its HTTPS endpoint (port 8443) + - Validating client certificates for MTLS + +2. **For Management Node**: + - The application.yml references the certificate files: + ```yaml + server: + ssl: + key-store: /path/to/keystore.jks + key-store-password: changeit + trust-store: /path/to/truststore.jks + trust-store-password: changeit + ``` + - When running in Docker, the Dockerfile copies these files: + ```dockerfile + COPY docker/keystore.jks /app/docker/keystore.jks + COPY docker/truststore.jks /app/docker/truststore.jks + ``` + +3. **For Client Applications**: + - Client applications connecting to the Management Node need: + - The client certificate and private key for authentication + - The server's certificate in their truststore to validate the server + +### Certificate Password Management + +All certificates use the password "changeit" for development. These passwords are configured in the `.env` file: + +``` +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +``` + +For production environments, use strong, unique passwords and secure storage solutions for managing these credentials. + + +### Setting up Keycloak with Docker Compose + +#### Configuration + +For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: + +``` +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. + +The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: + +1. Navigate to the docker directory: + ```bash + cd docker + ``` + +2. Make sure you have the required certificates in the `docker` directory: see lower for local certificate setup + - `keystore.jks` - Java keystore containing the server certificate + - `truststore.jks` - Java truststore containing trusted certificates + - `localhost.p12` - PKCS12 keystore for client authentication + - `localhost.crt` - Certificate file + - `localhost.key` - Private key file + + If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. + +3. Start Keycloak and PostgreSQL using Docker Compose: + ```bash + docker compose -f keycloak/docker-compose.yml up -d + ``` + +4. Verify that Keycloak is running: + ```bash + curl -k https://localhost:8443/realms/master --cert client.crt --key client.key + ``` + Note: Keycloak takes about 30 seconds before its ready and the client certificate files (client.crt and client.key) must be in your current directory or provide the full path. If you haven't generated these yet, see the [Certificate Setup](#certificate-setup) section. + +5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: + - Username: `admin` + - Password: `password` + + you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. for chrome got to. settings - privacy and security - security - manage certificate - manage imported certificates from windows, then import and follow the wizard. + + + + +## Keycloak Realm Setup + +After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin. + +### Option 1: Import the Realm Configuration (Recommended) + +1. Log in to the Keycloak admin console at https://localhost:8443/admin +2. Click on the dropdown menu in the top-left corner (it may show "master" if you haven't created any realms yet) +3. Click on "Manage realms" +4. Click on "Create Realm" or "Add realm" button +5. Click on the "Browse" or "Select file" button +6. Navigate to and select the `docker/keycloak/management-node.json` file from your project directory +7. Click "Create" or "Import" +8. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations +9. Note the client secret for the `management-node` client from the Credentials tab (Clients โ†’ management-node โ†’ Credentials) click regenerate, view it and do ```export KEYCLOAK_CLIENTID=*************``` + +### Option 2: Manual Configuration + +If you prefer to set up the realm manually (updated for Keycloak 26.x): + +1. Log in to the Keycloak admin console at https://localhost:8443/admin (you must first import your `client.p12` certificate into your browser) + +2. Create a new realm named `management-node` by clicking the dropdown in the top-left and selecting "Create Realm" + +3. Create the **management-node** client: + - In the `management-node` realm, navigate to **Clients** and click **Create client** + + **General Settings:** + - Client type: `OpenID Connect` + - Client ID: `management-node` + - Click **Next** + + **Capability config:** + - Client authentication: **ON** (this enables the Credentials tab) + - Authorization: **OFF** + - Authentication flow: Enable **Service accounts roles** + - Click **Next** + + **Login settings:** + - Valid redirect URIs: `https://localhost:8090/*` + - Valid post logout redirect URIs: `+` + - Web origins: `+` + - Click **Save** + +4. After saving, click on the **Credentials** tab to view the **Client Secret**. Copy this secret. + +5. Add required roles to the client: + - Go to **Clients** โ†’ **management-node** โ†’ **Roles** tab + - Click **Create role** and add the following roles: + - `access_producer_configurations` + - `access_consumer_configurations` + +6. Assign roles to the service account: + - Go to **Clients** โ†’ **management-node** โ†’ **Service accounts roles** tab + - Click **Assign role** + - Filter by **Filter by clients** and select **management-node** + - Check both roles (`access_producer_configurations` and `access_consumer_configurations`) + - Click **Assign** + +7. Update your `application.yml` with the client configuration, if needed (or do ```export KEYCLOAK_CLIENTID=*************```): + ```yaml + spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://localhost:8443/realms/management-node + jwk-set-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/certs + audiences: account + opaquetoken: + introspection-uri: https://localhost:8443/realms/management-node/protocol/openid-connect/token/introspect + client-secret: "client_secret=${KEYCLOAK_CLIENTID}" + client-id: management-node + + application: + client: + key-store: keystore.jks + key-store-password: changeit + keyStoreType: JKS + ``` + +### Testing mTLS connectivity: + +Once Keycloak is running and configured, you can test mTLS connectivity using the command below. Replace `YOUR_CLIENT_SECRET` with the actual client secret obtained from the Keycloak Credentials tab (step 4 in the manual configuration above): + +```bash +export KEYCLOAK_CLIENTID=`YOUR_CLIENT_SECRET` +cd docker # or where your certificates are stored +``` + +```bash +curl -k --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + --data-urlencode 'grant_type=client_credentials' +``` + +**Note:** The `client_secret` parameter is required for confidential clients. Make sure to: +1. Copy the client secret from Keycloak admin console: **Clients** โ†’ **management-node** โ†’ **Credentials** tab +2. Replace `YOUR_CLIENT_SECRET` in the command above with your actual client secret +3. The `-k` flag is used to allow insecure connections (self-signed certificates) for development + +If successful, you will receive a JSON response containing an `access_token` with the assigned roles in the `resource_access.management-node.roles` claim. This confirms that: +- โœ… mTLS authentication is working (client certificates validated) +- โœ… Client credentials are correct +- โœ… Keycloak is properly configured +- โœ… Service account has the required roles assigned + +## Building and Running with Maven + +### Building the Application + +The Management Node Module uses Maven for dependency management and build automation. To build the application: + +1. Ensure you have Maven 3.9+ installed: + ```bash + mvn --version + ``` + +2. Build the application: + ```bash + cd management-node # change to suit, if following along do cd ../ (from the docker folder) + mvn clean package + ``` + This command will: + - Clean the target directory + - Compile the source code + - Run the tests + - Package the application into a JAR file + +3. If you want to skip tests during the build: + ```bash + mvn clean package -DskipTests + ``` + +### Running the Application + +After building, you can run the application using one of these methods: + +Note: if running with defaults export your passwords first.eg + ``` + export POSTGRES_PASSWORD=keycloak_db_user_password + export CERTPASSWORD=changeit + ``` +Ensure certificate files are in the management-node root directory (if not already there from certificate setup): +```sh +cp docker/keystore.jks keystore.jks +cp docker/truststore.jks truststore.jks +cp docker/client.crt client.crt +cp docker/client.key client.key +``` + +1. Using the Java command: + ```bash + java -jar target/management-node-1.0.1.jar + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run + ``` + +The application will be available at https://localhost:8090 + +### Testing API Endpoints: + +Once you have a valid token, you can test the protected API endpoints: + +**Step 1: Get your Keycloak Client Secret** + +1. Log in to Keycloak admin console at https://localhost:8443/admin +2. Navigate to: **management-node realm** โ†’ **Clients** โ†’ **management-node** โ†’ **Credentials** tab +3. Copy the **Client Secret** value (you can regenerate if needed) +4. Export it as an environment variable: + +```bash +export KEYCLOAK_CLIENTID=your_actual_client_secret_here +``` + +**Step 2: Get a JWT token and test the endpoints** + +```bash +# Navigate to the root directory where client certificates are located +cd /path/to/management-node + +# First, verify you can get a token (view the full response) +curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq . + +# Get a token and save it +TOKEN=$(curl -k https://localhost:8443/realms/management-node/protocol/openid-connect/token \ + --cert client.crt --key client.key \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_id=management-node' \ + --data-urlencode "client_secret=${KEYCLOAK_CLIENTID}" \ + -s | jq -r '.access_token') + +# Verify the token was retrieved successfully +echo "Token (first 50 chars): ${TOKEN:0:50}..." + +# If TOKEN is "null", check that KEYCLOAK_CLIENTID is set correctly + +# Test the producer endpoint +curl -k https://localhost:8090/api/v1/configuration/producer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Test the consumer endpoint +curl -k https://localhost:8090/api/v1/configuration/consumer \ + --cert client.crt --key client.key \ + -H "Authorization: Bearer $TOKEN" | jq . +``` + +Expected response (if no configuration data exists yet): +```json +{ + "clientId": "management-node", + "producers": [] +} +``` + +If successful, you will receive a JSON response containing an `access_token`. This confirms that: +- โœ… mTLS authentication is working (client certificates validated) +- โœ… Client credentials are correct +- โœ… Keycloak is properly configured + +### Using Profile-Specific Configuration Files + +Spring Boot supports profile-specific property files, which are essential for local development environments where you need to configure sensitive information like passwords and URLs without committing them to version control. + +#### Why Use Profile-Specific Configuration? + +1. **Security**: Keep sensitive information like passwords and API keys out of version control +2. **Environment-Specific Settings**: Configure different settings for development, testing, and production +3. **Local Development**: Each developer can have their own configuration without affecting others + +#### Creating a Profile-Specific YAML File + +1. Create a file named `application-{profile}.yml` in the `src/main/resources` directory, where `{profile}` is the name of your profile (e.g., `application-local.yml` for a "local" profile) + +2. Add your environment-specific configuration to this file. For example: + + ```yaml + spring: + security: + oauth2: + resourceserver: + opaquetoken: + client-secret: your-client-secret-here + client-id: ztf-client + datasource: + password: your-database-password-here + + server: + ssl: + key-store-password: your-keystore-password-here + trust-store-password: your-truststore-password-here + key-store: /path/to/your/local/keystore.jks + trust-store: /path/to/your/local/truststore.jks + ``` + +3. Make sure not to commit this file to version control by adding it to your `.gitignore` file: + ``` + src/main/resources/application-local.yml + ``` + +#### Running the Application with a Specific Profile + +To run the application with your profile, use one of these methods: + +1. Using the Java command with the `spring.profiles.active` parameter: + ```bash + java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + +3. Using environment variables: + ```bash + export SPRING_PROFILES_ACTIVE=local + java -jar target/management-node-0.0.1.jar + ``` + +4. When running with Docker, you can pass the profile as an environment variable: + ```bash + docker run -p 8090:8090 -e "SPRING_PROFILES_ACTIVE=local" management-node + ``` + +The application will load both the default `application.yml` and your profile-specific `application-local.yml`, with the latter overriding any duplicate properties. + +## Code Coverage with JaCoCo + +The project uses JaCoCo for code coverage analysis. For detailed information about the JaCoCo setup, thresholds, and recommendations, see the [JaCoCo Coverage Documentation](docs/JACOCO_COVERAGE.md). + +### Running Code Coverage + +To generate code coverage reports: + +1. Run the Maven verify goal: + ```bash + mvn clean verify + ``` + +2. The JaCoCo report will be generated in the `target/site/jacoco` directory. + +3. Open `target/site/jacoco/index.html` in a web browser to view the detailed coverage report. + +The current configuration aims for 80% code coverage across instructions, branches, lines, methods, and 50% for classes. + +## Troubleshooting + +### Common Issues + +1. **Certificate Issues**: + - **Error**: `SSL routines::sslv3 alert certificate unknown` + - The server doesn't trust your client certificate + - **Solution**: Regenerate the truststore with the current rootCA: + ```bash + cd docker + mv truststore.jks truststore.jks.old + keytool -import -trustcacerts -noprompt -alias ca -file rootCA.crt -keystore truststore.jks -storepass changeit + # Rebuild the Docker image + cd .. + docker build -t management-node -f docker/Dockerfile-dev . + ``` + - Ensure that the paths to the keystore and truststore files in application.yml are correct + - Verify that the certificate passwords match those in the .env file + - If certificates were regenerated, ensure the truststore contains the new rootCA + +2. **Keycloak Connection Issues**: + - **Error**: `Connection refused` when trying to reach Keycloak + - **From Docker container**: Use `--network keycloak_keycloak_network` and connect to `keycloak:8443` + - **From host machine**: Use `localhost:8443` or `host.docker.internal:8443` + - **Error**: Token validation fails with 401 Unauthorized + - Check that `KEYCLOAK_CLIENTID` environment variable is set correctly + - Verify the token contains required roles using: `echo $TOKEN | cut -d. -f2 | base64 -d | jq .` + - Check that Keycloak is running: `docker ps | grep keycloak` + - Verify that the client secret matches the one in Keycloak admin console + +3. **Database Connection Issues**: + - **Error**: `Connection to localhost:5433 refused` from Docker container + - Docker containers can't reach `localhost` on the host + - **Solution**: Use `--network keycloak_keycloak_network` and `jdbc:postgresql://keycloak-postgres-1:5432/keycloak_db` + - Or use `--add-host=host.docker.internal:host-gateway` and `jdbc:postgresql://host.docker.internal:5433/keycloak_db` + - Ensure PostgreSQL is running: `docker ps | grep postgres` + - Check the database credentials match those in the .env file + - Verify you can connect manually: `docker exec -it keycloak-postgres-1 psql -U keycloak_db_user -d keycloak_db` + +4. **Docker-Specific Issues**: + - **Issue**: Management Node can't fetch JWKs from Keycloak (SSL trust issues between containers) + - **Symptom**: Application starts but JWT validation fails silently + - **Workaround**: Run the application directly using Maven instead of Docker for local development + - **Alternative**: Use docker-compose to set up all services with proper SSL configuration + - **Issue**: Environment variables not being passed to container + - Ensure you use `-e` flag for each environment variable + - Verify with: `docker exec env | grep VARIABLE_NAME` + +## Security Considerations + +This setup implements a zero-trust security model with: +- MTLS for all service-to-service communication +- JWT-based authentication and authorization via Keycloak +- HTTPS for all endpoints +- Client certificate authentication + +For production deployments, consider: +- Using properly signed certificates from a trusted CA +- Implementing network segmentation +- Regularly rotating secrets and certificates +- Setting up monitoring and alerting for security events +## API Documentation + +The project includes interactive API documentation powered by Springdoc OpenAPI (OAS 3.1). This exposes both a human-friendly Swagger UI and machine-readable OpenAPI definitions. + +How to access locally (default settings): +- Swagger UI: https://localhost:8090/swagger-ui.html +- OpenAPI JSON: https://localhost:8090/v3/api-docs + + +Notes +- HTTPS: The application serves over HTTPS by default (see server.ssl in application.yml). If you use development certificates, your browser may warn about trust; proceed after trusting the dev CA as described in Certificate Setup. +- Security: The security configuration explicitly permits unauthenticated access to the documentation endpoints (/v3/api-docs/**, /swagger-ui/**, /swagger-ui.html) while keeping all other endpoints protected via OAuth2 Resource Server (JWT). See src/main/java/.../config/SecurityConfig.java for details. +- Port/environment: If you run on a different port or behind a reverse proxy, adjust the base URL accordingly. + +How Springdoc OpenAPI works in this project +- Auto-scanning: The springdoc-openapi-starter-webmvc-ui dependency scans Spring MVC controllers at startup and automatically builds an OpenAPI 3.1 specification from your request mappings, parameters, request/response bodies, and status codes. +- Annotations (optional but recommended): + - @Operation(summary = "...", description = "...") adds summaries, descriptions, and operation-level metadata. + - @Tag(name = "...") groups endpoints in the UI. + - @Parameter, @Schema, @ApiResponse add fine-grained control over params, models, and responses. +- Security schema: Because this app is an OAuth2 Resource Server (JWT), you can declare a bearerAuth security scheme to document Authorization: Bearer . Example: + + @io.swagger.v3.oas.annotations.security.SecurityScheme( + name = "bearerAuth", + type = io.swagger.v3.oas.annotations.enums.SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" + ) + + Then add @SecurityRequirement(name = "bearerAuth") on secured controllers or operations. +- Global metadata: You can set title, version, and contact details using @OpenAPIDefinition on a @Configuration class if desired. + + +## Authentication Requirements + +All protected endpoints require JWT bearer tokens. Tokens must: +- Include the audience (aud) claim with value `account` (default Keycloak audience for service accounts). +- Contain a `resource_access` claim with client-specific roles under `resource_access.management-node.roles`. + +**Required Client Roles:** +- `access_producer_configurations` - Required to access `/api/v1/configuration/producer` endpoint +- `access_consumer_configurations` - Required to access `/api/v1/configuration/consumer` endpoint + +**Token Structure Example:** +```json +{ + "aud": "account", + "resource_access": { + "management-node": { + "roles": [ + "access_producer_configurations", + "access_consumer_configurations" + ] + } + }, + "client_id": "management-node" +} +``` + +These roles must be: +1. Created as client roles in the Keycloak `management-node` client +2. Assigned to the service account of the `management-node` client + +Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). + +## Public Funding Acknowledgment +This repository has been developed with public funding as part of the National Digital Twin Programme (NDTP), a UK Government initiative. NDTP, alongside its partners, has invested in this work to advance open, secure, and reusable digital twin technologies for any organisation, whether from the public or private sector, irrespective of size. +## License +This repository contains both source code and documentation, which are covered by different licenses: +- **Code:** Developed and maintained by National Digital Twin Programme. Licensed under the Apache License 2.0. +- **Documentation:** Licensed under the Open Government Licence v3.0. + See `LICENSE.md`, `OGL_LICENCE.md`, and `NOTICE.md` for details. +## Security and Responsible Disclosure +We take security seriously. If you believe you have found a security vulnerability in this repository, please follow our responsible disclosure process outlined in `SECURITY.md`. +## Software Bill of Materials (SBOM) +This project provides a Software Bill of Materials (SBOM) to help users and integrators understand its dependencies. +### Current SBOM +Download the [latest SBOM for this codebase](https://github.com/National-digital-twin/management-node/dependency-graph/sbom) to view the current list of components used in this repository. +## Contributing +We welcome contributions that align with the Programmeโ€™s objectives. Please read our `CONTRIBUTING.md` guidelines before submitting pull requests. +## Acknowledgements +This repository has benefited from collaboration with various organisations. For a list of acknowledgments, see `ACKNOWLEDGEMENTS.md`. +## Support and Contact +For questions or support, check our Issues or contact the NDTP team on ndtp@businessandtrade.gov.uk. + +**Maintained by the National Digital Twin Programme (NDTP).** +ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entityright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. \ No newline at end of file diff --git a/docs/jobs-scheduler.md b/docs/jobs-scheduler.md new file mode 100644 index 0000000..c21bad6 --- /dev/null +++ b/docs/jobs-scheduler.md @@ -0,0 +1,107 @@ +# Jobs Scheduler + +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- +This document describes the two ways to schedule recurring jobs in the system: CRON and Interval. It also includes examples for both CRON expressions and ISOโ€‘8601 durations. + +## 1. CRON type + +Use CRON when you want precise calendar-based schedules (e.g., "every weekday at 09:00" or "at 2:30 AM on the first of every month"). + +A typical CRON expression uses 5 or 6 space-separated fields, depending on the scheduler implementation: + +- Second (optional) โ€” 0โ€“59 +- Minute โ€” 0โ€“59 +- Hour โ€” 0โ€“23 +- Day of month โ€” 1โ€“31 +- Month โ€” 1โ€“12 or JANโ€“DEC +- Day of week โ€” 0โ€“7 (0 or 7 = Sunday) or SUNโ€“SAT + +Common special characters: +- * โ€” any value +- , โ€” value list separator +- - โ€” range of values +- / โ€” step values (e.g., */5) +- ? โ€” no specific value (used in some cron dialects where both DOM and DOW exist) + +Examples: +- Every day at 02:30 (with seconds): 0 30 2 * * * +- Every day at 02:30 (5-field style): 30 2 * * * +- Every 5 minutes: */5 * * * * (or 0 */5 * * * * when using seconds) +- Every Monday at 09:00: 0 0 9 * * MON +- At 00:00 on the first of every month: 0 0 0 1 * * +- Weekdays at 18:15: 0 15 18 * * MON-FRI + +Tips: +- If your scheduler expects the seconds field, use 6 fields; otherwise use 5. +- If both Day-of-month and Day-of-week are present, some schedulers require one of them to be ?, indicating "not specified." + +## 2. Interval type + +Use Interval when you want a fixed duration between runs (e.g., "every 15 minutes"), independent of calendar concepts. Intervals are represented as ISOโ€‘8601 duration strings. + +ISOโ€‘8601 Duration format: PnYnMnDTnHnMnS +- P โ€” designator meaning "period" +- nY โ€” years +- nM โ€” months (in the date part) +- nW โ€” weeks (alternative to days; if used, donโ€™t combine with D) +- nD โ€” days +- T โ€” time designator that precedes the time components +- nH โ€” hours +- nM โ€” minutes (in the time part) +- nS โ€” seconds + +Common duration examples: +- PT15M โ€” every 15 minutes +- PT1H โ€” every 1 hour +- PT1H30M โ€” every 1 hour and 30 minutes +- P1D โ€” every 1 day (24 hours) +- P2DT12H โ€” every 2 days and 12 hours + +Some systems also support repeating intervals using the ISOโ€‘8601 repeating interval notation: +- Rn/start/duration, where Rn is the repeat count (R without a number means unlimited repeats) +- Example (repeat 5 times starting on a given instant, once per day): R5/2025-10-14T00:00:00Z/P1D + +Notes: +- When only a duration is provided (e.g., PT15M), the next run is typically computed from the last run time plus the duration. +- If your platform supports a startAt or firstRunAt property, pair it with the duration to control the initial trigger time. + +## Choosing between CRON and Interval +- Choose CRON for calendar-aware schedules or when you need specific days/times (like "every weekday at 09:00"). +- Choose Interval for simple, uniform spacing between runs (like "every 15 minutes"), irrespective of wall-clock boundaries. + +## Quick reference examples + +CRON: +- 0 0 9 * * MON-FRI โ€” Weekdays at 09:00 +- 0 0 0 1 * * โ€” Midnight on the first day of each month +- 0 */10 * * * * โ€” Every 10 minutes (with seconds field) + +ISOโ€‘8601 durations (Interval): +- PT5M โ€” every five minutes +- PT2H โ€” every two hours +- P1D โ€” every day +- R/2025-10-14T08:00:00Z/PT30M โ€” from 2025-10-14 08:00Z, every 30 minutes, repeat indefinitely + +## Database tables that accept schedule expressions and types + +The following tables store schedule configuration and accept both CRON expressions and Interval (ISOโ€‘8601 duration) values: + +- consumer + - schedule_type (varchar): expected values are 'cron' or 'interval' (case-insensitive depending on DB usage). + - schedule_expression (varchar): + - If schedule_type = 'cron' โ†’ a CRON expression (e.g., "0 */10 * * * *" or "*/5 * * * *"). + - If schedule_type = 'interval' โ†’ an ISOโ€‘8601 duration (e.g., "PT15M", "P1D"). + +- product_consumer + - schedule_type (varchar): expected values are 'cron' or 'interval'. + - schedule_expression (varchar): + - If schedule_type = 'cron' โ†’ a CRON expression. + - If schedule_type = 'interval' โ†’ an ISOโ€‘8601 duration. + +Notes: +- Default/backfill in migration sets schedule_type to 'cron' with a sample expression (*/5 * * * *) for existing rows. +- Ensure expressions match the scheduler dialect in use (5-field or 6-field with seconds). \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..dcd897e --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +/* Custom primary color for Material for MkDocs */ + +/* Light (default) scheme */ +:root { + /* Primary brand color used for header, active nav, etc. */ + --md-primary-fg-color: #002244; + /* Optional variants (used for hover/focus states) */ + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Dark (slate) scheme */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #002244; + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Increase logo size in the header */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 0.8rem; /* default is ~1.8rem; increase for better visibility */ + width: auto; +} + +/* Improve link readability in dark (slate) mode */ +[data-md-color-scheme="slate"] .md-typeset a:link { + /* Light blue for high contrast on dark backgrounds */ + color: #8ab4f8; +} +[data-md-color-scheme="slate"] .md-typeset a:visited { + /* Slightly desaturated/lighter to distinguish visited links */ + color: #b3c7ff; +} +[data-md-color-scheme="slate"] .md-typeset a:hover, +[data-md-color-scheme="slate"] .md-typeset a:focus { + color: #c4ddff; +} +/* Also adjust visited links in navigation (sidebar, toc) */ +[data-md-color-scheme="slate"] .md-nav__link:visited { + color: #b3c7ff; +} + +/* Custom primary color for Material for MkDocs */ + +/* Light (default) scheme */ +:root { + /* Primary brand color used for header, active nav, etc. */ + --md-primary-fg-color: #002244; + /* Optional variants (used for hover/focus states) */ + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Dark (slate) scheme */ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #002244; + --md-primary-fg-color--light: #2e4a6f; + --md-primary-fg-color--dark: #00152e; +} + +/* Increase logo size in the header */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 3.6rem; /* default is ~1.8rem; increase for better visibility */ + width: auto; +} + +/* Improve link readability in dark (slate) mode */ +[data-md-color-scheme="slate"] .md-typeset a:link { + /* Light blue for high contrast on dark backgrounds */ + color: #8ab4f8; +} +[data-md-color-scheme="slate"] .md-typeset a:visited { + /* Slightly desaturated/lighter to distinguish visited links */ + color: #b3c7ff; +} +[data-md-color-scheme="slate"] .md-typeset a:hover, +[data-md-color-scheme="slate"] .md-typeset a:focus { + color: #c4ddff; +} +/* Also adjust visited links in navigation (sidebar, toc) */ +[data-md-color-scheme="slate"] .md-nav__link:visited { + color: #b3c7ff; +} + +/* Footer styling: background #00152e and white text/links */ +.md-footer, +.md-footer__inner, +.md-footer-meta, +.md-footer-meta__inner { + background-color: #00152e !important; +} + +/* Ensure all footer text is white for readability */ +.md-footer, +.md-footer * { + color: #ffffff !important; +} + +/* Footer links states */ +.md-footer a, +.md-footer a:visited { + color: #ffffff !important; + text-decoration: underline; +} + +.md-footer a:hover, +.md-footer a:focus { + color: #ffffff !important; + opacity: 0.85; + text-decoration: underline; +} + +/* Reduce header logo size for better balance */ +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + height: 1.6rem !important; /* smaller than the previous 3.6rem */ + width: auto; +} + +/* Emphasize active/selected links in navigation and tabs */ +/* Sidebar (primary) and Table of contents (secondary) active links */ +.md-nav__item .md-nav__link--active, +.md-nav__item .md-nav__link[aria-current="page"], +.md-nav__item .md-nav__link[aria-current="true"], +.md-nav--primary .md-nav__item--active > .md-nav__link, +.md-nav--secondary .md-nav__link--active, +.md-nav--secondary .md-nav__link[aria-current="true"], +/* Top navigation tabs */ +.md-tabs__link--active, +.md-tabs__link[aria-current="page"] { + font-weight: 700 !important; +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..47e6f0c --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,81 @@ +site_name: Management-Node Documentation +site_description: Management Node is is the control plane for the National Digital Twin Programme IA Node Net. +site_author: NDTP +site_url: https://docs.ndtp.co.uk/ +#repo_url: https://github.com/National-Digital-Twin/federator +edit_uri: edit/main/docs/ +nav: [] +theme: + features: + - content.code.annotate + - content.code.copy + - content.code.select + - content.tooltips + - navigation.indexes + - navigation.tracking + - search.highlight + - search.share + - search.suggest + - search.share + - navigation.instant + - navigation.instant.prefetch + - navigation.instant + - navigation.instant.progress + - navigation.path + - toc.follow + + + icon: + repo: fontawesome/brands/github + language: en + name: material + logo: assets/light-page_header_logo.png + favicon: assets/android-chrome-512x512-1-150x150.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + font: + text: Roboto + code: Roboto Mono +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - tables + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format +extra_css: + - stylesheets/extra.css +extra: + version: + provider: mike + generator: false +plugins: + - include-markdown: + rewrite_relative_urls: true + - search + - git-revision-date-localized: + enabled: true +copyright: | + ©Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + diff --git a/pom.xml b/pom.xml index 9761ca6..5804698 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ @@ -14,7 +14,7 @@ uk.gov.dbt.ndtp.ia.management.node management-node - 1.0.1 + 1.1.0 jar management-node Provides Management capabilities over IA Node Net @@ -155,6 +155,11 @@ ${mockito-junit-jupiter.version} test + + com.h2database + h2 + test + @@ -264,7 +269,7 @@ report - test + verify check diff --git a/scripts/script.sh b/scripts/script.sh new file mode 100644 index 0000000..ca2a342 --- /dev/null +++ b/scripts/script.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: Apache-2.0 +# ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Automate setting up a Python venv and installing MkDocs and common plugins. +# Optionally runs `mkdocs serve`. +# +# Usage examples: +# bash script.sh # Do everything except `mkdocs serve` +# bash script.sh --serve # Do everything and start the dev server +# bash script.sh --no-apt # Skip apt steps (useful on non-Debian or if already installed) +# bash script.sh --py 3.12 # Prefer python3.12 for venv if available +# bash script.sh --help # Show help +# +# This script is idempotent: it will skip steps that are already satisfied. + +set -euo pipefail + +PREFERRED_PY_MINOR="" +DO_APT=1 +DO_SERVE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --serve) + DO_SERVE=1 + shift + ;; + --no-apt) + DO_APT=0 + shift + ;; + --py) + PREFERRED_PY_MINOR="${2:-}" + if [[ -z "$PREFERRED_PY_MINOR" ]]; then + echo "--py requires a version like 3.12" >&2 + exit 1 + fi + shift 2 + ;; + -h|--help) + cat < Prefer pythonX.Y for the virtual environment (e.g., 3.12). + -h, --help Show this help message. + +The script will: + - (Optionally) apt update and install python3-venv and python3-pip. + - Create/Reuse a Python virtual environment at ./venv and upgrade pip. + - Install mkdocs and common plugins (material, git plugins, etc.). + - Initialize mkdocs project only if mkdocs.yml is missing. + - (Optionally) start mkdocs dev server with livereload. +EOF + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +need_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +run_sudo() { + if need_cmd sudo; then + sudo "$@" + else + "$@" + fi +} + +apt_install_if_missing() { + local pkg="$1" + dpkg -s "$pkg" >/dev/null 2>&1 || run_sudo apt-get install -y "$pkg" +} + +if [[ "$DO_APT" -eq 1 ]]; then + if need_cmd apt-get; then + + echo "[INFO] Ensuring python3-pip and python3-venv are installed..." + apt_install_if_missing python3-pip || true + apt_install_if_missing python3-venv || true + + # Also try the explicit minor version venv if requested or if 3.12 exists + if [[ -n "$PREFERRED_PY_MINOR" ]]; then + apt_install_if_missing "python${PREFERRED_PY_MINOR}-venv" || true + else + # Best-effort for common newer Python + if apt-cache show python3.12-venv >/dev/null 2>&1; then + apt_install_if_missing python3.12-venv || true + fi + fi + else + echo "[WARN] apt-get not found. Skipping apt steps. Use --no-apt to silence." + fi +fi + +# Choose python executable for venv +PY=python3 +if [[ -n "$PREFERRED_PY_MINOR" ]] && need_cmd "python${PREFERRED_PY_MINOR}"; then + PY="python${PREFERRED_PY_MINOR}" +elif need_cmd python3; then + PY=python3 +elif need_cmd python; then + # Fallback to 'python' if it is Python 3 + if python -c 'import sys; exit(0 if sys.version_info.major==3 else 1)' 2>/dev/null; then + PY=python + else + echo "[ERROR] Python 3 is required but not found." >&2 + exit 1 + fi +else + echo "[ERROR] python3 not found. Install Python 3 and try again." >&2 + exit 1 +fi + +echo "[INFO] Using Python interpreter: $(command -v "$PY")" + +# Create venv if missing +if [[ ! -d venv ]]; then + echo "[INFO] Creating virtual environment in ./venv ..." + "$PY" -m venv venv +else + echo "[INFO] Reusing existing virtual environment at ./venv" +fi + +# shellcheck disable=SC1091 +source venv/bin/activate + +# Ensure recent pip +python -m pip install --upgrade pip + +# Install mkdocs and plugins +PKGS=( + mkdocs + mkdocs-material + mkdocs-git-revision-date-localized-plugin + mkdocs-git-committers-plugin-2 + Pygments + mkdocs-include-markdown-plugin + pymdown-extensions + mkdocs-open-in-new-tab==1.0.8 + mike +) + +echo "[INFO] Installing Python packages: ${PKGS[*]}" +pip install -U "${PKGS[@]}" + +# Initialize mkdocs project if needed +if [[ ! -f mkdocs.yml ]]; then + echo "[INFO] mkdocs.yml not found. Initializing a new MkDocs project in current directory..." + mkdocs new . +else + echo "[INFO] mkdocs.yml exists. Skipping 'mkdocs new .'" +fi + +# Optionally run the dev server +if [[ "$DO_SERVE" -eq 1 ]]; then + echo "[INFO] Starting MkDocs dev server with livereload... (Ctrl+C to stop)" + exec mkdocs serve --livereload +else + echo "[INFO] Setup complete. To start the dev server, run:" + echo " source venv/bin/activate && mkdocs serve --livereload" +fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..b739192 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# SPDX-License-Identifier: Apache-2.0 +# ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Uninstall helper: deactivate the virtual environment (if possible) and remove ./venv +# +# Usage: +# bash scripts/uninstall.sh # Prompt, then remove ./venv +# bash scripts/uninstall.sh --yes # Do not prompt, proceed immediately +# bash scripts/uninstall.sh -h|--help # Show help +# +# Notes: +# - Deactivating a virtual environment from a child process (this script) cannot +# affect your parent shell session. If your current shell has the venv active, +# this script will try to deactivate if possible, otherwise it will instruct you +# to run 'deactivate' after it finishes. + +set -euo pipefail + +CONFIRM=1 + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes) + CONFIRM=0 + shift + ;; + -h|--help) + cat <&2 + exit 1 + ;; + esac +done + +PROJ_VENV_DIR="$(pwd)/venv" + +# Inform and confirm +if [[ $CONFIRM -eq 1 ]]; then + read -r -p "This will remove the virtual environment at ./venv. Continue? [y/N] " ans + case "${ans:-}" in + y|Y|yes|YES) + ;; + *) + echo "Aborted." + exit 0 + ;; + esac +fi + +# Try to deactivate if the current shell is using this venv +if [[ "${VIRTUAL_ENV:-}" != "" ]]; then + if [[ "${VIRTUAL_ENV}" == "$PROJ_VENV_DIR" ]]; then + echo "[INFO] Detected active virtual environment: $VIRTUAL_ENV" + if declare -F deactivate >/dev/null 2>&1; then + echo "[INFO] Attempting to deactivate current shell venv..." + deactivate || true + else + echo "[WARN] Cannot deactivate the parent shell from this script." + echo " After this script finishes, run: deactivate" + fi + fi +fi + +# Remove the venv directory +if [[ -d "$PROJ_VENV_DIR" ]]; then + echo "[INFO] Removing $PROJ_VENV_DIR ..." + rm -rf "$PROJ_VENV_DIR" + echo "[INFO] Removed ./venv" +else + echo "[INFO] No ./venv directory found. Nothing to remove." +fi + +# Final note if shell still shows (venv) +echo "[INFO] Uninstall complete. If your shell still shows (venv), run: deactivate" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..2d780d8 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=National-Digital-Twin_management-node +sonar.organization=national-digital-twin +sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml \ No newline at end of file diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java index ee1501c..3024367 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java @@ -7,6 +7,7 @@ package uk.gov.dbt.ndtp.ia.node.management.config; import java.util.Collection; +import java.util.Objects; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; @@ -37,4 +38,17 @@ public CustomJwtAuthenticationToken( public EnhancedPrincipal getPrincipal() { return this.principal; } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + CustomJwtAuthenticationToken that = (CustomJwtAuthenticationToken) o; + return Objects.equals(getPrincipal(), that.getPrincipal()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), getPrincipal()); + } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java index a103a47..ff1d69b 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java @@ -1,13 +1,12 @@ /* * 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.config; import java.util.*; -import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.convert.converter.Converter; @@ -61,10 +60,7 @@ public class KeycloakJwtAuthenticationConverter implements Converter formData = new LinkedMultiValueMap<>(); formData.add(FORM_CLIENT_ID, clientId); - formData.add(FORM_CLIENT_SECRET, clientSecret); formData.add(FORM_TOKEN, tokenValue); // Create the request entity @@ -139,15 +127,15 @@ private JwtToken performTokenIntrospection(String tokenValue) throws TokenIntros @Override public AbstractAuthenticationToken convert(Jwt jwt) { try { - log.debug("Converting JWT to authentication token"); - + String clientId = jwt.getClaimAsString(CLAIM_AZP); + log.debug("Converting JWT to authentication token:{}", clientId); // Perform token introspection - JwtToken introspectionData = performTokenIntrospection(jwt.getTokenValue()); + JwtToken introspectionData = performTokenIntrospection(jwt.getTokenValue(), clientId); // Extract authorities from the introspection data Collection authorities = extractAuthoritiesFromIntrospection(introspectionData); - // Extract client_id from introspection data + // Extract clientId from introspection data String tokenClientId = extractClientIdFromIntrospection(introspectionData); // Extract subject from introspection data @@ -235,28 +223,28 @@ private String extractClientId(Jwt jwt) { */ private Collection extractAuthoritiesFromIntrospection(JwtToken jwtToken) { Collection authorities = new ArrayList<>(); - String clientId = extractClientIdFromIntrospection(jwtToken); + String extractedClientId = extractClientIdFromIntrospection(jwtToken); try { - log.debug("Extracting authorities from introspection data for client ID: {}", clientId); + log.debug("Extracting authorities from introspection data for client ID: {}", extractedClientId); // Process resource_access if (jwtToken.getResourceAccess() != null) { jwtToken.getResourceAccess().forEach((resource, resourceAccess) -> { if (resourceAccess != null && resourceAccess.getRoles() != null) { - resourceAccess.getRoles().forEach(role -> { - authorities.add(new SimpleGrantedAuthority( - ROLE_PREFIX + resource + RESOURCE_ROLE_SEPARATOR + role)); - }); + resourceAccess + .getRoles() + .forEach(role -> authorities.add(new SimpleGrantedAuthority( + ROLE_PREFIX + resource + RESOURCE_ROLE_SEPARATOR + role))); } }); } - log.trace("Successfully extracted {} authorities for client ID: {}", authorities.size(), clientId); + log.trace("Successfully extracted {} authorities for client ID: {}", authorities.size(), extractedClientId); } catch (Exception e) { - log.error("Error extracting authorities from introspection data for client ID: {}", clientId, e); + log.error("Error extracting authorities from introspection data for client ID: {}", extractedClientId, e); throw new ResourceAccessParsingException( - "Failed to parse resource access from introspection data", e, clientId); + "Failed to parse resource access from introspection data", e, extractedClientId); } return authorities; @@ -330,17 +318,17 @@ private Collection processResourceRoles(String resourceName, M } }) .map(authority -> authority) - .collect(Collectors.toList())) + .toList()) .orElse(Collections.emptyList()); } private Collection extractAuthorities(Jwt jwt) { // Add default authorities if any Collection authorities = new ArrayList<>(defaultGrantedAuthoritiesConverter.convert(jwt)); - String clientId = extractClientId(jwt); + String extractClientId = extractClientId(jwt); try { - log.trace("Extracting authorities from JWT for client ID: {}", clientId); + log.trace("Extracting authorities from JWT for client ID: {}", extractClientId); // Extract and process resource_access claim extractMap(jwt.getClaim(CLAIM_RESOURCE_ACCESS)) @@ -349,9 +337,12 @@ private Collection extractAuthorities(Jwt jwt) { .ifPresent(resourceData -> authorities.addAll(processResourceRoles(resource, resourceData))))); - log.trace("Successfully extracted {} authorities from JWT for client ID: {}", authorities.size(), clientId); + log.trace( + "Successfully extracted {} authorities from JWT for client ID: {}", + authorities.size(), + extractClientId); } catch (Exception e) { - log.error("Error extracting authorities from JWT for client ID: {}", clientId, e); + log.error("Error extracting authorities from JWT for client ID: {}", extractClientId, e); // We're not throwing the exception here because we want to continue with default authorities // This is a fallback method, so we want to be more lenient } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java index 323fbde..88e12cf 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java @@ -11,7 +11,6 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.Optional; @@ -46,28 +45,26 @@ public ConfigurationController(ConfigurationProvider configurationProvider) { description = "Returns configuration for the authenticated client, optionally scoped to a specific producer.", security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "Federator Producer configuration returned", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = ProducerConfigDTO.class))), - @ApiResponse(responseCode = "400", description = "Invalid request parameters"), - @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Not found"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) + @ApiResponse( + responseCode = "200", + description = "Federator Producer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProducerConfigDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "404", description = "Not found") + @ApiResponse(responseCode = "500", description = "Internal server error") public ProducerConfigDTO getProducerConfigurations( @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, @Parameter(name = "producer_id", description = "Optional Producer identifier to filter configuration") @RequestParam(value = "producer_id", required = false) - Long producer_id) { - log.info("Preparing Federator Producer Config for producer {}", producer_id); + Long producerId) { + log.info("Preparing Federator Producer Config for producer {}", producerId); return configurationProvider.getProducerConfigByClientId( - principal.clientId(), producer_id != null ? Optional.of(producer_id) : Optional.empty()); + principal.clientId(), producerId != null ? Optional.of(producerId) : Optional.empty()); } @GetMapping("/consumer") @@ -77,20 +74,18 @@ public ProducerConfigDTO getProducerConfigurations( description = "Returns configuration for the authenticated client, optionally scoped to a specific consumer.", security = {@SecurityRequirement(name = "bearerAuth")}) - @ApiResponses({ - @ApiResponse( - responseCode = "200", - description = "Consumer configuration returned", - content = - @Content( - mediaType = "application/json", - schema = @Schema(implementation = ConsumerConfigDTO.class))), - @ApiResponse(responseCode = "400", description = "Invalid request parameters"), - @ApiResponse(responseCode = "401", description = "Unauthorized"), - @ApiResponse(responseCode = "403", description = "Forbidden"), - @ApiResponse(responseCode = "404", description = "Not found"), - @ApiResponse(responseCode = "500", description = "Internal server error") - }) + @ApiResponse( + responseCode = "200", + description = "Consumer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ConsumerConfigDTO.class))) + @ApiResponse(responseCode = "400", description = "Invalid request parameters") + @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "403", description = "Forbidden") + @ApiResponse(responseCode = "404", description = "Not found") + @ApiResponse(responseCode = "500", description = "Internal server error") public ConsumerConfigDTO getConsumerConfigurations( @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, @Parameter(name = "consumer_id", description = "Optional Consumer identifier to filter configuration") diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java index fa798c9..ee57b85 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java @@ -7,7 +7,6 @@ package uk.gov.dbt.ndtp.ia.node.management.converter; import java.util.List; -import java.util.stream.Collectors; /** * Generic interface for converting between entity and DTO objects. @@ -43,7 +42,7 @@ default List toDtoList(List entities) { if (entities == null) { return List.of(); } - return entities.stream().map(this::toDto).collect(Collectors.toList()); + return entities.stream().map(this::toDto).toList(); } /** @@ -56,6 +55,6 @@ default List toEntityList(List dtos) { if (dtos == null) { return List.of(); } - return dtos.stream().map(this::toEntity).collect(Collectors.toList()); + return dtos.stream().map(this::toEntity).toList(); } } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java index 26d2f01..457db0d 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java @@ -48,6 +48,8 @@ public ConsumerDTO toDto(Consumer entity) { .name(entity.getName()) .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) .idpClientId(entity.getIdpClientId()) + .scheduleExpression(entity.getScheduleExpression()) + .scheduleType(entity.getScheduleType()) .build(); // Populate attributes from associated ProductConsumers @@ -89,7 +91,8 @@ public Consumer toEntity(ConsumerDTO dto) { entity.setId(dto.getId()); entity.setName(dto.getName()); entity.setIdpClientId(dto.getIdpClientId()); - + entity.setScheduleExpression(dto.getScheduleExpression()); + entity.setScheduleType(dto.getScheduleType()); // Set the organisation if orgId is provided if (dto.getOrgId() != null) { Organisation organisation = diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java index e0c7b30..141f296 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java @@ -106,10 +106,10 @@ public Producer toEntity(ProducerDTO dto) { if (dataProviderDTO.getProducerId() == null && dto.getId() != null) { dataProviderDTO.setProducerId(dto.getId()); } - Product dataProvider = productConverter.toEntity(dataProviderDTO); - if (dataProvider != null) { - dataProvider.setProducer(entity); - dataProviders.add(dataProvider); + Product product = productConverter.toEntity(dataProviderDTO); + if (product != null) { + product.setProducer(entity); + dataProviders.add(product); } }); entity.setProducts(dataProviders); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java index e59d656..f327ab6 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java @@ -39,6 +39,10 @@ public ProductConsumerDTO toDto(ProductConsumer entity) { .consumerId(entity.getConsumer() != null ? entity.getConsumer().getId() : null) .grantedTs(entity.getGrantedTs()) .validity(entity.getValidity()) + .destination(entity.getDestination()) + .scheduleExpression(entity.getScheduleExpression()) + .scheduleType(entity.getScheduleType()) + .destination(entity.getDestination()) .build(); // Map attributes if available @@ -72,7 +76,9 @@ public ProductConsumer toEntity(ProductConsumerDTO dto) { entity.setGrantedTs(dto.getGrantedTs()); entity.setValidity(dto.getValidity()); - + entity.setDestination(dto.getDestination()); + entity.setScheduleExpression(dto.getScheduleExpression()); + entity.setScheduleType(dto.getScheduleType()); if (dto.getProductId() != null) { Product product = new Product(); product.setId(dto.getProductId()); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java index bc10ad4..6802fcb 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java @@ -42,10 +42,14 @@ public ProductDTO toDto(Product entity) { return null; } + String typeName = + entity.getProductType() != null ? entity.getProductType().getName() : null; return ProductDTO.builder() .id(entity.getId()) .name(entity.getName()) .topic(entity.getTopic()) + .type(typeName) + .source(entity.getSource()) .producerId(entity.getProducer() != null ? entity.getProducer().getId() : null) .build(); } @@ -66,6 +70,7 @@ public Product toEntity(ProductDTO dto) { entity.setId(dto.getId()); entity.setName(dto.getName()); entity.setTopic(dto.getTopic()); + entity.setSource(dto.getSource()); // Set the producer if producerId is provided if (dto.getProducerId() != null) { diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java index aa311c2..cd29bbe 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java @@ -97,8 +97,8 @@ public ResponseEntity handleAllExceptions(Exception ex, WebReques String errorId = generateErrorId(); log.debug("Runtime exception occurred, error_id={}, path={}: ", errorId, request.getContextPath(), ex); - ErrorResponse errorResponse = new ErrorResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred: " + ex.getMessage(), errorId); + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred", errorId); return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java index 93c566b..65b5dfd 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java @@ -15,5 +15,8 @@ public class ConsumerConfigDTO { private final String clientId; + private final String name; + private final String scheduleType; + private final String scheduleExpression; private final List producers; } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java index 89477b6..bcf3d6a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java @@ -30,5 +30,9 @@ public class ConsumerDTO { private String idpClientId; + private String scheduleType; + + private String scheduleExpression; + private final List attributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java index 8ce94a7..26baef0 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java @@ -6,6 +6,7 @@ package uk.gov.dbt.ndtp.ia.node.management.model.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import java.math.BigDecimal; import java.sql.Timestamp; import java.util.ArrayList; @@ -21,9 +22,20 @@ @NoArgsConstructor @AllArgsConstructor public class ProductConsumerDTO { + @JsonIgnore private Long productId; + + @JsonIgnore private Long consumerId; + + @JsonIgnore private Timestamp grantedTs; + + @JsonIgnore private BigDecimal validity; + + private String scheduleType; + private String scheduleExpression; + private String destination; private final List attributes = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java index cdddd9a..0480026 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java @@ -31,5 +31,11 @@ public class ProductDTO { private String topic; + private String type; + + private String source; + private List consumers = new ArrayList<>(); + + private List configurations = new ArrayList<>(); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java index 10d7e22..a382d58 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java @@ -24,6 +24,12 @@ public class Consumer { @Column(name = "name", nullable = false, length = 50) private String name; + @Column(name = "schedule_type", nullable = false) + private String scheduleType; + + @Column(name = "schedule_expression") + private String scheduleExpression; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "org_id", nullable = false) private Organisation org; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java index 1492166..7093256 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java @@ -27,10 +27,17 @@ public class Product { @Column(name = "topic", nullable = false, length = 150) private String topic; + @Column(name = "source", length = 500) + private String source; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "producer_id", nullable = false) private Producer producer; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_type_id") + private ProductType productType; + @OneToMany(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", referencedColumnName = "id", insertable = false, updatable = false) private List productConsumer; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java index c707e8b..2ee8f15 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java @@ -30,6 +30,15 @@ public class ProductConsumer { @Column(name = "validity", nullable = false) private BigDecimal validity; + @Column(name = "schedule_type", nullable = false) + private String scheduleType; + + @Column(name = "schedule_expression") + private String scheduleExpression; + + @Column(name = "destination") + private String destination; + @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id", nullable = false) private Product product; diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java new file mode 100644 index 0000000..040aae1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductType.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "product_type") +public class ProductType { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java index c3ea1ea..ac6f79a 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java @@ -24,7 +24,9 @@ public interface ConsumerRepository extends JpaRepository { * @param providers a list of IDs of the providers whose associated consumers need to be retrieved * @return a list of {@link Consumer} entities associated with the specified provider IDs */ - @Query("SELECT c FROM Consumer c JOIN fetch c.productConsumers cp " + "inner join fetch cp.product p " + @Query("SELECT c FROM Consumer c JOIN fetch c.productConsumers cp " + + "inner join fetch cp.product p " + + "JOIN FETCH p.productType t " + "inner join fetch cp.consumer consumer " + " WHERE p.id IN :providers") List findConsumersByProviderIds(List providers); diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java index a929301..0509622 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java @@ -31,8 +31,9 @@ public interface ProducerRepository extends JpaRepository { * @param consumerIds a list of consumer IDs used to filter the {@link Producer} and associated entities * @return a list of {@link Producer} entities along with their associated {@link Product} entities and product consumers */ - @Query( - "SELECT o FROM Producer o JOIN FETCH o.products p JOIN p.productConsumer pc WHERE pc.consumer.id IN :consumerIds") + @Query(" SELECT o FROM Producer o " + "JOIN FETCH o.products p JOIN p.productConsumer pc " + + "JOIN FETCH p.productType t " + + "WHERE pc.consumer.id IN :consumerIds ") List findByConsumerIds(List consumerIds); /** @@ -42,6 +43,8 @@ public interface ProducerRepository extends JpaRepository { * @param idpClientId the Identity Provider client identifier used to retrieve corresponding {@link Producer} entities * @return a list of {@link Producer} entities with their associated {@link Product} entities */ - @Query("SELECT o FROM Producer o JOIN FETCH o.products WHERE o.idpClientId IN :idpClientId") + @Query("SELECT o FROM Producer o " + "JOIN FETCH o.products p " + + "JOIN FETCH p.productType t " + + "WHERE o.idpClientId IN :idpClientId") List findByIdpClientId(String idpClientId); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java index ad27315..2c74b10 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java @@ -30,7 +30,7 @@ public interface ProductRepository extends JpaRepository { * @param ids a list of product IDs for which the {@link Product} entities are to be retrieved * @return a list of {@link Product} entities matching the provided IDs */ - @Query("SELECT o FROM Product o WHERE o.id IN :ids") + @Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + "WHERE o.id IN :ids") List findByIds(List ids); /** @@ -39,6 +39,6 @@ public interface ProductRepository extends JpaRepository { * @param producers a list of producer IDs whose associated {@link Product} entities need to be retrieved * @return a list of {@link Product} entities linked to the specified producer IDs */ - @Query("SELECT o FROM Product o WHERE o.producer.id IN :producers") + @Query("SELECT o FROM Product o " + "JOIN FETCH o.productType t " + " WHERE o.producer.id IN :producers") List findByProducerIds(List producers); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java index edcbfec..1507ed7 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java @@ -25,8 +25,8 @@ public interface ProductService { /** * Retrieves a list of DataProviderDTO objects associated with the specified producer IDs. * - * @param ProducerIds the list of producer IDs for which data providers need to be retrieved + * @param producerIds the list of producer IDs for which data providers need to be retrieved * @return a list of DataProviderDTO objects corresponding to the given producer IDs */ - List getProductsByProducerIds(List ProducerIds); + List getProductsByProducerIds(List producerIds); } diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java deleted file mode 100644 index 4d38476..0000000 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; - -import org.springframework.stereotype.Service; -import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; -import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationService; - -/** - * Implementation of the OrganisationService interface. - */ -@Service -public class OrganisationServiceImpl implements OrganisationService { - - private final OrganisationRepository organisationRepository; - - /** - * Constructor-based dependency injection. - * - * @param organisationRepository the organisation repository - */ - public OrganisationServiceImpl(OrganisationRepository organisationRepository) { - this.organisationRepository = organisationRepository; - } -} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java index 74f71ba..68e2880 100644 --- a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java @@ -23,7 +23,7 @@ public class ConfigurationProviderImpl implements ConfigurationProvider { private final ConsumerService consumerService; - private final ProductConsumerService consumerAllowedDataProvidersService; + private final ProductConsumerService productConsumerService; private final ProducerService producerService; @@ -33,7 +33,7 @@ public ConfigurationProviderImpl( ProducerService producerService) { this.consumerService = consumerService; - this.consumerAllowedDataProvidersService = consumerAllowedDataProviders; + this.productConsumerService = consumerAllowedDataProviders; this.producerService = producerService; } @@ -50,13 +50,10 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumers = getFilteredConsumers(clientId, consumerId); List consumerIds = consumers.stream().map(ConsumerDTO::getId).toList(); - List validProductIds = new ArrayList<>(); - - consumers.forEach(consumer -> - validProductIds.addAll(consumerAllowedDataProvidersService.findByConsumerId(consumer.getId()).stream() - .filter(this::isValidProvider) - .map(ProductConsumerDTO::getProductId) - .toList())); + List validProductConsumers = getValidProductConsumers(consumers); + List validProductIds = validProductConsumers.stream() + .map(ProductConsumerDTO::getProductId) + .toList(); List producers = producerService.getProducersByConsumerIds(consumerIds).stream() .filter(ProducerDTO::getActive) @@ -72,12 +69,37 @@ public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional p.getProducts().clear()); } + // finding the products and adding the configurations + producers.forEach(producer -> producer.getProducts().forEach(product -> { + List configs = validProductConsumers.stream() + .filter(pc -> pc.getProductId().equals(product.getId())) + .toList(); + product.setConfigurations(configs); + })); + + ConsumerDTO firstConsumer = consumers.getFirst(); return ConsumerConfigDTO.builder() + .scheduleExpression(firstConsumer.getScheduleExpression()) + .scheduleType(firstConsumer.getScheduleType()) .clientId(clientId) + .name(firstConsumer.getName()) .producers(producers) .build(); } + private List getValidProductConsumers(List consumers) { + List validProductIds = new ArrayList<>(); + + consumers.forEach(consumer -> { + List list = productConsumerService.findByConsumerId(consumer.getId()).stream() + .filter(this::isValidProvider) + .toList(); + + validProductIds.addAll(list); + }); + return validProductIds; + } + @Override public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) { List producers = getFilteredActiveProducers(clientId, producerId); @@ -174,8 +196,7 @@ private void processConsumersForProducers(List producers) { private void processConsumersForProvider(ProductDTO provider) { // Get consumer providers for this data provider - List consumerProviders = - consumerAllowedDataProvidersService.findByDataProviderId(provider.getId()); + List consumerProviders = productConsumerService.findByDataProviderId(provider.getId()); // Filter valid providers and add their consumers addValidConsumersToProvider(consumerProviders, provider); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0532b6f..f8b919b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,11 +8,10 @@ spring: issuer-uri: https://localhost:8443/realms/mng-node jwk-set-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/certs audiences: management-node - authorities-claim-name: resource_access opaquetoken: introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect + client-id: management-node client-secret: - client-id: MANAGEMENT_NODE_CLIENT # required client id for introspect endpoint flyway: create-schemas: on default-schema: mn @@ -20,14 +19,14 @@ spring: enabled: true baseline-on-migrate: true datasource: - url: jdbc:postgresql://localhost:5433/postgres - username: # required postgress username - password: # required postgress username + url: jdbc:postgresql://localhost:5433/keycloak_db # this is setup to get you going easily using the Keycloak postgres so change this ! + username: ${POSTGRES_USER:keycloak_db_user} + password: ${POSTGRES_PASSWORD:} jpa: properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect - show_sql: true + show_sql: false default_schema: mn # Server configuration @@ -36,17 +35,17 @@ server: ssl: key-alias: localhost key-store: keystore.jks #path to ssl keystore - key-store-type: JKS - key-store-password: #keystore password - trust-store: #path to ssl truststore - trust-store-password: #truststore password - trust-store-type: JKS + key-store-type: PKCS12 + key-store-password: ${CERTPASSWORD:} #keystore password + trust-store: truststore.jks #path to ssl truststore + trust-store-password: ${CERTPASSWORD:} #truststore password + trust-store-type: PKCS12 client-auth: need enabled: true # disable for local development Only application: client: key-store: keystore.jks # path to MTLS client keystore - keyStorePassword: # MTLS client keystore password + key-store-password: ${CERTPASSWORD:} # MTLS client keystore password keyStoreType: JKS # Actuator Configuration diff --git a/src/main/resources/db/migration/V20251013135858__add_product_type.sql b/src/main/resources/db/migration/V20251013135858__add_product_type.sql new file mode 100644 index 0000000..0a18c66 --- /dev/null +++ b/src/main/resources/db/migration/V20251013135858__add_product_type.sql @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Create product_type table with autogenerated primary key, name, and description (max 255) +create table if not exists product_type +( + id bigserial + constraint pk_product_type + primary key, + name varchar(150) not null, + description varchar(255) +); + +-- Add foreign key column to product referencing product_type(id) +alter table product add column if not exists product_type_id bigint; + +alter table product + add constraint fk_product__product_type_id + foreign key (product_type_id) references product_type (id); + +-- Optional but recommended: index for faster lookups on FK +create index if not exists idx_product__product_type_id on product (product_type_id); + + +-- Add default values for product_type +INSERT INTO product_type (name, description) VALUES ('topic', 'data exchange using kafka topics'); +INSERT INTO product_type (name, description) VALUES ('file', 'file exchange using cloud file storage'); + +---- update all existing products to use topic product type +UPDATE product set product_type_id = (select id from product_type where name = 'topic') where product_type_id is null; + diff --git a/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql b/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql new file mode 100644 index 0000000..786447e --- /dev/null +++ b/src/main/resources/db/migration/V20251013135880__add_schedule_expression.sql @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +-- Add foreign key column to product referencing product_type(id) +alter table product_consumer add column if not exists schedule_type varchar(100); +alter table product_consumer add column if not exists schedule_expression varchar(255); +alter table product_consumer add column if not exists destination varchar(500); + + +alter table consumer add column if not exists schedule_type varchar(100); +alter table consumer add column if not exists schedule_expression varchar(255); + +-- add source to product +alter table product add column if not exists source varchar(500); + +-- schedule_type: cron, interval +update consumer set schedule_type = 'cron', schedule_expression='*/5 * * * *' where 1=1; +update product_consumer set schedule_type = 'cron', schedule_expression='*/5 * * * *' where 1=1; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java deleted file mode 100644 index 7e7663a..0000000 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -package uk.gov.dbt.ndtp.ia.node.management; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ManagementNodeApplicationTests { - - @Test - void contextLoads() {} -} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java index e75ac25..a0a667e 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java @@ -39,9 +39,9 @@ class ConfigurationControllerTest { @InjectMocks private ConfigurationController configurationController; - private final String CLIENT_ID = "test-client-id"; - private final Long PRODUCER_ID = 1L; - private final Long CONSUMER_ID = 2L; + private final String clientId = "test-client-id"; + private final Long producerId = 1L; + private final Long consumerId = 2L; private ProducerConfigDTO producerConfigDTO; private ConsumerConfigDTO consumerConfigDTO; @@ -52,19 +52,19 @@ void setUp() { // Set up producer config ProducerDTO producerDTO = ProducerDTO.builder() - .id(PRODUCER_ID) + .id(producerId) .name("Test Producer") .active(true) .build(); producerConfigDTO = ProducerConfigDTO.builder() - .clientId(CLIENT_ID) + .clientId(clientId) .producers(Collections.singletonList(producerDTO)) .build(); // Set up consumer config consumerConfigDTO = ConsumerConfigDTO.builder() - .clientId(CLIENT_ID) + .clientId(clientId) .producers(new ArrayList<>()) .build(); } @@ -86,7 +86,7 @@ void getProducerConfigurations_shouldReturnConfig() throws Exception { // Act & Assert mockMvc.perform(get("/api/v1/configuration/producer").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -96,10 +96,10 @@ void getProducerConfigurations_withProducerId_shouldReturnFilteredConfig() throw // Act & Assert mockMvc.perform(get("/api/v1/configuration/producer") - .param("producer_id", PRODUCER_ID.toString()) + .param("producer_id", producerId.toString()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -110,7 +110,7 @@ void getConsumerConfigurations_shouldReturnConfig() throws Exception { // Act & Assert mockMvc.perform(get("/api/v1/configuration/consumer").contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } @Test @@ -120,9 +120,9 @@ void getConsumerConfigurations_withConsumerId_shouldReturnFilteredConfig() throw // Act & Assert mockMvc.perform(get("/api/v1/configuration/consumer") - .param("consumer_id", CONSUMER_ID.toString()) + .param("consumer_id", consumerId.toString()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + .andExpect(jsonPath("$.clientId").value(clientId)); } } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java index 2c11ac1..a03df33 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java @@ -259,7 +259,7 @@ void toEntity_withNullDTO_shouldReturnNull() { } @Test - void toEntity_withValidDTO_shouldReturnCorrectEntity() { + void toEntity_withValidDTO_shouldMapBasicFields() { // Arrange when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); @@ -276,35 +276,62 @@ void toEntity_withValidDTO_shouldReturnCorrectEntity() { assertEquals(port, result.getPort()); assertEquals(tls, result.getTls()); assertEquals(idpClientId, result.getIdpClientId()); + } + + @Test + void toEntity_withValidDTO_shouldMapOrganisation() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getOrg()); assertEquals(orgId, result.getOrg().getId()); assertEquals(orgName, result.getOrg().getName()); + } - // Verify dataProviders mapping + @Test + void toEntity_withValidDTO_shouldMapProductsAndBackReference() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getProducts()); assertEquals(2, result.getProducts().size()); - // Verify first data provider - Product dataProvider1 = result.getProducts().get(0); - assertEquals(dataProviderId1, dataProvider1.getId()); - assertEquals(dataProviderName1, dataProvider1.getName()); - assertEquals(topic1, dataProvider1.getTopic()); - assertNotNull(dataProvider1.getProducer()); - assertEquals(result, dataProvider1.getProducer()); + // Verify first product + Product product1 = result.getProducts().get(0); + assertEquals(dataProviderId1, product1.getId()); + assertEquals(dataProviderName1, product1.getName()); + assertEquals(topic1, product1.getTopic()); + assertNotNull(product1.getProducer()); + assertEquals(result, product1.getProducer()); + + // Verify second product + Product product2 = result.getProducts().get(1); + assertEquals(dataProviderId2, product2.getId()); + assertEquals(dataProviderName2, product2.getName()); + assertEquals(topic2, product2.getTopic()); + assertNotNull(product2.getProducer()); + assertEquals(result, product2.getProducer()); + } - // Verify second data provider - Product dataProvider2 = result.getProducts().get(1); - assertEquals(dataProviderId2, dataProvider2.getId()); - assertEquals(dataProviderName2, dataProvider2.getName()); - assertEquals(topic2, dataProvider2.getTopic()); - assertNotNull(dataProvider2.getProducer()); - assertEquals(result, dataProvider2.getProducer()); - - // Verify productConverter was called for each data provider DTO + @Test + void toEntity_withValidDTO_shouldInvokeDependencies() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + converter.toEntity(dto); + + // Assert / Verify verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); - - // Verify organisation repository was called verify(organisationRepository, times(1)).findById(orgId); } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java index 10b42ac..e98f354 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java @@ -259,7 +259,7 @@ void toEntity_withNullDTO_shouldReturnNull() { } @Test - void toEntity_withValidDTO_shouldReturnCorrectEntity() { + void toEntity_withValidDTO_shouldMapBasicFields() { // Arrange when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); @@ -276,35 +276,62 @@ void toEntity_withValidDTO_shouldReturnCorrectEntity() { assertEquals(port, result.getPort()); assertEquals(tls, result.getTls()); assertEquals(idpClientId, result.getIdpClientId()); + } + + @Test + void toEntity_withValidDTO_shouldMapOrganisation() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getOrg()); assertEquals(orgId, result.getOrg().getId()); assertEquals(orgName, result.getOrg().getName()); + } - // Verify dataProviders mapping + @Test + void toEntity_withValidDTO_shouldMapProductsAndBackReference() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert assertNotNull(result.getProducts()); assertEquals(2, result.getProducts().size()); - // Verify first data provider - Product dataProvider1 = result.getProducts().get(0); - assertEquals(dataProviderId1, dataProvider1.getId()); - assertEquals(dataProviderName1, dataProvider1.getName()); - assertEquals(topic1, dataProvider1.getTopic()); - assertNotNull(dataProvider1.getProducer()); - assertEquals(result, dataProvider1.getProducer()); + // Verify first product + Product product1 = result.getProducts().get(0); + assertEquals(dataProviderId1, product1.getId()); + assertEquals(dataProviderName1, product1.getName()); + assertEquals(topic1, product1.getTopic()); + assertNotNull(product1.getProducer()); + assertEquals(result, product1.getProducer()); + + // Verify second product + Product product2 = result.getProducts().get(1); + assertEquals(dataProviderId2, product2.getId()); + assertEquals(dataProviderName2, product2.getName()); + assertEquals(topic2, product2.getTopic()); + assertNotNull(product2.getProducer()); + assertEquals(result, product2.getProducer()); + } - // Verify second data provider - Product dataProvider2 = result.getProducts().get(1); - assertEquals(dataProviderId2, dataProvider2.getId()); - assertEquals(dataProviderName2, dataProvider2.getName()); - assertEquals(topic2, dataProvider2.getTopic()); - assertNotNull(dataProvider2.getProducer()); - assertEquals(result, dataProvider2.getProducer()); - - // Verify productConverter was called for each data provider DTO + @Test + void toEntity_withValidDTO_shouldInvokeDependencies() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + converter.toEntity(dto); + + // Assert / Verify verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); - - // Verify organisation repository was called verify(organisationRepository, times(1)).findById(orgId); } diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java index 9f2c238..1d5f222 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java @@ -29,7 +29,7 @@ import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductConsumerRepository; @ExtendWith(MockitoExtension.class) -public class ConsumerProviderOrganisationServiceImplTest { +class ConsumerProviderOrganisationServiceImplTest { @Mock private ProductConsumerRepository productConsumerRepository; diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java index ad89403..752bd27 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java @@ -138,7 +138,6 @@ void getConsumersOfProviders_withValidProviderIds_shouldReturnMappedConsumers() // Arrange List providerIds = List.of(1L, 2L); List consumers = List.of(consumer); - List consumerDTOs = List.of(consumerDTO); when(consumerRepository.findConsumersByProviderIds(providerIds)).thenReturn(consumers); when(consumerConverter.toDto(consumer)).thenReturn(consumerDTO); diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java deleted file mode 100644 index 99ffb52..0000000 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - -package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; - -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; - -@ExtendWith(MockitoExtension.class) -class OrganisationServiceImplTest { - - @Mock - private OrganisationRepository organisationRepository; - - @InjectMocks - private OrganisationServiceImpl organisationService; - - @BeforeEach - void setUp() { - // No setup needed as the service has no methods to test yet - } - - @Test - void organisationService_shouldBeInitialized() { - // This test verifies that the service is properly initialized with its dependencies - assertNotNull(organisationService); - assertNotNull(organisationRepository); - } -} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java index 1f72d7c..ef2635d 100644 --- a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -1,43 +1,33 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ยฉ Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally - * attributed to the Department for Business and Trade (UK) as the governing entity. - */ - package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import java.math.BigDecimal; import java.sql.Timestamp; import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; -@ExtendWith(MockitoExtension.class) class ConfigurationProviderImplTest { @Mock private ConsumerService consumerService; @Mock - private ProductConsumerService consumerAllowedDataProvidersService; + private ProductConsumerService productConsumerService; @Mock private ProducerService producerService; @@ -45,462 +35,168 @@ class ConfigurationProviderImplTest { @InjectMocks private ConfigurationProviderImpl configurationProvider; - private final String clientId = "test-client-id"; - private final Long consumerId = 1L; - private final Long producerId = 2L; - private final Long productId = 3L; - - private ConsumerDTO consumerDTO; - private ProducerDTO producerDTO; - private ProductDTO productDTO; - private ProductConsumerDTO productConsumerDTO; - @BeforeEach void setUp() { - // Set up consumer - consumerDTO = ConsumerDTO.builder() - .id(consumerId) - .name("Test Consumer") - .idpClientId(clientId) - .build(); + MockitoAnnotations.openMocks(this); + configurationProvider = new ConfigurationProviderImpl(consumerService, productConsumerService, producerService); + } - // Set up producer - producerDTO = ProducerDTO.builder() - .id(producerId) - .name("Test Producer") + private ConsumerDTO consumer( + long id, String clientId, String name, String scheduleType, String scheduleExpression) { + ConsumerDTO dto = ConsumerDTO.builder() .idpClientId(clientId) - .active(true) - .build(); - - // Set up product - productDTO = ProductDTO.builder() - .id(productId) - .name("Test Product") - .producerId(producerId) - .consumers(new ArrayList<>()) + .name(name) + .scheduleType(scheduleType) + .scheduleExpression(scheduleExpression) .build(); - - // Set up product consumer relationship - productConsumerDTO = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(null) // No validity constraint - .build(); - } - - // Tests for getConsumerConfigByClientId - - @Test - void getConsumerConfigByClientId_withValidClientIdAndNoConsumerId_shouldReturnConfig() { - // Arrange - List consumers = List.of(consumerDTO); - List producers = List.of(producerDTO); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); - } - - @Test - void getConsumerConfigByClientId_withValidClientIdAndConsumerId_shouldReturnFilteredConfig() { - // Arrange - List allConsumers = List.of(consumerDTO); - List producers = List.of(producerDTO); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(allConsumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); - } - - @Test - void getConsumerConfigByClientId_withNoMatchingConsumers_shouldReturnEmptyConfig() { - // Arrange - when(consumerService.findByIdpClientId(clientId)).thenReturn(Collections.emptyList()); - when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(Collections.emptyList()); - verifyNoInteractions(consumerAllowedDataProvidersService); - } - - @Test - void getConsumerConfigByClientId_withNoMatchingConsumerForSpecificId_shouldReturnEmptyConfig() { - // Arrange - ConsumerDTO differentConsumer = - ConsumerDTO.builder().id(999L).idpClientId(clientId).build(); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(differentConsumer)); - when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(Collections.emptyList()); - verifyNoInteractions(consumerAllowedDataProvidersService); - } - - @Test - void getConsumerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { - // Arrange - List consumers = List.of(consumerDTO); - - // Create inactive producer - ProducerDTO inactiveProducer = - ProducerDTO.builder().id(producerId).active(false).build(); - - when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); - when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(List.of(inactiveProducer)); - - // Act - ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(consumerService).findByIdpClientId(clientId); - verify(producerService).getProducersByConsumerIds(List.of(consumerId)); - verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); + dto.setId(id); + return dto; } - // Tests for getProducerConfigByClientId - - @Test - void getProducerConfigByClientId_withValidClientIdAndNoProducerId_shouldReturnConfig() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - assertEquals(1, result.getProducers().getFirst().getProducts().size()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + private ProducerDTO producer(long id, boolean active, ProductDTO... products) { + ProducerDTO p = ProducerDTO.builder() + .id(id) + .active(active) + .idpClientId("cid") + .name("p") + .build(); + for (ProductDTO pr : products) { + p.getProducts().add(pr); + } + return p; } - @Test - void getProducerConfigByClientId_withValidClientIdAndProducerId_shouldReturnFilteredConfig() { - // Arrange - List allProducers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(allProducers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertEquals(producerId, result.getProducers().getFirst().getId()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + private ProductDTO product(Long id, String name) { + ProductDTO d = ProductDTO.builder().name(name).build(); + d.setId(id); + return d; } - @Test - void getProducerConfigByClientId_withNoMatchingProducers_shouldReturnEmptyConfig() { - // Arrange - when(producerService.getProducersByClientId(clientId)).thenReturn(Collections.emptyList()); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + private ProductConsumerDTO productConsumer( + long productId, long consumerId, BigDecimal validityDays, Instant grantedAt) { + return ProductConsumerDTO.builder() + .productId(productId) + .consumerId(consumerId) + .validity(validityDays) + .grantedTs(grantedAt != null ? Timestamp.from(grantedAt) : null) + .scheduleType("CRON") + .scheduleExpression("0 0 * * * *") + .destination("topic") + .build(); } @Test - void getProducerConfigByClientId_withNoMatchingProducerForSpecificId_shouldReturnEmptyConfig() { - // Arrange - ProducerDTO differentProducer = ProducerDTO.builder() - .id(999L) - .idpClientId(clientId) - .active(true) - .build(); - - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(differentProducer)); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + void getConsumerConfigByClientId_filtersInactiveProducers_andProductsByValidIds_andSetsConfigs() { + String clientId = "clientA"; + ConsumerDTO c1 = consumer(1L, clientId, "c1", "CRON", "@hourly"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); + + // Valid configs for product 100 only (null validity treated as valid) + ProductConsumerDTO pc1 = productConsumer(100L, 1L, null, null); + ProductConsumerDTO pc2 = productConsumer(100L, 1L, BigDecimal.ZERO, null); + when(productConsumerService.findByConsumerId(1L)).thenReturn(List.of(pc1, pc2)); + + // One active and one inactive producer; active has products 100 (kept) and 102 (removed) + ProducerDTO active = producer(10L, true, product(100L, "dp-100"), product(102L, "dp-102")); + ProducerDTO inactive = producer(11L, false, product(100L, "dp-100")); + when(producerService.getProducersByConsumerIds(List.of(1L))).thenReturn(List.of(active, inactive)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + // Only active producer remains + assertThat(cfg.getProducers()).containsExactly(active); + // Products filtered to valid productIds (only 100) + assertThat(active.getProducts()).extracting(ProductDTO::getId).containsExactly(100L); + // Configurations set on product 100 + assertThat(active.getProducts().get(0).getConfigurations()).containsExactlyInAnyOrder(pc1, pc2); + // Schedule and name propagated from first consumer + assertThat(cfg.getScheduleType()).isEqualTo("CRON"); + assertThat(cfg.getScheduleExpression()).isEqualTo("@hourly"); + assertThat(cfg.getClientId()).isEqualTo(clientId); } @Test - void getProducerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { - // Arrange - ProducerDTO inactiveProducer = ProducerDTO.builder() - .id(producerId) - .idpClientId(clientId) - .active(false) - .build(); + void getConsumerConfigByClientId_whenNoValidProducts_clearsAllProducerProducts() { + String clientId = "clientB"; + ConsumerDTO c1 = consumer(2L, clientId, "c2", "FIXED", "PT10M"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1)); - when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(inactiveProducer)); - when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); + // No valid product-consumers returned + when(productConsumerService.findByConsumerId(2L)).thenReturn(List.of()); - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + ProducerDTO active = producer(20L, true, product(200L, "dp-200"), product(201L, "dp-201")); + when(producerService.getProducersByConsumerIds(List.of(2L))).thenReturn(List.of(active)); - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertTrue(result.getProducers().isEmpty()); + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + assertThat(cfg.getProducers()).hasSize(1); + assertThat(cfg.getProducers().get(0).getProducts()).isEmpty(); } @Test - void getProducerConfigByClientId_withNullConsumers_shouldInitializeConsumersList() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list with null consumers - productDTO.setConsumers(null); // Null consumers list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertNotNull(result.getProducers().getFirst().getProducts().getFirst().getConsumers()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + void getConsumerConfigByClientId_withConsumerIdFilter_appliesFilter_andRemovesNullProductIds() { + String clientId = "clientC"; + ConsumerDTO c1 = consumer(3L, clientId, "c3", "CRON", "@daily"); + ConsumerDTO cOther = consumer(99L, clientId, "other", "CRON", "@minutely"); + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(c1, cOther)); + + ProductConsumerDTO pc = productConsumer(300L, 3L, null, null); + when(productConsumerService.findByConsumerId(3L)).thenReturn(List.of(pc)); + + ProductDTO pNull = product(null, "no-id"); + ProductDTO pKept = product(300L, "ok"); + ProducerDTO active = producer(30L, true, pNull, pKept); + when(producerService.getProducersByConsumerIds(List.of(3L))).thenReturn(List.of(active)); + + ConsumerConfigDTO cfg = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(3L)); + + // Only products with ids in valid set are kept => null removed, only 300 remains + assertThat(cfg.getProducers().get(0).getProducts()) + .extracting(ProductDTO::getId) + .containsExactly(300L); + // And configurations attached to remaining product + assertThat(cfg.getProducers().get(0).getProducts().get(0).getConfigurations()) + .containsExactly(pc); } @Test - void getProducerConfigByClientId_withExpiredValidity_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - // Create expired product consumer relationship - ProductConsumerDTO expiredProductConsumer = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(BigDecimal.valueOf(30)) // 30 days validity - .grantedTs(Timestamp.from(Instant.now().minus(60, ChronoUnit.DAYS))) // 60 days ago - .build(); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(expiredProductConsumer)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService, never()).findById(any()); - } - - @Test - void getProducerConfigByClientId_withValidityButNoGrantedTs_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - // Create product consumer relationship with validity but no grantedTs - ProductConsumerDTO invalidProductConsumer = ProductConsumerDTO.builder() - .consumerId(consumerId) - .productId(productId) - .validity(BigDecimal.valueOf(30)) // 30 days validity - .grantedTs(null) // No granted timestamp - .build(); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(invalidProductConsumer)); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService, never()).findById(any()); - } - - @Test - void getProducerConfigByClientId_withConsumerNotFound_shouldNotAddConsumer() { - // Arrange - List producers = List.of(producerDTO); - // Add product to producer's dataProviders list - producerDTO.getProducts().add(productDTO); - - Map> consumersMap = new HashMap<>(); - consumersMap.put(productId.toString(), List.of(consumerDTO)); - - when(producerService.getProducersByClientId(clientId)).thenReturn(producers); - when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); - when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) - .thenReturn(List.of(productConsumerDTO)); - when(consumerService.findById(consumerId)).thenReturn(Optional.empty()); - - // Act - ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); - - // Assert - assertNotNull(result); - assertEquals(clientId, result.getClientId()); - assertEquals(1, result.getProducers().size()); - assertTrue(result.getProducers() - .getFirst() - .getProducts() - .getFirst() - .getConsumers() - .isEmpty()); - - // Verify - verify(producerService).getProducersByClientId(clientId); - verify(consumerService).getConsumersOfProviders(List.of(productId)); - verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); - verify(consumerService).findById(consumerId); + void getProducerConfigByClientId_onlyActiveProducers_kept_andOnlyValidConsumersAdded() { + String clientId = "clientP"; + ProductDTO pr1 = product(900L, "prov1"); + ProductDTO pr2 = product(901L, "prov2"); + ProducerDTO active = producer(91L, true, pr1, pr2); + ProducerDTO inactive = producer(92L, false, product(902L, "prov3")); + + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(active, inactive)); + + // product ids should be collected and passed to consumerService.getConsumersOfProviders + when(consumerService.getConsumersOfProviders(any())).thenReturn(Map.of()); + + // For pr1: one valid consumer-provider (validity 10 days from now) and one invalid (expired) + ProductConsumerDTO validCP = productConsumer(900L, 501L, BigDecimal.TEN, Instant.now()); + ProductConsumerDTO expiredCP = + productConsumer(900L, 502L, BigDecimal.ONE, Instant.now().minusSeconds(86400 * 5)); + when(productConsumerService.findByDataProviderId(900L)).thenReturn(List.of(validCP, expiredCP)); + when(productConsumerService.findByDataProviderId(901L)).thenReturn(List.of()); + + // Resolve consumer lookups + ConsumerDTO c501 = consumer(501L, "cid501", "c501", "CRON", "@hourly"); + when(consumerService.findById(501L)).thenReturn(Optional.of(c501)); + when(consumerService.findById(502L)).thenReturn(Optional.empty()); + + ProducerConfigDTO cfg = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Only active producer present + assertThat(cfg.getProducers()).containsExactly(active); + + // Verify consumersOfProviders called with both product ids + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(consumerService).getConsumersOfProviders(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(900L, 901L); + + // For pr1, only valid consumer added + assertThat(pr1.getConsumers()).containsExactly(c501); + // pr2 has none + assertThat(pr2.getConsumers()).isEmpty(); } - - // Tests for isValidProvider method through public methods - } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..61b6538 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,22 @@ +spring: + flyway: + enabled: false + datasource: + url: jdbc:h2:mem:mn_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + show_sql: false + format_sql: false +server: + port: 0 +logging: + level: + root: WARN + uk.gov.dbt.ndtp.ia.node.management: INFO