From 128595cdb052e79c725aad0042cb674497edfae8 Mon Sep 17 00:00:00 2001 From: karmaking Date: Tue, 13 Jan 2026 14:45:22 +0100 Subject: [PATCH 01/12] fix github action --- .github/workflows/auto_update_base_image.yml | 35 ---- .../build_container_develop_branch.yml | 30 ---- .../build_container_non_develop_branch.yml | 151 ------------------ .github/workflows/build_pull_request.yml | 124 -------------- .github/workflows/run_trivy.yml | 54 ------- .gitignore | 1 - 6 files changed, 395 deletions(-) delete mode 100644 .github/workflows/auto_update_base_image.yml delete mode 100644 .github/workflows/build_container_non_develop_branch.yml delete mode 100644 .github/workflows/build_pull_request.yml delete mode 100644 .github/workflows/run_trivy.yml diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml deleted file mode 100644 index 3048faf15e..0000000000 --- a/.github/workflows/auto_update_base_image.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Regular base image update check -on: - schedule: - - cron: "0 5 * * *" - workflow_dispatch: - -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Docker Image Update Checker - id: baseupdatecheck - uses: lucacome/docker-image-update-checker@v2.0.0 - with: - base-image: jetty:9.4-jdk11-alpine - image: ${{ env.DOCKER_HUB_ORGANIZATION }}/obp-api:latest - - - name: Trigger build_container_develop_branch workflow - uses: actions/github-script@v6 - with: - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'build_container_develop_branch.yml', - ref: 'refs/heads/develop' - }); - if: steps.baseupdatecheck.outputs.needs-updating == 'true' diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index 3afc3d6ec5..db6bd51602 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -124,33 +124,3 @@ jobs: with: name: ${{ github.sha }} path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml deleted file mode 100644 index fda13bb721..0000000000 --- a/.github/workflows/build_container_non_develop_branch.yml +++ /dev/null @@ -1,151 +0,0 @@ -name: Build and publish container non develop - -on: - push: - branches: - - '**' - - '!develop' - -env: - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log - - - name: Report failing tests (if any) - if: always() - run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 - fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." - exit 1 - else - echo "No failing tests detected in maven-build.log." - fi - - - name: Upload Maven build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: maven-build-log - if-no-files-found: ignore - path: | - maven-build.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - if-no-files-found: ignore - path: | - obp-api/target/surefire-reports/** - obp-commons/target/surefire-reports/** - **/target/scalatest-reports/** - **/target/site/surefire-report.html - **/target/site/surefire-report/* - - - name: Save .war artifact - run: | - mkdir -p ./push - cp obp-api/target/obp-api-1.*.war ./push/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: push/ - - - name: Build the Docker image - run: | - echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags - echo docker done - - - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - - - name: Write signing key to disk (only needed for `cosign sign --key`) - run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - - - name: Sign container image - run: | - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC - cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA - env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml deleted file mode 100644 index 61d1e05a5a..0000000000 --- a/.github/workflows/build_pull_request.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: Build on Pull Request - -on: - pull_request: - branches: - - '**' -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - - -jobs: - build: - runs-on: ubuntu-latest - services: - # Label used to access the service container - redis: - # Docker Hub image - image: redis - ports: - # Opens tcp port 6379 on the host and service container - - 6379:6379 - # Set health checks to wait until redis has started - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - - name: Set up JDK 11 - uses: actions/setup-java@v4 - with: - java-version: '11' - distribution: 'adopt' - cache: maven - - name: Build with Maven - run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props - echo connector=star > obp-api/src/main/resources/props/test.default.props - echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props - echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props - echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props - echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props - echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props - echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props - echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props - echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props - echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props - echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props - echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props - echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props - echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props - echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props - echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log - - - name: Report failing tests (if any) - if: always() - run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 - fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." - exit 1 - else - echo "No failing tests detected in maven-build.log." - fi - - - name: Upload Maven build log - if: always() - uses: actions/upload-artifact@v4 - with: - name: maven-build-log - if-no-files-found: ignore - path: | - maven-build.log - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - if-no-files-found: ignore - path: | - obp-api/target/surefire-reports/** - obp-commons/target/surefire-reports/** - **/target/scalatest-reports/** - **/target/site/surefire-report.html - **/target/site/surefire-report/* - - - name: Save .war artifact - run: | - mkdir -p ./pull - cp obp-api/target/obp-api-1.*.war ./pull/ - - uses: actions/upload-artifact@v4 - with: - name: ${{ github.sha }} - path: pull/ - - - diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml deleted file mode 100644 index 4636bd3116..0000000000 --- a/.github/workflows/run_trivy.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: scan container image - -on: - workflow_run: - workflows: - - Build and publish container develop - - Build and publish container non develop - types: - - completed -env: - ## Sets environment variable - DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - DOCKER_HUB_REPOSITORY: obp-api - - -jobs: - build: - runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} - - steps: - - uses: actions/checkout@v4 - - id: trivy-db - name: Check trivy db sha - env: - GH_TOKEN: ${{ github.token }} - run: | - endpoint='/orgs/aquasecurity/packages/container/trivy-db/versions' - headers='Accept: application/vnd.github+json' - jqFilter='.[] | select(.metadata.container.tags[] | contains("latest")) | .name | sub("sha256:";"")' - sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}") - echo "Trivy DB sha256:${sha}" - echo "::set-output name=sha::${sha}" - - uses: actions/cache@v4 - with: - path: .trivy - key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }} - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' - format: 'template' - template: '@/contrib/sarif.tpl' - output: 'trivy-results.sarif' - security-checks: 'vuln' - severity: 'CRITICAL,HIGH' - timeout: '30m' - cache-dir: .trivy - - name: Fix .trivy permissions - run: sudo chown -R $(stat . -c %u:%g) .trivy - - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: 'trivy-results.sarif' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7e1e1bd937..1f8aabc66e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.github/* *.class *.db .DS_Store From 2b4bda715777afda8e71a1394d95cfcb8b8593fa Mon Sep 17 00:00:00 2001 From: karmaking Date: Tue, 13 Jan 2026 14:46:31 +0100 Subject: [PATCH 02/12] edit gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1f8aabc66e..7e1e1bd937 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.github/* *.class *.db .DS_Store From 3616788df211b45477821907675ecf9a5791a9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 19 Jan 2026 17:10:28 +0100 Subject: [PATCH 03/12] feature/Remove Await.result from executeRule --- .../scala/code/abacrule/AbacRuleEngine.scala | 462 ++++++++++-------- .../src/main/scala/code/abacrule/README.md | 129 +++-- .../scala/code/api/v6_0_0/APIMethods600.scala | 58 +-- 3 files changed, 396 insertions(+), 253 deletions(-) diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 93fb815371..057193ec5f 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -13,7 +13,7 @@ import net.liftweb.util.Helpers.tryo import java.util.concurrent.ConcurrentHashMap import scala.collection.JavaConverters._ import scala.collection.concurrent -import scala.concurrent.Await +import scala.concurrent.{Await, Future} import scala.concurrent.duration._ /** @@ -112,190 +112,221 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None + ): Future[Box[Boolean]] = { + val ruleBox = MappedAbacRuleProvider.getAbacRuleById(ruleId) + ruleBox match { + case Failure(msg, ex, chain) => Future.successful(Failure(msg, ex, chain)) + case Empty => Future.successful(Empty) + case Full(rule) => + if (!rule.isActive) { + Future.successful(Failure(s"ABAC Rule ${rule.ruleName} is not active")) + } else { + // Fetch authenticated user + val authenticatedUserBox = Users.users.vend.getUserByUserId(authenticatedUserId) + authenticatedUserBox match { + case Failure(msg, ex, chain) => Future.successful(Failure(msg, ex, chain)) + case Empty => Future.successful(Empty) + case Full(authenticatedUser) => + + // Create futures for all async operations + val authenticatedUserAttributesFuture = + code.api.util.NewStyle.function.getNonPersonalUserAttributes(authenticatedUserId, Some(callContext)).map(_._1) + + val authenticatedUserAuthContextFuture = + code.api.util.NewStyle.function.getUserAuthContexts(authenticatedUserId, Some(callContext)).map(_._1) + + val authenticatedUserEntitlementsFuture = + code.api.util.NewStyle.function.getEntitlementsByUserId(authenticatedUserId, Some(callContext)) + + val onBehalfOfUserFuture = onBehalfOfUserId match { + case Some(obUserId) => Future.successful(Users.users.vend.getUserByUserId(obUserId).map(Some(_))) + case None => Future.successful(Full(None)) + } + + val onBehalfOfUserAttributesFuture = onBehalfOfUserId match { + case Some(obUserId) => + code.api.util.NewStyle.function.getNonPersonalUserAttributes(obUserId, Some(callContext)).map(_._1) + case None => Future.successful(List.empty[UserAttributeTrait]) + } + + val onBehalfOfUserAuthContextFuture = onBehalfOfUserId match { + case Some(obUserId) => + code.api.util.NewStyle.function.getUserAuthContexts(obUserId, Some(callContext)).map(_._1) + case None => Future.successful(List.empty[UserAuthContext]) + } + + val onBehalfOfUserEntitlementsFuture = onBehalfOfUserId match { + case Some(obUserId) => + code.api.util.NewStyle.function.getEntitlementsByUserId(obUserId, Some(callContext)) + case None => Future.successful(List.empty[Entitlement]) + } + + val userFuture = userId match { + case Some(uId) => Future.successful(Users.users.vend.getUserByUserId(uId).map(Some(_))) + case None => Future.successful(Full(None)) + } + + val userAttributesFuture = userId match { + case Some(uId) => + code.api.util.NewStyle.function.getNonPersonalUserAttributes(uId, Some(callContext)).map(_._1) + case None => Future.successful(List.empty[UserAttributeTrait]) + } + + val bankFuture = bankId match { + case Some(bId) => + code.api.util.NewStyle.function.getBank(BankId(bId), Some(callContext)).map(_._1).map(bank => Full(Some(bank))).recover { + case _ => Full(None) + } + case None => Future.successful(Full(None)) + } + + val bankAttributesFuture = bankId match { + case Some(bId) => + code.api.util.NewStyle.function.getBankAttributesByBank(BankId(bId), Some(callContext)).map(_._1) + case None => Future.successful(List.empty[BankAttributeTrait]) + } + + val accountFuture = (bankId, accountId) match { + case (Some(bId), Some(aId)) => + code.api.util.NewStyle.function.getBankAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1).map(account => Full(Some(account))).recover { + case _ => Full(None) + } + case _ => Future.successful(Full(None)) + } + + val accountAttributesFuture = (bankId, accountId) match { + case (Some(bId), Some(aId)) => + code.api.util.NewStyle.function.getAccountAttributesByAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1) + case _ => Future.successful(List.empty[AccountAttribute]) + } + + val transactionFuture = (bankId, accountId, transactionId) match { + case (Some(bId), Some(aId), Some(tId)) => + code.api.util.NewStyle.function.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), Some(callContext)).map(_._1).map(trans => Full(Some(trans))).recover { + case _ => Full(None) + } + case _ => Future.successful(Full(None)) + } + + val transactionAttributesFuture = (bankId, transactionId) match { + case (Some(bId), Some(tId)) => + code.api.util.NewStyle.function.getTransactionAttributes(BankId(bId), TransactionId(tId), Some(callContext)).map(_._1) + case _ => Future.successful(List.empty[TransactionAttribute]) + } + + val transactionRequestFuture = transactionRequestId match { + case Some(trId) => + code.api.util.NewStyle.function.getTransactionRequestImpl(TransactionRequestId(trId), Some(callContext)).map(_._1).map(tr => Full(Some(tr))).recover { + case _ => Full(None) + } + case _ => Future.successful(Full(None)) + } + + val transactionRequestAttributesFuture = (bankId, transactionRequestId) match { + case (Some(bId), Some(trId)) => + code.api.util.NewStyle.function.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), Some(callContext)).map(_._1) + case _ => Future.successful(List.empty[TransactionRequestAttributeTrait]) + } + + val customerFuture = (bankId, customerId) match { + case (Some(bId), Some(cId)) => + code.api.util.NewStyle.function.getCustomerByCustomerId(cId, Some(callContext)).map(_._1).map(cust => Full(Some(cust))).recover { + case _ => Full(None) + } + case _ => Future.successful(Full(None)) + } + + val customerAttributesFuture = (bankId, customerId) match { + case (Some(bId), Some(cId)) => + code.api.util.NewStyle.function.getCustomerAttributes(BankId(bId), CustomerId(cId), Some(callContext)).map(_._1) + case _ => Future.successful(List.empty[CustomerAttribute]) + } + + // Combine all futures + for { + authenticatedUserAttributes <- authenticatedUserAttributesFuture + authenticatedUserAuthContext <- authenticatedUserAuthContextFuture + authenticatedUserEntitlements <- authenticatedUserEntitlementsFuture + onBehalfOfUserOpt <- onBehalfOfUserFuture + onBehalfOfUserAttributes <- onBehalfOfUserAttributesFuture + onBehalfOfUserAuthContext <- onBehalfOfUserAuthContextFuture + onBehalfOfUserEntitlements <- onBehalfOfUserEntitlementsFuture + userOpt <- userFuture + userAttributes <- userAttributesFuture + bankOpt <- bankFuture + bankAttributes <- bankAttributesFuture + accountOpt <- accountFuture + accountAttributes <- accountAttributesFuture + transactionOpt <- transactionFuture + transactionAttributes <- transactionAttributesFuture + transactionRequestOpt <- transactionRequestFuture + transactionRequestAttributes <- transactionRequestAttributesFuture + customerOpt <- customerFuture + customerAttributes <- customerAttributesFuture + } yield { + // Compile and execute the rule + val compiledFuncBox = compileRule(ruleId, rule.ruleCode) + compiledFuncBox.flatMap { compiledFunc => + (for { + onBehalfOfUser <- onBehalfOfUserOpt + user <- userOpt + bank <- bankOpt + account <- accountOpt + transaction <- transactionOpt + transactionRequest <- transactionRequestOpt + customer <- customerOpt + } yield { + tryo { + compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, onBehalfOfUser, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, user, userAttributes, bank, bankAttributes, account, accountAttributes, transaction, transactionAttributes, transactionRequest, transactionRequestAttributes, customer, customerAttributes, Some(callContext)) + } + }).flatten + } + } + } + } + } + } + + /** + * Synchronous wrapper for executeRule - DEPRECATED + * This function blocks the thread and should be avoided. Use the async version instead. + * + * @deprecated Use the async executeRule that returns Future[Box[Boolean]] instead + */ + @deprecated("Use async executeRule that returns Future[Box[Boolean]]", "6.0.0") + def executeRuleSync( + ruleId: String, + authenticatedUserId: String, + onBehalfOfUserId: Option[String] = None, + userId: Option[String] = None, + callContext: CallContext, + bankId: Option[String] = None, + accountId: Option[String] = None, + viewId: Option[String] = None, + transactionId: Option[String] = None, + transactionRequestId: Option[String] = None, + customerId: Option[String] = None ): Box[Boolean] = { - for { - rule <- MappedAbacRuleProvider.getAbacRuleById(ruleId) - _ <- if (rule.isActive) Full(true) else Failure(s"ABAC Rule ${rule.ruleName} is not active") - - // Fetch authenticated user (the actual person logged in) - authenticatedUser <- Users.users.vend.getUserByUserId(authenticatedUserId) - - // Fetch non-personal attributes for authenticated user - authenticatedUserAttributes = Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(authenticatedUserId, Some(callContext)).map(_._1), - 5.seconds - ) - - // Fetch auth context for authenticated user - authenticatedUserAuthContext = Await.result( - code.api.util.NewStyle.function.getUserAuthContexts(authenticatedUserId, Some(callContext)).map(_._1), - 5.seconds - ) - - // Fetch entitlements for authenticated user - authenticatedUserEntitlements = Await.result( - code.api.util.NewStyle.function.getEntitlementsByUserId(authenticatedUserId, Some(callContext)), - 5.seconds - ) - - // Fetch onBehalfOf user if provided (delegation scenario) - onBehalfOfUserOpt <- onBehalfOfUserId match { - case Some(obUserId) => Users.users.vend.getUserByUserId(obUserId).map(Some(_)) - case None => Full(None) - } - - // Fetch attributes for onBehalfOf user if provided - onBehalfOfUserAttributes = onBehalfOfUserId match { - case Some(obUserId) => - Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(obUserId, Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[UserAttributeTrait] - } - - // Fetch auth context for onBehalfOf user if provided - onBehalfOfUserAuthContext = onBehalfOfUserId match { - case Some(obUserId) => - Await.result( - code.api.util.NewStyle.function.getUserAuthContexts(obUserId, Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[UserAuthContext] - } - - // Fetch entitlements for onBehalfOf user if provided - onBehalfOfUserEntitlements = onBehalfOfUserId match { - case Some(obUserId) => - Await.result( - code.api.util.NewStyle.function.getEntitlementsByUserId(obUserId, Some(callContext)), - 5.seconds - ) - case None => List.empty[Entitlement] - } - - // Fetch target user if userId is provided - userOpt <- userId match { - case Some(uId) => Users.users.vend.getUserByUserId(uId).map(Some(_)) - case None => Full(None) - } - - // Fetch attributes for target user if provided - userAttributes = userId match { - case Some(uId) => - Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(uId, Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[UserAttributeTrait] - } - - // Fetch bank if bankId is provided - bankOpt <- bankId match { - case Some(bId) => - tryo(Await.result( - code.api.util.NewStyle.function.getBank(BankId(bId), Some(callContext)).map(_._1), - 5.seconds - )).map(Some(_)) - case None => Full(None) - } - - // Fetch bank attributes if bank is provided - bankAttributes = bankId match { - case Some(bId) => - Await.result( - code.api.util.NewStyle.function.getBankAttributesByBank(BankId(bId), Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[BankAttributeTrait] - } - - // Fetch account if accountId and bankId are provided - accountOpt <- (bankId, accountId) match { - case (Some(bId), Some(aId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getBankAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), - 5.seconds - )).map(Some(_)) - case _ => Full(None) - } - - // Fetch account attributes if account is provided - accountAttributes = (bankId, accountId) match { - case (Some(bId), Some(aId)) => - Await.result( - code.api.util.NewStyle.function.getAccountAttributesByAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[AccountAttribute] - } - - // Fetch transaction if transactionId, accountId, and bankId are provided - transactionOpt <- (bankId, accountId, transactionId) match { - case (Some(bId), Some(aId), Some(tId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), Some(callContext)).map(_._1), - 5.seconds - )).map(trans => Some(trans)) - case _ => Full(None) - } - - // Fetch transaction attributes if transaction is provided - transactionAttributes = (bankId, transactionId) match { - case (Some(bId), Some(tId)) => - Await.result( - code.api.util.NewStyle.function.getTransactionAttributes(BankId(bId), TransactionId(tId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[TransactionAttribute] - } - - // Fetch transaction request if transactionRequestId is provided - transactionRequestOpt <- transactionRequestId match { - case Some(trId) => - tryo(Await.result( - code.api.util.NewStyle.function.getTransactionRequestImpl(TransactionRequestId(trId), Some(callContext)).map(_._1), - 5.seconds - )).map(tr => Some(tr)) - case _ => Full(None) - } - - // Fetch transaction request attributes if transaction request is provided - transactionRequestAttributes = (bankId, transactionRequestId) match { - case (Some(bId), Some(trId)) => - Await.result( - code.api.util.NewStyle.function.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[TransactionRequestAttributeTrait] - } - - // Fetch customer if customerId and bankId are provided - customerOpt <- (bankId, customerId) match { - case (Some(bId), Some(cId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getCustomerByCustomerId(cId, Some(callContext)).map(_._1), - 5.seconds - )).map(cust => Some(cust)) - case _ => Full(None) - } - - // Fetch customer attributes if customer is provided - customerAttributes = (bankId, customerId) match { - case (Some(bId), Some(cId)) => - Await.result( - code.api.util.NewStyle.function.getCustomerAttributes(BankId(bId), CustomerId(cId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[CustomerAttribute] - } - - // Compile and execute the rule - compiledFunc <- compileRule(ruleId, rule.ruleCode) - result <- tryo { - compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, onBehalfOfUserOpt, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, userOpt, userAttributes, bankOpt, bankAttributes, accountOpt, accountAttributes, transactionOpt, transactionAttributes, transactionRequestOpt, transactionRequestAttributes, customerOpt, customerAttributes, Some(callContext)) - } - } yield result + try { + Await.result(executeRule( + ruleId = ruleId, + authenticatedUserId = authenticatedUserId, + onBehalfOfUserId = onBehalfOfUserId, + userId = userId, + callContext = callContext, + bankId = bankId, + accountId = accountId, + viewId = viewId, + transactionId = transactionId, + transactionRequestId = transactionRequestId, + customerId = customerId + ), 30.seconds) + } catch { + case _: java.util.concurrent.TimeoutException => + Failure("ABAC rule execution timed out") + case ex: Exception => + Failure(s"ABAC rule execution failed: ${ex.getMessage}") + } } @@ -329,15 +360,15 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None - ): Box[Boolean] = { + ): Future[Box[Boolean]] = { val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy) if (rules.isEmpty) { // No rules for this policy - default to allow - Full(true) + Future.successful(Full(true)) } else { // Execute all rules and check if at least one passes - val results = rules.map { rule => + val ruleFutures = rules.map { rule => executeRule( ruleId = rule.abacRuleId, authenticatedUserId = authenticatedUserId, @@ -353,14 +384,59 @@ object AbacRuleEngine { ) } - // Count successes and failures - val successes = results.filter { - case Full(true) => true - case _ => false + // Wait for all rule executions to complete + Future.sequence(ruleFutures).map { results => + // Count successes and failures + val successes = results.filter { + case Full(true) => true + case _ => false + } + + // At least one rule must pass (OR logic) + Full(successes.nonEmpty) } + } + } - // At least one rule must pass (OR logic) - Full(successes.nonEmpty) + /** + * Synchronous wrapper for executeRulesByPolicy - DEPRECATED + * This function blocks the thread and should be avoided. Use the async version instead. + * + * @deprecated Use async executeRulesByPolicy that returns Future[Box[Boolean]] instead + */ + @deprecated("Use async executeRulesByPolicy that returns Future[Box[Boolean]]", "6.0.0") + def executeRulesByPolicySync( + policy: String, + authenticatedUserId: String, + onBehalfOfUserId: Option[String] = None, + userId: Option[String] = None, + callContext: CallContext, + bankId: Option[String] = None, + accountId: Option[String] = None, + viewId: Option[String] = None, + transactionId: Option[String] = None, + transactionRequestId: Option[String] = None, + customerId: Option[String] = None + ): Box[Boolean] = { + try { + Await.result(executeRulesByPolicy( + policy = policy, + authenticatedUserId = authenticatedUserId, + onBehalfOfUserId = onBehalfOfUserId, + userId = userId, + callContext = callContext, + bankId = bankId, + accountId = accountId, + viewId = viewId, + transactionId = transactionId, + transactionRequestId = transactionRequestId, + customerId = customerId + ), 30.seconds) + } catch { + case _: java.util.concurrent.TimeoutException => + Failure("ABAC rules execution timed out") + case ex: Exception => + Failure(s"ABAC rules execution failed: ${ex.getMessage}") } } diff --git a/obp-api/src/main/scala/code/abacrule/README.md b/obp-api/src/main/scala/code/abacrule/README.md index f845490bea..c428594985 100644 --- a/obp-api/src/main/scala/code/abacrule/README.md +++ b/obp-api/src/main/scala/code/abacrule/README.md @@ -177,18 +177,54 @@ val ruleCode = """user.emailAddress.contains("admin")""" val compiled = AbacRuleEngine.compileRule("rule123", ruleCode) ``` -### Execute a Rule +### Execute a Rule (Async - Recommended) ```scala import code.abacrule.AbacRuleEngine import com.openbankproject.commons.model._ +import scala.concurrent.Future -val result = AbacRuleEngine.executeRule( +val resultFuture: Future[Box[Boolean]] = AbacRuleEngine.executeRule( ruleId = "rule123", - user = currentUser, - bankOpt = Some(bank), - accountOpt = Some(account), - transactionOpt = None, - customerOpt = None + authenticatedUserId = currentUser.userId, + onBehalfOfUserId = None, + userId = Some(targetUser.userId), + callContext = callContext, + bankId = Some(bank.bankId.value), + accountId = Some(account.accountId.value), + viewId = None, + transactionId = None, + transactionRequestId = None, + customerId = None +) + +resultFuture.map { result => + result match { + case Full(true) => println("Access granted") + case Full(false) => println("Access denied") + case Failure(msg, _, _) => println(s"Error: $msg") + case Empty => println("Rule not found") + } +} +``` + +### Execute a Rule (Sync - Deprecated) +```scala +import code.abacrule.AbacRuleEngine +import com.openbankproject.commons.model._ + +// This is deprecated and blocks the thread - use async version above instead +val result = AbacRuleEngine.executeRuleSync( + ruleId = "rule123", + authenticatedUserId = currentUser.userId, + onBehalfOfUserId = None, + userId = Some(targetUser.userId), + callContext = callContext, + bankId = Some(bank.bankId.value), + accountId = Some(account.accountId.value), + viewId = None, + transactionId = None, + transactionRequestId = None, + customerId = None ) result match { @@ -199,24 +235,56 @@ result match { } ``` -### Execute Multiple Rules (AND Logic) -All rules must pass: +### Execute Rules by Policy (Async - Recommended) +Execute all active rules associated with a policy (OR logic - at least one must pass): ```scala -val result = AbacRuleEngine.executeRulesAnd( - ruleIds = List("rule1", "rule2", "rule3"), - user = currentUser, - bankOpt = Some(bank) +val resultFuture: Future[Box[Boolean]] = AbacRuleEngine.executeRulesByPolicy( + policy = "account_access_policy", + authenticatedUserId = currentUser.userId, + onBehalfOfUserId = None, + userId = Some(targetUser.userId), + callContext = callContext, + bankId = Some(bank.bankId.value), + accountId = Some(account.accountId.value), + viewId = None, + transactionId = None, + transactionRequestId = None, + customerId = None ) + +resultFuture.map { result => + result match { + case Full(true) => println("Access granted by policy") + case Full(false) => println("Access denied by policy") + case Failure(msg, _, _) => println(s"Error: $msg") + case Empty => println("Policy not found") + } +} ``` -### Execute Multiple Rules (OR Logic) -At least one rule must pass: +### Execute Rules by Policy (Sync - Deprecated) ```scala -val result = AbacRuleEngine.executeRulesOr( - ruleIds = List("rule1", "rule2", "rule3"), - user = currentUser, - bankOpt = Some(bank) +// This is deprecated and blocks the thread - use async version above instead +val result = AbacRuleEngine.executeRulesByPolicySync( + policy = "account_access_policy", + authenticatedUserId = currentUser.userId, + onBehalfOfUserId = None, + userId = Some(targetUser.userId), + callContext = callContext, + bankId = Some(bank.bankId.value), + accountId = Some(account.accountId.value), + viewId = None, + transactionId = None, + transactionRequestId = None, + customerId = None ) + +result match { + case Full(true) => println("Access granted by policy") + case Full(false) => println("Access denied by policy") + case Failure(msg, _, _) => println(s"Error: $msg") + case Empty => println("Policy not found") +} ``` ### Validate Rule Code @@ -341,16 +409,19 @@ for { (account, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) // Check ABAC rules - allowed <- Future { - AbacRuleEngine.executeRulesAnd( - ruleIds = List("bank_access_rule", "account_limit_rule"), - user = user, - bankOpt = Some(bank), - accountOpt = Some(account) - ) - } map { - unboxFullOrFail(_, callContext, "ABAC access check failed", 403) - } + allowed <- AbacRuleEngine.executeRulesByPolicy( + policy = "bank_access_policy", + authenticatedUserId = user.userId, + onBehalfOfUserId = None, + userId = Some(user.userId), + callContext = callContext, + bankId = Some(bank.bankId.value), + accountId = Some(account.accountId.value), + viewId = None, + transactionId = None, + transactionRequestId = None, + customerId = None + ) _ <- Helper.booleanToFuture(s"Access denied by ABAC rules", cc = callContext) { allowed diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 6331f2a442..919bddcc84 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -5610,7 +5610,7 @@ trait APIMethods600 { result = true ), List( - UserNotLoggedIn, + $UserNotLoggedIn, UserHasMissingRoles, InvalidJsonFormat, UnknownError @@ -5640,21 +5640,19 @@ trait APIMethods600 { // userId: the target user being evaluated (defaults to authenticated user) effectiveAuthenticatedUserId = execJson.authenticated_user_id.getOrElse(user.userId) - result <- Future { - val resultBox = AbacRuleEngine.executeRule( - ruleId = ruleId, - authenticatedUserId = effectiveAuthenticatedUserId, - onBehalfOfUserId = execJson.on_behalf_of_user_id, - userId = execJson.user_id, - callContext = callContext.getOrElse(cc), - bankId = execJson.bank_id, - accountId = execJson.account_id, - viewId = execJson.view_id, - transactionId = execJson.transaction_id, - transactionRequestId = execJson.transaction_request_id, - customerId = execJson.customer_id - ) - + result <- AbacRuleEngine.executeRule( + ruleId = ruleId, + authenticatedUserId = effectiveAuthenticatedUserId, + onBehalfOfUserId = execJson.on_behalf_of_user_id, + userId = execJson.user_id, + callContext = callContext.getOrElse(cc), + bankId = execJson.bank_id, + accountId = execJson.account_id, + viewId = execJson.view_id, + transactionId = execJson.transaction_id, + transactionRequestId = execJson.transaction_request_id, + customerId = execJson.customer_id + ).map { resultBox => resultBox match { case Full(allowed) => AbacRuleResultJsonV600(result = allowed) @@ -5746,21 +5744,19 @@ trait APIMethods600 { // userId: the target user being evaluated (defaults to authenticated user) effectiveAuthenticatedUserId = execJson.authenticated_user_id.getOrElse(user.userId) - result <- Future { - val resultBox = AbacRuleEngine.executeRulesByPolicy( - policy = policy, - authenticatedUserId = effectiveAuthenticatedUserId, - onBehalfOfUserId = execJson.on_behalf_of_user_id, - userId = execJson.user_id, - callContext = callContext.getOrElse(cc), - bankId = execJson.bank_id, - accountId = execJson.account_id, - viewId = execJson.view_id, - transactionId = execJson.transaction_id, - transactionRequestId = execJson.transaction_request_id, - customerId = execJson.customer_id - ) - + result <- AbacRuleEngine.executeRulesByPolicy( + policy = policy, + authenticatedUserId = effectiveAuthenticatedUserId, + onBehalfOfUserId = execJson.on_behalf_of_user_id, + userId = execJson.user_id, + callContext = callContext.getOrElse(cc), + bankId = execJson.bank_id, + accountId = execJson.account_id, + viewId = execJson.view_id, + transactionId = execJson.transaction_id, + transactionRequestId = execJson.transaction_request_id, + customerId = execJson.customer_id + ).map { resultBox => resultBox match { case Full(allowed) => AbacRuleResultJsonV600(result = allowed) From f0eaedaf3a0933d3c9366212e26f930506f7df36 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 23 Jan 2026 12:21:18 +0100 Subject: [PATCH 04/12] test/(http4s): add comprehensive Http4sCallContextBuilder unit tests - Add Http4sCallContextBuilderTest with 454 lines of test coverage - Test URL extraction including path, query parameters, and path parameters - Test header extraction and conversion to HTTPParam format - Test body extraction for POST, PUT, and GET requests - Test correlation ID generation and extraction from X-Request-ID header - Test IP address extraction from X-Forwarded-For and direct connection - Test authentication header extraction for all supported auth types - Test error handling and edge cases in CallContext building - Ensure Http4sCallContextBuilder correctly processes http4s Request[IO] objects --- .../http4s/Http4sCallContextBuilderTest.scala | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala diff --git a/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala b/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala new file mode 100644 index 0000000000..d6d22baee5 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala @@ -0,0 +1,454 @@ +package code.api.util.http4s + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import net.liftweb.common.{Empty, Full} +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} + +/** + * Unit tests for Http4sCallContextBuilder + * + * Tests CallContext building from http4s Request[IO]: + * - URL extraction (including query parameters) + * - Header extraction and conversion to HTTPParam + * - Body extraction for POST requests + * - Correlation ID generation/extraction + * - IP address extraction (X-Forwarded-For and direct) + * - Auth header extraction for all auth types + * + */ +class Http4sCallContextBuilderTest extends FeatureSpec with Matchers with GivenWhenThen { + + object Http4sCallContextBuilderTag extends Tag("Http4sCallContextBuilder") + + feature("Http4sCallContextBuilder - URL extraction") { + + scenario("Extract URL with path only", Http4sCallContextBuilderTag) { + Given("A request with path /obp/v7.0.0/banks") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should match the request URI") + callContext.url should equal("/obp/v7.0.0/banks") + } + + scenario("Extract URL with query parameters", Http4sCallContextBuilderTag) { + Given("A request with query parameters") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10&offset=0") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should include query parameters") + callContext.url should equal("/obp/v7.0.0/banks?limit=10&offset=0") + } + + scenario("Extract URL with path parameters", Http4sCallContextBuilderTag) { + Given("A request with path parameters") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("URL should include path parameters") + callContext.url should equal("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + } + } + + feature("Http4sCallContextBuilder - Header extraction") { + + scenario("Extract headers and convert to HTTPParam", Http4sCallContextBuilderTag) { + Given("A request with multiple headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("Accept"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("X-Custom-Header"), "test-value") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Headers should be converted to HTTPParam list") + callContext.requestHeaders should not be empty + callContext.requestHeaders.exists(_.name == "Content-Type") should be(true) + callContext.requestHeaders.exists(_.name == "Accept") should be(true) + callContext.requestHeaders.exists(_.name == "X-Custom-Header") should be(true) + } + + scenario("Extract empty headers list", Http4sCallContextBuilderTag) { + Given("A request with no custom headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Headers list should be empty or contain only default headers") + // http4s may add default headers, so we just check it's a list + callContext.requestHeaders should be(a[List[_]]) + } + } + + feature("Http4sCallContextBuilder - Body extraction") { + + scenario("Extract body from POST request", Http4sCallContextBuilderTag) { + Given("A POST request with JSON body") + val jsonBody = """{"name": "Test Bank", "id": "test-bank-1"}""" + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be extracted as Some(string)") + callContext.httpBody should be(Some(jsonBody)) + } + + scenario("Extract empty body from GET request", Http4sCallContextBuilderTag) { + Given("A GET request with no body") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be None") + callContext.httpBody should be(None) + } + + scenario("Extract body from PUT request", Http4sCallContextBuilderTag) { + Given("A PUT request with JSON body") + val jsonBody = """{"name": "Updated Bank"}""" + val request = Request[IO]( + method = Method.PUT, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks/test-bank-1") + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Body should be extracted") + callContext.httpBody should be(Some(jsonBody)) + } + } + + feature("Http4sCallContextBuilder - Correlation ID") { + + scenario("Extract correlation ID from X-Request-ID header", Http4sCallContextBuilderTag) { + Given("A request with X-Request-ID header") + val requestId = "test-correlation-id-12345" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), requestId) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Correlation ID should match the header value") + callContext.correlationId should equal(requestId) + } + + scenario("Generate correlation ID when header missing", Http4sCallContextBuilderTag) { + Given("A request without X-Request-ID header") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Correlation ID should be generated (UUID format)") + callContext.correlationId should not be empty + // UUID format: 8-4-4-4-12 hex digits + callContext.correlationId should fullyMatch regex "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + } + } + + feature("Http4sCallContextBuilder - IP address extraction") { + + scenario("Extract IP from X-Forwarded-For header", Http4sCallContextBuilderTag) { + Given("A request with X-Forwarded-For header") + val clientIp = "192.168.1.100" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should match the header value") + callContext.ipAddress should equal(clientIp) + } + + scenario("Extract first IP from X-Forwarded-For with multiple IPs", Http4sCallContextBuilderTag) { + Given("A request with X-Forwarded-For containing multiple IPs") + val forwardedFor = "192.168.1.100, 10.0.0.1, 172.16.0.1" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), forwardedFor) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should be the first IP in the list") + callContext.ipAddress should equal("192.168.1.100") + } + + scenario("Handle missing IP address", Http4sCallContextBuilderTag) { + Given("A request without X-Forwarded-For or remote address") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("IP address should be empty string") + callContext.ipAddress should equal("") + } + } + + feature("Http4sCallContextBuilder - Authentication header extraction") { + + scenario("Extract DirectLogin token from DirectLogin header (new format)", Http4sCallContextBuilderTag) { + Given("A request with DirectLogin header") + val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain token") + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + } + + scenario("Extract DirectLogin token from Authorization header (old format)", Http4sCallContextBuilderTag) { + Given("A request with Authorization: DirectLogin header") + val token = "eyJhbGciOiJIUzI1NiJ9.eyIiOiIifQ.test" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), s"DirectLogin token=$token") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain token") + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + + And("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(s"DirectLogin token=$token")) + } + + scenario("Extract DirectLogin with username and password", Http4sCallContextBuilderTag) { + Given("A request with DirectLogin username and password") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), """username="testuser", password="testpass", consumer_key="key123"""") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("DirectLogin params should contain all parameters") + callContext.directLoginParams should contain key "username" + callContext.directLoginParams should contain key "password" + callContext.directLoginParams should contain key "consumer_key" + callContext.directLoginParams("username") should equal("testuser") + callContext.directLoginParams("password") should equal("testpass") + callContext.directLoginParams("consumer_key") should equal("key123") + } + + scenario("Extract OAuth parameters from Authorization header", Http4sCallContextBuilderTag) { + Given("A request with OAuth Authorization header") + val oauthHeader = """OAuth oauth_consumer_key="consumer123", oauth_token="token456", oauth_signature="sig789"""" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), oauthHeader) + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("OAuth params should be extracted") + callContext.oAuthParams should contain key "oauth_consumer_key" + callContext.oAuthParams should contain key "oauth_token" + callContext.oAuthParams should contain key "oauth_signature" + callContext.oAuthParams("oauth_consumer_key") should equal("consumer123") + callContext.oAuthParams("oauth_token") should equal("token456") + callContext.oAuthParams("oauth_signature") should equal("sig789") + + And("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(oauthHeader)) + } + + scenario("Extract Bearer token from Authorization header", Http4sCallContextBuilderTag) { + Given("A request with Bearer token") + val bearerToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature" + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Authorization"), s"Bearer $bearerToken") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Authorization header should be stored") + callContext.authReqHeaderField should equal(Full(s"Bearer $bearerToken")) + } + + scenario("Handle missing Authorization header", Http4sCallContextBuilderTag) { + Given("A request without Authorization header") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Auth header field should be Empty") + callContext.authReqHeaderField should equal(Empty) + + And("DirectLogin params should be empty") + callContext.directLoginParams should be(empty) + + And("OAuth params should be empty") + callContext.oAuthParams should be(empty) + } + } + + feature("Http4sCallContextBuilder - Request metadata") { + + scenario("Extract HTTP verb", Http4sCallContextBuilderTag) { + Given("A POST request") + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("Verb should be POST") + callContext.verb should equal("POST") + } + + scenario("Set implementedInVersion from parameter", Http4sCallContextBuilderTag) { + Given("A request with API version v7.0.0") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext with version parameter") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("implementedInVersion should match the parameter") + callContext.implementedInVersion should equal("v7.0.0") + } + + scenario("Set startTime to current date", Http4sCallContextBuilderTag) { + Given("A request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Building CallContext") + val beforeTime = new java.util.Date() + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + val afterTime = new java.util.Date() + + Then("startTime should be set and within reasonable range") + callContext.startTime should be(defined) + callContext.startTime.get.getTime should be >= beforeTime.getTime + callContext.startTime.get.getTime should be <= afterTime.getTime + } + } + + feature("Http4sCallContextBuilder - Complete integration") { + + scenario("Build complete CallContext with all fields", Http4sCallContextBuilderTag) { + Given("A complete POST request with all headers and body") + val jsonBody = """{"name": "Test Bank"}""" + val token = "test-token-123" + val correlationId = "correlation-123" + val clientIp = "192.168.1.100" + + val request = Request[IO]( + method = Method.POST, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks?limit=10") + ).withHeaders( + Header.Raw(org.typelevel.ci.CIString("Content-Type"), "application/json"), + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token"), + Header.Raw(org.typelevel.ci.CIString("X-Request-ID"), correlationId), + Header.Raw(org.typelevel.ci.CIString("X-Forwarded-For"), clientIp) + ).withEntity(jsonBody) + + When("Building CallContext") + val callContext = Http4sCallContextBuilder.fromRequest(request, "v7.0.0").unsafeRunSync() + + Then("All fields should be populated correctly") + callContext.url should equal("/obp/v7.0.0/banks?limit=10") + callContext.verb should equal("POST") + callContext.implementedInVersion should equal("v7.0.0") + callContext.correlationId should equal(correlationId) + callContext.ipAddress should equal(clientIp) + callContext.httpBody should be(Some(jsonBody)) + callContext.directLoginParams should contain key "token" + callContext.directLoginParams("token") should equal(token) + callContext.requestHeaders should not be empty + callContext.startTime should be(defined) + } + } +} From 493a7858e0550466b91b0ff6f88195f35a830973 Mon Sep 17 00:00:00 2001 From: hongwei Date: Fri, 23 Jan 2026 12:37:21 +0100 Subject: [PATCH 05/12] test/(http4s): add ResourceDocMatcher unit tests - Add comprehensive test suite for ResourceDocMatcher with 545 lines of test coverage - Test exact path matching for GET, POST, and multi-segment paths - Test verb and path mismatch scenarios returning None - Test BANK_ID variable matching and parameter extraction - Test BANK_ID + ACCOUNT_ID variable matching and extraction - Test BANK_ID + ACCOUNT_ID + VIEW_ID variable matching and extraction - Test COUNTERPARTY_ID variable matching and extraction - Test non-matching request scenarios - Ensure ResourceDocMatcher correctly identifies and extracts path parameters for all variable types - Use FeatureSpec with Given-When-Then style for clear test documentation --- .../util/http4s/ResourceDocMatcherTest.scala | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala diff --git a/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala new file mode 100644 index 0000000000..a686295aa6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala @@ -0,0 +1,545 @@ +package code.api.util.http4s + +import code.api.util.APIUtil.ResourceDoc +import code.api.util.ApiTag.ResourceDocTag +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.JsonAST.JObject +import org.http4s._ +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers, Tag} + +import scala.collection.mutable.ArrayBuffer + +/** + * Unit tests for ResourceDocMatcher + * + * Tests ResourceDoc matching and path parameter extraction: + * - Matching by verb and exact path + * - Matching with BANK_ID variable + * - Matching with BANK_ID + ACCOUNT_ID variables + * - Matching with BANK_ID + ACCOUNT_ID + VIEW_ID variables + * - Matching with COUNTERPARTY_ID variable + * - Non-matching requests return None + * - Path parameter extraction for all variable types + * + */ +class ResourceDocMatcherTest extends FeatureSpec with Matchers with GivenWhenThen { + + object ResourceDocMatcherTag extends Tag("ResourceDocMatcher") + + // Helper to create minimal ResourceDoc for testing + private def createResourceDoc( + verb: String, + url: String, + operationId: String = "testOperation" + ): ResourceDoc = { + ResourceDoc( + partialFunction = null, // Not needed for matching tests + implementedInApiVersion = ApiVersion.v7_0_0, + partialFunctionName = operationId, + requestVerb = verb, + requestUrl = url, + summary = "Test endpoint", + description = "Test description", + exampleRequestBody = JObject(Nil), + successResponseBody = JObject(Nil), + errorResponseBodies = List.empty, + tags = List(ResourceDocTag("test")), + roles = None + ) + } + + feature("ResourceDocMatcher - Exact path matching") { + + scenario("Match GET request with exact path", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a GET request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks") + } + + scenario("Match POST request with exact path", ResourceDocMatcherTag) { + Given("A ResourceDoc for POST /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a POST request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("createBank") + } + + scenario("Match request with multi-segment path", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /management/metrics") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/management/metrics", "getMetrics") + ) + + When("Matching a GET request to /obp/v7.0.0/management/metrics") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/metrics") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getMetrics") + } + + scenario("Verb mismatch returns None", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a POST request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("POST", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Path mismatch returns None", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a GET request to /obp/v7.0.0/accounts") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + } + + feature("ResourceDocMatcher - BANK_ID variable matching") { + + scenario("Match request with BANK_ID variable", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID", "getBank") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBank") + } + + scenario("Match request with BANK_ID and additional segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank-1/accounts") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank-1/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccounts") + } + + scenario("Extract BANK_ID parameter value", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract BANK_ID value") + params should contain key "BANK_ID" + params("BANK_ID") should equal("gh.29.de") + } + } + + feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID variables") { + + scenario("Match request with BANK_ID and ACCOUNT_ID variables", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccount") + } + + scenario("Extract BANK_ID and ACCOUNT_ID parameter values", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID and ACCOUNT_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID", "getBankAccount") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract both BANK_ID and ACCOUNT_ID values") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + } + + scenario("Match request with BANK_ID, ACCOUNT_ID and additional segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/transactions") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions", "getTransactions") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-123/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getTransactions") + } + } + + feature("ResourceDocMatcher - BANK_ID + ACCOUNT_ID + VIEW_ID variables") { + + scenario("Match request with BANK_ID, ACCOUNT_ID and VIEW_ID variables", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getTransactionsForView") + } + + scenario("Extract BANK_ID, ACCOUNT_ID and VIEW_ID parameter values", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with BANK_ID, ACCOUNT_ID and VIEW_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "getTransactionsForView") + + When("Extracting path parameters from /obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/transactions") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract all three parameter values") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params should contain key "VIEW_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + params("VIEW_ID") should equal("owner") + } + + scenario("Match request with VIEW_ID in different position", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", "getAccountForView") + ) + + When("Matching a GET request to /obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/test-bank/accounts/acc-1/public/account") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getAccountForView") + } + } + + feature("ResourceDocMatcher - COUNTERPARTY_ID variable") { + + scenario("Match request with COUNTERPARTY_ID variable", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty") + ) + + When("Matching a GET request with counterparty ID") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getCounterparty") + } + + scenario("Extract COUNTERPARTY_ID parameter value", ResourceDocMatcherTag) { + Given("A matched ResourceDoc with COUNTERPARTY_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", "getCounterparty") + + When("Extracting path parameters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts/test1/owner/counterparties/ff010868-ac7d-4f96-9fc5-70dd5757e891") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract all parameter values including COUNTERPARTY_ID") + params should contain key "BANK_ID" + params should contain key "ACCOUNT_ID" + params should contain key "VIEW_ID" + params should contain key "COUNTERPARTY_ID" + params("BANK_ID") should equal("gh.29.de") + params("ACCOUNT_ID") should equal("test1") + params("VIEW_ID") should equal("owner") + params("COUNTERPARTY_ID") should equal("ff010868-ac7d-4f96-9fc5-70dd5757e891") + } + + scenario("Match request with COUNTERPARTY_ID in different URL structure", ResourceDocMatcherTag) { + Given("A ResourceDoc for DELETE /management/counterparties/COUNTERPARTY_ID") + val resourceDocs = ArrayBuffer( + createResourceDoc("DELETE", "/management/counterparties/COUNTERPARTY_ID", "deleteCounterparty") + ) + + When("Matching a DELETE request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/management/counterparties/counterparty-123") + val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("deleteCounterparty") + } + } + + feature("ResourceDocMatcher - Non-matching requests") { + + scenario("Return None when no ResourceDoc matches", ResourceDocMatcherTag) { + Given("ResourceDocs for specific endpoints") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks"), + createResourceDoc("GET", "/banks/BANK_ID", "getBank"), + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a request that doesn't match any ResourceDoc") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when verb doesn't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching a DELETE request to /obp/v7.0.0/banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("DELETE", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when path segment count doesn't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a request with different segment count") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + + scenario("Return None when literal segments don't match", ResourceDocMatcherTag) { + Given("A ResourceDoc for GET /banks/BANK_ID/accounts") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts") + ) + + When("Matching a request with different literal segment") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/transactions") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return None") + result should be(None) + } + } + + feature("ResourceDocMatcher - Path parameter extraction edge cases") { + + scenario("Extract parameters from path with no variables", ResourceDocMatcherTag) { + Given("A ResourceDoc with no path variables") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + + When("Extracting path parameters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should return empty map") + params should be(empty) + } + + scenario("Extract parameters with special characters in values", ResourceDocMatcherTag) { + Given("A ResourceDoc with BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting path parameters with special characters") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de-test_bank") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should extract the full value including special characters") + params should contain key "BANK_ID" + params("BANK_ID") should equal("gh.29.de-test_bank") + } + + scenario("Return empty map when path doesn't match template", ResourceDocMatcherTag) { + Given("A ResourceDoc for /banks/BANK_ID") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + + When("Extracting parameters from path with different segment count") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/accounts") + val params = ResourceDocMatcher.extractPathParams(path, resourceDoc) + + Then("Should return empty map due to segment count mismatch") + params should be(empty) + } + } + + feature("ResourceDocMatcher - attachToCallContext") { + + scenario("Attach ResourceDoc to CallContext", ResourceDocMatcherTag) { + Given("A CallContext and a matched ResourceDoc") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + val callContext = code.api.util.CallContext( + correlationId = "test-correlation-id" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc) + + Then("CallContext should have resourceDocument set") + updatedContext.resourceDocument should be(defined) + updatedContext.resourceDocument.get should equal(resourceDoc) + } + + scenario("Attach ResourceDoc sets operationId", ResourceDocMatcherTag) { + Given("A CallContext and a matched ResourceDoc") + val resourceDoc = createResourceDoc("GET", "/banks/BANK_ID", "getBank") + val callContext = code.api.util.CallContext( + correlationId = "test-correlation-id" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(callContext, resourceDoc) + + Then("CallContext should have operationId set") + updatedContext.operationId should be(defined) + updatedContext.operationId.get should equal(resourceDoc.operationId) + } + + scenario("Preserve other CallContext fields when attaching ResourceDoc", ResourceDocMatcherTag) { + Given("A CallContext with existing fields") + val resourceDoc = createResourceDoc("GET", "/banks", "getBanks") + val originalContext = code.api.util.CallContext( + correlationId = "test-correlation-id", + url = "/obp/v7.0.0/banks", + verb = "GET", + implementedInVersion = "v7.0.0" + ) + + When("Attaching ResourceDoc to CallContext") + val updatedContext = ResourceDocMatcher.attachToCallContext(originalContext, resourceDoc) + + Then("Other fields should be preserved") + updatedContext.correlationId should equal(originalContext.correlationId) + updatedContext.url should equal(originalContext.url) + updatedContext.verb should equal(originalContext.verb) + updatedContext.implementedInVersion should equal(originalContext.implementedInVersion) + } + } + + feature("ResourceDocMatcher - Multiple ResourceDocs selection") { + + scenario("Select correct ResourceDoc from multiple candidates", ResourceDocMatcherTag) { + Given("Multiple ResourceDocs with different paths") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks"), + createResourceDoc("GET", "/banks/BANK_ID", "getBank"), + createResourceDoc("GET", "/banks/BANK_ID/accounts", "getBankAccounts"), + createResourceDoc("POST", "/banks", "createBank") + ) + + When("Matching a specific request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks/gh.29.de/accounts") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should select the most specific matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBankAccounts") + } + + scenario("Match first ResourceDoc when multiple exact matches exist", ResourceDocMatcherTag) { + Given("Multiple ResourceDocs with same path and verb") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks1"), + createResourceDoc("GET", "/banks", "getBanks2") + ) + + When("Matching a request") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should return the first matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks1") + } + } + + feature("ResourceDocMatcher - Case sensitivity") { + + scenario("HTTP verb matching is case-insensitive", ResourceDocMatcherTag) { + Given("A ResourceDoc with uppercase GET") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching with lowercase get") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/banks") + val result = ResourceDocMatcher.findResourceDoc("get", path, resourceDocs) + + Then("Should find the matching ResourceDoc") + result should be(defined) + result.get.partialFunctionName should equal("getBanks") + } + + scenario("Path matching is case-sensitive for literal segments", ResourceDocMatcherTag) { + Given("A ResourceDoc for /banks") + val resourceDocs = ArrayBuffer( + createResourceDoc("GET", "/banks", "getBanks") + ) + + When("Matching with different case /Banks") + val path = Uri.Path.unsafeFromString("/obp/v7.0.0/Banks") + val result = ResourceDocMatcher.findResourceDoc("GET", path, resourceDocs) + + Then("Should not match (case-sensitive)") + result should be(None) + } + } +} From 79ea9231a19ab723f732413beb3a7f5d61f6aab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 23 Jan 2026 12:41:26 +0100 Subject: [PATCH 06/12] Merge upstream/develop into develop after conflict resolution --- .../scala/code/abacrule/AbacRuleEngine.scala | 462 ++++++++---------- .../src/main/scala/code/abacrule/README.md | 129 ++--- 2 files changed, 222 insertions(+), 369 deletions(-) diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 057193ec5f..93fb815371 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -13,7 +13,7 @@ import net.liftweb.util.Helpers.tryo import java.util.concurrent.ConcurrentHashMap import scala.collection.JavaConverters._ import scala.collection.concurrent -import scala.concurrent.{Await, Future} +import scala.concurrent.Await import scala.concurrent.duration._ /** @@ -112,221 +112,190 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None - ): Future[Box[Boolean]] = { - val ruleBox = MappedAbacRuleProvider.getAbacRuleById(ruleId) - ruleBox match { - case Failure(msg, ex, chain) => Future.successful(Failure(msg, ex, chain)) - case Empty => Future.successful(Empty) - case Full(rule) => - if (!rule.isActive) { - Future.successful(Failure(s"ABAC Rule ${rule.ruleName} is not active")) - } else { - // Fetch authenticated user - val authenticatedUserBox = Users.users.vend.getUserByUserId(authenticatedUserId) - authenticatedUserBox match { - case Failure(msg, ex, chain) => Future.successful(Failure(msg, ex, chain)) - case Empty => Future.successful(Empty) - case Full(authenticatedUser) => - - // Create futures for all async operations - val authenticatedUserAttributesFuture = - code.api.util.NewStyle.function.getNonPersonalUserAttributes(authenticatedUserId, Some(callContext)).map(_._1) - - val authenticatedUserAuthContextFuture = - code.api.util.NewStyle.function.getUserAuthContexts(authenticatedUserId, Some(callContext)).map(_._1) - - val authenticatedUserEntitlementsFuture = - code.api.util.NewStyle.function.getEntitlementsByUserId(authenticatedUserId, Some(callContext)) - - val onBehalfOfUserFuture = onBehalfOfUserId match { - case Some(obUserId) => Future.successful(Users.users.vend.getUserByUserId(obUserId).map(Some(_))) - case None => Future.successful(Full(None)) - } - - val onBehalfOfUserAttributesFuture = onBehalfOfUserId match { - case Some(obUserId) => - code.api.util.NewStyle.function.getNonPersonalUserAttributes(obUserId, Some(callContext)).map(_._1) - case None => Future.successful(List.empty[UserAttributeTrait]) - } - - val onBehalfOfUserAuthContextFuture = onBehalfOfUserId match { - case Some(obUserId) => - code.api.util.NewStyle.function.getUserAuthContexts(obUserId, Some(callContext)).map(_._1) - case None => Future.successful(List.empty[UserAuthContext]) - } - - val onBehalfOfUserEntitlementsFuture = onBehalfOfUserId match { - case Some(obUserId) => - code.api.util.NewStyle.function.getEntitlementsByUserId(obUserId, Some(callContext)) - case None => Future.successful(List.empty[Entitlement]) - } - - val userFuture = userId match { - case Some(uId) => Future.successful(Users.users.vend.getUserByUserId(uId).map(Some(_))) - case None => Future.successful(Full(None)) - } - - val userAttributesFuture = userId match { - case Some(uId) => - code.api.util.NewStyle.function.getNonPersonalUserAttributes(uId, Some(callContext)).map(_._1) - case None => Future.successful(List.empty[UserAttributeTrait]) - } - - val bankFuture = bankId match { - case Some(bId) => - code.api.util.NewStyle.function.getBank(BankId(bId), Some(callContext)).map(_._1).map(bank => Full(Some(bank))).recover { - case _ => Full(None) - } - case None => Future.successful(Full(None)) - } - - val bankAttributesFuture = bankId match { - case Some(bId) => - code.api.util.NewStyle.function.getBankAttributesByBank(BankId(bId), Some(callContext)).map(_._1) - case None => Future.successful(List.empty[BankAttributeTrait]) - } - - val accountFuture = (bankId, accountId) match { - case (Some(bId), Some(aId)) => - code.api.util.NewStyle.function.getBankAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1).map(account => Full(Some(account))).recover { - case _ => Full(None) - } - case _ => Future.successful(Full(None)) - } - - val accountAttributesFuture = (bankId, accountId) match { - case (Some(bId), Some(aId)) => - code.api.util.NewStyle.function.getAccountAttributesByAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1) - case _ => Future.successful(List.empty[AccountAttribute]) - } - - val transactionFuture = (bankId, accountId, transactionId) match { - case (Some(bId), Some(aId), Some(tId)) => - code.api.util.NewStyle.function.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), Some(callContext)).map(_._1).map(trans => Full(Some(trans))).recover { - case _ => Full(None) - } - case _ => Future.successful(Full(None)) - } - - val transactionAttributesFuture = (bankId, transactionId) match { - case (Some(bId), Some(tId)) => - code.api.util.NewStyle.function.getTransactionAttributes(BankId(bId), TransactionId(tId), Some(callContext)).map(_._1) - case _ => Future.successful(List.empty[TransactionAttribute]) - } - - val transactionRequestFuture = transactionRequestId match { - case Some(trId) => - code.api.util.NewStyle.function.getTransactionRequestImpl(TransactionRequestId(trId), Some(callContext)).map(_._1).map(tr => Full(Some(tr))).recover { - case _ => Full(None) - } - case _ => Future.successful(Full(None)) - } - - val transactionRequestAttributesFuture = (bankId, transactionRequestId) match { - case (Some(bId), Some(trId)) => - code.api.util.NewStyle.function.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), Some(callContext)).map(_._1) - case _ => Future.successful(List.empty[TransactionRequestAttributeTrait]) - } - - val customerFuture = (bankId, customerId) match { - case (Some(bId), Some(cId)) => - code.api.util.NewStyle.function.getCustomerByCustomerId(cId, Some(callContext)).map(_._1).map(cust => Full(Some(cust))).recover { - case _ => Full(None) - } - case _ => Future.successful(Full(None)) - } - - val customerAttributesFuture = (bankId, customerId) match { - case (Some(bId), Some(cId)) => - code.api.util.NewStyle.function.getCustomerAttributes(BankId(bId), CustomerId(cId), Some(callContext)).map(_._1) - case _ => Future.successful(List.empty[CustomerAttribute]) - } - - // Combine all futures - for { - authenticatedUserAttributes <- authenticatedUserAttributesFuture - authenticatedUserAuthContext <- authenticatedUserAuthContextFuture - authenticatedUserEntitlements <- authenticatedUserEntitlementsFuture - onBehalfOfUserOpt <- onBehalfOfUserFuture - onBehalfOfUserAttributes <- onBehalfOfUserAttributesFuture - onBehalfOfUserAuthContext <- onBehalfOfUserAuthContextFuture - onBehalfOfUserEntitlements <- onBehalfOfUserEntitlementsFuture - userOpt <- userFuture - userAttributes <- userAttributesFuture - bankOpt <- bankFuture - bankAttributes <- bankAttributesFuture - accountOpt <- accountFuture - accountAttributes <- accountAttributesFuture - transactionOpt <- transactionFuture - transactionAttributes <- transactionAttributesFuture - transactionRequestOpt <- transactionRequestFuture - transactionRequestAttributes <- transactionRequestAttributesFuture - customerOpt <- customerFuture - customerAttributes <- customerAttributesFuture - } yield { - // Compile and execute the rule - val compiledFuncBox = compileRule(ruleId, rule.ruleCode) - compiledFuncBox.flatMap { compiledFunc => - (for { - onBehalfOfUser <- onBehalfOfUserOpt - user <- userOpt - bank <- bankOpt - account <- accountOpt - transaction <- transactionOpt - transactionRequest <- transactionRequestOpt - customer <- customerOpt - } yield { - tryo { - compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, onBehalfOfUser, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, user, userAttributes, bank, bankAttributes, account, accountAttributes, transaction, transactionAttributes, transactionRequest, transactionRequestAttributes, customer, customerAttributes, Some(callContext)) - } - }).flatten - } - } - } - } - } - } - - /** - * Synchronous wrapper for executeRule - DEPRECATED - * This function blocks the thread and should be avoided. Use the async version instead. - * - * @deprecated Use the async executeRule that returns Future[Box[Boolean]] instead - */ - @deprecated("Use async executeRule that returns Future[Box[Boolean]]", "6.0.0") - def executeRuleSync( - ruleId: String, - authenticatedUserId: String, - onBehalfOfUserId: Option[String] = None, - userId: Option[String] = None, - callContext: CallContext, - bankId: Option[String] = None, - accountId: Option[String] = None, - viewId: Option[String] = None, - transactionId: Option[String] = None, - transactionRequestId: Option[String] = None, - customerId: Option[String] = None ): Box[Boolean] = { - try { - Await.result(executeRule( - ruleId = ruleId, - authenticatedUserId = authenticatedUserId, - onBehalfOfUserId = onBehalfOfUserId, - userId = userId, - callContext = callContext, - bankId = bankId, - accountId = accountId, - viewId = viewId, - transactionId = transactionId, - transactionRequestId = transactionRequestId, - customerId = customerId - ), 30.seconds) - } catch { - case _: java.util.concurrent.TimeoutException => - Failure("ABAC rule execution timed out") - case ex: Exception => - Failure(s"ABAC rule execution failed: ${ex.getMessage}") - } + for { + rule <- MappedAbacRuleProvider.getAbacRuleById(ruleId) + _ <- if (rule.isActive) Full(true) else Failure(s"ABAC Rule ${rule.ruleName} is not active") + + // Fetch authenticated user (the actual person logged in) + authenticatedUser <- Users.users.vend.getUserByUserId(authenticatedUserId) + + // Fetch non-personal attributes for authenticated user + authenticatedUserAttributes = Await.result( + code.api.util.NewStyle.function.getNonPersonalUserAttributes(authenticatedUserId, Some(callContext)).map(_._1), + 5.seconds + ) + + // Fetch auth context for authenticated user + authenticatedUserAuthContext = Await.result( + code.api.util.NewStyle.function.getUserAuthContexts(authenticatedUserId, Some(callContext)).map(_._1), + 5.seconds + ) + + // Fetch entitlements for authenticated user + authenticatedUserEntitlements = Await.result( + code.api.util.NewStyle.function.getEntitlementsByUserId(authenticatedUserId, Some(callContext)), + 5.seconds + ) + + // Fetch onBehalfOf user if provided (delegation scenario) + onBehalfOfUserOpt <- onBehalfOfUserId match { + case Some(obUserId) => Users.users.vend.getUserByUserId(obUserId).map(Some(_)) + case None => Full(None) + } + + // Fetch attributes for onBehalfOf user if provided + onBehalfOfUserAttributes = onBehalfOfUserId match { + case Some(obUserId) => + Await.result( + code.api.util.NewStyle.function.getNonPersonalUserAttributes(obUserId, Some(callContext)).map(_._1), + 5.seconds + ) + case None => List.empty[UserAttributeTrait] + } + + // Fetch auth context for onBehalfOf user if provided + onBehalfOfUserAuthContext = onBehalfOfUserId match { + case Some(obUserId) => + Await.result( + code.api.util.NewStyle.function.getUserAuthContexts(obUserId, Some(callContext)).map(_._1), + 5.seconds + ) + case None => List.empty[UserAuthContext] + } + + // Fetch entitlements for onBehalfOf user if provided + onBehalfOfUserEntitlements = onBehalfOfUserId match { + case Some(obUserId) => + Await.result( + code.api.util.NewStyle.function.getEntitlementsByUserId(obUserId, Some(callContext)), + 5.seconds + ) + case None => List.empty[Entitlement] + } + + // Fetch target user if userId is provided + userOpt <- userId match { + case Some(uId) => Users.users.vend.getUserByUserId(uId).map(Some(_)) + case None => Full(None) + } + + // Fetch attributes for target user if provided + userAttributes = userId match { + case Some(uId) => + Await.result( + code.api.util.NewStyle.function.getNonPersonalUserAttributes(uId, Some(callContext)).map(_._1), + 5.seconds + ) + case None => List.empty[UserAttributeTrait] + } + + // Fetch bank if bankId is provided + bankOpt <- bankId match { + case Some(bId) => + tryo(Await.result( + code.api.util.NewStyle.function.getBank(BankId(bId), Some(callContext)).map(_._1), + 5.seconds + )).map(Some(_)) + case None => Full(None) + } + + // Fetch bank attributes if bank is provided + bankAttributes = bankId match { + case Some(bId) => + Await.result( + code.api.util.NewStyle.function.getBankAttributesByBank(BankId(bId), Some(callContext)).map(_._1), + 5.seconds + ) + case None => List.empty[BankAttributeTrait] + } + + // Fetch account if accountId and bankId are provided + accountOpt <- (bankId, accountId) match { + case (Some(bId), Some(aId)) => + tryo(Await.result( + code.api.util.NewStyle.function.getBankAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), + 5.seconds + )).map(Some(_)) + case _ => Full(None) + } + + // Fetch account attributes if account is provided + accountAttributes = (bankId, accountId) match { + case (Some(bId), Some(aId)) => + Await.result( + code.api.util.NewStyle.function.getAccountAttributesByAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), + 5.seconds + ) + case _ => List.empty[AccountAttribute] + } + + // Fetch transaction if transactionId, accountId, and bankId are provided + transactionOpt <- (bankId, accountId, transactionId) match { + case (Some(bId), Some(aId), Some(tId)) => + tryo(Await.result( + code.api.util.NewStyle.function.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), Some(callContext)).map(_._1), + 5.seconds + )).map(trans => Some(trans)) + case _ => Full(None) + } + + // Fetch transaction attributes if transaction is provided + transactionAttributes = (bankId, transactionId) match { + case (Some(bId), Some(tId)) => + Await.result( + code.api.util.NewStyle.function.getTransactionAttributes(BankId(bId), TransactionId(tId), Some(callContext)).map(_._1), + 5.seconds + ) + case _ => List.empty[TransactionAttribute] + } + + // Fetch transaction request if transactionRequestId is provided + transactionRequestOpt <- transactionRequestId match { + case Some(trId) => + tryo(Await.result( + code.api.util.NewStyle.function.getTransactionRequestImpl(TransactionRequestId(trId), Some(callContext)).map(_._1), + 5.seconds + )).map(tr => Some(tr)) + case _ => Full(None) + } + + // Fetch transaction request attributes if transaction request is provided + transactionRequestAttributes = (bankId, transactionRequestId) match { + case (Some(bId), Some(trId)) => + Await.result( + code.api.util.NewStyle.function.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), Some(callContext)).map(_._1), + 5.seconds + ) + case _ => List.empty[TransactionRequestAttributeTrait] + } + + // Fetch customer if customerId and bankId are provided + customerOpt <- (bankId, customerId) match { + case (Some(bId), Some(cId)) => + tryo(Await.result( + code.api.util.NewStyle.function.getCustomerByCustomerId(cId, Some(callContext)).map(_._1), + 5.seconds + )).map(cust => Some(cust)) + case _ => Full(None) + } + + // Fetch customer attributes if customer is provided + customerAttributes = (bankId, customerId) match { + case (Some(bId), Some(cId)) => + Await.result( + code.api.util.NewStyle.function.getCustomerAttributes(BankId(bId), CustomerId(cId), Some(callContext)).map(_._1), + 5.seconds + ) + case _ => List.empty[CustomerAttribute] + } + + // Compile and execute the rule + compiledFunc <- compileRule(ruleId, rule.ruleCode) + result <- tryo { + compiledFunc(authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, onBehalfOfUserOpt, onBehalfOfUserAttributes, onBehalfOfUserAuthContext, onBehalfOfUserEntitlements, userOpt, userAttributes, bankOpt, bankAttributes, accountOpt, accountAttributes, transactionOpt, transactionAttributes, transactionRequestOpt, transactionRequestAttributes, customerOpt, customerAttributes, Some(callContext)) + } + } yield result } @@ -360,15 +329,15 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None - ): Future[Box[Boolean]] = { + ): Box[Boolean] = { val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy) if (rules.isEmpty) { // No rules for this policy - default to allow - Future.successful(Full(true)) + Full(true) } else { // Execute all rules and check if at least one passes - val ruleFutures = rules.map { rule => + val results = rules.map { rule => executeRule( ruleId = rule.abacRuleId, authenticatedUserId = authenticatedUserId, @@ -384,59 +353,14 @@ object AbacRuleEngine { ) } - // Wait for all rule executions to complete - Future.sequence(ruleFutures).map { results => - // Count successes and failures - val successes = results.filter { - case Full(true) => true - case _ => false - } - - // At least one rule must pass (OR logic) - Full(successes.nonEmpty) + // Count successes and failures + val successes = results.filter { + case Full(true) => true + case _ => false } - } - } - /** - * Synchronous wrapper for executeRulesByPolicy - DEPRECATED - * This function blocks the thread and should be avoided. Use the async version instead. - * - * @deprecated Use async executeRulesByPolicy that returns Future[Box[Boolean]] instead - */ - @deprecated("Use async executeRulesByPolicy that returns Future[Box[Boolean]]", "6.0.0") - def executeRulesByPolicySync( - policy: String, - authenticatedUserId: String, - onBehalfOfUserId: Option[String] = None, - userId: Option[String] = None, - callContext: CallContext, - bankId: Option[String] = None, - accountId: Option[String] = None, - viewId: Option[String] = None, - transactionId: Option[String] = None, - transactionRequestId: Option[String] = None, - customerId: Option[String] = None - ): Box[Boolean] = { - try { - Await.result(executeRulesByPolicy( - policy = policy, - authenticatedUserId = authenticatedUserId, - onBehalfOfUserId = onBehalfOfUserId, - userId = userId, - callContext = callContext, - bankId = bankId, - accountId = accountId, - viewId = viewId, - transactionId = transactionId, - transactionRequestId = transactionRequestId, - customerId = customerId - ), 30.seconds) - } catch { - case _: java.util.concurrent.TimeoutException => - Failure("ABAC rules execution timed out") - case ex: Exception => - Failure(s"ABAC rules execution failed: ${ex.getMessage}") + // At least one rule must pass (OR logic) + Full(successes.nonEmpty) } } diff --git a/obp-api/src/main/scala/code/abacrule/README.md b/obp-api/src/main/scala/code/abacrule/README.md index c428594985..f845490bea 100644 --- a/obp-api/src/main/scala/code/abacrule/README.md +++ b/obp-api/src/main/scala/code/abacrule/README.md @@ -177,54 +177,18 @@ val ruleCode = """user.emailAddress.contains("admin")""" val compiled = AbacRuleEngine.compileRule("rule123", ruleCode) ``` -### Execute a Rule (Async - Recommended) +### Execute a Rule ```scala import code.abacrule.AbacRuleEngine import com.openbankproject.commons.model._ -import scala.concurrent.Future -val resultFuture: Future[Box[Boolean]] = AbacRuleEngine.executeRule( +val result = AbacRuleEngine.executeRule( ruleId = "rule123", - authenticatedUserId = currentUser.userId, - onBehalfOfUserId = None, - userId = Some(targetUser.userId), - callContext = callContext, - bankId = Some(bank.bankId.value), - accountId = Some(account.accountId.value), - viewId = None, - transactionId = None, - transactionRequestId = None, - customerId = None -) - -resultFuture.map { result => - result match { - case Full(true) => println("Access granted") - case Full(false) => println("Access denied") - case Failure(msg, _, _) => println(s"Error: $msg") - case Empty => println("Rule not found") - } -} -``` - -### Execute a Rule (Sync - Deprecated) -```scala -import code.abacrule.AbacRuleEngine -import com.openbankproject.commons.model._ - -// This is deprecated and blocks the thread - use async version above instead -val result = AbacRuleEngine.executeRuleSync( - ruleId = "rule123", - authenticatedUserId = currentUser.userId, - onBehalfOfUserId = None, - userId = Some(targetUser.userId), - callContext = callContext, - bankId = Some(bank.bankId.value), - accountId = Some(account.accountId.value), - viewId = None, - transactionId = None, - transactionRequestId = None, - customerId = None + user = currentUser, + bankOpt = Some(bank), + accountOpt = Some(account), + transactionOpt = None, + customerOpt = None ) result match { @@ -235,56 +199,24 @@ result match { } ``` -### Execute Rules by Policy (Async - Recommended) -Execute all active rules associated with a policy (OR logic - at least one must pass): +### Execute Multiple Rules (AND Logic) +All rules must pass: ```scala -val resultFuture: Future[Box[Boolean]] = AbacRuleEngine.executeRulesByPolicy( - policy = "account_access_policy", - authenticatedUserId = currentUser.userId, - onBehalfOfUserId = None, - userId = Some(targetUser.userId), - callContext = callContext, - bankId = Some(bank.bankId.value), - accountId = Some(account.accountId.value), - viewId = None, - transactionId = None, - transactionRequestId = None, - customerId = None +val result = AbacRuleEngine.executeRulesAnd( + ruleIds = List("rule1", "rule2", "rule3"), + user = currentUser, + bankOpt = Some(bank) ) - -resultFuture.map { result => - result match { - case Full(true) => println("Access granted by policy") - case Full(false) => println("Access denied by policy") - case Failure(msg, _, _) => println(s"Error: $msg") - case Empty => println("Policy not found") - } -} ``` -### Execute Rules by Policy (Sync - Deprecated) +### Execute Multiple Rules (OR Logic) +At least one rule must pass: ```scala -// This is deprecated and blocks the thread - use async version above instead -val result = AbacRuleEngine.executeRulesByPolicySync( - policy = "account_access_policy", - authenticatedUserId = currentUser.userId, - onBehalfOfUserId = None, - userId = Some(targetUser.userId), - callContext = callContext, - bankId = Some(bank.bankId.value), - accountId = Some(account.accountId.value), - viewId = None, - transactionId = None, - transactionRequestId = None, - customerId = None +val result = AbacRuleEngine.executeRulesOr( + ruleIds = List("rule1", "rule2", "rule3"), + user = currentUser, + bankOpt = Some(bank) ) - -result match { - case Full(true) => println("Access granted by policy") - case Full(false) => println("Access denied by policy") - case Failure(msg, _, _) => println(s"Error: $msg") - case Empty => println("Policy not found") -} ``` ### Validate Rule Code @@ -409,19 +341,16 @@ for { (account, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) // Check ABAC rules - allowed <- AbacRuleEngine.executeRulesByPolicy( - policy = "bank_access_policy", - authenticatedUserId = user.userId, - onBehalfOfUserId = None, - userId = Some(user.userId), - callContext = callContext, - bankId = Some(bank.bankId.value), - accountId = Some(account.accountId.value), - viewId = None, - transactionId = None, - transactionRequestId = None, - customerId = None - ) + allowed <- Future { + AbacRuleEngine.executeRulesAnd( + ruleIds = List("bank_access_rule", "account_limit_rule"), + user = user, + bankOpt = Some(bank), + accountOpt = Some(account) + ) + } map { + unboxFullOrFail(_, callContext, "ABAC access check failed", 403) + } _ <- Helper.booleanToFuture(s"Access denied by ABAC rules", cc = callContext) { allowed From 1534831ff434964bdadf19981adb3b49dd429135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 23 Jan 2026 18:13:42 +0100 Subject: [PATCH 07/12] refactor/Refactor ResourceDocMiddleware: integrate JSON content type and improve validation DSL - Ensure all responses (errors and successful) have JSON Content-Type - Replace repeated EitherT patterns with a clean Validation DSL (success/failure) - Add ValidationContext to accumulate user, bank, account, view, and counterparty entities - Add detailed comments for authentication, authorization, and entity validation steps - Simplify middleware logic while preserving original validation order and behavior --- .../util/http4s/ResourceDocMiddleware.scala | 447 +++++++++--------- 1 file changed, 216 insertions(+), 231 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 878d398dd7..2b7876b88f 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -1,27 +1,26 @@ package code.api.util.http4s -import cats.data.{Kleisli, OptionT} +import cats.data.{EitherT, Kleisli, OptionT} import cats.effect._ import code.api.APIFailureNewStyle import code.api.util.APIUtil.ResourceDoc import code.api.util.ErrorMessages._ -import code.api.util.{APIUtil, CallContext, NewStyle} import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ -import net.liftweb.common.{Box, Empty, Full, Failure => LiftFailure} +import net.liftweb.common.{Box, Empty, Full} import org.http4s._ import org.http4s.headers.`Content-Type` import scala.collection.mutable.ArrayBuffer -import scala.language.higherKinds /** * ResourceDoc-driven validation middleware for http4s. - * + * * This middleware wraps http4s routes with automatic validation based on ResourceDoc metadata. * Validation is performed in a specific order to ensure security and proper error responses. - * + * * VALIDATION ORDER: * 1. Authentication - Check if user is authenticated (if required by ResourceDoc) * 2. Authorization - Verify user has required roles/entitlements @@ -29,18 +28,42 @@ import scala.language.higherKinds * 4. Account validation - Validate ACCOUNT_ID path parameter (if present) * 5. View validation - Validate VIEW_ID and check user access (if present) * 6. Counterparty validation - Validate COUNTERPARTY_ID (if present) - * + * * Validated entities are stored in CallContext fields for use in endpoint handlers. */ -object ResourceDocMiddleware extends MdcLoggable{ - +object ResourceDocMiddleware extends MdcLoggable { + + /** Type alias for http4s OptionT route effect */ type HttpF[A] = OptionT[IO, A] - type Middleware[F[_]] = HttpRoutes[F] => HttpRoutes[F] + + /** Type alias for validation effect using EitherT */ + type Validation[A] = EitherT[IO, Response[IO], A] + + /** JSON content type for responses */ private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) - + + /** + * Context that accumulates all validated entities during request processing. + * This context is passed along the validation chain. + */ + final case class ValidationContext( + user: Box[User] = Empty, + callContext: CallContext, + bank: Option[Bank] = None, + account: Option[BankAccount] = None, + view: Option[View] = None, + counterparty: Option[CounterpartyTrait] = None + ) + + /** Simple DSL for success/failure in the validation chain */ + object DSL { + def success[A](a: A): Validation[A] = EitherT.rightT(a) + def failure(resp: Response[IO]): Validation[Nothing] = EitherT.leftT(resp) + } + /** * Check if ResourceDoc requires authentication. - * + * * Authentication is required if: * - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired * - ResourceDoc has roles (roles always require authenticated user) @@ -53,241 +76,203 @@ object ResourceDocMiddleware extends MdcLoggable{ resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) } } - + /** - * Create middleware that applies ResourceDoc-driven validation. - * - * @param resourceDocs Collection of ResourceDoc entries for matching - * @return Middleware that wraps HttpRoutes with validation + * Middleware factory: wraps HttpRoutes with ResourceDoc validation. + * Finds the matching ResourceDoc, validates the request, and enriches CallContext. */ - def apply(resourceDocs: ArrayBuffer[ResourceDoc]): Middleware[IO] = { routes => - Kleisli[HttpF, Request[IO], Response[IO]] { req => - OptionT(validateAndRoute(req, routes, resourceDocs).map(Option(_))) + def apply(resourceDocs: ArrayBuffer[ResourceDoc]): HttpRoutes[IO] => HttpRoutes[IO] = { routes => + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + // Build initial CallContext from request + OptionT.liftF(Http4sCallContextBuilder.fromRequest(req, "v7.0.0")).flatMap { cc => + ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) match { + case Some(resourceDoc) => + val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) + val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc) + // Run full validation chain + OptionT(validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes).map(Option(_))) + + case None => + // No matching ResourceDoc: fallback to original route + routes.run(req) + } + } } } - + /** - * Validate request and route to handler if validation passes. - * - * Steps: - * 1. Build CallContext from request - * 2. Find matching ResourceDoc - * 3. Run validation chain - * 4. Route to handler with enriched CallContext + * Executes the full validation chain for the request. + * Returns either an error Response or enriched request routed to the handler. */ - private def validateAndRoute( - req: Request[IO], - routes: HttpRoutes[IO], - resourceDocs: ArrayBuffer[ResourceDoc] - ): IO[Response[IO]] = { - for { - cc <- Http4sCallContextBuilder.fromRequest(req, "v7.0.0") - resourceDocOpt = ResourceDocMatcher.findResourceDoc(req.method.name, req.uri.path, resourceDocs) - response <- resourceDocOpt match { - case Some(resourceDoc) => - val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) - val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc) - runValidationChainForRoutes(req, resourceDoc, ccWithDoc, pathParams, routes) - .map(ensureJsonContentType) - case None => - routes.run(req).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound))) + private def validateRequest( + req: Request[IO], + resourceDoc: ResourceDoc, + pathParams: Map[String, String], + cc: CallContext, + routes: HttpRoutes[IO] + ): IO[Response[IO]] = { + + // Initial context with just CallContext + val initialCtx = ValidationContext(callContext = cc) + + // Compose all validation steps using EitherT + val result: Validation[ValidationContext] = for { + ctx1 <- authenticate(req, resourceDoc, initialCtx) + ctx2 <- authorizeRoles(resourceDoc, pathParams, ctx1) + ctx3 <- validateBank(pathParams, ctx2) + ctx4 <- validateAccount(pathParams, ctx3) + ctx5 <- validateView(pathParams, ctx4) + ctx6 <- validateCounterparty(pathParams, ctx5) + } yield ctx6 + + // Convert Validation result to Response + result.value.flatMap { + case Left(errorResponse) => IO.pure(ensureJsonContentType(errorResponse)) // Ensure all error responses are JSON + case Right(validCtx) => + // Enrich request with validated CallContext + val enrichedReq = req.withAttribute( + Http4sRequestAttributes.callContextKey, + validCtx.callContext.copy( + bank = validCtx.bank, + bankAccount = validCtx.account, + view = validCtx.view, + counterparty = validCtx.counterparty + ) + ) + routes.run(enrichedReq) + .map(ensureJsonContentType) // Ensure routed response has JSON content type + .getOrElseF(IO.pure(ensureJsonContentType(Response[IO](org.http4s.Status.NotFound)))) + } + } + + /** Authentication step: verifies user and updates ValidationContext */ + private def authenticate(req: Request[IO], resourceDoc: ResourceDoc, ctx: ValidationContext): Validation[ValidationContext] = { + val needsAuth = ResourceDocMiddleware.needsAuthentication(resourceDoc) + logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") + + val io = + if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext))) + else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext))) + + EitherT( + io.attempt.flatMap { + case Right((boxUser, Some(updatedCC))) => + IO.pure(Right(ctx.copy(user = boxUser, callContext = updatedCC))) + case Right((boxUser, None)) => + IO.pure(Right(ctx.copy(user = boxUser))) + case Left(e: APIFailureNewStyle) => + ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) + case Left(_) => + ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext).map(Left(_)) } - } yield response + ) } - /** - * Ensure response has JSON content type. - */ - private def ensureJsonContentType(response: Response[IO]): Response[IO] = { - response.contentType match { - case Some(contentType) if contentType.mediaType == MediaType.application.json => response - case _ => response.withContentType(jsonContentType) + /** Role authorization step: ensures user has required roles */ + private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { + import DSL._ + + resourceDoc.roles match { + case Some(roles) if roles.nonEmpty => + ctx.user match { + case Full(user) => + val bankId = pathParams.getOrElse("BANK_ID", "") + val ok = roles.exists { role => + val checkBankId = if (role.requiresBankId) bankId else "" + APIUtil.hasEntitlement(checkBankId, user.userId, role) + } + if (ok) success(ctx) + else EitherT[IO, Response[IO], ValidationContext]( + ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), ctx.callContext) + .map[Either[Response[IO], ValidationContext]](Left(_)) + ) + case _ => + EitherT[IO, Response[IO], ValidationContext]( + ErrorResponseConverter + .createErrorResponse(401, $AuthenticatedUserIsRequired, ctx.callContext) + .map[Either[Response[IO], ValidationContext]](resp => Left(resp)) + ) + } + case _ => success(ctx) } } - - /** - * Run validation chain for HttpRoutes and return Response. - * - * This method performs all validation steps in order: - * 1. Authentication (if required) - * 2. Role authorization (if roles specified) - * 3. Bank validation (if BANK_ID in path) - * 4. Account validation (if ACCOUNT_ID in path) - * 5. View validation (if VIEW_ID in path) - * 6. Counterparty validation (if COUNTERPARTY_ID in path) - * - * On success: Enriches CallContext with validated entities and routes to handler - * On failure: Returns error response immediately - */ - private def runValidationChainForRoutes( - req: Request[IO], - resourceDoc: ResourceDoc, - cc: CallContext, - pathParams: Map[String, String], - routes: HttpRoutes[IO] - ): IO[Response[IO]] = { - - val needsAuth = needsAuthentication(resourceDoc) - logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") - - // Step 1: Authentication - val authResult: IO[Either[Response[IO], (Box[User], CallContext)]] = - if (needsAuth) { - IO.fromFuture(IO(APIUtil.authenticatedAccess(cc))).attempt.flatMap { - case Right((boxUser, optCC)) => - val updatedCC = optCC.getOrElse(cc) - boxUser match { - case Full(user) => - IO.pure(Right((boxUser, updatedCC))) - case Empty => - ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, updatedCC).map(Left(_)) - case LiftFailure(msg, _, _) => - ErrorResponseConverter.createErrorResponse(401, msg, updatedCC).map(Left(_)) + + /** Bank validation: checks BANK_ID and fetches bank */ + private def validateBank(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { + + pathParams.get("BANK_ID") match { + case Some(bankId) => + EitherT( + IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext)))) + .attempt.flatMap { + case Right((bank, Some(updatedCC))) => IO.pure(Right(ctx.copy(bank = Some(bank), callContext = updatedCC))) + case Right((bank, None)) => IO.pure(Right(ctx.copy(bank = Some(bank)))) + case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) + case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankNotFound + s": $bankId", ctx.callContext).map(Left(_)) } - case Left(e: APIFailureNewStyle) => - ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc).map(Left(_)) - case Left(e) => - val (code, msg) = try { - import net.liftweb.json._ - implicit val formats = net.liftweb.json.DefaultFormats - val json = parse(e.getMessage) - val failCode = (json \ "failCode").extractOpt[Int].getOrElse(401) - val failMsg = (json \ "failMsg").extractOpt[String].getOrElse($AuthenticatedUserIsRequired) - (failCode, failMsg) - } catch { - case _: Exception => (401, $AuthenticatedUserIsRequired) + ) + case None => DSL.success(ctx) + } + } + + /** Account validation: checks ACCOUNT_ID and fetches bank account */ + private def validateAccount(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { + + (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match { + case (Some(bankId), Some(accountId)) => + EitherT( + IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext)))) + .attempt.flatMap { + case Right((acc, Some(updatedCC))) => IO.pure(Right(ctx.copy(account = Some(acc), callContext = updatedCC))) + case Right((acc, None)) => IO.pure(Right(ctx.copy(account = Some(acc)))) + case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) + case Left(_) => ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankId, accountId=$accountId", ctx.callContext).map(Left(_)) } - ErrorResponseConverter.createErrorResponse(code, msg, cc).map(Left(_)) - } - } else { - IO.fromFuture(IO(APIUtil.anonymousAccess(cc))).attempt.flatMap { - case Right((boxUser, Some(updatedCC))) => - IO.pure(Right((boxUser, updatedCC))) - case Right((boxUser, None)) => - IO.pure(Right((boxUser, cc))) - case Left(e) => - // For anonymous endpoints, continue with Empty user even if auth fails - IO.pure(Right((Empty, cc))) - } - } + ) + case _ => DSL.success(ctx) + } + } - authResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right((boxUser, cc1)) => - // Step 2: Role authorization - val rolesResult: IO[Either[Response[IO], CallContext]] = - resourceDoc.roles match { - case Some(roles) if roles.nonEmpty => - boxUser match { - case Full(user) => - val userId = user.userId - val bankId = pathParams.get("BANK_ID").getOrElse("") - val hasRole = roles.exists { role => - val checkBankId = if (role.requiresBankId) bankId else "" - APIUtil.hasEntitlement(checkBankId, userId, role) - } - if (hasRole) IO.pure(Right(cc1)) - else ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(", "), cc1).map(Left(_)) - case _ => - ErrorResponseConverter.createErrorResponse(401, $AuthenticatedUserIsRequired, cc1).map(Left(_)) - } - case _ => IO.pure(Right(cc1)) - } - - rolesResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right(cc2) => - // Step 3: Bank validation - val bankResult: IO[Either[Response[IO], (Option[Bank], CallContext)]] = - pathParams.get("BANK_ID") match { - case Some(bankIdStr) => - IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankIdStr), Some(cc2)))).attempt.flatMap { - case Right((bank, Some(updatedCC))) => - IO.pure(Right((Some(bank), updatedCC))) - case Right((bank, None)) => - IO.pure(Right((Some(bank), cc2))) - case Left(e: APIFailureNewStyle) => - ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc2).map(Left(_)) - case Left(e) => - ErrorResponseConverter.createErrorResponse(404, BankNotFound + ": " + bankIdStr, cc2).map(Left(_)) - } - case None => IO.pure(Right((None, cc2))) - } - - bankResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right((bankOpt, cc3)) => - // Step 4: Account validation (if ACCOUNT_ID in path) - val accountResult: IO[Either[Response[IO], (Option[BankAccount], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match { - case (Some(bankIdStr), Some(accountIdStr)) => - IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankIdStr), AccountId(accountIdStr), Some(cc3)))).attempt.flatMap { - case Right((account, Some(updatedCC))) => IO.pure(Right((Some(account), updatedCC))) - case Right((account, None)) => IO.pure(Right((Some(account), cc3))) - case Left(e: APIFailureNewStyle) => - ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc3).map(Left(_)) - case Left(e) => - ErrorResponseConverter.createErrorResponse(404, BankAccountNotFound + s": bankId=$bankIdStr, accountId=$accountIdStr", cc3).map(Left(_)) - } - case _ => IO.pure(Right((None, cc3))) - } + /** View validation: checks VIEW_ID and user access */ + private def validateView(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { - - accountResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right((accountOpt, cc4)) => - // Step 5: View validation (if VIEW_ID in path) - val viewResult: IO[Either[Response[IO], (Option[View], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match { - case (Some(bankIdStr), Some(accountIdStr), Some(viewIdStr)) => - val bankIdAccountId = BankIdAccountId(BankId(bankIdStr), AccountId(accountIdStr)) - IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewIdStr), bankIdAccountId, boxUser.toOption, Some(cc4)))).attempt.flatMap { - case Right(view) => IO.pure(Right((Some(view), cc4))) - case Left(e: APIFailureNewStyle) => - ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc4).map(Left(_)) - case Left(e) => - ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewIdStr", cc4).map(Left(_)) - } - case _ => IO.pure(Right((None, cc4))) - } - - viewResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right((viewOpt, cc5)) => - // Step 6: Counterparty validation (if COUNTERPARTY_ID in path) - val counterpartyResult: IO[Either[Response[IO], (Option[CounterpartyTrait], CallContext)]] = - (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match { - case (Some(bankIdStr), Some(accountIdStr), Some(counterpartyIdStr)) => - IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankIdStr), AccountId(accountIdStr), counterpartyIdStr, Some(cc5)))).attempt.flatMap { - case Right((counterparty, Some(updatedCC))) => IO.pure(Right((Some(counterparty), updatedCC))) - case Right((counterparty, None)) => IO.pure(Right((Some(counterparty), cc5))) - case Left(e: APIFailureNewStyle) => - ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, cc5).map(Left(_)) - case Left(e) => - ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyIdStr", cc5).map(Left(_)) - } - case _ => IO.pure(Right((None, cc5))) - } - - counterpartyResult.flatMap { - case Left(errorResponse) => IO.pure(errorResponse) - case Right((counterpartyOpt, finalCC)) => - // All validations passed - update CallContext with validated entities - val enrichedCC = finalCC.copy( - bank = bankOpt, - bankAccount = accountOpt, - view = viewOpt, - counterparty = counterpartyOpt - ) - - // Store enriched CallContext in request attributes - val updatedReq = req.withAttribute(Http4sRequestAttributes.callContextKey, enrichedCC) - routes.run(updatedReq).getOrElseF(IO.pure(Response[IO](org.http4s.Status.NotFound))) - } - } - } + (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match { + case (Some(bankId), Some(accountId), Some(viewId)) => + EitherT( + IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext)))) + .attempt.flatMap { + case Right(view) => IO.pure(Right(ctx.copy(view = Some(view)))) + case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) + case Left(_) => ErrorResponseConverter.createErrorResponse(403, UserNoPermissionAccessView + s": viewId=$viewId", ctx.callContext).map(Left(_)) } - } + ) + case _ => DSL.success(ctx) + } + } + + /** Counterparty validation: checks COUNTERPARTY_ID and fetches counterparty */ + private def validateCounterparty(pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { + + (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match { + case (Some(bankId), Some(accountId), Some(counterpartyId)) => + EitherT( + IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext)))) + .attempt.flatMap { + case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC))) + case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp)))) + case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) + case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_)) + } + ) + case _ => DSL.success(ctx) + } + } + + /** Ensure the response has JSON content type */ + private def ensureJsonContentType(response: Response[IO]): Response[IO] = { + response.contentType match { + case Some(contentType) if contentType.mediaType == MediaType.application.json => response + case _ => response.withContentType(jsonContentType) } } } From 0188ba61a06016a98acc567e3a9fd4f3c51464b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 26 Jan 2026 10:25:36 +0100 Subject: [PATCH 08/12] refactor/Tweak variables names --- .../api/util/http4s/ResourceDocMiddleware.scala | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 2b7876b88f..cee52b861e 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -113,17 +113,17 @@ object ResourceDocMiddleware extends MdcLoggable { ): IO[Response[IO]] = { // Initial context with just CallContext - val initialCtx = ValidationContext(callContext = cc) + val initialContext = ValidationContext(callContext = cc) // Compose all validation steps using EitherT val result: Validation[ValidationContext] = for { - ctx1 <- authenticate(req, resourceDoc, initialCtx) - ctx2 <- authorizeRoles(resourceDoc, pathParams, ctx1) - ctx3 <- validateBank(pathParams, ctx2) - ctx4 <- validateAccount(pathParams, ctx3) - ctx5 <- validateView(pathParams, ctx4) - ctx6 <- validateCounterparty(pathParams, ctx5) - } yield ctx6 + context <- authenticate(req, resourceDoc, initialContext) + context <- authorizeRoles(resourceDoc, pathParams, context) + context <- validateBank(pathParams, context) + context <- validateAccount(pathParams, context) + context <- validateView(pathParams, context) + context <- validateCounterparty(pathParams, context) + } yield context // Convert Validation result to Response result.value.flatMap { From 18daf8dfb226424f4157af015564b602fab1a985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 26 Jan 2026 12:42:31 +0100 Subject: [PATCH 09/12] Revert "fix Github Action" This reverts commit 3a264ed32676adbd2a761a6125cb2b09f120e61e. --- .github/workflows/auto_update_base_image.yml | 35 ++++++ .../build_container_develop_branch.yml | 31 +++++ .../build_container_non_develop_branch.yml | 114 ++++++++++++++++++ .github/workflows/build_pull_request.yml | 87 +++++++++++++ .github/workflows/run_trivy.yml | 54 +++++++++ 5 files changed, 321 insertions(+) create mode 100644 .github/workflows/auto_update_base_image.yml create mode 100644 .github/workflows/build_container_non_develop_branch.yml create mode 100644 .github/workflows/build_pull_request.yml create mode 100644 .github/workflows/run_trivy.yml diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml new file mode 100644 index 0000000000..3048faf15e --- /dev/null +++ b/.github/workflows/auto_update_base_image.yml @@ -0,0 +1,35 @@ +name: Regular base image update check +on: + schedule: + - cron: "0 5 * * *" + workflow_dispatch: + +env: + ## Sets environment variable + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Docker Image Update Checker + id: baseupdatecheck + uses: lucacome/docker-image-update-checker@v2.0.0 + with: + base-image: jetty:9.4-jdk11-alpine + image: ${{ env.DOCKER_HUB_ORGANIZATION }}/obp-api:latest + + - name: Trigger build_container_develop_branch workflow + uses: actions/github-script@v6 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'build_container_develop_branch.yml', + ref: 'refs/heads/develop' + }); + if: steps.baseupdatecheck.outputs.needs-updating == 'true' diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index 3dfbad23d8..4f82e563bd 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -124,3 +124,34 @@ jobs: with: name: ${{ github.sha }} path: push/ + + - name: Build the Docker image + run: | + echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io + docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop + docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags + echo docker done + + - uses: sigstore/cosign-installer@main + + - name: Write signing key to disk (only needed for `cosign sign --key`) + run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key + + - name: Sign container image + run: | + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop-OC + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC + env: + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" + + + diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml new file mode 100644 index 0000000000..946d81de4d --- /dev/null +++ b/.github/workflows/build_container_non_develop_branch.yml @@ -0,0 +1,114 @@ +name: Build and publish container non develop + +on: + push: + branches: + - '*' + - '!develop' + +env: + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + DOCKER_HUB_REPOSITORY: obp-api + +jobs: + build: + runs-on: ubuntu-latest + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + ports: + # Opens tcp port 6379 on the host and service container + - 6379:6379 + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: maven + - name: Build with Maven + run: | + cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props + echo connector=star > obp-api/src/main/resources/props/test.default.props + echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props + echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props + echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props + echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props + echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props + echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props + echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props + echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props + echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props + echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props + + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + + echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props + echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props + + echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props + + echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + + - name: Save .war artifact + run: | + mkdir -p ./push + cp obp-api/target/obp-api-1.*.war ./push/ + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.sha }} + path: push/ + + - name: Build the Docker image + run: | + echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io + docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + docker build . --file .github/Dockerfile_PreBuild_OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA-OC --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags + echo docker done + + - uses: sigstore/cosign-installer@main + + - name: Write signing key to disk (only needed for `cosign sign --key`) + run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key + + - name: Sign container image + run: | + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/}-OC + cosign sign -y --key cosign.key \ + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA + env: + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" + + + diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml new file mode 100644 index 0000000000..859d309ec2 --- /dev/null +++ b/.github/workflows/build_pull_request.yml @@ -0,0 +1,87 @@ +name: Build on Pull Request + +on: + pull_request: + branches: + - '**' +env: + ## Sets environment variable + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + + +jobs: + build: + runs-on: ubuntu-latest + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + ports: + # Opens tcp port 6379 on the host and service container + - 6379:6379 + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'adopt' + cache: maven + - name: Build with Maven + run: | + cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props + echo connector=star > obp-api/src/main/resources/props/test.default.props + echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props + echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props + echo tests.port=8016 >> obp-api/src/main/resources/props/test.default.props + echo End of minimum settings >> obp-api/src/main/resources/props/test.default.props + echo payments_enabled=false >> obp-api/src/main/resources/props/test.default.props + echo importer_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.updateBankAccountsTransaction=false >> obp-api/src/main/resources/props/test.default.props + echo messageQueue.createBankAccounts=false >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_account_creation=true >> obp-api/src/main/resources/props/test.default.props + echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props + echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props + echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props + echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props + echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props + echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props + echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props + + echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props + + + echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props + echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props + + echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props + + echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + + - name: Save .war artifact + run: | + mkdir -p ./pull + cp obp-api/target/obp-api-1.*.war ./pull/ + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.sha }} + path: pull/ + + + diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml new file mode 100644 index 0000000000..4636bd3116 --- /dev/null +++ b/.github/workflows/run_trivy.yml @@ -0,0 +1,54 @@ +name: scan container image + +on: + workflow_run: + workflows: + - Build and publish container develop + - Build and publish container non develop + types: + - completed +env: + ## Sets environment variable + DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} + DOCKER_HUB_REPOSITORY: obp-api + + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + + steps: + - uses: actions/checkout@v4 + - id: trivy-db + name: Check trivy db sha + env: + GH_TOKEN: ${{ github.token }} + run: | + endpoint='/orgs/aquasecurity/packages/container/trivy-db/versions' + headers='Accept: application/vnd.github+json' + jqFilter='.[] | select(.metadata.container.tags[] | contains("latest")) | .name | sub("sha256:";"")' + sha=$(gh api -H "${headers}" "${endpoint}" | jq --raw-output "${jqFilter}") + echo "Trivy DB sha256:${sha}" + echo "::set-output name=sha::${sha}" + - uses: actions/cache@v4 + with: + path: .trivy + key: ${{ runner.os }}-trivy-db-${{ steps.trivy-db.outputs.sha }} + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' + format: 'template' + template: '@/contrib/sarif.tpl' + output: 'trivy-results.sarif' + security-checks: 'vuln' + severity: 'CRITICAL,HIGH' + timeout: '30m' + cache-dir: .trivy + - name: Fix .trivy permissions + run: sudo chown -R $(stat . -c %u:%g) .trivy + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' \ No newline at end of file From 897a6a158415c8851e15c35bcdb40471f011ab90 Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 26 Jan 2026 12:47:14 +0100 Subject: [PATCH 10/12] test/(http4s): improve error handling and request header extraction - Safely extract request headers using `S.request.map(...).openOr(Nil)` instead of `openOrThrowException` - Add proper error handling for resource-docs endpoint using `ErrorResponseConverter` - Extract query parameters directly from URI instead of parsing URL string - Add comprehensive test suite for Http4s700 routes --- .../main/scala/code/api/util/APIUtil.scala | 10 +- .../scala/code/api/v7_0_0/Http4s700.scala | 95 +++-- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 396 ++++++++++++++++++ 3 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 7ba6c81769..14279692fe 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3078,10 +3078,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ else getCorrelationId() - val reqHeaders = if (cc.requestHeaders.nonEmpty) - cc.requestHeaders - else - S.request.openOrThrowException(attemptedToOpenAnEmptyBox).request.headers + val reqHeaders = if (cc.requestHeaders.nonEmpty) + cc.requestHeaders + else + S.request.map(_.request.headers).openOr(Nil) val remoteIpAddress = if (cc.ipAddress.nonEmpty) cc.ipAddress @@ -5189,4 +5189,4 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ .distinct // List pairs (bank_id, account_id) } -} \ No newline at end of file +} diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 55da729fcf..16c6fc61e4 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -9,7 +9,7 @@ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ -import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware} +import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, ResourceDocMiddleware} import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers} import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} import code.api.v1_3_0.JSONFactory1_3_0 @@ -202,43 +202,62 @@ object Http4s700 { val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => implicit val cc: CallContext = req.callContext - for { - result <- IO.fromFuture(IO { - // Check resource_docs_requires_role property - val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false) - - for { - // Authentication based on property - (boxUser, cc1) <- if (resourceDocsRequireRole) - authenticatedAccess(cc) - else - anonymousAccess(cc) - - // Role check based on property - _ <- if (resourceDocsRequireRole) { - NewStyle.function.hasAtLeastOneEntitlement( - failMsg = UserHasMissingRoles + canReadResourceDoc.toString - )("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1) - } else { - Future.successful(()) - } - - httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) - tagsParam = httpParams.filter(_.name == "tags").map(_.values).headOption - functionsParam = httpParams.filter(_.name == "functions").map(_.values).headOption - localeParam = httpParams.filter(param => param.name == "locale" || param.name == "language").map(_.values).flatten.headOption - contentParam = httpParams.filter(_.name == "content").map(_.values).flatten.flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam).headOption - apiCollectionIdParam = httpParams.filter(_.name == "api-collection-id").map(_.values).flatten.headOption - tags = tagsParam.map(_.map(ResourceDocTag(_))) - functions = functionsParam.map(_.toList) - requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) - resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) - filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) - resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) - } yield convertAnyToJsonString(resourceDocsJson) - }) - response <- Ok(result) - } yield response + val resultF: Future[String] = { + val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false) + for { + (boxUser, cc1) <- if (resourceDocsRequireRole) + authenticatedAccess(cc) + else + anonymousAccess(cc) + + _ <- if (resourceDocsRequireRole) { + NewStyle.function.hasAtLeastOneEntitlement( + failMsg = UserHasMissingRoles + canReadResourceDoc.toString + )("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1) + } else { + Future.successful(()) + } + + queryParams = req.uri.query.multiParams + tags = queryParams + .get("tags") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList) + functions = queryParams + .get("functions") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList) + localeParam = queryParams + .get("locale") + .flatMap(_.headOption) + .orElse(queryParams.get("language").flatMap(_.headOption)) + .map(_.trim) + .filter(_.nonEmpty) + contentParam = queryParams + .get("content") + .flatMap(_.headOption) + .map(_.trim) + .flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam) + requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) + resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) + resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) + } yield convertAnyToJsonString(resourceDocsJson) + } + + IO.fromFuture(IO(resultF)).attempt.flatMap { + case Right(result) => Ok(result) + case Left(e) => + val (code, msg) = try { + import net.liftweb.json._ + implicit val formats = net.liftweb.json.DefaultFormats + val json = parse(e.getMessage) + val failCode = (json \ "failCode").extractOpt[Int].getOrElse(400) + val failMsg = (json \ "failMsg").extractOpt[String].getOrElse(UnknownError) + (failCode, failMsg) + } catch { + case _: Exception => (500, UnknownError) + } + ErrorResponseConverter.createErrorResponse(code, msg, cc) + } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala new file mode 100644 index 0000000000..93d5f5a9d2 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -0,0 +1,396 @@ +package code.api.v7_0_0 + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound} +import code.setup.ServerSetupWithTestData +import net.liftweb.json.JValue +import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString} +import net.liftweb.json.JsonParser.parse +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.implicits._ +import org.scalatest.Tag + +class Http4s700RoutesTest extends ServerSetupWithTestData { + + object Http4s700RoutesTag extends Tag("Http4s700Routes") + + private def runAndParseJson(request: Request[IO]): (Status, JValue) = { + val response = Http4s700.wrappedRoutesV700Services.orNotFound.run(request).unsafeRunSync() + val body = response.as[String].unsafeRunSync() + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (response.status, json) + } + + private def withDirectLoginToken(request: Request[IO], token: String): Request[IO] = { + request.withHeaders( + Header.Raw(org.typelevel.ci.CIString("DirectLogin"), s"token=$token") + ) + } + + private def toFieldMap(fields: List[JField]): Map[String, JValue] = { + fields.map(field => field.name -> field.value).toMap + } + + feature("Http4s700 root endpoint") { + + scenario("Return API info JSON", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/root request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/root") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with API info fields") + status shouldBe Status.Ok + json match { + case JObject(fields) => + val keys = fields.map(_.name) + keys should contain("version") + keys should contain("version_status") + keys should contain("git_commit") + keys should contain("connector") + case _ => + fail("Expected JSON object for root endpoint") + } + } + } + + feature("Http4s700 banks endpoint") { + + scenario("Return banks list JSON", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks request") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/banks") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with banks array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + val valueOpt = toFieldMap(fields).get("banks") + valueOpt should not be empty + valueOpt.get match { + case JArray(_) => + succeed + case _ => + fail("Expected banks field to be an array") + } + case _ => + fail("Expected JSON object for banks endpoint") + } + } + } + + feature("Http4s700 cards endpoint") { + + scenario("Reject unauthenticated access to cards", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/cards request without auth headers") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/cards") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 401 Unauthorized with appropriate error message") + status.code shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(AuthenticatedUserIsRequired) + case _ => + fail("Expected message field as JSON string for cards unauthorized response") + } + case _ => + fail("Expected JSON object for cards unauthorized response") + } + } + + scenario("Return cards list JSON when authenticated", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/cards request with DirectLogin header") + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with cards array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("cards") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected cards field to be an array") + } + case _ => fail("Expected JSON object for cards endpoint") + } + } + } + + feature("Http4s700 bank cards endpoint") { + + scenario("Return bank cards list JSON when authenticated and entitled", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header and role") + val bankId = testBankId1.value + addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards?limit=10&offset=0") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with cards array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("cards") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected cards field to be an array") + } + case _ => fail("Expected JSON object for bank cards endpoint") + } + } + + scenario("Reject bank cards access when missing required role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request with DirectLogin header but no role") + val bankId = testBankId1.value + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, _) = runAndParseJson(request) + + Then("Response is 403 Forbidden") + status.code shouldBe 403 + } + + scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/banks/BANK_ID/cards request for non-existing bank") + val bankId = "non-existing-bank-id" + addEntitlement(bankId, resourceUser1.userId, canGetCardsForBank.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString(s"/obp/v7.0.0/banks/$bankId/cards") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 404 Not Found with BankNotFound message") + status.code shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(BankNotFound) + case _ => + fail("Expected message field as JSON string for BankNotFound response") + } + case _ => + fail("Expected JSON object for BankNotFound response") + } + } + } + + feature("Http4s700 resource-docs endpoint") { + + scenario("Allow public access when resource docs role is not required", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with resource_docs array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(_)) => + succeed + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Reject unauthenticated access when resource docs role is required", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers and role required") + setPropsValues("resource_docs_requires_role" -> "true") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 401 Unauthorized") + status.code shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(AuthenticatedUserIsRequired) + case _ => + fail("Expected message field as JSON string for resource-docs unauthorized response") + } + case _ => + fail("Expected JSON object for resource-docs unauthorized response") + } + } + + scenario("Reject access when authenticated but missing canReadResourceDoc role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth but no canReadResourceDoc role") + setPropsValues("resource_docs_requires_role" -> "true") + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, _) = runAndParseJson(request) + + Then("Response is 400 Bad Request") + status.code shouldBe 400 + } + + scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth and canReadResourceDoc role") + setPropsValues("resource_docs_requires_role" -> "true") + addEntitlement("", resourceUser1.userId, canReadResourceDoc.toString) + + val baseRequest = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp") + ) + val request = withDirectLoginToken(baseRequest, token1.value) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK with resource_docs array") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(_)) => + succeed + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Filter docs by tags parameter", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card request") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK and all returned docs contain Card tag") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(resourceDocs)) => + resourceDocs.foreach { + case JObject(rdFields) => + toFieldMap(rdFields).get("tags") match { + case Some(JArray(tags)) => + tags.exists { + case JString(tag) => tag == "Card" + case _ => false + } shouldBe true + case _ => + fail("Expected tags field to be an array") + } + case _ => + fail("Expected resource doc to be a JSON object") + } + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + + scenario("Filter docs by functions parameter", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks request") + setPropsValues("resource_docs_requires_role" -> "false") + val request = Request[IO]( + method = Method.GET, + uri = Uri.unsafeFromString("/obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks") + ) + + When("Running through wrapped routes") + val (status, json) = runAndParseJson(request) + + Then("Response is 200 OK and includes GET /banks") + status shouldBe Status.Ok + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(resourceDocs)) => + resourceDocs.foreach { + case JObject(rdFields) => + val fieldMap = toFieldMap(rdFields) + (fieldMap.get("request_verb"), fieldMap.get("request_url")) match { + case (Some(JString(verb)), Some(JString(url))) => + verb shouldBe "GET" + url should endWith("/banks") + case _ => + fail("Expected request_verb and request_url fields as JSON strings") + } + case _ => + fail("Expected resource doc to be a JSON object") + } + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object for resource-docs endpoint") + } + } + } + +} From 87b48fc71d935b57778bea8284ea1582d75e5264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Mon, 26 Jan 2026 12:54:28 +0100 Subject: [PATCH 11/12] feature/Tweak GitHub workflows --- .github/workflows/auto_update_base_image.yml | 3 +- .../build_container_develop_branch.yml | 15 +++-- .../build_container_non_develop_branch.yml | 58 +++++++++++++++---- .github/workflows/build_pull_request.yml | 54 +++++++++++++---- .github/workflows/run_trivy.yml | 19 +++--- 5 files changed, 110 insertions(+), 39 deletions(-) diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml index 3048faf15e..e47d6d95bc 100644 --- a/.github/workflows/auto_update_base_image.yml +++ b/.github/workflows/auto_update_base_image.yml @@ -11,6 +11,7 @@ env: jobs: build: runs-on: ubuntu-latest + if: github.repository == 'OpenBankProject/OBP-API' steps: - name: Checkout repository uses: actions/checkout@v4 @@ -32,4 +33,4 @@ jobs: workflow_id: 'build_container_develop_branch.yml', ref: 'refs/heads/develop' }); - if: steps.baseupdatecheck.outputs.needs-updating == 'true' + if: steps.baseupdatecheck.outputs.needs-updating == 'true' \ No newline at end of file diff --git a/.github/workflows/build_container_develop_branch.yml b/.github/workflows/build_container_develop_branch.yml index 4f82e563bd..1fc605d172 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -13,7 +13,6 @@ env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api - jobs: build: runs-on: ubuntu-latest @@ -36,8 +35,8 @@ jobs: - name: Set up JDK 11 uses: actions/setup-java@v4 with: - java-version: '11' - distribution: 'adopt' + java-version: "11" + distribution: "adopt" cache: maven - name: Build with Maven run: | @@ -126,6 +125,7 @@ jobs: path: push/ - name: Build the Docker image + if: github.repository == 'OpenBankProject/OBP-API' run: | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop @@ -133,12 +133,14 @@ jobs: docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags echo docker done - - uses: sigstore/cosign-installer@main + - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - name: Write signing key to disk (only needed for `cosign sign --key`) + if: github.repository == 'OpenBankProject/OBP-API' run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - name: Sign container image + if: github.repository == 'OpenBankProject/OBP-API' run: | cosign sign -y --key cosign.key \ docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:develop @@ -151,7 +153,4 @@ jobs: cosign sign -y --key cosign.key \ docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest-OC env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - - + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" \ No newline at end of file diff --git a/.github/workflows/build_container_non_develop_branch.yml b/.github/workflows/build_container_non_develop_branch.yml index 946d81de4d..61e8bfcd23 100644 --- a/.github/workflows/build_container_non_develop_branch.yml +++ b/.github/workflows/build_container_non_develop_branch.yml @@ -3,8 +3,8 @@ name: Build and publish container non develop on: push: branches: - - '*' - - '!develop' + - "*" + - "!develop" env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} @@ -35,11 +35,12 @@ jobs: - name: Set up JDK 11 uses: actions/setup-java@v4 with: - java-version: '11' - distribution: 'adopt' + java-version: "11" + distribution: "adopt" cache: maven - name: Build with Maven run: | + set -o pipefail cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props @@ -75,7 +76,44 @@ jobs: echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log + + - name: Report failing tests (if any) + if: always() + run: | + echo "Checking build log for failing tests via grep..." + if [ ! -f maven-build.log ]; then + echo "No maven-build.log found; skipping failure scan." + exit 0 + fi + if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then + echo "Failing tests detected above." + exit 1 + else + echo "No failing tests detected in maven-build.log." + fi + + - name: Upload Maven build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: maven-build-log + if-no-files-found: ignore + path: | + maven-build.log + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + if-no-files-found: ignore + path: | + obp-api/target/surefire-reports/** + obp-commons/target/surefire-reports/** + **/target/scalatest-reports/** + **/target/site/surefire-report.html + **/target/site/surefire-report/* - name: Save .war artifact run: | @@ -87,6 +125,7 @@ jobs: path: push/ - name: Build the Docker image + if: github.repository == 'OpenBankProject/OBP-API' run: | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} @@ -94,12 +133,14 @@ jobs: docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags echo docker done - - uses: sigstore/cosign-installer@main + - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - name: Write signing key to disk (only needed for `cosign sign --key`) + if: github.repository == 'OpenBankProject/OBP-API' run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - name: Sign container image + if: github.repository == 'OpenBankProject/OBP-API' run: | cosign sign -y --key cosign.key \ docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} @@ -108,7 +149,4 @@ jobs: cosign sign -y --key cosign.key \ docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA env: - COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" - - - + COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" \ No newline at end of file diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 859d309ec2..425177f0cb 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -3,15 +3,15 @@ name: Build on Pull Request on: pull_request: branches: - - '**' + - "**" env: ## Sets environment variable DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} - jobs: build: runs-on: ubuntu-latest + if: github.repository == 'OpenBankProject/OBP-API' services: # Label used to access the service container redis: @@ -31,11 +31,12 @@ jobs: - name: Set up JDK 11 uses: actions/setup-java@v4 with: - java-version: '11' - distribution: 'adopt' + java-version: "11" + distribution: "adopt" cache: maven - name: Build with Maven run: | + set -o pipefail cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props @@ -65,14 +66,50 @@ jobs: echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod + MAVEN_OPTS="-Xmx3G -Xss2m" mvn clean package -Pprod 2>&1 | tee maven-build.log + + - name: Report failing tests (if any) + if: always() + run: | + echo "Checking build log for failing tests via grep..." + if [ ! -f maven-build.log ]; then + echo "No maven-build.log found; skipping failure scan." + exit 0 + fi + if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then + echo "Failing tests detected above." + exit 1 + else + echo "No failing tests detected in maven-build.log." + fi + + - name: Upload Maven build log + if: always() + uses: actions/upload-artifact@v4 + with: + name: maven-build-log + if-no-files-found: ignore + path: | + maven-build.log + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + if-no-files-found: ignore + path: | + obp-api/target/surefire-reports/** + obp-commons/target/surefire-reports/** + **/target/scalatest-reports/** + **/target/site/surefire-report.html + **/target/site/surefire-report/* - name: Save .war artifact run: | @@ -81,7 +118,4 @@ jobs: - uses: actions/upload-artifact@v4 with: name: ${{ github.sha }} - path: pull/ - - - + path: pull/ \ No newline at end of file diff --git a/.github/workflows/run_trivy.yml b/.github/workflows/run_trivy.yml index 4636bd3116..e06a801f93 100644 --- a/.github/workflows/run_trivy.yml +++ b/.github/workflows/run_trivy.yml @@ -12,11 +12,10 @@ env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api - jobs: build: runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: github.repository == 'OpenBankProject/OBP-API' && github.event.workflow_run.conclusion == 'success' steps: - uses: actions/checkout@v4 @@ -38,17 +37,17 @@ jobs: - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: - image-ref: 'docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}' - format: 'template' - template: '@/contrib/sarif.tpl' - output: 'trivy-results.sarif' - security-checks: 'vuln' - severity: 'CRITICAL,HIGH' - timeout: '30m' + image-ref: "docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }}" + format: "template" + template: "@/contrib/sarif.tpl" + output: "trivy-results.sarif" + security-checks: "vuln" + severity: "CRITICAL,HIGH" + timeout: "30m" cache-dir: .trivy - name: Fix .trivy permissions run: sudo chown -R $(stat . -c %u:%g) .trivy - name: Upload Trivy scan results to GitHub Security tab uses: github/codeql-action/upload-sarif@v3 with: - sarif_file: 'trivy-results.sarif' \ No newline at end of file + sarif_file: "trivy-results.sarif" \ No newline at end of file From 5ff7b299a6512cf5bf3e0491819a9f53f000b39d Mon Sep 17 00:00:00 2001 From: hongwei Date: Mon, 26 Jan 2026 14:29:54 +0100 Subject: [PATCH 12/12] refactor/(resource-docs): enforce canReadResourceDoc role via middleware Move role-based authorization for resource-docs endpoint from endpoint implementation to ResourceDocMiddleware. This ensures consistent authentication handling across all endpoints and removes duplicate authorization logic. The middleware now checks the `resource_docs_requires_role` property and enforces the `canReadResourceDoc` role when enabled. Tests are updated to verify proper 403 responses with missing role messages. --- .../util/http4s/ResourceDocMiddleware.scala | 15 +++- .../scala/code/api/v7_0_0/Http4s700.scala | 73 +++++-------------- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 34 +++++++-- 3 files changed, 59 insertions(+), 63 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index cee52b861e..78e946fb05 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -2,13 +2,15 @@ package code.api.util.http4s import cats.data.{EitherT, Kleisli, OptionT} import cats.effect._ +import code.api.v7_0_0.Http4s700 import code.api.APIFailureNewStyle import code.api.util.APIUtil.ResourceDoc import code.api.util.ErrorMessages._ import code.api.util.newstyle.ViewNewStyle -import code.api.util.{APIUtil, CallContext, NewStyle} +import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ +import com.github.dwickern.macros.NameOf.nameOf import net.liftweb.common.{Box, Empty, Full} import org.http4s._ import org.http4s.headers.`Content-Type` @@ -70,7 +72,7 @@ object ResourceDocMiddleware extends MdcLoggable { * - Special case: resource-docs endpoint checks resource_docs_requires_role property */ private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = { - if (resourceDoc.partialFunctionName == "getResourceDocsObpV700") { + if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700)) { APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) } else { resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) @@ -172,7 +174,14 @@ object ResourceDocMiddleware extends MdcLoggable { private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { import DSL._ - resourceDoc.roles match { + val rolesToCheck: Option[List[ApiRole]] = + if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700) && APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)) { + Some(List(ApiRole.canReadResourceDoc)) + } else { + resourceDoc.roles + } + + rolesToCheck match { case Some(roles) if roles.nonEmpty => ctx.user match { case Full(user) => diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 16c6fc61e4..229c610276 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -6,12 +6,12 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} -import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} +import code.api.util.ApiRole.canGetCardsForBank import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ -import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, ResourceDocMiddleware} +import code.api.util.http4s.{Http4sRequestAttributes, ResourceDocMiddleware} import code.api.util.http4s.Http4sRequestAttributes.{RequestOps, EndpointHelpers} -import code.api.util.{ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} +import code.api.util.{ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} import code.api.v1_3_0.JSONFactory1_3_0 import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v4_0_0.JSONFactory400 @@ -201,62 +201,25 @@ object Http4s700 { val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => - implicit val cc: CallContext = req.callContext - val resultF: Future[String] = { - val resourceDocsRequireRole = getPropsAsBoolValue("resource_docs_requires_role", false) + EndpointHelpers.executeAndRespond(req) { _ => + val queryParams = req.uri.query.multiParams + val tags = queryParams + .get("tags") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList) + val functions = queryParams + .get("functions") + .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList) + val localeParam = queryParams + .get("locale") + .flatMap(_.headOption) + .orElse(queryParams.get("language").flatMap(_.headOption)) + .map(_.trim) + .filter(_.nonEmpty) for { - (boxUser, cc1) <- if (resourceDocsRequireRole) - authenticatedAccess(cc) - else - anonymousAccess(cc) - - _ <- if (resourceDocsRequireRole) { - NewStyle.function.hasAtLeastOneEntitlement( - failMsg = UserHasMissingRoles + canReadResourceDoc.toString - )("", boxUser.map(_.userId).getOrElse(""), ApiRole.canReadResourceDoc :: Nil, cc1) - } else { - Future.successful(()) - } - - queryParams = req.uri.query.multiParams - tags = queryParams - .get("tags") - .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList) - functions = queryParams - .get("functions") - .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList) - localeParam = queryParams - .get("locale") - .flatMap(_.headOption) - .orElse(queryParams.get("language").flatMap(_.headOption)) - .map(_.trim) - .filter(_.nonEmpty) - contentParam = queryParams - .get("content") - .flatMap(_.headOption) - .map(_.trim) - .flatMap(ResourceDocsAPIMethodsUtil.stringToContentParam) requestedApiVersion <- Future(ApiVersionUtils.valueOf(requestedApiVersionString)) resourceDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs, tags, functions) - resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) - } yield convertAnyToJsonString(resourceDocsJson) - } - - IO.fromFuture(IO(resultF)).attempt.flatMap { - case Right(result) => Ok(result) - case Left(e) => - val (code, msg) = try { - import net.liftweb.json._ - implicit val formats = net.liftweb.json.DefaultFormats - val json = parse(e.getMessage) - val failCode = (json \ "failCode").extractOpt[Int].getOrElse(400) - val failMsg = (json \ "failMsg").extractOpt[String].getOrElse(UnknownError) - (failCode, failMsg) - } catch { - case _: Exception => (500, UnknownError) - } - ErrorResponseConverter.createErrorResponse(code, msg, cc) + } yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam) } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 93d5f5a9d2..c43070d730 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -3,7 +3,7 @@ package code.api.v7_0_0 import cats.effect.IO import cats.effect.unsafe.implicits.global import code.api.util.ApiRole.{canGetCardsForBank, canReadResourceDoc} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles} import code.setup.ServerSetupWithTestData import net.liftweb.json.JValue import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString} @@ -180,10 +180,22 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val request = withDirectLoginToken(baseRequest, token1.value) When("Running through wrapped routes") - val (status, _) = runAndParseJson(request) + val (status, json) = runAndParseJson(request) Then("Response is 403 Forbidden") status.code shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(UserHasMissingRoles) + message should include(canGetCardsForBank.toString) + case _ => + fail("Expected message field as JSON string for missing-role response") + } + case _ => + fail("Expected JSON object for missing-role response") + } } scenario("Return BankNotFound when bank does not exist and user is entitled", Http4s700RoutesTag) { @@ -280,10 +292,22 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { val request = withDirectLoginToken(baseRequest, token1.value) When("Running through wrapped routes") - val (status, _) = runAndParseJson(request) + val (status, json) = runAndParseJson(request) - Then("Response is 400 Bad Request") - status.code shouldBe 400 + Then("Response is 403 Forbidden") + status.code shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(message)) => + message should include(UserHasMissingRoles) + message should include(canReadResourceDoc.toString) + case _ => + fail("Expected message field as JSON string for missing-role response") + } + case _ => + fail("Expected JSON object for missing-role response") + } } scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) {