diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml new file mode 100644 index 0000000..aa299bd --- /dev/null +++ b/.github/actions/build/action.yml @@ -0,0 +1,39 @@ +name: Maven Build +description: "Builds a Maven project." + +inputs: + java-version: + description: "The Java version the build shall run with." + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + mutation-testing: + description: "Whether to run mutation testing." + default: 'true' + required: false + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Piper Maven build + uses: SAP/project-piper-action@main + with: + step-name: mavenBuild + + #- name: Mutation Testing + # if: ${{ inputs.mutation-testing == 'true' }} + # run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-auditlog-ng/pom.xml -ntp -B + # shell: bash diff --git a/.github/actions/deploy-release/action.yml b/.github/actions/deploy-release/action.yml new file mode 100644 index 0000000..37a881a --- /dev/null +++ b/.github/actions/deploy-release/action.yml @@ -0,0 +1,94 @@ +name: Deploy Release to Maven Central +description: "Deploys released artifacts to Maven Central repository." + +inputs: + user: + description: "The user used for the upload (technical user for maven central upload)" + required: true + password: + description: "The password used for the upload (technical user for maven central upload)" + required: true + profile: + description: "The profile id" + required: true + pgp-pub-key: + description: "The public pgp key ID" + required: true + pgp-private-key: + description: "The private pgp key" + required: true + pgp-passphrase: + description: "The passphrase for pgp" + required: true + revision: + description: "The revision of cds-feature-auditlog-ng" + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Echo Inputs + run: | + echo "user: ${{ inputs.user }}" + echo "profile: ${{ inputs.profile }}" + echo "revision: ${{ inputs.revision }}" + shell: bash + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: sapmachine + java-version: '17' + cache: maven + server-id: ossrh + server-username: MAVEN_CENTRAL_USER + server-password: MAVEN_CENTRAL_PASSWORD + + - name: Set up Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Import GPG Key + run: | + echo "${{ inputs.pgp-private-key }}" | gpg --batch --passphrase "$PASSPHRASE" --import + shell: bash + env: + PASSPHRASE: ${{ inputs.pgp-passphrase }} + + - name: Deploy Locally + run: > + mvn -B -ntp -fae --show-version + -Durl=file:./temp_local_repo + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -Dgpg.passphrase="$GPG_PASSPHRASE" + -Dgpg.keyname="$GPG_PUB_KEY" + -Drevision="${{ inputs.revision }}" + deploy + working-directory: ./deploy-oss + shell: bash + env: + MAVEN_CENTRAL_USER: ${{ inputs.user }} + MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} + GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} + GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} + + - name: Deploy Staging + run: > + mvn -B -ntp -fae --show-version + org.sonatype.plugins:nexus-staging-maven-plugin:1.6.13:deploy-staged-repository + -DserverId=ossrh + -DnexusUrl=https://oss.sonatype.org + -DrepositoryDirectory=./temp_local_repo + -DstagingProfileId="$MAVEN_CENTRAL_PROFILE_ID" + -Drevision="${{ inputs.revision }}" + working-directory: ./deploy-oss + shell: bash + env: + MAVEN_CENTRAL_USER: ${{ inputs.user }} + MAVEN_CENTRAL_PASSWORD: ${{ inputs.password }} + MAVEN_CENTRAL_PROFILE_ID: ${{ inputs.profile }} diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml new file mode 100644 index 0000000..fd3e1cd --- /dev/null +++ b/.github/actions/deploy/action.yml @@ -0,0 +1,62 @@ +name: Deploy to artifactory +description: "Deploys artifacts to artifactory." + +inputs: + repository-url: + description: "The URL of the repository to upload to." + required: true + server-id: + description: "The service id of the repository to upload to." + required: true + user: + description: "The user used for the upload." + required: true + password: + description: "The password used for the upload." + required: true + pom-file: + description: "The path to the POM file." + required: false + default: "pom.xml" + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Echo Inputs + run: | + echo "repository-url: ${{ inputs.repository-url }}" + echo "user: ${{ inputs.user }}" + echo "password: ${{ inputs.password }}" + echo "pom-file: ${{ inputs.pom-file }}" + echo "altDeploymentRepository: ${{inputs.server-id}}::${{inputs.repository-url}}" + shell: bash + + - name: Setup Java 17 + uses: actions/setup-java@v4 + with: + distribution: sapmachine + java-version: '17' + server-id: ${{ inputs.server-id }} + server-username: DEPLOYMENT_USER + server-password: DEPLOYMENT_PASS + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Deploy + run: > + mvn -B -ntp -fae --show-version + -DaltDeploymentRepository=${{inputs.server-id}}::${{inputs.repository-url}} + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -f ${{ inputs.pom-file }} + deploy + env: + DEPLOYMENT_USER: ${{ inputs.user }} + DEPLOYMENT_PASS: ${{ inputs.password }} + shell: bash diff --git a/.github/actions/newrelease/action.yml b/.github/actions/newrelease/action.yml new file mode 100644 index 0000000..c7b7113 --- /dev/null +++ b/.github/actions/newrelease/action.yml @@ -0,0 +1,36 @@ +name: Update POM with new release +description: Updates the revision property in the POM file with the new release version. + +inputs: + java-version: + description: "The Java version the build shall run with." + required: true + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Update version + run: | + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + mvn --no-transfer-progress versions:set-property -Dproperty=revision -DnewVersion=$VERSION + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -b main + git commit -am "Update version to $VERSION" + git push --set-upstream origin main + shell: bash \ No newline at end of file diff --git a/.github/actions/scan-with-blackduck/action.yaml b/.github/actions/scan-with-blackduck/action.yaml new file mode 100644 index 0000000..76be691 --- /dev/null +++ b/.github/actions/scan-with-blackduck/action.yaml @@ -0,0 +1,54 @@ +name: "Scan with BlackDuck" +description: "Scans the project with BlackDuck" + +inputs: + blackduck_token: + description: "The token to use for BlackDuck authentication" + required: true + github_token: + description: "The token to use for GitHub authentication" + required: true + java-version: + description: "The version of Java to use" + default: '17' + required: false + maven-version: + description: "The Maven version the build shall run with." + required: true + +runs: + using: composite + steps: + - name: Set up Java ${{ inputs.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ inputs.java-version }} + distribution: sapmachine + cache: maven + + - name: Setup Maven ${{ inputs.maven-version }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ inputs.maven-version }} + + - name: Get Major Version + id: get-major-version + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Version Number + run: echo "${{ steps.get-major-version.outputs.REVISION }}" + shell: bash + + - name: BlackDuck Scan + uses: SAP/project-piper-action@main + with: + step-name: detectExecuteScan + flags: \ + --githubToken=$GITHUB_token \ + --version=${{ steps.get-major-version.outputs.REVISION }} + env: + PIPER_token: ${{ inputs.blackduck_token }} + GITHUB_token: ${{ inputs.github_token }} + SCAN_MODE: FULL diff --git a/.github/actions/scan-with-sonar/action.yaml b/.github/actions/scan-with-sonar/action.yaml new file mode 100644 index 0000000..34522cf --- /dev/null +++ b/.github/actions/scan-with-sonar/action.yaml @@ -0,0 +1,48 @@ +name: Scan with SonarQube +description: Scans the project with SonarQube + +inputs: + sonarq-token: + description: The token to use for SonarQube authentication + required: true + github-token: + description: The token to use for GitHub authentication + required: true + java-version: + description: The version of Java to use + required: true + maven-version: + description: The version of Maven to use + required: true + +runs: + using: composite + + steps: + - name: Set up Java ${{inputs.java-version}} + uses: actions/setup-java@v4 + with: + java-version: ${{inputs.java-version}} + distribution: sapmachine + cache: maven + + - name: Set up Maven ${{inputs.maven-version}} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{inputs.maven-version}} + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Revision + run: echo "${{steps.get-revision.outputs.REVISION}}" + shell: bash + + - name: SonarQube Scan + uses: SAP/project-piper-action@main + with: + step-name: sonarExecuteScan + flags: --token=${{inputs.sonarq-token}} --githubToken=${{inputs.github-token}} --version=${{steps.get-revision.outputs.REVISION}} --inferJavaBinaries=true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..50390b2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: maven + directories: + - "/**/*" + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4337ef6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,92 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '24 18 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: java-kotlin + build-mode: none # This mode only analyzes Java. Set this to 'autobuild' or 'manual' to analyze Kotlin too. + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/main-build-and-deploy-oss.yml b/.github/workflows/main-build-and-deploy-oss.yml new file mode 100644 index 0000000..c7d91e1 --- /dev/null +++ b/.github/workflows/main-build-and-deploy-oss.yml @@ -0,0 +1,104 @@ +name: Deploy to OSS + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + +on: + release: + types: [ "released" ] + +jobs: + + # blackduck: + # name: "Blackduck Scan" + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: "Scan With Black Duck" + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + update-version: + runs-on: ubuntu-latest + # needs: blackduck + steps: + - name: Checkout + uses: actions/checkout@v4 + #with: + # token: ${{ secrets.GH_TOKEN }} + + #- name: Update version + # uses: ./.github/actions/newrelease + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-new-version + path: . + include-hidden-files: true + retention-days: 1 + + build: + runs-on: ubuntu-latest + needs: update-version + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-new-version + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-build + include-hidden-files: true + path: . + retention-days: 1 + + deploy: + name: Deploy to Maven Central + runs-on: ubuntu-latest + needs: build + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-build + + - name: Deploy + uses: ./.github/actions/deploy-release + with: + user: ${{ secrets.OSSRH_SONATYPE_ORG_API_USER }} + password: ${{ secrets.OSSRH_SONATYPE_ORG_API_PASSWD }} + profile: ${{ secrets.OSSRH_SONATYPE_ORG_PROFILE_ID }} + pgp-pub-key: ${{ secrets.PGP_PUBKEY_ID }} + pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + revision: ${{ github.event.release.tag_name }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Echo Status + run: echo "The job status is ${{ job.status }}" diff --git a/.github/workflows/main-build-and-deploy.yml b/.github/workflows/main-build-and-deploy.yml new file mode 100644 index 0000000..5f10872 --- /dev/null +++ b/.github/workflows/main-build-and-deploy.yml @@ -0,0 +1,96 @@ +name: Deploy to Artifactory + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + DEPLOY_REPOSITORY_URL: 'https://common.repositories.cloud.sap/artifactory/cap-java' + POM_FILE: '.flattened-pom.xml' + +on: + release: + types: [ "prereleased" ] + +jobs: + + # blackduck: + # name: Blackduck Scan + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: Scan With Black Duck + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + update-version: + runs-on: ubuntu-latest + # needs: blackduck + steps: + - name: Checkout + uses: actions/checkout@v4 + #with: + # token: ${{ secrets.GH_TOKEN }} + + #- name: Update version + # uses: ./.github/actions/newrelease + # with: + # java-version: ${{ env.JAVA_VERSION }} + # maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-new-version + include-hidden-files: true + path: . + retention-days: 1 + + build: + runs-on: ubuntu-latest + needs: update-version + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-new-version + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ env.JAVA_VERSION }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Upload Changed Artifacts + uses: actions/upload-artifact@v4 + with: + name: root-build + include-hidden-files: true + path: . + retention-days: 1 + + deploy: + name: Deploy to Artifactory + runs-on: ubuntu-latest + needs: build + steps: + - name: Download artifact + uses: actions/download-artifact@v4 + with: + name: root-build + + - name: Deploy with Maven + uses: ./.github/actions/deploy + with: + user: ${{ secrets.DEPLOYMENT_USER }} + password: ${{ secrets.DEPLOYMENT_PASS }} + server-id: artifactory + repository-url: ${{ env.DEPLOY_REPOSITORY_URL }} + pom-file: ${{ env.POM_FILE }} + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Echo Status + run: echo "The job status is ${{ job.status }}" diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml new file mode 100644 index 0000000..c4c8162 --- /dev/null +++ b/.github/workflows/main-build.yml @@ -0,0 +1,96 @@ +name: Main build and deploy + +env: + JAVA_VERSION: '17' + MAVEN_VERSION: '3.6.3' + +on: + push: + branches: [ "main" ] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 17, 21 ] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # if: ${{ matrix.java-version == 17 }} + # with: + # java-version: ${{ matrix.java-version }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} + + # scan: + # name: Blackduck Scan + # runs-on: ubuntu-latest + # timeout-minutes: 15 + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # - name: Scan + # uses: ./.github/actions/scan-with-blackduck + # with: + # blackduck_token: ${{ secrets.BLACK_DUCK_TOKEN }} + # github_token: ${{ secrets.GITHUB_TOKEN }} + # maven-version: ${{ env.MAVEN_VERSION }} + + deploy-snapshot: + name: Deploy snapshot to Artifactory + runs-on: ubuntu-latest + needs: [build] # [build, scan] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v4 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: sapmachine + cache: maven + server-id: artifactory + server-username: DEPLOYMENT_USER + server-password: DEPLOYMENT_PASS + + - name: Set up Maven ${{ env.MAVEN_VERSION }} + uses: stCarolas/setup-maven@v5 + with: + maven-version: ${{ env.MAVEN_VERSION }} + + - name: Get Revision + id: get-revision + run: | + echo "REVISION=$(mvn help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_OUTPUT + shell: bash + + - name: Print Revision + run: echo "Current revision ${{ steps.get-revision.outputs.REVISION }}" + shell: bash + + - name: Deploy snapshot + if: ${{ endsWith(steps.get-revision.outputs.REVISION, '-SNAPSHOT') }} + # https://maven.apache.org/plugins/maven-deploy-plugin/usage.html#the-deploy-deploy-mojo + run: > + mvn -B -ntp -fae + -Dmaven.install.skip=true + -Dmaven.test.skip=true + -DdeployAtEnd=true + deploy + env: + DEPLOYMENT_USER: ${{ secrets.DEPLOYMENT_USER }} + DEPLOYMENT_PASS: ${{ secrets.DEPLOYMENT_PASS }} + shell: bash diff --git a/.github/workflows/pull-request-build.yml b/.github/workflows/pull-request-build.yml new file mode 100644 index 0000000..21f8beb --- /dev/null +++ b/.github/workflows/pull-request-build.yml @@ -0,0 +1,36 @@ +name: Pull Request Voter + +env: + MAVEN_VERSION: '3.6.3' + +on: + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [ 17, 21 ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + uses: ./.github/actions/build + with: + java-version: ${{ matrix.java-version }} + maven-version: ${{ env.MAVEN_VERSION }} + + #- name: SonarQube Scan + # uses: ./.github/actions/scan-with-sonar + # if: ${{ matrix.java-version == 17 }} + # with: + # java-version: ${{ matrix.java-version }} + # maven-version: ${{ env.MAVEN_VERSION }} + # sonarq-token: ${{ secrets.SONARQ_TOKEN }} + # github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ba6cfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +## IntelliJ +.idea +*.iml + +## Eclipse +.project +.classpath +.settings/ + +## Java +target/ + +## Maven Flatten Plugin +.flattened-pom.xml +node_modules + +## PMD +.pmd +.pmdruleset.xml + +## files required for local execution of github actions with act +.env +.secrets +event.json + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk \ No newline at end of file diff --git a/.pipeline/config.yml b/.pipeline/config.yml new file mode 100644 index 0000000..9881379 --- /dev/null +++ b/.pipeline/config.yml @@ -0,0 +1,40 @@ +steps: + mavenBuild: + verbose: false + verify: true + flatten: true + # https://www.project-piper.io/steps/mavenBuild/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + + detectExecuteScan: + projectName: 'com.sap.cds.feature.auditlog-ng' + groups: + - 'CDSJAVA-OPEN-SOURCE' + serverUrl: 'https://sap.blackducksoftware.com/' + mavenExcludedScopes: [ "provided", "test" ] + failOn: [ 'BLOCKER', 'CRITICAL', 'MAJOR' ] + versioningModel: "major-minor" + detectTools: [ 'DETECTOR', 'BINARY_SCAN' ] + installArtifacts: true + repository: '/cap-java/auditlog-ng' + verbose: true + scanProperties: + - --detect.included.detector.types=MAVEN + - --detect.excluded.directories='**/node_modules,**/*test*,**/localrepo,**/target/site,**/*-site.jar' + - --detect.maven.build.command='-pl com.sap.cds:cds-feature-auditlog-ng' + # https://www.project-piper.io/steps/detectExecuteScan/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + + sonarExecuteScan: + serverUrl: https://sonar.tools.sap + projectKey: cds-feature-auditlog-ng + # https://www.project-piper.io/steps/sonarExecuteScan/#dockerimage + # If empty, Docker is not used and the command is executed directly on the Jenkins system. + dockerImage: '' + options: + - sonar.qualitygate.wait=true + - sonar.java.source=17 + - sonar.exclusions=**/node_modules/**,**/target/** + - sonar.coverage.jacoco.xmlReportPaths=cds-feature-auditlog-ng/target/site/jacoco/jacoco.xml diff --git a/README.md b/README.md index f81c1d2..d000728 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,134 @@ ## About this project -Emit standardized audit log events from Java CAP applications with the Audit Log Service NG Java CAP plugin. The plugin ensures compatibility with the Audit Log Event Catalog, aligning emitted events with shared semantic conventions for consistency across systems. +The Audit Log Service NG Java CAP plugin enables Java CAP applications to emit audit log events in a standardized way. It is fully compatible with the [Audit Log Event Catalog](https://github.tools.sap/wg-observability/telemetry-semantic-conventions/tree/audit-log-events?tab=readme-ov-file#event-catalog), ensuring standardized event semantics and compatibility. + +You can emit the following types of audit log events: +- Personal Data Access Event +- Personal Data Modification Event +- Configuration Change Event +- Security Event + +Official CAP documentation can be found [here](https://pages.github.tools.sap/cap/docs/java/auditlog). + +# Consumption + +To consume the Audit Log Service NG, follow these steps: + +1. Complete the onboarding [process](https://jira.tools.sap/browse/ALSREQ-163). +2. Create a [user-provided service instance](https://docs.cloudfoundry.org/devguide/services/user-provided.html) in Cloud Foundry with the following credentials: + +```json +{ + "url": "als-endpoint", + "region": "als-region", + "namespace": "registered namespace", + "cert": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----", + "passphrase": "private key pass phrase" // optional +} +``` + +Example command: +```sh +cf cups auditlog-ng -p '{ + "url": "https://your-als-endpoint", + "region": "your-region", + "namespace": "your-namespace", + "cert": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----", + "passphrase": "your-passphrase" +}' -t auditlog-ng +``` + +3. Bind the user-provided service instance to your application: +``` +cf bind-service auditlog-ng +``` + +4. Add the Maven Dependency +```xml + + com.sap.cds + cds-feature-auditlog-ng + auditlog-ng.version + +``` + +# Testing + +For both local and cloud testing, refer to the [cloud-cap-samples-java](https://github.com/SAP-samples/cloud-cap-samples-java) repository and follow the instructions provided in its README. + +For local testing, make sure to create a default-env.json file at the root of your project. This file should contain the following content: + +```json +{ + "VCAP_SERVICES": { + "application-logs": [ + { + "binding_guid": "binding_guid", + "binding_name": null, + "credentials": {}, + "instance_guid": "instance_guid", + "instance_name": "cf-logging", + "label": "application-logs", + "name": "cf-logging", + "plan": "lite", + "provider": null, + "syslog_drain_url": null, + "tags": [], + "volume_mounts": [] + } + ], + "user-provided": [ + { + "binding_guid": "binding_guid", + "binding_name": null, + "credentials": { + "url": "als-endpoint", + "region": "als-region", + "namespace": "registered namespace", + "cert": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----", + "key": "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----", + "passphrase": "private key pass phrase" + }, + "instance_guid": "instance_guid", + "instance_name": "auditlog-ng", + "label": "user-provided", + "name": "auditlog-ng", + "syslog_drain_url": null, + "tags": [ + "auditlog-ng" + ], + "volume_mounts": [] + } + ] + }, + "VCAP_APPLICATION": { + "application_id": "application_id", + "application_name": "bookshop-srv", + "application_uris": [ + "application_uris" + ], + "cf_api": "cf_api", + "limits": { + "fds": 32768 + }, + "name": "bookshop-srv", + "organization_id": "organization_id", + "organization_name": "organization_name", + "space_id": "space_id", + "space_name": "space_name", + "uris": [ + "application_uris" + ], + "users": null + } +} +``` + +This file simulates the Cloud Foundry environment variables required for your application to run locally. + ## Requirements and Setup diff --git a/cds-feature-auditlog-ng/pom.xml b/cds-feature-auditlog-ng/pom.xml new file mode 100644 index 0000000..4ddffc8 --- /dev/null +++ b/cds-feature-auditlog-ng/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + com.sap.cds + cds-feature-auditlog-ng-root + ${revision} + + + AuditLog NG Feature + Handler to send auditlog messages to AuditLog Service NG + cds-feature-auditlog-ng + ${cds.url} + + + com.sap.xs.audit + com.sap.cds.repackaged.audit + src/gen/java + ${basedir}/${gen.folder.relative} + + + + + com.sap.cds + cds-services-api + + + + com.sap.cds + cds-services-utils + + + + com.sap.cds + cds-services-impl + test + + + + org.slf4j + slf4j-api + + + + org.mockito + mockito-core + test + + + + + + + maven-surefire-plugin + + false + + + + + \ No newline at end of file diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java new file mode 100644 index 0000000..6d3cdc4 --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGCommunicator.java @@ -0,0 +1,159 @@ +/* + * © 2023-2024 SAP SE or an SAP affiliate company. All rights reserved. + */ +package com.sap.cds.feature.auditlog.ng; + +import java.io.IOException; +import java.time.Duration; + +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.services.utils.CdsErrorStatuses; +import com.sap.cds.services.utils.ErrorStatusException; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceDecorator; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceIsolationMode; + +/** + * Implementation for an audit log {@link Communicator} which provides + * connectivity to the audit log api via the Cloud SDK. + */ +public class AuditLogNGCommunicator { + + private static final Logger logger = LoggerFactory.getLogger(AuditLogNGCommunicator.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final int NUMBER_RETRIES = 3; + private static final Duration TIMEOUT_DURATION = Duration.ofMillis(30000); + private static final String RESILIENCE_CONFIG_NAME = "auditlog"; + private static final String AUDITLOG_EVENTS_ENDPOINT = "/ingestion/v1/events"; + + private final ResilienceConfiguration resilienceConfig; + private final String serviceUrl; + private final CloseableHttpClient certHttpClient; + private final String region; + private final String namespace; + + public AuditLogNGCommunicator(ServiceBinding binding) { + this.serviceUrl = (String) binding.getCredentials().get("url"); + this.region = (String) binding.getCredentials().get("region"); + this.namespace = (String) binding.getCredentials().get("namespace"); + + // Configure resilience patterns + this.resilienceConfig = ResilienceConfiguration.empty(RESILIENCE_CONFIG_NAME); + this.resilienceConfig.isolationMode(ResilienceIsolationMode.NO_ISOLATION); + this.resilienceConfig.timeLimiterConfiguration( + ResilienceConfiguration.TimeLimiterConfiguration.of().timeoutDuration(TIMEOUT_DURATION)); + this.resilienceConfig.retryConfiguration( + ResilienceConfiguration.RetryConfiguration.of(NUMBER_RETRIES)); + + // Configure HTTP client with certificate authentication + try { + this.certHttpClient = CertificateHttpClientConfig.builder() + .certPem((String) binding.getCredentials().get("cert")) + .keyPem((String) binding.getCredentials().get("key")) + .keyPassphrase((String) binding.getCredentials().get("passphrase")) + .maxRetries(NUMBER_RETRIES) + .timeoutMillis((int) TIMEOUT_DURATION.toMillis()) + .build().getHttpClient(); + } catch (Exception e) { + throw new IllegalStateException("Failed to create HttpClient with certificate", e); + } + } + + public String sendBulkRequest(Object auditLogEvents) throws JsonProcessingException { + logger.debug("Sending bulk request to audit log service"); + String bulkRequestJson = serializeBulkRequest(auditLogEvents); + HttpPost request = new HttpPost(serviceUrl + AUDITLOG_EVENTS_ENDPOINT); + request.setEntity(new StringEntity(bulkRequestJson, ContentType.APPLICATION_JSON)); + try { + return ResilienceDecorator.executeCallable(() -> executeBulkRequest(request), resilienceConfig); + } catch (ErrorStatusException ese) { + logger.error("Audit Log service returned unexpected HTTP status", ese); + throw ese; + } catch (JsonProcessingException jpe) { + logger.error("JSON processing error while serializing bulk request object", jpe); + throw jpe; + } catch (Exception e) { + logger.error("Exception while calling Audit Log service", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); + } + } + + /** + * Serializes the audit log events object to JSON. + */ + private String serializeBulkRequest(Object auditLogEvents) throws JsonProcessingException { + String json = OBJECT_MAPPER.writeValueAsString(auditLogEvents); + logger.debug("Bulk request object serialized to JSON: {}", json); + return json; + } + + /** + * Executes the HTTP POST request to the Audit Log service and handles the + * response. + */ + private String executeBulkRequest(HttpPost request) throws IOException, ErrorStatusException { + HttpResponse response = null; + try { + response = certHttpClient.execute(request); + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode == HttpStatus.SC_OK + || statusCode == HttpStatus.SC_CREATED + || statusCode == HttpStatus.SC_NO_CONTENT) { + String resultBody = EntityUtils.toString(response.getEntity()); + logger.info("Bulk request to Audit Log service sent successfully. Status: {}", statusCode); + logger.debug("Audit Log service response: {}", resultBody); + return resultBody; + } else { + handleHttpError(response, statusCode); + return null; // unreachable, handleHttpError always throws + } + } catch (ErrorStatusException ex) { + logger.error("Error status received from Audit Log service: {} - {}", ex.getErrorStatus(), ex.getMessage(), ex); + throw ex; + } catch (IOException ex) { + logger.error("Exception during HTTP request to Audit Log service", ex); + throw ex; + } finally { + if (response != null && response.getEntity() != null) { + EntityUtils.consumeQuietly(response.getEntity()); + } + } + } + + /** + * Handles HTTP error responses from the Audit Log service. + */ + private void handleHttpError(HttpResponse response, int statusCode) throws ErrorStatusException { + String errorBody = ""; + try { + if (response.getEntity() != null) { + errorBody = EntityUtils.toString(response.getEntity()); + } + } catch (IOException e) { + logger.warn("Failed to read error response body from Audit Log service", e); + } + logger.error("Unexpected HTTP status from Audit Log service: {}. Response body: {}", statusCode, errorBody); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_UNEXPECTED_HTTP_STATUS, statusCode); + } + + public String getRegion() { + return region; + } + + public String getNamespace() { + return namespace; + } +} diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGConfiguration.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGConfiguration.java new file mode 100644 index 0000000..6c9687b --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGConfiguration.java @@ -0,0 +1,69 @@ +/* + * © 2021-2025 SAP SE or an SAP affiliate company. All rights reserved. + */ +package com.sap.cds.feature.auditlog.ng; + +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.annotations.VisibleForTesting; +import com.sap.cds.services.mt.TenantProviderService; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfiguration; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.CdsErrorStatuses; +import com.sap.cds.services.utils.ErrorStatusException; +import com.sap.cds.services.utils.StringUtils; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; + +/** CDS runtime configuration for the {@link AuditLogNGHandler}. */ +public class AuditLogNGConfiguration implements CdsRuntimeConfiguration { + private static final Logger LOGGER = LoggerFactory.getLogger(AuditLogNGConfiguration.class); + static final String AUDITLOG = "auditlog-ng"; + + @Override + public void eventHandlers(CdsRuntimeConfigurer configurer) { + CdsRuntime runtime = configurer.getCdsRuntime(); + ServiceBinding binding = runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, AUDITLOG)) + .findFirst() + .orElse(null); + + if (binding != null) { + validateBinding(binding); + LOGGER.info("Using Auditlog NG service to register Auditlog NG event handler."); + AuditLogNGHandler handler = createHandler(binding, configurer); + configurer.eventHandler(handler); + } else { + LOGGER.info("No Auditlog NG service binding found, NG handler not registered."); + } + } + + @VisibleForTesting + AuditLogNGHandler createHandler(ServiceBinding binding, CdsRuntimeConfigurer configurer) { + AuditLogNGCommunicator communicator = new AuditLogNGCommunicator(binding); + TenantProviderService tenantService = configurer + .getCdsRuntime() + .getServiceCatalog() + .getService(TenantProviderService.class, TenantProviderService.DEFAULT_NAME); + return new AuditLogNGHandler(communicator, tenantService); + } + + private void validateBinding(ServiceBinding binding) { + Map cred = binding.getCredentials(); + if (cred.isEmpty()) { + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_CONFIG, "credentials"); + } + String[] requiredFields = {"url", "region", "namespace", "cert", "key"}; + for (String field : requiredFields) { + if (!cred.containsKey(field) || StringUtils.isEmpty((String) cred.get(field))) { + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_CONFIG, "credentials." + field); + } + } + } +} diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java new file mode 100644 index 0000000..1ef1262 --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandler.java @@ -0,0 +1,584 @@ +/* + * © 2021-2024 SAP SE or an SAP affiliate company. All rights reserved. + */ +package com.sap.cds.feature.auditlog.ng; + +import java.time.Instant; +import java.util.Collection; +import static java.util.Objects.requireNonNull; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import static org.slf4j.LoggerFactory.getLogger; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sap.cds.services.auditlog.Access; +import com.sap.cds.services.auditlog.Attachment; +import com.sap.cds.services.auditlog.Attribute; +import com.sap.cds.services.auditlog.AuditLogService; +import com.sap.cds.services.auditlog.ChangedAttribute; +import com.sap.cds.services.auditlog.ConfigChange; +import com.sap.cds.services.auditlog.ConfigChangeLog; +import com.sap.cds.services.auditlog.ConfigChangeLogContext; +import com.sap.cds.services.auditlog.DataAccessLog; +import com.sap.cds.services.auditlog.DataAccessLogContext; +import com.sap.cds.services.auditlog.DataModification; +import com.sap.cds.services.auditlog.DataModificationLog; +import com.sap.cds.services.auditlog.DataModificationLogContext; +import com.sap.cds.services.auditlog.DataObject; +import com.sap.cds.services.auditlog.DataSubject; +import com.sap.cds.services.auditlog.KeyValuePair; +import com.sap.cds.services.auditlog.SecurityLog; +import com.sap.cds.services.auditlog.SecurityLogContext; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.TenantProviderService; +import com.sap.cds.services.request.UserInfo; +import com.sap.cds.services.utils.CdsErrorStatuses; +import com.sap.cds.services.utils.ErrorStatusException; + +/** + * Handler that reacts on audit log events to log audit messages with the auditlog NG API. + */ +@ServiceName(value = "*", type = AuditLogService.class) +public class AuditLogNGHandler implements EventHandler { + + private static final Logger LOGGER = getLogger(AuditLogNGHandler.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String LEGACY_SECURITY_WRAPPER = "legacySecurityWrapper"; + + private final AuditLogNGCommunicator communicator; + private final TenantProviderService tenantService; + + AuditLogNGHandler(AuditLogNGCommunicator communicator, TenantProviderService tenantService) { + this.communicator = communicator; + this.tenantService = tenantService; + } + + @On + public void handleSecurityEvent(SecurityLogContext context) { + try { + ArrayNode alsEvents = createSecurityEvent(context); + communicator.sendBulkRequest(alsEvents); + } catch (JsonParseException e) { + LOGGER.error("Audit Log write exception occurred for security event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } catch (ErrorStatusException e) { + LOGGER.error("Audit Log service not available for security event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); + } catch (Exception e) { + LOGGER.error("Unexpected exception while handling security event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } + } + + /** + * Creates an ArrayNode representing security events for the Audit Log service. + * + * The returned ArrayNode contains a single ObjectNode with the following structure: + * - id: A unique identifier for the event (UUID) + * - specversion: The specification version (integer) + * - source: The source of the event, including region, namespace, and tenant + * - type: The type of the event (e.g., "legacySecurityWrapper") + * - time: The timestamp of the event (ISO-8601 format) + * - data: An ObjectNode containing: + * - metadata: An ObjectNode with the timestamp of the event + * - legacySecurityWrapper: An ObjectNode with: + * - origEvent: A serialized JSON string representing the original security event + * + * @param context the SecurityLogContext containing user and event data + * @return an ArrayNode representing the security event + */ + private ArrayNode createSecurityEvent(SecurityLogContext context) { + SecurityLog data = requireNonNull(context.getData(), "SecurityLogContext.getData() is null"); + UserInfo userInfo = requireNonNull(context.getUserInfo(), "SecurityLogContext.getUserInfo() is null"); + ObjectNode alsEvent = buildEventEnvelope(OBJECT_MAPPER, LEGACY_SECURITY_WRAPPER, userInfo); + ObjectNode metadata = buildEventMetadata(); + ObjectNode origEvent = createLegacySecurityOrigEvent(userInfo, data); + ObjectNode legacySecurityWrapper = OBJECT_MAPPER.createObjectNode(); + try { + legacySecurityWrapper.put("origEvent", OBJECT_MAPPER.writeValueAsString(origEvent)); + } catch (JsonProcessingException e) { + LOGGER.error("Failed to serialize origEvent: {}", origEvent, e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, "Failed to serialize origEvent", e); + } + ObjectNode dataNode = OBJECT_MAPPER.createObjectNode(); + dataNode.set(LEGACY_SECURITY_WRAPPER, legacySecurityWrapper); + ObjectNode alsData = buildAuditLogEventData(metadata, dataNode); + alsEvent.set("data", alsData); + return OBJECT_MAPPER.createArrayNode().add(alsEvent); + } + + /** + * Creates a legacy security origin event as an ObjectNode containing audit log information. + * + * The resulting JSON object includes the following fields: + * - uuid: A randomly generated UUID string for the event + * - user: The name of the user from userInfo, or "unknown" if userInfo is null + * - identityProvider: A constant value "$IDP" + * - time: The current timestamp as an ISO-8601 string + * - data: The data from the SecurityLog object, or an empty string if data is null + * + * @param userInfo the user information, may be null + * @param data the security log data, may be null + * @return an ObjectNode representing the legacy security origin event + */ + private ObjectNode createLegacySecurityOrigEvent(UserInfo userInfo, SecurityLog data) { + ObjectNode envelop = OBJECT_MAPPER.createObjectNode(); + String formattedData = "action: %s, data: %s".formatted(data.getAction(), data.getData()); + formattedData = formattedData.replace("\r\n", "\\n").replace("\n", "\\n"); + setFieldIfNotNull(envelop, "uuid", UUID.randomUUID().toString()); + setFieldIfNotNull(envelop, "user", userInfo != null ? userInfo.getName() : "unknown"); + setFieldIfNotNull(envelop, "identityProvider", "$IDP"); + setFieldIfNotNull(envelop, "time", Instant.now().toString()); + setFieldIfNotNull(envelop, "data", formattedData != null ? formattedData : ""); + return envelop; + } + + @On + public void handleDataAccessEvent(DataAccessLogContext context) { + try { + ArrayNode alsEvents = createAlsDataAccessEvents(context); + communicator.sendBulkRequest(alsEvents); + } catch (JsonParseException e) { + LOGGER.error("Audit Log write exception occurred for data access event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } catch (ErrorStatusException e) { + LOGGER.error("Audit Log service not available for data access event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); + } catch (Exception e) { + LOGGER.error("Unexpected exception while handling data access event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } + } + + /** + * Creates an ArrayNode representing data access events for the Audit Log service. + * + * Iterates over all accesses in the provided DataAccessLogContext and adds corresponding + * ALS events for each attribute and attachment combination. + * + * @param context the DataAccessLogContext containing access data + * @return an ArrayNode containing ALS data access event objects + * @throws NullPointerException if context data or accesses are null + * @throws IllegalArgumentException if accesses are empty + */ + private ArrayNode createAlsDataAccessEvents(DataAccessLogContext context) { + UserInfo userInfo = requireNonNull(context.getUserInfo(), "DataAccessLogContext.getUserInfo() is null"); + DataAccessLog data = requireNonNull(context.getData(), "DataAccessLogContext.getData() is null"); + Collection accesses = requireNonNull(data.getAccesses(), "DataAccessLog.getAccesses() is null"); + ArrayNode eventArray = OBJECT_MAPPER.createArrayNode(); + for (Access access : accesses) { + addAccessEvents(userInfo, eventArray, access); + } + return eventArray; + } + + /** + * Adds access events for each attribute in the given {@link Access} object to the specified event array. + * For each attribute, this method retrieves its name and delegates the creation of the access event + * to {@code addAttributeAccessEvents}. + * + * @param userInfo the user information associated with the access event + * @param eventArray the array to which access events will be added + * @param access the access object containing the attributes to process + * @throws NullPointerException if {@code access.getAttributes()} or any attribute name is {@code null} + */ + private void addAccessEvents(UserInfo userInfo, ArrayNode eventArray, Access access) { + Collection attributes = requireNonNull(access.getAttributes(), "Access.getAttributes() is null"); + for (Attribute attribute : attributes) { + String attributeName = requireNonNull(attribute.getName(), "Attribute.getName() is null"); + addAttributeAccessEvents(userInfo, eventArray, access, attributeName); + } + } + + /** + * Adds attribute access events to the provided event array based on the given access and attribute information. + * If the {@link Access} object contains attachments, an event is created for each attachment using its name and ID. + * If there are no attachments, a single event is created without attachment details. + * + * @param userInfo the user information associated with the access event + * @param eventArray the JSON array node to which the generated events will be added + * @param access the access object containing details about the attribute access and any attachments + * @param attributeName the name of the attribute being accessed + */ + private void addAttributeAccessEvents(UserInfo userInfo, ArrayNode eventArray, Access access, String attributeName) { + Collection attachments = access.getAttachments(); + if (attachments == null || attachments.isEmpty()) { + ObjectNode alsEvent = buildDataAccessAlsEvent(userInfo, access, attributeName, null, null); + eventArray.add(alsEvent); + } else { + for (Attachment attachment : attachments) { + ObjectNode alsEvent = buildDataAccessAlsEvent(userInfo, access, attributeName, attachment.getName(), attachment.getId()); + eventArray.add(alsEvent); + } + } + } + + @On + public void handleConfigChangeEvent(ConfigChangeLogContext context) { + try { + ArrayNode alsEvents = createAlsConfigChangeEvents(context); + communicator.sendBulkRequest(alsEvents); + } catch (JsonParseException e) { + LOGGER.error("Audit Log write exception occurred for configuration change event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } catch (ErrorStatusException e) { + LOGGER.error("Audit Log service not available for configuration change event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); + } catch (Exception e) { + LOGGER.error("Unexpected exception while handling configuration change event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } + } + + /** + * Creates an {@link ArrayNode} containing configuration change event objects based on the provided {@link ConfigChangeLogContext}. + * This method extracts the {@link ConfigChangeLog} data from the context, retrieves the collection of {@link ConfigChange} objects, + * and for each configuration change, generates event nodes for each attribute using {@code buildConfigChangeEvent}. + * The resulting events are aggregated into a single {@link ArrayNode}. + * + * @param context the {@link ConfigChangeLogContext} containing the configuration change log data; must not be {@code null} + * @return an {@link ArrayNode} containing the generated configuration change event nodes + * @throws NullPointerException if the context data or configurations are {@code null} + * @throws IllegalArgumentException if the configurations collection is empty + */ + private ArrayNode createAlsConfigChangeEvents(ConfigChangeLogContext context) { + ConfigChangeLog data = requireNonNull(context.getData(), "ConfigChangeLogContext.getData() is null"); + UserInfo userInfo = requireNonNull(context.getUserInfo(), "ConfigChangeLogContext.getUserInfo() is null"); + Collection configChanges = requireNonNull(data.getConfigurations(), "ConfigChangeLog.getConfigurations() is null"); + ArrayNode result = OBJECT_MAPPER.createArrayNode(); + configChanges.forEach(cfg -> { + Collection attributes = requireNonNull(cfg.getAttributes(), "ConfigChange.getAttributes() is null"); + attributes.stream().map(attribute -> buildConfigChangeEvent(userInfo, cfg, attribute)).forEach(result::add); + }); + return result; + } + + /** + * Builds an audit log event for a configuration change. + * This method constructs an ObjectNode representing an audit log event for a configuration change, + * including metadata, details about the changed attribute, and information about the affected data object. + * + * @param context the context containing user and request information for the configuration change + * @param cfg the configuration change object containing details about the change + * @param attribute the specific attribute that was changed + * @return an ObjectNode representing the audit log event for the configuration change + */ + private ObjectNode buildConfigChangeEvent(UserInfo userInfo, ConfigChange configChanges, ChangedAttribute attribute) { + ObjectNode metadata = buildEventMetadata(); + ObjectNode changeNode = OBJECT_MAPPER.createObjectNode(); + addValueDetails(changeNode, attribute, "propertyName"); + var dataObject = requireNonNull(configChanges.getDataObject(), "ConfigChange.getDataObject() is null"); + addObjectDetails(changeNode, dataObject); + return buildAlsEvent("configurationChange", userInfo, metadata, "configurationChange", changeNode); + } + + @On + public void handleDataModificationEvent(DataModificationLogContext context) { + try { + ArrayNode alsEvents = createAlsDataModificationEvents(context); + communicator.sendBulkRequest(alsEvents); + } catch (JsonParseException e) { + LOGGER.error("Audit Log write exception occurred for data modification event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } catch (ErrorStatusException e) { + LOGGER.error("Audit Log service not available for data modification event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_NOT_AVAILABLE, e); + } catch (Exception e) { + LOGGER.error("Unexpected exception while handling data modification event", e); + throw new ErrorStatusException(CdsErrorStatuses.AUDITLOG_SERVICE_INVALID_MESSAGE, e); + } + } + + /** + * Creates ALS (Audit Logging Service) data modification events based on the provided context. + * This method extracts the {@link DataModificationLog} from the given {@link DataModificationLogContext}, + * validates that the modifications collection is not null or empty, and then builds attribute-based ALS events. + * + * @param context the context containing data modification log information; must not be null + * @return an {@link ArrayNode} containing the generated ALS data modification events + * @throws NullPointerException if the context data or modifications are null + * @throws IllegalArgumentException if the modifications collection is empty + */ + private ArrayNode createAlsDataModificationEvents(DataModificationLogContext context) { + DataModificationLog data = requireNonNull(context.getData(), "DataModificationLogContext.getData() is null"); + Collection modifications = requireNonNull(data.getModifications(), "DataModificationLog.getModifications() is null"); + UserInfo userInfo = requireNonNull(context.getUserInfo(), "DataModificationLogContext.getUserInfo() is null"); + return buildAttributeBasedAlsEvents(userInfo, modifications); + } + + /** + * Builds an array of ALS (Audit Log Service) events based on the attributes of the given data modifications. + * For each {@link DataModification} in the provided collection, this method iterates through its changed attributes + * and creates an ALS event for each attribute using {@code buildDataModificationAlsEvent}. + * + * @param context the context of the data modification log, containing relevant metadata for event creation + * @param items a collection of {@link DataModification} objects to process + * @return an {@link ArrayNode} containing the generated ALS events for each changed attribute + * @throws IllegalArgumentException if any {@link DataModification} item has no attributes + */ + private ArrayNode buildAttributeBasedAlsEvents(UserInfo userInfo, Collection modifications) { + ArrayNode eventArray = OBJECT_MAPPER.createArrayNode(); + for (DataModification modification : modifications) { + Collection attributes = requireNonNull(modification.getAttributes(), "DataModification.getAttributes() is null"); + for (ChangedAttribute attribute : attributes) { + eventArray.add(buildDataModificationAlsEvent(userInfo, modification, attribute)); + } + } + return eventArray; + } + + /** + * Builds an ALS (Audit Logging Service) event for a data modification operation. + * This method constructs an ObjectNode representing a data modification event, + * including relevant metadata, object and subject information, and changed attribute details. + * + * @param context the context of the data modification log, containing user and request information + * @param modification the data modification details, including the affected data object and subject + * @param attribute the specific attribute that was changed during the modification + * @return an ObjectNode representing the constructed ALS event for the data modification + */ + private ObjectNode buildDataModificationAlsEvent(UserInfo userInfo, DataModification modification, ChangedAttribute attribute) { + DataObject dataObject = requireNonNull(modification.getDataObject(), "DataModification.getDataObject() is null"); + ObjectNode metadata = buildEventMetadata(); + ObjectNode dataModificationNode = buildDataModificationNode(attribute, modification.getDataSubject(), dataObject); + return buildAlsEvent("dppDataModification", userInfo, metadata, "dppDataModification", dataModificationNode); + } + + /** + * Builds the data modification node for a single ChangedAttribute. + * + * This node contains the following fields: + * - objectType: The type of the modified object (if available) + * - objectId: The identifier(s) of the modified object (if available) + * - attribute: The name of the changed attribute + * - oldValue: The previous value of the attribute (if available) + * - newValue: The new value of the attribute (if available) + * - dataSubjectType: The type of the data subject (if available) + * - dataSubjectId: The identifier(s) of the data subject (if available) + * + * @param objectType the type of the modified object + * @param objectId the identifier(s) of the modified object + * @param attribute the changed attribute + * @param dataSubjectType the type of the data subject + * @param dataSubjectId the identifier(s) of the data subject + * @return an ObjectNode representing the data modification details + */ + private ObjectNode buildDataModificationNode(ChangedAttribute attribute, DataSubject dataSubject, DataObject dataObject) { + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + addValueDetails(node, attribute, "attribute"); + addObjectDetails(node, dataObject); + addDataSubjectDetails(node, dataSubject); + return node; + } + + /** + * Builds an event envelope as an ObjectNode for audit logging purposes. + * + * The envelope includes a unique event ID, specification version, source, + * type, and timestamp. The source is constructed using the communicator's + * region, namespace, and the tenant information. If the tenant is not + * provided in the UserInfo, the provider tenant is used. + * + * @param mapper the ObjectMapper used to create the JSON object node + * @param type the type of the event to be set in the envelope + * @param userInfo the user information containing tenant details + * @return an ObjectNode representing the event envelope + */ + private ObjectNode buildEventEnvelope(ObjectMapper mapper, String type, UserInfo userInfo) { + ObjectNode alsEvent = mapper.createObjectNode(); + alsEvent.put("id", UUID.randomUUID().toString()); + alsEvent.put("specversion", 1); + String tenant = (userInfo.getTenant() == null || userInfo.getTenant().isEmpty()) ? tenantService.readProviderTenant() : userInfo.getTenant(); + alsEvent.put("source", String.format("/%s/%s/%s", communicator.getRegion(), communicator.getNamespace(), tenant)); + alsEvent.put("type", type); + alsEvent.put("time", Instant.now().toString()); + return alsEvent; + } + + /** + * Builds an ObjectNode containing event metadata. + * Currently, this method adds a timestamp ("ts") field with the current instant in ISO-8601 format. + * + * @param mapper the {@link ObjectMapper} used to create the ObjectNode + * @return an {@link ObjectNode} containing the event metadata + */ + private ObjectNode buildEventMetadata() { + ObjectNode metadata = OBJECT_MAPPER.createObjectNode(); + metadata.put("ts", Instant.now().toString()); + ObjectNode infraOther = metadata.putObject("infrastructure").putObject("other"); + infraOther.put("runtimeType", "Java"); + ObjectNode platformOther = metadata.putObject("platform").putObject("other"); + platformOther.put("platformName", "CAP"); + return metadata; + } + + /** + * Builds an ALS (Audit Logging Service) event for data access operations. + * + * @param context the context containing user and request information for the data access event + * @param access the type of access performed (e.g., READ, WRITE) + * @param attribute the specific attribute or field being accessed + * @param attachmentType the type of attachment associated with the access, if any + * @param attachmentId the identifier of the attachment, if applicable + * @return an {@link ObjectNode} representing the constructed ALS event for data access + */ + private ObjectNode buildDataAccessAlsEvent(UserInfo userInfo, Access access, String attribute, String attachmentType, String attachmentId) { + ObjectNode metadata = buildEventMetadata(); + ObjectNode dataAccessNode = buildDataAccessNode(access, attribute, attachmentType, attachmentId); + return buildAlsEvent("dppDataAccess", userInfo, metadata, "dppDataAccess", dataAccessNode); + } + + /** + * Builds an {@link ObjectNode} representing data access information for audit logging purposes. + * The resulting node includes details about the access channel, data subject, data object, + * and optional attributes such as attribute name, attachment type, and attachment ID. + * + * @param access the {@link Access} object containing data subject and data object information + * @param attribute the name of the accessed attribute (may be {@code null}) + * @param attachmentType the type of the attachment (may be {@code null}) + * @param attachmentId the ID of the attachment (may be {@code null}) + * @return an {@link ObjectNode} containing the structured data access information + */ + private ObjectNode buildDataAccessNode(Access access, String attribute, String attachmentType, String attachmentId) { + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + node.put("channelType", "not specified"); + node.put("channelId", "not specified"); + DataSubject dataSubject = requireNonNull(access.getDataSubject(), "Access.getDataSubject() is null"); + addDataSubjectDetails(node, dataSubject); + + DataObject dataObject = requireNonNull(access.getDataObject(), "Access.getDataObject() is null"); + addObjectDetails(node, dataObject); + + // setFieldIfNotNull(node, "attribute", attribute); + node.put("attribute", attribute); + setFieldIfNotNull(node, "attachmentType", attachmentType); + setFieldIfNotNull(node, "attachmentId", attachmentId); + return node; + } + + /** + * Adds details about a changed value to the given JSON node. + * + * @param node The JSON node where the value details will be added. + * @param attribute The changed attribute containing the old and new values. + * @param fieldName The name of the field representing the attribute in the JSON node. + */ + private void addValueDetails(ObjectNode node, ChangedAttribute attribute, String fieldName) { + String attributeName = requireNonNull(attribute.getName(), "ChangedAttribute.getName() is null"); + String newValue = requireNonNull(attribute.getNewValue(), "ChangedAttribute.getNewValue() is null"); + node.put(fieldName, attributeName); + node.put("newValue", newValue); + node.put("oldValue", attribute.getOldValue() != null ? attribute.getOldValue() : "null"); + } + + /** + * Adds object details to the given JSON node based on the provided {@link DataObject}. + * The method extracts the object IDs from the {@code dataObject}, formats them alphabetically, + * and adds them to the node under the "objectId" key. It also adds the object type under the + * "objectType" key, or "null" if the type is not specified. + * + * @param node the {@link ObjectNode} to which object details will be added + * @param dataObject the {@link DataObject} containing the object type and IDs + * @throws NullPointerException if {@code dataObject.getId()} is {@code null} + */ + private void addObjectDetails(ObjectNode node, DataObject dataObject) { + Collection objectIds = requireNonNull(dataObject.getId(), "Access.getDataObject().getId() is null"); + String formatedObjectIds = formatAlpabeticallyIds(objectIds); + node.put("objectId", formatedObjectIds); + node.put("objectType", dataObject.getType() != null ? dataObject.getType() : "null"); + } + + /** + * Adds data subject details to the given JSON node. + * If the provided {@code dataSubject} is {@code null}, sets both "dataSubjectType" and "dataSubjectId" fields to "null". + * Otherwise, extracts the data subject's IDs, formats them alphabetically, and adds them as "dataSubjectId". + * Also adds the data subject's type as "dataSubjectType", or "null" if the type is not specified. + * + * @param node the JSON node to which data subject details will be added + * @param dataSubject the data subject whose details are to be added; may be {@code null} + */ + private void addDataSubjectDetails(ObjectNode node, DataSubject dataSubject) { + if (dataSubject == null) { + node.put("dataSubjectType", "null"); + node.put("dataSubjectId", "null"); + } else { + Collection dataSubjectIds = requireNonNull(dataSubject.getId(), "Access.getDataSubject().getId() is null"); + String formatedDataSubjectIds = formatAlpabeticallyIds(dataSubjectIds); + node.put("dataSubjectId", formatedDataSubjectIds); + node.put("dataSubjectType", dataSubject.getType() != null ? dataSubject.getType() : "null"); + } + } + + /** + * Builds an ALS event as an ObjectNode for audit logging purposes. + * + * @param eventType the type of the event + * @param userInfo the user information containing tenant and user details + * @param metadata the metadata node containing timestamp and other event-specific details + * @param dataKey the key representing the type of data in the event, e.g., "dppDataAccess" + * @param dataValue the value node containing the event-specific data + * @return an ObjectNode representing the ALS event + */ + private ObjectNode buildAlsEvent(String eventType, UserInfo userInfo, ObjectNode metadata, String dataKey, ObjectNode dataValue) { + ObjectNode alsEvent = buildEventEnvelope(OBJECT_MAPPER, eventType, userInfo); + ObjectNode dataNode = OBJECT_MAPPER.createObjectNode(); + dataNode.set(dataKey, dataValue); + ObjectNode alsData = buildAuditLogEventData(metadata, dataNode); + alsEvent.set("data", alsData); + return alsEvent; + } + + /** + * Sets a field in the given ObjectNode only if the provided value is not null. + * + * If the value is a String, the field is set using ObjectNode#put(String, String). + * For other types, the field is set using ObjectNode#set(String, JsonNode) after converting the value to a JsonNode using ObjectMapper#valueToTree(Object). + * + * @param node the ObjectNode where the field will be set + * @param field the name of the field to set + * @param value the value to set; if null, the field will not be set + */ + private void setFieldIfNotNull(ObjectNode node, String field, Object value) { + if (value != null) { + if (value instanceof String str) { + node.put(field, str); + } else { + node.set(field, OBJECT_MAPPER.valueToTree(value)); + } + } + } + + /** + * Builds an audit log event data object by combining the provided metadata and data nodes. + * + * @param metadata the metadata to include in the audit log event + * @param dataNode the data node containing the event-specific data + * @return an ObjectNode representing the combined audit log event data with "metadata" and "data" fields + */ + private ObjectNode buildAuditLogEventData(ObjectNode metadata, ObjectNode dataNode) { + ObjectNode alsData = OBJECT_MAPPER.createObjectNode(); + alsData.set("metadata", metadata); + alsData.set("data", dataNode); + return alsData; + } + + /** + * Helper method to build a readable objectId string with alphabetically ordered keys. + * The returned string is a space-separated list of key-value pairs in the format "key:value". Example: "id:123 name:John". + * + * @param ids the collection of key-value pairs representing object IDs + * @return a formatted string of object IDs + */ + private String formatAlpabeticallyIds(Collection ids) { + return ids.stream().sorted((a, b) -> a.getKeyName().compareToIgnoreCase(b.getKeyName())).map(kv -> kv.getKeyName() + ":" + kv.getValue()).collect(Collectors.joining(" ")); + } + +} diff --git a/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/CertificateHttpClientConfig.java b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/CertificateHttpClientConfig.java new file mode 100644 index 0000000..8a987c8 --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/java/com/sap/cds/feature/auditlog/ng/CertificateHttpClientConfig.java @@ -0,0 +1,253 @@ +package com.sap.cds.feature.auditlog.ng; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringReader; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; + +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.DefaultHttpRequestRetryHandler; +import org.apache.http.impl.client.HttpClients; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides a configurable HTTP client for certificate-based authentication with retry logic. + * Usage example: + * CloseableHttpClient client = RetryHttpClientConfig.builder() + * .certPem(certString) + * .keyPem(keyString) + * .keyPassphrase(passphrase) // optional, only for encrypted keys + * .maxRetries(3) + * .timeoutMillis(30000) + * .build() + * .getHttpClient(); + * + * This class supports both encrypted and unencrypted PKCS#8 private keys. If the key is encrypted, + * a passphrase must be provided. If the key is unencrypted, passphrase can be null or empty. + */ +public class CertificateHttpClientConfig { + + private static final Logger logger = LoggerFactory.getLogger(CertificateHttpClientConfig.class); + + static { + // Register BouncyCastle provider if not already present + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + private final String certPem; + private final String keyPem; + private final String keyPassphrase; + private final int maxRetries; + private final int timeoutMillis; + private final CloseableHttpClient httpClient; + + CertificateHttpClientConfig(Builder builder) { + this.certPem = builder.certPem; + this.keyPem = builder.keyPem; + this.keyPassphrase = builder.keyPassphrase; + this.maxRetries = builder.maxRetries; + this.timeoutMillis = builder.timeoutMillis; + this.httpClient = createHttpClient(); + } + + /** + * Returns the configured HTTP client with certificate authentication and retry logic. + * + * @return a configured CloseableHttpClient + */ + public CloseableHttpClient getHttpClient() { + return httpClient; + } + + /** + * Returns a builder for {@link RetryHttpClientConfig}. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link RetryHttpClientConfig}. + *

+ * All fields are optional except for certPem and keyPem, which are required. + *

+ */ + public static class Builder { + + private String certPem; + private String keyPem; + private String keyPassphrase; + private int maxRetries = 3; + private int timeoutMillis = 30000; + + /** + * Sets the PEM-encoded certificate chain. + * @param certPem PEM string + * @return this builder + */ + public Builder certPem(String certPem) { + this.certPem = certPem; + return this; + } + /** + * Sets the PEM-encoded private key. + * @param keyPem PEM string + * @return this builder + */ + public Builder keyPem(String keyPem) { + this.keyPem = keyPem; + return this; + } + /** + * Sets the passphrase for the private key (optional, only for encrypted keys). + * @param keyPassphrase passphrase string + * @return this builder + */ + public Builder keyPassphrase(String keyPassphrase) { + this.keyPassphrase = keyPassphrase; + return this; + } + /** + * Sets the maximum number of HTTP retries. + * @param maxRetries number of retries + * @return this builder + */ + public Builder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + /** + * Sets the HTTP client timeout in milliseconds. + * @param timeoutMillis timeout in ms + * @return this builder + */ + public Builder timeoutMillis(int timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + /** + * Builds the {@link RetryHttpClientConfig} instance. + * @return a configured RetryHttpClientConfig + * @throws IllegalArgumentException if certPem or keyPem is missing + */ + public CertificateHttpClientConfig build() { + return new CertificateHttpClientConfig(this); + } + } + + /** + * Creates the configured HTTP client with certificate authentication and retry logic. + * + * @return a configured CloseableHttpClient + * @throws RuntimeException if client creation fails + */ + private CloseableHttpClient createHttpClient() { + try { + char[] effectivePassphrase = (keyPassphrase != null) ? keyPassphrase.toCharArray() : new char[0]; + logger.info("Creating HttpClient with certificate authentication and {} retries", maxRetries); + X509Certificate[] certChain = parseCertificateChain(certPem); + PrivateKey privateKey = parsePrivateKey(keyPem, effectivePassphrase); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("client", privateKey, new char[0], certChain); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, new char[0]); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), null, new SecureRandom()); + + return HttpClients.custom() + .setSSLContext(sslContext) + .setRetryHandler(new DefaultHttpRequestRetryHandler(maxRetries, true)) + .build(); + } catch (Exception e) { + logger.error("Failed to create HttpClient with certificate/key", e); + throw new RuntimeException("Failed to create HttpClient with certificate/key: " + e.getMessage(), e); + } finally { + // Overwrite passphrase for security + if (keyPassphrase != null) { + Arrays.fill(keyPassphrase.toCharArray(), '\0'); + } + } + } + + /** + * Parses a PEM-encoded certificate chain into X509Certificate array. + * @param certPem PEM string + * @return array of X509Certificate + * @throws Exception if parsing fails + */ + private static X509Certificate[] parseCertificateChain(String certPem) throws Exception { + try (PEMParser pemParser = new PEMParser(new StringReader(certPem))) { + List certList = new ArrayList<>(); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Object object; + while ((object = pemParser.readObject()) != null) { + if (object instanceof X509CertificateHolder holder) { + Certificate cert = cf.generateCertificate( + new ByteArrayInputStream(holder.getEncoded())); + certList.add((X509Certificate) cert); + } + } + return certList.toArray(new X509Certificate[0]); + } + } + + /** + * Parses a PEM-encoded PKCS#8 private key, supporting both encrypted and unencrypted keys. + * @param keyPem PEM string + * @param passphrase passphrase char array (optional, only for encrypted keys) + * @return the PrivateKey + * @throws Exception if parsing or decryption fails + */ + private static PrivateKey parsePrivateKey(String keyPem, char[] passphrase) throws Exception { + char[] effectivePassphrase = (passphrase != null) ? passphrase : new char[0]; + try (PEMParser pemParser = new PEMParser(new StringReader(keyPem))) { + Object object = pemParser.readObject(); + if (object instanceof PrivateKeyInfo keyInfo) { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyInfo.getEncoded()); + return KeyFactory.getInstance("RSA").generatePrivate(keySpec); + } else if (object instanceof PKCS8EncryptedPrivateKeyInfo encInfo) { + try { + InputDecryptorProvider decryptorProvider = new JceOpenSSLPKCS8DecryptorProviderBuilder().build(effectivePassphrase); + PrivateKeyInfo keyInfo = encInfo.decryptPrivateKeyInfo(decryptorProvider); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyInfo.getEncoded()); + return KeyFactory.getInstance("RSA").generatePrivate(keySpec); + } catch (IOException e) { + throw new RuntimeException("Failed to decrypt private key. Check that the passphrase is correct and the key is compatible. Original error: " + e.getMessage(), e); + } + } else { + throw new IllegalArgumentException("Invalid private key format: " + + (object != null ? object.getClass().getName() : "null")); + } + } + } +} diff --git a/cds-feature-auditlog-ng/src/main/resources/LICENSE.txt b/cds-feature-auditlog-ng/src/main/resources/LICENSE.txt new file mode 100644 index 0000000..3cdf773 --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/resources/LICENSE.txt @@ -0,0 +1,32 @@ +SAP DEVELOPER LICENSE AGREEMENT + +Version 3.2 CAP + +Please scroll down and read the following Developer License Agreement carefully ("Developer Agreement"). By clicking "I Accept" or by attempting to download, or install, or use the SAP software and other materials that accompany this Developer Agreement ("SAP Materials"), You agree that this Developer Agreement forms a legally binding agreement between You ("You" or "Your") and SAP SE, for and on behalf of itself and its subsidiaries and affiliates (as defined in Section 15 of the German Stock Corporation Act) and You agree to be bound by all of the terms and conditions stated in this Developer Agreement. If You are trying to access or download the SAP Materials on behalf of Your employer or as a consultant or agent of a third party (either "Your Company"), You represent and warrant that You have the authority to act on behalf of and bind Your Company to the terms of this Developer Agreement and everywhere in this Developer Agreement that refers to 'You' or 'Your' shall also include Your Company. If You do not agree to these terms, do not click "I Accept", and do not attempt to access or use the SAP Materials. + +1. LICENSE: SAP grants You a non-exclusive, non-transferable, non-sublicensable, revocable, limited use license to copy and reproduce the application programming interfaces ("API"), documentation, plug-ins, templates, scripts and sample code, libraries, software development kits ("Tools") to create new applications ("Customer Applications") being developed either for (a) testing and only non-productive use (“Customer Test Applications”) or (b) for productive use deployed and operated exclusively on “SAP Business Technology Platform (BTP)” or any other platform licensed from SAP (“Customer Productive Applications”). Only under this prerequisite SAP will grant You a non-exclusive, non-transferable, revocable, limited use license to copy, reproduce and distribute SAP’s underlying rights in the Customer Productive Application. You agree that any Customer Applications will not: (a) unreasonably impair, degrade or reduce the performance or security of any SAP software applications, services or related technology ("Software"); (b) enable the bypassing or circumventing of SAP's license restrictions and/or provide users with access to the Software to which such users are not licensed; (c) render or provide, without prior written consent from SAP, any information concerning SAP software license terms, Software, or any other information related to SAP products; or (d) permit mass data extraction from an SAP product to a non-SAP product, including use, modification, saving or other processing of such data in the non-SAP product, except and only to the extent that the extraction is solely used for and required for interoperability with an SAP product. In exchange for the right to develop any Customer Applications under this Agreement, You covenant not to assert any Intellectual Property Rights in Customer Applications created by You against any SAP product, service, or future SAP development. + +2. INTELLECTUAL PROPERTY: (a) SAP or its licensors retain all ownership and intellectual property rights in the APIs, Tools and Software. You may not: a) remove or modify any marks or proprietary notices of SAP, b) provide or make the APIs, Tools or Software available to any third party, except in cases APIs and Tools have been made part of the overall Customer Productive Application to function, c) assign this Developer Agreement or give or transfer the APIs, Tools or Software or an interest in them to another individual or entity, d) decompile, disassemble or reverse engineer (except to the extent permitted by applicable law) the APIs Tools or Software, e) create derivative works of or based on the APIs, Tools or Software, subject to Customer Productive Applications, being exclusively deployed and operated on “SAP Business Technology Platform (BTP)” or on any other platform licensed from SAP, f) use any SAP name, trademark or logo, or g) use the APIs or Tools to modify existing Software or other SAP product functionality or to access the Software or other SAP products' source code or metadata. (b) Subject to SAP's underlying rights in any part of the APIs, Tools or Software, You retain all ownership and intellectual property rights in Your Customer Applications. + +3. ARTIFICIAL INTELLIGENCE TRAINING: You are expressly prohibited from using the Software, Tools or APIs as well as any Customer Applications or any part thereof for the purpose of training (developing) artificial intelligence models or systems (“AI Training”). Prohibition of AI Training includes, but is not limited to, using the Software, Tools, APIs and/or Customer Applications or part thereof in any training data set, algorithm development, model development or refinement (including language learning models) related to artificial intelligence, as well as text and data mining in accordance with §44b UrhG and Art. 4 of EU Directive 2019/790. For the avoidance of doubt, by accepting this Developer Agreement You agree that Your ownership of Customer Applications shall not create nor encompass any right to use Customer Applications for AI Training and, hence, You will not use Customer Applications or any part of it for AI Training. + +4. FREE AND OPEN SOURCE COMPONENTS: The SAP Materials may include certain third party free or open source components ("FOSS Components"). You may have additional rights in such FOSS Components that are provided by the third party licensors of those components. + +5. THIRD PARTY DEPENDENCIES: The SAP Materials may require certain third party software dependencies ("Dependencies") for the use or operation of such SAP Materials. These dependencies may be identified by SAP in Maven POM files, product documentation or by other means. SAP does not grant You any rights in or to such Dependencies under this Developer Agreement. You are solely responsible for the acquisition, installation and use of Dependencies. SAP DOES NOT MAKE ANY REPRESENTATIONS OR WARRANTIES IN RESPECT OF DEPENDENCIES, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY AND OF FITNESS FOR A PARTICULAR PURPOSE. IN PARTICULAR, SAP DOES NOT WARRANT THAT DEPENDENCIES WILL BE AVAILABLE, ERROR FREE, INTEROPERABLE WITH THE SAP MATERIALS, SUITABLE FOR ANY PARTICULAR PURPOSE OR NON-INFRINGING. YOU ASSUME ALL RISKS ASSOCIATED WITH THE USE OF DEPENDENCIES, INCLUDING WITHOUT LIMITATION RISKS RELATING TO QUALITY, AVAILABILITY, PERFORMANCE, DATA LOSS, UTILITY IN A PRODUCTION ENVIRONMENT, AND NON-INFRINGEMENT. IN NO EVENT WILL SAP BE LIABLE DIRECTLY OR INDIRECTLY IN RESPECT OF ANY USE OF DEPENDENCIES BY YOU. + +6. WARRANTY: a) If You are located outside the US or Canada: AS THE API AND TOOLS ARE PROVIDED TO YOU FREE OF CHARGE, SAP DOES NOT GUARANTEE OR WARRANT ANY FEATURES OR QUALITIES OF THE TOOLS OR API OR GIVE ANY UNDERTAKING WITH REGARD TO ANY OTHER QUALITY OR TO YOUR CUSTOMERS. NO SUCH WARRANTY OR UNDERTAKING SHALL BE IMPLIED BY YOU FROM ANY DESCRIPTION IN THE API OR TOOLS OR ANY AVAILABLE DOCUMENTATION OR ANY OTHER COMMUNICATION OR ADVERTISEMENT. IN PARTICULAR, SAP DOES NOT WARRANT THAT THE SOFTWARE WILL BE AVAILABLE UNINTERRUPTED, ERROR FREE, OR PERMANENTLY AVAILABLE. FOR THE TOOLS AND API ALL WARRANTY CLAIMS ARE SUBJECT TO THE LIMITATION OF LIABILITY STIPULATED IN SECTION 4 BELOW. b) If You are located in the US or Canada: THE API AND TOOLS ARE LICENSED TO YOU "AS IS", WITHOUT ANY WARRANTY, ESCROW, TRAINING, MAINTENANCE, OR SERVICE OBLIGATIONS WHATSOEVER ON THE PART OF SAP. SAP MAKES NO EXPRESS OR IMPLIED WARRANTIES OR CONDITIONS OF SALE OF ANY TYPE WHATSOEVER, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY AND OF FITNESS FOR A PARTICULAR PURPOSE. IN PARTICULAR, SAP DOES NOT WARRANT THAT THE SOFTWARE WILL BE AVAILABLE UNINTERRUPTED, ERROR FREE, OR PERMANENTLY AVAILABLE. YOU ASSUME ALL RISKS ASSOCIATED WITH THE USE AND DISTRIBUTION (IF ANY) OF THE API AND TOOLS, INCLUDING WITHOUT LIMITATION RISKS RELATING TO QUALITY, AVAILABILITY, PERFORMANCE, DATA LOSS, AND UTILITY IN A PRODUCTION ENVIRONMENT. + +7. LIMITATION OF LIABILITY: a) If You are located outside the US or Canada: IRRESPECTIVE OF THE LEGAL REASONS, SAP SHALL ONLY BE LIABLE FOR DAMAGES UNDER THIS AGREEMENT IF SUCH DAMAGE (I) CAN BE CLAIMED UNDER THE GERMAN PRODUCT LIABILITY ACT OR (II) IS CAUSED BY INTENTIONAL MISCONDUCT OF SAP OR (III) CONSISTS OF PERSONAL INJURY. IN ALL OTHER CASES, NEITHER SAP NOR ITS EMPLOYEES, AGENTS AND SUBCONTRACTORS SHALL BE LIABLE FOR ANY KIND OF DAMAGE OR CLAIMS HEREUNDER. +b) If You are located in the US or Canada: IN NO EVENT SHALL SAP BE LIABLE TO YOU, YOUR COMPANY, YOUR CUSTOMERS OR TO ANY THIRD PARTY FOR ANY DAMAGES IN AN AMOUNT IN EXCESS OF $100 ARISING IN CONNECTION WITH YOUR USE OF OR INABILITY TO USE THE TOOLS OR API OR IN CONNECTION WITH SAP'S PROVISION OF OR FAILURE TO PROVIDE SERVICES PERTAINING TO THE TOOLS OR API, OR AS A RESULT OF ANY DEFECT IN THE API OR TOOLS OR ANY THIRD PARTY RIGHTS INFRINGEMENT BY THE APIs, TOOLS OR SOFTWARE. THIS DISCLAIMER OF LIABILITY SHALL APPLY REGARDLESS OF THE FORM OF ACTION THAT MAY BE BROUGHT AGAINST SAP, WHETHER IN CONTRACT OR TORT, INCLUDING WITHOUT LIMITATION ANY ACTION FOR NEGLIGENCE. YOUR SOLE REMEDY IN THE EVENT OF BREACH OF THIS DEVELOPER AGREEMENT BY SAP OR FOR ANY OTHER CLAIM RELATED TO THE API OR TOOLS SHALL BE TERMINATION OF THIS AGREEMENT. NOTWITHSTANDING ANYTHING TO THE CONTRARY HEREIN, UNDER NO CIRCUMSTANCES SHALL SAP AND ITS LICENSORS BE LIABLE TO YOU OR ANY OTHER PERSON IN PARTICULAR COMPANY AND YOUR CUSTOMERS OR ENTITY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, OR INDIRECT DAMAGES, LOSS OF GOOD WILL OR BUSINESS PROFITS, WORK STOPPAGE, DATA LOSS, COMPUTER FAILURE OR MALFUNCTION, ANY AND ALL OTHER COMMERCIAL DAMAGES OR LOSS, OR EXEMPLARY OR PUNITIVE DAMAGES. + +8. INDEMNITY: You will fully indemnify, hold harmless and defend SAP against law suits based on any claim: (a) that any Customer Application created by You infringes or misappropriates any patent, copyright, trademark, trade secrets, or other proprietary rights of a third party, or (b) related to Your alleged violation of the terms of this Developer Agreement + +9. EXPORT: The Tools and API are subject to German, EU and US export control regulations. You confirm that: a) You will not use the Tools or API for, and will not allow the Tools or API to be used for, any purposes prohibited by German, EU and US law, including, without limitation, for the development, design, manufacture or production of nuclear, chemical or biological weapons of mass destruction; b) You are not located in and/or will not download or otherwise export or re-export the API or Tools, directly or indirectly, to Cuba, Iran, North Korea, Syria, Crimea/Sevastopol or the so-called Donetsk People’s Republic (DNR) / Luhansk People’s Republic (LNR)nor any other country to which Germany, the European Union and/or the United States has prohibited export; c) You are not listed on any applicable sanctioned party lists (e.g., European Union Sanctions List, U.S. Specially Designated National (SDN) lists, U.S. Denied Persons List, BIS Entity List, United Nations Security Council Sanctions); d) You will not download or otherwise export or re-export the API or Tools , directly or indirectly, to persons on the above-mentioned lists. + +10. SUPPORT: Other than what is made available on the SAP Community Website (SCN) by SAP at its sole discretion and by SCN members, SAP does not offer support for the API or Tools which are the subject of this Developer Agreement. In case of Customer Productive Applications developed and made available by You in accordance with this Developer Agreement, You and third parties may request support in line with Your licensing agreement for SAP. + +11. TERM AND TERMINATION: You may terminate this Developer Agreement by destroying all copies of the API and Tools on Your Computer(s). SAP may terminate Your license to use the API and Tools immediately if You fail to comply with any of the terms of this Developer Agreement, or, for SAP's convenience by providing you with ten (10) day's written notice of termination (including email). In case of termination or expiration of this Developer Agreement, You must destroy all copies of the API and Tools immediately. In the event Your Company or any of the intellectual property you create using the API, Tools or Software are acquired (by merger, purchase of stock, assets or intellectual property or exclusive license), or You become employed, by a direct competitor of SAP, then this Development Agreement and all licenses granted in this Developer Agreement shall immediately terminate upon the date of such acquisition. + +12. LAW/VENUE: a) If You are located outside the US or Canada: This Developer Agreement is governed by and construed in accordance with the laws of the Germany. You and SAP agree to submit to the exclusive jurisdiction of, and venue in, the courts of Karlsruhe in Germany in any dispute arising out of or relating to this Developer Agreement. b) If You are located in the US or Canada: This Developer Agreement shall be governed by and construed under the Commonwealth of Pennsylvania law without reference to its conflicts of law principles. In the event of any conflicts between foreign law, rules, and regulations, and United States of America law, rules, and regulations, United States of America law, rules, and regulations shall prevail and govern. The United Nations Convention on Contracts for the International Sale of Goods shall not apply to this Developer Agreement. The Uniform Computer Information Transactions Act as enacted shall not apply. + +13. MISCELLANEOUS: This Developer Agreement is the complete agreement for the API and Tools licensed (including reference to information/documentation contained in a URL). This Developer Agreement supersedes all prior or contemporaneous agreements or representations with regards to the subject matter of this Developer Agreement. If any term of this Developer Agreement is found to be invalid or unenforceable, the surviving provisions shall remain effective. SAP's failure to enforce any right or provisions stipulated in this Developer Agreement will not constitute a waiver of such provision, or any other provision of this Developer Agreement. diff --git a/cds-feature-auditlog-ng/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration b/cds-feature-auditlog-ng/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration new file mode 100644 index 0000000..c45dbd2 --- /dev/null +++ b/cds-feature-auditlog-ng/src/main/resources/META-INF/services/com.sap.cds.services.runtime.CdsRuntimeConfiguration @@ -0,0 +1 @@ +com.sap.cds.feature.auditlog.ng.AuditLogNGConfiguration diff --git a/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java b/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java new file mode 100644 index 0000000..6b203d1 --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/java/com/sap/cds/feature/auditlog/ng/AuditLogNGHandlerTest.java @@ -0,0 +1,366 @@ +package com.sap.cds.feature.auditlog.ng; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Assertions; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.MockitoAnnotations; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import com.sap.cds.services.auditlog.Access; +import com.sap.cds.services.auditlog.Attachment; +import com.sap.cds.services.auditlog.Attribute; +import com.sap.cds.services.auditlog.ChangedAttribute; +import com.sap.cds.services.auditlog.ConfigChange; +import com.sap.cds.services.auditlog.ConfigChangeLog; +import com.sap.cds.services.auditlog.ConfigChangeLogContext; +import com.sap.cds.services.auditlog.DataAccessLog; +import com.sap.cds.services.auditlog.DataAccessLogContext; +import com.sap.cds.services.auditlog.DataModification; +import com.sap.cds.services.auditlog.DataModificationLog; +import com.sap.cds.services.auditlog.DataModificationLogContext; +import com.sap.cds.services.auditlog.DataObject; +import com.sap.cds.services.auditlog.DataSubject; +import com.sap.cds.services.auditlog.KeyValuePair; +import com.sap.cds.services.auditlog.SecurityLog; +import com.sap.cds.services.auditlog.SecurityLogContext; +import com.sap.cds.services.mt.TenantProviderService; +import com.sap.cds.services.request.UserInfo; +import com.sap.cds.services.utils.ErrorStatusException; + +public class AuditLogNGHandlerTest { + + @Mock + private AuditLogNGCommunicator communicator; + @Mock + private TenantProviderService tenantService; + @Mock + private UserInfo userInfo; + + private AuditLogNGHandler handler; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + handler = new AuditLogNGHandler(communicator, tenantService); + } + + private void runAndAssertEvent(String schemaPath, Runnable handlerMethod) throws Exception { + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handlerMethod.run(); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema(schemaPath, actualEvents); + } + + @Test + public void testHandleSecurityEventSchemaValidation() throws Exception { + SecurityLogContext context = mock(SecurityLogContext.class); + SecurityLog securityLog = mock(SecurityLog.class); + when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(securityLog); + when(securityLog.getData()).thenReturn("security event data"); + runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); + } + + @Test + public void testHandleDataAccessEvent_MultiAttrAttach_MultiAccess() throws Exception { + DataAccessLogContext context = mock(DataAccessLogContext.class); + DataAccessLog dataAccessLog = mock(DataAccessLog.class); + // Access 1 + KeyValuePair id1 = mockKeyValuePair("userId", "user-1"); + DataObject dataObject1 = mockDataObject("User", List.of(id1)); + DataSubject dataSubject1 = mockDataSubject("Person", List.of(id1)); + Attribute attr1 = mockAttribute("email"); + Attribute attr2 = mockAttribute("phone"); + Attachment att1 = mockAttachment("file", "file-1"); + Attachment att2 = mockAttachment("img", "img-2"); + Access access1 = mock(Access.class); + when(access1.getDataObject()).thenReturn(dataObject1); + when(access1.getDataSubject()).thenReturn(dataSubject1); + when(access1.getAttributes()).thenReturn(List.of(attr1, attr2)); + when(access1.getAttachments()).thenReturn(List.of(att1, att2)); + // Access 2 + KeyValuePair id2 = mockKeyValuePair("userId", "user-2"); + DataObject dataObject2 = mockDataObject("User", List.of(id2)); + DataSubject dataSubject2 = mockDataSubject("Person", List.of(id2)); + Access access2 = mock(Access.class); + when(access2.getDataObject()).thenReturn(dataObject2); + when(access2.getDataSubject()).thenReturn(dataSubject2); + when(access2.getAttributes()).thenReturn(List.of(attr1, attr2)); + when(access2.getAttachments()).thenReturn(List.of(att1, att2)); + when(dataAccessLog.getAccesses()).thenReturn(List.of(access1, access2)); + when(context.getData()).thenReturn(dataAccessLog); + when(context.getUserInfo()).thenReturn(userInfo); + runAndAssertEvent("src/test/resources/dpp-data-access-schema.json", () -> handler.handleDataAccessEvent(context)); + } + + @Test + public void testHandleConfigChangeEvent_MultiConfig() throws Exception { + ConfigChangeLogContext context = mock(ConfigChangeLogContext.class); + ConfigChangeLog configChangeLog = mock(ConfigChangeLog.class); + ChangedAttribute attr1 = mockChangedAttribute("logLevel", "INFO", "DEBUG"); + KeyValuePair id1 = mockKeyValuePair("appId", "app-999"); + DataObject dataObject1 = mockDataObject("AppConfig", List.of(id1)); + ConfigChange config1 = mockConfigChange(List.of(attr1), dataObject1); + ChangedAttribute attr2 = mockChangedAttribute("maxConnections", "100", "200"); + KeyValuePair id2a = mockKeyValuePair("dbId", "db-12345"); + KeyValuePair id2b = mockKeyValuePair("region", "us30"); + DataObject dataObject2 = mockDataObject("DatabaseConfig", List.of(id2a, id2b)); + ConfigChange config2 = mockConfigChange(List.of(attr2), dataObject2); + when(configChangeLog.getConfigurations()).thenReturn(List.of(config1, config2)); + when(context.getData()).thenReturn(configChangeLog); + when(context.getUserInfo()).thenReturn(userInfo); + handler.handleConfigChangeEvent(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents); + } + + @Test + public void testHandleDataModificationEvent_MultiModification() throws Exception { + DataModificationLogContext context = mock(DataModificationLogContext.class); + DataModificationLog dataModificationLog = mock(DataModificationLog.class); + ChangedAttribute attr1 = mockChangedAttribute("email", "old1@example.com", "new1@example.com"); + KeyValuePair id1 = mockKeyValuePair("userId", "user-111"); + DataObject dataObject1 = mockDataObject("User", List.of(id1)); + DataSubject dataSubject1 = mockDataSubject("Person", List.of(id1)); + DataModification modification1 = mockDataModification(List.of(attr1), dataObject1, dataSubject1); + ChangedAttribute attr2 = mockChangedAttribute("phone", "12345", "67890"); + KeyValuePair id2 = mockKeyValuePair("userId", "user-222"); + DataObject dataObject2 = mockDataObject("User", List.of(id2)); + DataSubject dataSubject2 = mockDataSubject("Person", List.of(id2)); + DataModification modification2 = mockDataModification(List.of(attr2), dataObject2, dataSubject2); + when(dataModificationLog.getModifications()).thenReturn(List.of(modification1, modification2)); + when(context.getData()).thenReturn(dataModificationLog); + when(context.getUserInfo()).thenReturn(userInfo); + handler.handleDataModificationEvent(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents); + + } + + @Test + public void testObjectIdAndSubjectIdAreAlphabeticallyOrdered() throws Exception { + // Prepare test data with intentionally unordered keys + KeyValuePair idA = mockKeyValuePair("zKey", "zValue"); + KeyValuePair idB = mockKeyValuePair("aKey", "aValue"); + KeyValuePair idC = mockKeyValuePair("mKey", "mValue"); + List unorderedIds = List.of(idA, idB, idC); + DataObject dataObject = mockDataObject("TestType", unorderedIds); + DataSubject dataSubject = mockDataSubject("TestSubject", unorderedIds); + ChangedAttribute attr = mockChangedAttribute("testAttr", "old", "new"); + DataModification modification = mockDataModification(List.of(attr), dataObject, dataSubject); + DataModificationLog dataModificationLog = mock(DataModificationLog.class); + when(dataModificationLog.getModifications()).thenReturn(List.of(modification)); + DataModificationLogContext context = mock(DataModificationLogContext.class); + when(context.getData()).thenReturn(dataModificationLog); + when(context.getUserInfo()).thenReturn(userInfo); + // Capture the event JSON + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + handler.handleDataModificationEvent(context); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + // Validate the JSON structure + assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents); + // Further assertions can be done here to check the content of actualEvents + } + + // --- Additional Tests for Robustness and Coverage --- + @Test + public void testHandleDataAccessEvent_NullAttributesAndAttachments() throws Exception { + DataAccessLogContext context = mock(DataAccessLogContext.class); + DataAccessLog dataAccessLog = mock(DataAccessLog.class); + Access access = mock(Access.class); + KeyValuePair id1 = mockKeyValuePair("userId", "user-111"); + DataObject dataObject = mockDataObject("User", List.of(id1)); + DataSubject dataSubject = mockDataSubject("Person", List.of(id1)); + when(access.getDataObject()).thenReturn(dataObject); + when(access.getDataSubject()).thenReturn(dataSubject); + when(access.getAttributes()).thenReturn(null); + when(access.getAttachments()).thenReturn(null); + when(dataAccessLog.getAccesses()).thenReturn(List.of(access)); + when(context.getData()).thenReturn(dataAccessLog); + when(context.getUserInfo()).thenReturn(userInfo); + + ErrorStatusException ex = assertThrows(ErrorStatusException.class, () -> handler.handleDataAccessEvent(context)); + Assertions.assertTrue(ex.getCause() instanceof NullPointerException); + } + + @Test + public void testHandleConfigChangeEvent_EmptyAttributes() throws Exception { + ConfigChangeLogContext context = mock(ConfigChangeLogContext.class); + ConfigChangeLog configChangeLog = mock(ConfigChangeLog.class); + ConfigChange config = mockConfigChange(List.of(), mockDataObject("AppConfig", List.of())); + when(configChangeLog.getConfigurations()).thenReturn(List.of(config)); + when(context.getData()).thenReturn(configChangeLog); + when(context.getUserInfo()).thenReturn(userInfo); + handler.handleConfigChangeEvent(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents); + } + + @Test + public void testHandleDataModificationEvent_LargeBulk() throws Exception { + DataModificationLogContext context = mock(DataModificationLogContext.class); + DataModificationLog dataModificationLog = mock(DataModificationLog.class); + List mods = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + ChangedAttribute attr = mockChangedAttribute("field" + i, "old" + i, "new" + i); + KeyValuePair id = mockKeyValuePair("id", String.valueOf(i)); + DataObject obj = mockDataObject("Type", List.of(id)); + DataSubject subj = mockDataSubject("Subject", List.of(id)); + mods.add(mockDataModification(List.of(attr), obj, subj)); + } + when(dataModificationLog.getModifications()).thenReturn(mods); + when(context.getData()).thenReturn(dataModificationLog); + when(context.getUserInfo()).thenReturn(userInfo); + handler.handleDataModificationEvent(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents); + Assertions.assertEquals(100, actualEvents.size(), "Should produce 100 events"); + } + + @Test + public void testHandleDataModificationEvent_CommunicatorThrows() throws Exception { + DataModificationLogContext context = mock(DataModificationLogContext.class); + DataModificationLog dataModificationLog = mock(DataModificationLog.class); + DataModification modification = mockDataModification(List.of(), mockDataObject("Type", List.of()), mockDataSubject("Subject", List.of())); + when(dataModificationLog.getModifications()).thenReturn(List.of(modification)); + when(context.getData()).thenReturn(dataModificationLog); + when(context.getUserInfo()).thenReturn(userInfo); + // Simulate communicator throwing + Mockito.doThrow(new RuntimeException("Simulated failure")).when(communicator).sendBulkRequest(ArgumentMatchers.any()); + boolean failed = false; + try { + handler.handleDataModificationEvent(context); + } catch (RuntimeException e) { + failed = true; + } + Assertions.assertTrue(failed, "Handler should propagate communicator exception"); + } + + @Test + public void testHandleUserInfoWithNullFields() throws Exception { + UserInfo userInfoNull = mock(UserInfo.class); + when(userInfoNull.getName()).thenReturn(null); + when(userInfoNull.getId()).thenReturn(null); + SecurityLogContext context = mock(SecurityLogContext.class); + SecurityLog securityLog = mock(SecurityLog.class); + when(context.getUserInfo()).thenReturn(userInfoNull); + when(context.getData()).thenReturn(securityLog); + when(securityLog.getData()).thenReturn("security event data"); + handler.handleSecurityEvent(context); + ArgumentCaptor captor = ArgumentCaptor.forClass(ArrayNode.class); + verify(communicator).sendBulkRequest(captor.capture()); + ArrayNode actualEvents = captor.getValue(); + assertJsonMatchesSchema("src/test/resources/legacy-security-wrapper-schema.json", actualEvents); + } + + @Test + public void testHandleLegacyWrapperEvent() throws Exception { + SecurityLogContext context = mock(SecurityLogContext.class); + SecurityLog securityLog = mock(SecurityLog.class); + when(context.getUserInfo()).thenReturn(userInfo); + when(context.getData()).thenReturn(securityLog); + when(securityLog.getData()).thenReturn("{\"legacy\":true}"); + runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context)); + } + + private ChangedAttribute mockChangedAttribute(String name, String oldValue, String newValue) { + ChangedAttribute attr = mock(ChangedAttribute.class); + when(attr.getName()).thenReturn(name); + when(attr.getOldValue()).thenReturn(oldValue); + when(attr.getNewValue()).thenReturn(newValue); + return attr; + } + + private KeyValuePair mockKeyValuePair(String key, String value) { + KeyValuePair kv = mock(KeyValuePair.class); + when(kv.getKeyName()).thenReturn(key); + when(kv.getValue()).thenReturn(value); + return kv; + } + + private Attribute mockAttribute(String name) { + Attribute attr = mock(Attribute.class); + when(attr.getName()).thenReturn(name); + return attr; + } + + private Attachment mockAttachment(String name, String id) { + Attachment att = mock(Attachment.class); + when(att.getName()).thenReturn(name); + when(att.getId()).thenReturn(id); + return att; + } + + private DataObject mockDataObject(String type, List ids) { + DataObject obj = mock(DataObject.class); + when(obj.getType()).thenReturn(type); + when(obj.getId()).thenReturn(ids); + return obj; + } + + private DataSubject mockDataSubject(String type, List ids) { + DataSubject subj = mock(DataSubject.class); + when(subj.getType()).thenReturn(type); + when(subj.getId()).thenReturn(ids); + return subj; + } + + private DataModification mockDataModification(List attrs, DataObject obj, DataSubject subj) { + DataModification mod = mock(DataModification.class); + when(mod.getAttributes()).thenReturn(attrs); + when(mod.getDataObject()).thenReturn(obj); + when(mod.getDataSubject()).thenReturn(subj); + return mod; + } + + private ConfigChange mockConfigChange(List attrs, DataObject obj) { + ConfigChange cc = mock(ConfigChange.class); + when(cc.getAttributes()).thenReturn(attrs); + when(cc.getDataObject()).thenReturn(obj); + return cc; + } + + private void assertJsonMatchesSchema(String schemaPath, JsonNode dataNode) throws Exception { + JsonSchema schema = getTestSchema(schemaPath); + Set errors = schema.validate(dataNode); + Assertions.assertTrue(errors.isEmpty(), "Schema validation errors: " + errors); + } + + private JsonSchema getTestSchema(String schemaPath) throws Exception { + JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode schemaContent = objectMapper.readTree(new File(schemaPath)); + JsonSchema schema = factory.getSchema(schemaContent); + schema.initializeValidators(); + return schema; + } +} diff --git a/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json b/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json new file mode 100644 index 0000000..e0c1c66 --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/resources/configuration-change-schema.json @@ -0,0 +1,72 @@ +[ + { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/ConfigurationChange", + "definitions": { + "ConfigurationChange": { + "required": [ + "newValue", + "oldValue", + "propertyName" + ], + "properties": { + "newValue": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Value", + "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + }, + "oldValue": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Value", + "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + }, + "propertyName": { + "type": "string" + }, + "objectType": { + "type": "string" + }, + "objectId": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "title": "Configuration Change", + "description": "ConfigurationChange states that Configuration has been modified." + } + } + } +] \ No newline at end of file diff --git a/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json b/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json new file mode 100644 index 0000000..9bae1be --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/resources/dpp-data-access-schema.json @@ -0,0 +1,73 @@ +[ + { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/DppDataAccess", + "definitions": { + "DppDataAccess": { + "required": [ + "channelType", + "channelId", + "dataSubjectType", + "dataSubjectId", + "objectType", + "objectId", + "attribute" + ], + "properties": { + "channelType": { + "type": "string" + }, + "channelId": { + "type": "string" + }, + "dataSubjectType": { + "type": "string" + }, + "dataSubjectId": { + "type": "string" + }, + "objectType": { + "type": "string" + }, + "objectId": { + "type": "string" + }, + "attribute": { + "type": "string" + }, + "value": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Value", + "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + }, + "attachmentType": { + "type": "string" + }, + "attachmentId": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object", + "title": "Dpp Data Access", + "description": "DppDataAccess states that DPP relevant data has been accessed." + } + } + } +] \ No newline at end of file diff --git a/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json b/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json new file mode 100644 index 0000000..37fbe1e --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/resources/dpp-data-modification-schema.json @@ -0,0 +1,80 @@ +[ + { + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/DppDataModification", + "definitions": { + "DppDataModification": { + "required": [ + "dataSubjectType", + "dataSubjectId", + "objectType", + "objectId", + "attribute" + ], + "properties": { + "dataSubjectType": { + "type": "string" + }, + "dataSubjectId": { + "type": "string" + }, + "objectType": { + "type": "string" + }, + "objectId": { + "type": "string" + }, + "attribute": { + "type": "string" + }, + "newValue": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Value", + "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + }, + "oldValue": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ], + "title": "Value", + "description": "`Value` represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. A producer of value is expected to set one of these variants. Absence of any variant indicates an error. The JSON representation for `Value` is JSON value." + } + }, + "additionalProperties": false, + "type": "object", + "title": "Dpp Data Modification", + "description": "DppDataModification states that DPP relevant data has been modified." + } + } + } +] \ No newline at end of file diff --git a/cds-feature-auditlog-ng/src/test/resources/legacy-security-wrapper-schema.json b/cds-feature-auditlog-ng/src/test/resources/legacy-security-wrapper-schema.json new file mode 100644 index 0000000..d96fb7e --- /dev/null +++ b/cds-feature-auditlog-ng/src/test/resources/legacy-security-wrapper-schema.json @@ -0,0 +1,79 @@ +[ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "id", + "specversion", + "source", + "type", + "time", + "data" + ], + "properties": { + "id": { + "type": "string" + }, + "specversion": { + "type": "string" + }, + "source": { + "type": "string" + }, + "type": { + "type": "string", + "const": "legacySecurityWrapper" + }, + "dataschema": { + "type": "string" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "xsapingestiontime": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "metadata", + "data" + ], + "properties": { + "metadata": { + "type": "object", + "required": [ + "ts" + ], + "properties": { + "ts": { + "type": "string" + } + } + }, + "data": { + "type": "object", + "required": [ + "legacySecurityWrapper" + ], + "properties": { + "legacySecurityWrapper": { + "type": "object", + "required": [ + "origEvent" + ], + "properties": { + "origEvent": { + "type": "string" + } + } + } + } + } + } + } + }, + "additionalProperties": false + } +] \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..69f7f71 --- /dev/null +++ b/pom.xml @@ -0,0 +1,248 @@ + + 4.0.0 + + + + The Apache Software License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + SAP SE + https://www.sap.com + + + + + SAP SE + https://www.sap.com + + + + + 0.0.1 + 17 + ${java.version} + UTF-8 + 3.0.2 + 1.5.7 + 2.7.2 + 2.8.3 + 1.61.0 + 5.13.1 + 3.27.3 + 5.18.0 + 5.17.0 + 5.18.0 + 1.81 + + + com.sap.cds + cds-feature-auditlog-ng-root + ${revision} + pom + + CDS Plugin for SAP Audit Log NG - Root + This artifact is a CAP Java plugin that provides out-of-the box SAP Audit Log NG Service. + + + + cds-feature-auditlog-ng + + + + + + com.sap.cds + cds-services-bom + ${cds.services.version} + pom + import + + + + com.sap.cloud.sdk + sdk-bom + ${sdk-bom.version} + pom + import + + + + org.mockito + mockito-bom + ${mockito-bom.version} + pom + import + + + + + + + + org.bouncycastle + bcprov-jdk18on + ${bcpkix-jdk18on.version} + + + + org.bouncycastle + bcpkix-jdk18on + ${bcpkix-jdk18on.version} + + + + com.networknt + json-schema-validator + ${json-schema-validator.version} + test + + + + com.sap.cds + cds-services-api + + + + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + + + + + maven-surefire-plugin + + + + + org.codehaus.mojo + flatten-maven-plugin + + true + resolveCiFriendliesOnly + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + maven-enforcer-plugin + + + no-duplicate-declared-dependencies + + enforce + + + + + + 3.6.3 + + + ${java.version} + + + + + + + + + + + + + maven-clean-plugin + 3.5.0 + + + maven-compiler-plugin + 3.14.0 + + + maven-source-plugin + 3.3.1 + + + maven-deploy-plugin + 3.1.4 + + + maven-javadoc-plugin + 3.11.2 + + + maven-surefire-plugin + 3.5.2 + + + maven-pmd-plugin + 3.26.0 + + + maven-enforcer-plugin + 3.5.0 + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.1 + + + org.jacoco + jacoco-maven-plugin + 0.8.13 + + + com.github.spotbugs + spotbugs-maven-plugin + 4.9.2.0 + + + + + + + + artifactory + Artifactory_DMZ-snapshots + https://common.repositories.cloud.sap/artifactory/cap-java + + + ossrh + MavenCentral + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + + + + + \ No newline at end of file