diff --git a/.github/workflows/auto_update_base_image.yml b/.github/workflows/auto_update_base_image.yml deleted file mode 100644 index 84cec88465..0000000000 --- a/.github/workflows/auto_update_base_image.yml +++ /dev/null @@ -1,36 +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 - if: github.repository == 'OpenBankProject/OBP-API' - 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 72f94bb144..3dfbad23d8 100644 --- a/.github/workflows/build_container_develop_branch.yml +++ b/.github/workflows/build_container_develop_branch.yml @@ -13,6 +13,7 @@ env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api + jobs: build: runs-on: ubuntu-latest @@ -35,8 +36,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: | @@ -123,34 +124,3 @@ jobs: with: name: ${{ github.sha }} 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 - 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`) - 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 - 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 6fdd52bdde..0000000000 --- a/.github/workflows/build_container_non_develop_branch.yml +++ /dev/null @@ -1,152 +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 - 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##*/} - 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`) - 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##*/} - 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 e0fc7a3d43..0000000000 --- a/.github/workflows/build_pull_request.yml +++ /dev/null @@ -1,121 +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 - if: github.repository == 'OpenBankProject/OBP-API' - 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 53213c0313..0000000000 --- a/.github/workflows/run_trivy.yml +++ /dev/null @@ -1,53 +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.repository == 'OpenBankProject/OBP-API' && 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" 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) } } }