diff --git a/.github/workflows/test_worktree_build.yml b/.github/workflows/test_worktree_build.yml index 86bf256e14..936d1895c6 100644 --- a/.github/workflows/test_worktree_build.yml +++ b/.github/workflows/test_worktree_build.yml @@ -18,15 +18,19 @@ jobs: ref: ${{ github.event.inputs.branch }} fetch-depth: 0 - - name: Set up JDK 11 + # Same JDK as build_pull_request.yml — the build compiles with -release 25, + # so the JDK 11 this job used to request could never get past compilation. + - name: Set up JDK uses: actions/setup-java@v4 with: - java-version: "11" - distribution: "adopt" + java-version: "25" + distribution: "temurin" cache: maven + # On a branch, not detached: the point of this job is that the stamp names + # THIS working copy, and a detached worktree has no branch to compare against. - name: Create git worktree - run: git worktree add --detach ../obp-api-worktree HEAD + run: git worktree add -b worktree-stamp-probe ../obp-api-worktree HEAD - name: Build from worktree working-directory: ../obp-api-worktree @@ -34,7 +38,11 @@ jobs: set -o pipefail MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn clean package -DskipTests 2>&1 | tee "$GITHUB_WORKSPACE/worktree-build.log" - - name: Verify git.properties was generated + # A worktree build used to inherit the MAIN checkout's branch and commit + # (git-commit-id-maven-plugin redirected the worktree gitdir to the main .git), + # so "the fields are non-empty" passed while the values were wrong. Compare + # them against the worktree's own HEAD instead. + - name: Verify the stamp describes the worktree run: | PROPS_FILE="../obp-api-worktree/obp-api/target/classes/git.properties" if [ ! -f "$PROPS_FILE" ]; then @@ -44,20 +52,85 @@ jobs: echo "Contents of git.properties:" cat "$PROPS_FILE" - check_field() { - local field=$1 - local value - value=$(grep "^${field}=" "$PROPS_FILE" | cut -d'=' -f2-) - if [ -z "$value" ]; then - echo "FAIL: $field is empty or missing" + read_field() { + grep "^${1}=" "$PROPS_FILE" | cut -d'=' -f2- | sed 's/\\//g' + } + + expect_field() { + local field=$1 expected=$2 actual + actual=$(read_field "$field") + if [ "$actual" != "$expected" ]; then + echo "FAIL: $field is '$actual', expected '$expected'" exit 1 fi - echo "OK: $field=$value" + echo "OK: $field=$actual" + } + + expect_field "git.commit.id" "$(git -C ../obp-api-worktree rev-parse HEAD)" + expect_field "git.branch" "worktree-stamp-probe" + + BUILD_TIME=$(read_field "git.build.time") + if [ -z "$BUILD_TIME" ]; then + echo "FAIL: git.build.time is empty" + exit 1 + fi + echo "OK: git.build.time=$BUILD_TIME" + echo "FIRST_COMMIT=$(read_field git.commit.id)" >> "$GITHUB_ENV" + echo "FIRST_BUILD_TIME=$BUILD_TIME" >> "$GITHUB_ENV" + + # The other half of the bug: the stamp was written once and then reused, so an + # incremental rebuild at a new commit shipped the previous commit's values. + - name: Rebuild at a new commit without clean and verify the stamp moved + working-directory: ../obp-api-worktree + run: | + set -o pipefail + sleep 1 + git -c user.name=ci -c user.email=ci@example.com commit --allow-empty -m "stamp probe" + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn package -DskipTests 2>&1 | tee -a "$GITHUB_WORKSPACE/worktree-build.log" + + PROPS_FILE="obp-api/target/classes/git.properties" + read_field() { + grep "^${1}=" "$PROPS_FILE" | cut -d'=' -f2- | sed 's/\\//g' } - check_field "git.commit.id" - check_field "git.branch" - check_field "git.build.time" + NEW_COMMIT=$(read_field git.commit.id) + NEW_BUILD_TIME=$(read_field git.build.time) + + if [ "$NEW_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "FAIL: after the rebuild git.commit.id is '$NEW_COMMIT', expected '$(git rev-parse HEAD)'" + exit 1 + fi + if [ "$NEW_COMMIT" = "$FIRST_COMMIT" ]; then + echo "FAIL: git.commit.id did not change across commits (stale stamp)" + exit 1 + fi + if [ "$NEW_BUILD_TIME" = "$FIRST_BUILD_TIME" ]; then + echo "FAIL: git.build.time did not change across builds (stale stamp)" + exit 1 + fi + echo "OK: stamp tracks the rebuild — $NEW_COMMIT at $NEW_BUILD_TIME" + + # The stamp belongs to obp-api alone; a second copy elsewhere on the + # classpath makes which one /status reports a coin flip. + FOUND=$(find . -name git.properties -not -path '*/target/lib/*' | sort) + if [ "$FOUND" != "./obp-api/target/classes/git.properties" ]; then + echo "FAIL: expected exactly one git.properties, found:" + echo "$FOUND" + exit 1 + fi + echo "OK: single git.properties on the classpath" + + - name: Verify the jar ships the stamp + working-directory: ../obp-api-worktree + run: | + unzip -p obp-api/target/obp-api.jar git.properties > /tmp/jar-git.properties + cat /tmp/jar-git.properties + JAR_COMMIT=$(grep '^git.commit.id=' /tmp/jar-git.properties | cut -d'=' -f2- | sed 's/\\//g') + if [ "$JAR_COMMIT" != "$(git rev-parse HEAD)" ]; then + echo "FAIL: jar stamp is '$JAR_COMMIT', expected '$(git rev-parse HEAD)'" + exit 1 + fi + echo "OK: obp-api.jar carries the current commit" - name: Upload build log if: always() diff --git a/CLAUDE.md b/CLAUDE.md index 8e3da6a443..177a1fb33a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ ## Working Style - Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. - Never add `Co-Authored-By` trailers to commit messages. +- Commit messages, code comments, and PR titles/descriptions: no AI/tool names (Claude, GPT, Copilot, etc.), no AI-typical filler phrasing ("Certainly!", "I'll help you with..."), no emoji, no "AI-generated"/"LLM" labels. Use plain Conventional Commits style (`fix:`, `feat:`, `refactor:`, ...) and set commit author/committer to the actual person directing the work. - **Goal is full http4s migration** — eliminate Lift Web and all deprecated libraries entirely. Treat Lift code as temporary scaffolding to be removed, not maintained. When fixing bugs or adding features, always prefer the http4s path. - **Versioning is tech-agnostic** — API version numbers reflect API signature changes (new/changed fields, new behaviour), never the underlying framework. A framework migration (Lift → http4s) happens in-place at the existing version; it does not justify a version bump. - **`APIMethodsXYZ.scala` (Lift) files are the source of truth for migration.** The commented-out Lift ResourceDocs and endpoints inside each `APIMethodsXYZ.scala` are the canonical reference for what the http4s version should match: URL templates, verb casing, summaries, descriptions, example bodies, error lists, tags. **Do NOT edit these files to make the parity audit pass.** The audit compares http4s against the Lift source-of-truth — when it flags a diff, the fix is to either (a) update http4s to match Lift, or (b) document the difference at the http4s site as a known intentional drift (e.g. a placeholder rename for `ResourceDocMatcher` middleware, or an upstream-driven case-class shape change). Rewriting the Lift comments to match http4s runs the comparison backwards and destroys the historical record. See `scripts/check_lift_http4s_resource_doc_parity.py` for the audit, and `scripts/rehydrate_resource_docs.py` / `scripts/restore_resource_doc_bodies.py` for the canonical Lift → http4s restoration tools. @@ -265,6 +266,8 @@ Symptoms in tests: a v4-specific assertion fails (e.g. an entitlement should-be- **`isStatisticallyTooPermissive` is sample-pool-dependent**: a fresh local test DB with a single user trips the ABAC-permissiveness check and causes spurious rejections. Seed enough users in any test exercising ABAC rules — it's a test-data issue, not a regression. +**The build stamp comes from a script, not a Maven plugin**: `git.properties` (what `/status` and the root endpoint's `git_commit` report) is written by `scripts/write_git_properties.sh`, invoked from `obp-api/pom.xml`'s `maven-antrun-plugin` execution `generate-git-properties` at `generate-resources`, straight into `target/classes`. It used to be `git-commit-id-maven-plugin`, which was wrong in two ways: its bundled JGit 6.7 has no `commondir` support, so `GitDirLocator.resolveWorktree()` redirects a linked worktree's gitdir to the *main* checkout's `.git` — every build run from `.claude/worktrees/*` stamped the main checkout's branch and commit — and its `PropertiesFileGenerator` skips rewriting when only `git.build.time` differs, freezing the timestamp. Add stamp fields by editing the script (keep the `git.*` key names; `StatusPage.scala` and `APIUtil.gitCommit` read them by name), and don't reintroduce a per-module generator: exactly one `git.properties` may be on the runtime classpath, otherwise which one is reported is incidental. `.github/workflows/test_worktree_build.yml` guards both failure modes. + ## CI (shard map + run tips) Perf note: integration tests are DB/HTTP-bound (~0.4 s/test) on both frameworks; the http4s win is the **pure-unit tier** (no running server, ~0.008 s/test). `ResourceDocsTest`/`SwaggerDocsTest` are the slowest per-test cost — they serialize the whole API surface, so cost grows with endpoint count. `Http4sResourceDocs` already caches the serialized output (`Caching.{getDynamic,getStatic,getAll}ResourceDocCache` + `getStaticSwaggerDocCache`, keyed via `APIUtil.createResourceDocCacheKey`), so repeat requests for the same version/params skip re-serialization. diff --git a/docs/operations/CONSENT_STALE_ACCESS.md b/docs/operations/CONSENT_STALE_ACCESS.md new file mode 100644 index 0000000000..09041b27f0 --- /dev/null +++ b/docs/operations/CONSENT_STALE_ACCESS.md @@ -0,0 +1,177 @@ +# Runbook: "could not revoke ... which consent ... no longer declares" + +## The log line + +``` +WARN code.api.util.Consent$ -- grantAccessToViews: could not revoke owner on gh.29.uk/1 +from user e326edaf-9783-4fe8-8d87-4a65115defdf, which consent 84d5a583-cad9-4f5c-989b-b874d13b877a +no longer declares. The access is still held: Failure(access cannot be revoked) +``` + +Emitted from [`ConsentUtil.scala:455`](../../obp-api/src/main/scala/code/api/util/ConsentUtil.scala#L455). + +## What it means + +Every time a consent is used, `grantAccessToViews` +([`ConsentUtil.scala:419`](../../obp-api/src/main/scala/code/api/util/ConsentUtil.scala#L419)) +reconciles the consent's shadow user against the views the consent's JWT declares: it grants what is +missing and revokes what is no longer declared. + +This line means the revoke half failed. **The access named in the message is still in place**, and +the request was served anyway. + +**It does not self-heal.** Every subsequent use of the consent will retry the same revoke, fail the +same way, and log the same line. The row stays until someone removes it. + +## Why the request still succeeds + +This is deliberate, not an oversight. Failing the request would make the consent unusable +altogether — including every view it still legitimately holds — and leave no way back except +editing rows by hand. + +The two failure directions are not symmetric: + +| | effect | who notices | +|---|---|---| +| a **grant** fails | the consent is denied access it asked for | the caller, immediately (the request fails) | +| a **revoke** fails | the consent keeps access it gave up | nobody — until this log line existed | + +So the request is served and the discrepancy is recorded. The cost is real: **the un-revoked access +keeps serving data the consent does not cover.** In the reproduction for this behaviour, a consent +naming one account returned a *different* account in `/my/accounts`, purely on the strength of the +stale row. Treat the WARN as live over-exposure, not as a cosmetic inconsistency. + +## Why the revoke was refused + +`revokeAccess` ([`MapperViews.scala:270`](../../obp-api/src/main/scala/code/views/MapperViews.scala#L270)) +delegates to `canRevokeOwnerAccess` +([`MapperViews.scala:405`](../../obp-api/src/main/scala/code/views/MapperViews.scala#L405)), which +refuses in exactly two cases, and **only for the `owner` view**: + +1. the user is an account holder on that account (`MapperAccountHolders`), or +2. that `AccountAccess` row is the **only** `owner` row on the account — + `findAllByBankIdAccountIdViewId(...).length > 1` is false + +Any other view id returns `true` unconditionally, so a WARN naming a view other than `owner` means +something outside these two rules failed — read the `Failure(...)` text at the end of the line +rather than following this runbook. + +### Case 2 is a signal about the account, not just the consent + +A shadow user is never an account holder, so in practice you are looking at case 2. And case 2 says +something stronger than "this consent is stuck": + +> **Nobody else holds `owner` on that account — not even the PSU.** + +A healthy account has the PSU's own `owner` row, which makes the count 2 and lets the revoke +succeed. If a consent's shadow user is the *sole* `owner` holder, the account has lost its real +owner access. **Investigate that before deleting anything**, or you will clear the symptom and leave +an account nobody owns. + +## Diagnosis + +Substitute the consent id, user id, bank and account from the log line. + +```sql +-- 1. The offending row. Confirm it exists and is the one named. +SELECT aa.id, aa.bank_id, aa.account_id, aa.view_id, aa.consumer_id, aa.createdat +FROM accountaccess aa +JOIN resourceuser ru ON ru.id = aa.user_fk +WHERE ru.userid_ = '' + AND aa.bank_id = '' AND aa.account_id = '' AND aa.view_id = 'owner'; + +-- 2. Who else holds owner on this account? An empty result besides row 1 is case 2, +-- and is the finding that matters. +SELECT aa.id, ru.userid_, ru.createdbyconsentid, aa.consumer_id +FROM accountaccess aa +JOIN resourceuser ru ON ru.id = aa.user_fk +WHERE aa.bank_id = '' AND aa.account_id = '' AND aa.view_id = 'owner'; + +-- 3. Is this account holder-less too? (the deeper problem, if row 2 came back thin) +SELECT user_c FROM mapperaccountholders +WHERE accountbankpermalink = '' AND accountpermalink = ''; + +-- 4. State of the consent itself. A REVOKED/EXPIRED consent whose row survived is a +-- different fault -- see "If the consent is already gone" below. +SELECT mconsentid, mstatus, mvaliduntil, mlastactiondate +FROM mappedconsent WHERE mconsentid = ''; + +-- 5. Confirm the user really is this consent's shadow user and nothing else. +-- createdbyconsentid must equal the consent in the log; a shadow user is 1:1 with its consent. +SELECT id, userid_, provider_, providerid, createdbyconsentid +FROM resourceuser WHERE userid_ = ''; +``` + +## Resolution + +**Preferred — have the PSU re-authorise.** A fresh consent creates a fresh shadow user, so the stale +row is orphaned rather than reused. This does not remove the row; it stops it being reachable +through a live consent. Follow with the cleanup below. + +**If query 3 showed the account has no holder / no other owner:** fix that first. Restoring the +PSU's own `owner` access makes the count exceed 1, at which point the *next use of the consent +revokes the stale row on its own* and the WARN stops without any manual deletion. This is the only +resolution that lets the code finish its own job — prefer it whenever the account is genuinely +missing its owner. + +**Manual removal**, when the two above do not apply. Take a backup first; there is no undo. + +```sql +-- Verify exactly one row matches BEFORE deleting. +SELECT count(*) FROM accountaccess aa JOIN resourceuser ru ON ru.id = aa.user_fk +WHERE ru.userid_ = '' AND aa.bank_id = '' + AND aa.account_id = '' AND aa.view_id = 'owner'; + +DELETE FROM accountaccess +WHERE id IN ( + SELECT aa.id FROM accountaccess aa JOIN resourceuser ru ON ru.id = aa.user_fk + WHERE ru.userid_ = '' AND aa.bank_id = '' + AND aa.account_id = '' AND aa.view_id = 'owner' +); +``` + +Delete by `id` from a verified `SELECT`. Do not delete by `user_fk` alone: a shadow user legitimately +holds the rows for every account the consent *does* name, and those are the consent's actual grants. + +### If the consent is already revoked + +There is a second, more serious line, from the sweep that runs when a consent is revoked: + +``` +WARN code.api.util.Consent$ -- revokeConsentAccountAccess: could not revoke owner on gh.29.uk/1 +for revoked consent 84d5a583-…. The access outlives the consent: Failure(access cannot be revoked) +``` + +`revokeConsentAccountAccess` +([`ConsentUtil.scala:920`](../../obp-api/src/main/scala/code/api/util/ConsentUtil.scala#L920)) goes +through `revokeAccessToViewForUserAndConsumer`, which applies the same `canRevokeOwnerAccess` rule, +so it can be refused for exactly the reasons above. + +**Treat this as higher priority than the `grantAccessToViews` line.** In that case a live consent is +over-serving, and revoking the consent would still clean up. Here the consent is *already gone* and +the access it created has outlived it — nothing in the system will come back for that row. Manual +removal is the only resolution; the "have the PSU re-authorise" option does not apply. + +> **Note on the neighbouring info line.** `revokeConsentAccountAccess: dropped N account access rows` +> counts successful revokes. Before the fix that added the WARN above, it counted *attempts*, so on +> an affected server it reported rows as dropped that were still present. If you are triaging on a +> build that predates it, do not take that count as evidence the rows are gone — check the table. + +## Verification + +```sql +-- The row is gone. +SELECT count(*) FROM accountaccess aa JOIN resourceuser ru ON ru.id = aa.user_fk +WHERE ru.userid_ = '' AND aa.bank_id = '' AND aa.account_id = ''; +``` + +Then use the consent once and confirm no new WARN appears with that consent id, and that the +account named in the original log line is **absent** from the consent's account listing. The second +check is the one that matters: it is the over-exposure closing, not merely the log going quiet. + +## Monitoring + +Alert on the string `could not revoke` from `code.api.util.Consent$`. Every occurrence is a +consent serving data it does not declare, so this warrants a ticket rather than a dashboard counter. +Because each use of a stuck consent re-logs it, alert on *distinct* consent ids rather than raw line +count — one stuck consent under load produces a large number of identical lines. diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index ac19725593..44b7245df7 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -174,15 +174,24 @@ JAVA_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED \ RUNTIME_LOG=/tmp/obp-api.log if [ "$RUN_BACKGROUND" = true ]; then - # Run in background with output to log file (tee'd to /tmp as well) - nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > >(tee "$RUNTIME_LOG") 2>&1 & + # Run in background with output redirected straight to a log file. Do NOT + # use `> >(tee "$RUNTIME_LOG")` here: process substitution's tee inherits + # this script's own stdout, so a caller that captures this script's output + # via `out=$(./flushall_build_and_run.sh --background ...)` never sees EOF + # — the substitution hangs forever, since the server (and thus the tee + # process backing the substitution) never exits on its own. + nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > "$RUNTIME_LOG" 2>&1 & SERVER_PID=$! + # Report the port the server will actually bind (dev.port in props), so callers + # that capture this script's output (e.g. smoke_test.sh) can parse it out. + SERVER_PORT=$(grep -E '^dev.port=' obp-api/src/main/resources/props/default.props 2>/dev/null | cut -d= -f2) echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" - echo " Log: http4s-server.log (also $RUNTIME_LOG)" + echo " PORT: ${SERVER_PORT:-8080}" + echo " Log: $RUNTIME_LOG" echo "" echo "To stop the server: kill $SERVER_PID" - echo "To view logs: tail -f http4s-server.log" + echo "To view logs: tail -f $RUNTIME_LOG" else # Run in foreground (Ctrl+C to stop). Also tee output to /tmp so it can be # tailed from another terminal without taking over this one. diff --git a/flushall_fast_build_and_run.sh b/flushall_fast_build_and_run.sh index 3b8bd8d4fd..cb12ee4923 100755 --- a/flushall_fast_build_and_run.sh +++ b/flushall_fast_build_and_run.sh @@ -344,8 +344,12 @@ if [ "$RUN_BACKGROUND" = true ]; then # Run in background with output to log file nohup java $JAVA_OPTS -jar obp-api/target/obp-api.jar > http4s-server.log 2>&1 & SERVER_PID=$! + # Report the port the server will actually bind (dev.port in props), so callers + # that capture this script's output (e.g. smoke_test.sh) can parse it out. + SERVER_PORT=$(grep -E '^dev.port=' obp-api/src/main/resources/props/default.props 2>/dev/null | cut -d= -f2) echo "✓ HTTP4S server started in background" echo " PID: $SERVER_PID" + echo " PORT: ${SERVER_PORT:-8080}" echo " Log: http4s-server.log" echo "" echo "To stop the server: kill $SERVER_PID" diff --git a/obp-api/pom.xml b/obp-api/pom.xml index df5266a20a..2ee120e9f1 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -620,6 +620,33 @@ maven-antrun-plugin 3.1.0 + + + generate-git-properties + generate-resources + + run + + + + + + + + + + delete-surefire-xml-after-html verify @@ -704,24 +731,6 @@ - - io.github.git-commit-id - git-commit-id-maven-plugin - 9.0.1 - - - - revision - - - - - ${project.basedir}/.git - true - src/main/resources/git.properties - false - - org.apache.maven.plugins maven-compiler-plugin diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index fce30960d1..b612de45c0 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1299,8 +1299,11 @@ database_messages_scheduler_interval=3600 ReadBalances,\ ReadTransactionsBasic,\ ReadTransactionsDebits,\ + ReadTransactionsCredits,\ ReadTransactionsDetail, \ ReadAccountsBerlinGroup, \ + ReadBalancesBerlinGroup, \ + ReadTransactionsBerlinGroup, \ InitiatePaymentsBerlinGroup # ----------------------------------------------------------------------------- @@ -1860,3 +1863,27 @@ securelogging_mask_email=true ############################################ # messaging.channel.ttl.seconds=3600 # messaging.channel.max.messages=1000 + +# The Consumers that are this ASPSP's own Strong Customer Authentication front end, comma separated +# consumer ids. Two consent standards need this, for the same reason: the approval screen arrives +# under its own Consumer rather than the TPP's, and nothing in the request tells it apart from a +# second TPP holding a PSU session, so it has to be declared. +# +# Berlin Group -- see the note below, which this key supersedes. +# UK Open Banking -- the screen reads the consent to show the PSU which permissions, status and +# expiry they are being asked to approve. Only while the consent is still unclaimed; once a PSU +# is bound to it, a declared front end gets no further than any other caller. +# +# Leave empty unless you run your own SCA screen: empty keeps the lodging-TPP rule applying to +# everyone. The older berlin_group_sca_front_end_consumer_ids below is still read, so an instance +# already configured for Berlin Group Redirect SCA needs no edit. +# sca_front_end_consumer_ids= + +# Berlin Group: the Consumers that are this ASPSP's own Strong Customer Authentication front end, +# comma separated consumer ids. Under the Redirect approach the PSU authenticates at the ASPSP, so +# the authorisation calls on /berlin-group/v1.3/consents/{id}/authorisations arrive from that front +# end rather than from the TPP that lodged the consent -- and the standard's same-TPP rule +# (Implementation Guidelines 4.11) would otherwise refuse them. Nothing in the request tells the +# ASPSP's own front end apart from a second TPP holding a PSU session, so it has to be declared. +# Leave empty unless you use Redirect SCA: empty keeps the same-TPP rule applying to every caller. +# berlin_group_sca_front_end_consumer_ids= diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ce8eac7691..d06ad20323 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -325,6 +325,7 @@ class Boot extends MdcLoggable { SYSTEM_READ_BALANCES_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID, SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID, SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, diff --git a/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala b/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala index 3cfca0a8e9..4cd8491147 100644 --- a/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala +++ b/obp-api/src/main/scala/code/accountapplication/MappedAccountApplication.scala @@ -14,6 +14,9 @@ import scala.concurrent.Future object MappedAccountApplicationProvider extends AccountApplicationProvider { + /** The status every application starts in, and the only status a decision may be taken from. */ + private val RequestedStatus = "REQUESTED" + override def getAll(): Future[Box[List[AccountApplication]]] = Future { tryo{MappedAccountApplication.findAll()} } @@ -25,7 +28,7 @@ object MappedAccountApplicationProvider extends AccountApplicationProvider { override def createAccountApplication(productCode: ProductCode, userId: Option[String], customerId: Option[String]): Future[Box[AccountApplication]] = Future { tryo { - MappedAccountApplication.create.mCode(productCode.value).mUserId(userId.orNull).mCustomerId(customerId.orNull).mStatus("REQUESTED").saveMe() + MappedAccountApplication.create.mCode(productCode.value).mUserId(userId.orNull).mCustomerId(customerId.orNull).mStatus(RequestedStatus).saveMe() } } @@ -36,15 +39,18 @@ object MappedAccountApplicationProvider extends AccountApplicationProvider { case Full(accountApplication) if(accountApplication.status == "ACCEPTED") => Failure(s"${ErrorMessages.AccountApplicationAlreadyAccepted} Current Account-Application-Id($accountApplicationId)") case Full(accountApplication) => - // Optimistic CAS: transition only if the status hasn't changed since we loaded it. Two - // concurrent updates that both read the same status can no longer both write — the loser - // (0 rows) gets a Failure instead of silently overwriting the winner's decision. + // The decision is one-shot: it may only be taken from REQUESTED. Guarding on the fixed + // initial status rather than the one just loaded is what makes that hold. A guard built + // from the loaded status matches whatever a preceding decision wrote, so a REJECTED + // application could be re-decided as ACCEPTED — and the ACCEPTED branch of the endpoint + // opens a bank account, so that overwrite is not recoverable. val rows = code.bankconnectors.DoobieBusinessStatusQueries.conditionalAccountApplicationStatus( - accountApplication.id.get, accountApplication.status, status) + accountApplication.id.get, RequestedStatus, status) if (rows == 1) MappedAccountApplication.find(By(MappedAccountApplication.mAccountApplicationId, accountApplicationId)) - // Use the generic update-failure code here: the concurrent winner may have written any - // status (ACCEPTED or REJECTED), so the "already accepted" message would be misleading. - else Failure(s"${ErrorMessages.UpdateAccountApplicationStatusError} Status changed concurrently. Current Account-Application-Id($accountApplicationId)") + // 0 rows means the application left REQUESTED — either a concurrent decision won the race + // or one was already recorded. Use the generic update-failure code: the winner may have + // written any status, so the "already accepted" message would be misleading. + else Failure(s"${ErrorMessages.UpdateAccountApplicationStatusError} The account application is no longer in $RequestedStatus status. Current Account-Application-Id($accountApplicationId)") case Empty => Failure(s"${ErrorMessages.AccountApplicationNotFound} Current Account-Application-Id($accountApplicationId)") case _ => Failure(ErrorMessages.UnknownError) } diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 696f2af7a5..72e58fcc88 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -5,7 +5,7 @@ import scala.language.implicitConversions import code.api.Constant import code.api.Constant._ import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200 -import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200.{Account, AccountBalancesUKV200, AccountInner, AccountList, Accounts, BalanceJsonUKV200, BalanceUKOpenBankingJson, BankTransactionCodeJson, CreditLineJson, DataJsonUKV200, Links, MetaBisJson, MetaInnerJson, TransactionCodeJson, TransactionInnerJson, TransactionsInnerJson, TransactionsJsonUKV200} +import code.api.UKOpenBanking.v2_0_0.JSONFactory_UKOpenBanking_200.{Account, AmountUKOpenBankingJson, AccountBalancesUKV200, AccountInner, AccountList, Accounts, BalanceJsonUKV200, BalanceUKOpenBankingJson, BankTransactionCodeJson, CreditLineJson, DataJsonUKV200, Links, MetaBisJson, MetaInnerJson, TransactionCodeJson, TransactionInnerJson, TransactionsInnerJson, TransactionsJsonUKV200} import code.api.dynamic.endpoint.helper.practise.PractiseEndpoint import code.api.util.APIUtil.{defaultJValue, _} import code.api.util.ApiRole._ @@ -554,6 +554,12 @@ object SwaggerDefinitionsJSON { amount = "0" ) + // UK Open Banking spells the same two members capitalised, so its examples take its own shape. + lazy val amountUKOpenBankingJson = AmountUKOpenBankingJson( + Amount = "0", + Currency = "EUR" + ) + lazy val transactionRequestTransferToPhone = TransactionRequestTransferToPhone( value = amountOfMoneyJsonV121, description = "String", @@ -4002,7 +4008,7 @@ object SwaggerDefinitionsJSON { ) lazy val balanceUKOpenBankingJson = BalanceUKOpenBankingJson( - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Type = "InterimBooked" ) @@ -4016,7 +4022,7 @@ object SwaggerDefinitionsJSON { AccountId = accountIdSwagger.value, TransactionId = "123", TransactionReference = "Ref 1", - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Status = "Booked", BookingDateTime = DateWithDayExampleObject, @@ -4045,13 +4051,13 @@ object SwaggerDefinitionsJSON { lazy val creditLineJson = CreditLineJson( Included = true, - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, Type = "Pre-Agreed" ) lazy val balanceJsonUK200 = BalanceJsonUKV200( AccountId = "22289", - Amount = amountOfMoneyJsonV121, + Amount = amountUKOpenBankingJson, CreditDebitIndicator = "Credit", Type = "InterimAvailable", DateTime = DateWithDayExampleObject, diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala new file mode 100644 index 0000000000..ea1c2ef96f --- /dev/null +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/UKAmounts.scala @@ -0,0 +1,116 @@ +package code.api.UKOpenBanking + +import code.api.util.{APIUtil, CallContext, OBPQueryParam, OBPTransactionDirection} +import code.model.ModeratedTransaction +import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, User, ViewId} + +/** + * How UK Open Banking writes a signed amount. + * + * The standard splits sign from magnitude: `Amount` is unsigned — `OBActiveCurrencyAndAmount_SimpleType` + * is `^\d{1,13}$|^\d{1,13}\.\d{1,5}$`, which no negative string matches — and the direction is carried + * beside it in `CreditDebitIndicator` (`OBCreditDebitCode`, `Credit` | `Debit`). OBP holds the same fact + * the other way round, as one signed BigDecimal, so every UK response has to split it. + * + * Shared by the v3.1 and v4.0.1 factories: the two used to hardcode `"Credit"` next to a signed amount, + * which reported a debit of 25 as a credit of -25 — wrong in both fields at once. One copy so they + * cannot drift back apart. + */ +object UKAmounts { + + /** `Credit` or `Debit` for a signed amount. Zero is a credit, as the standard says explicitly. */ + def creditDebitIndicator(amount: BigDecimal): String = + if (amount < 0) "Debit" else "Credit" + + def creditDebitIndicator(amount: Option[BigDecimal]): String = + creditDebitIndicator(amount.getOrElse(BigDecimal(0))) + + /** + * The magnitude, as the unsigned decimal string the `Amount` field's pattern allows. + * + * toPlainString, not toString: BigDecimal renders a negative scale in scientific notation, so + * BigDecimal("1E+3").toString is "1E+3" -- which the pattern this exists to satisfy rejects. + */ + def unsignedAmount(amount: BigDecimal): String = amount.abs.bigDecimal.toPlainString + + def unsignedAmount(amount: Option[BigDecimal]): String = + unsignedAmount(amount.getOrElse(BigDecimal(0))) + + /** + * The same split for an amount OBP already holds as a string (balances come through that way). + * An unparseable value is passed through untouched rather than turned into a fabricated zero. + */ + def unsignedAmountString(amount: String): String = + scala.util.Try(BigDecimal(amount)).map(unsignedAmount).getOrElse(amount) + + def creditDebitIndicatorOfString(amount: String): String = + scala.util.Try(BigDecimal(amount)).map(creditDebitIndicator).getOrElse("Credit") + + /** + * Whether a transaction of this amount is inside the directions a consent granted. + * + * `ReadTransactionsCredits` and `ReadTransactionsDebits` are independently selectable Permissions, + * and they restrict which rows come back rather than which fields are visible -- a PSU who grants + * only Credits sees the same fields, on credit rows alone. Granting both, or neither, places no + * direction restriction: neither is the plain `ReadTransactionsBasic`/`Detail` case, both is the + * TPP asking for everything. + * + * Reads the direction through creditDebitIndicator rather than testing the sign again here, so the + * row a response labels `Debit` is exactly the row this admits under Debits. + * + * A missing amount admits nothing. The input is already moderated, so None means the view withheld + * `CAN_SEE_TRANSACTION_AMOUNT` rather than that the amount is zero -- and creditDebitIndicator maps + * None to `Credit` for labelling, which as a permission test would hand every debit to a + * Credits-only consent. There is no direction to check without the amount, so refuse instead: + * a short response is recoverable, a leaked one is not. + */ + def admitsDirection(amount: Option[BigDecimal], grantsCredits: Boolean, grantsDebits: Boolean): Boolean = + if (grantsCredits == grantsDebits) true + else amount.exists(a => creditDebitIndicator(a) == (if (grantsCredits) "Credit" else "Debit")) + + /** + * Whether the caller holds a given direction view on this account. + * + * A soft check: the answer is a fact about the consent's scope, not a refusal. The endpoints + * already resolved a Detail-or-Basic view to read with, so a caller holding neither direction view + * is not an error -- it simply has no direction restriction. + */ + def grantsView( + viewId: String, + bankId: BankId, + accountId: AccountId, + user: User, + callContext: CallContext + ): Boolean = + APIUtil.checkViewAccessAndReturnView( + ViewId(viewId), BankIdAccountId(bankId, accountId), Some(user), Some(callContext)).isDefined + + /** + * The query restriction matching the directions a consent granted, if any. + * + * Pushed into the query so the database applies the direction and the page limit together. + * Filtering an already-limited page instead would hand the TPP a short page it cannot tell from + * the end of the data, and with an offset would make rows unreachable entirely: a Credits-only + * consent on an account whose first page is all debits would see nothing at all. + * + * None when both directions are granted or neither, which is no restriction. + */ + def directionQueryParam(grantsCredits: Boolean, grantsDebits: Boolean): List[OBPQueryParam] = + if (grantsCredits == grantsDebits) Nil + else List(OBPTransactionDirection(credits = grantsCredits)) + + /** + * admitsDirection over a transaction list, shared by the v3.1 and v4.0.1 endpoints. + * + * Still applied after directionQueryParam has narrowed the query, and deliberately so: a + * connector other than the mapped one may ignore the query param, and this is what enforces the + * consent's scope regardless of what the connector chose to return. + */ + def filterByGrantedDirections( + transactions: List[ModeratedTransaction], + grantsCredits: Boolean, + grantsDebits: Boolean + ): List[ModeratedTransaction] = + if (grantsCredits == grantsDebits) transactions + else transactions.filter(t => admitsDirection(t.amount, grantsCredits, grantsDebits)) +} diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala new file mode 100644 index 0000000000..ed55bf4be3 --- /dev/null +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/UKTransactionsQuery.scala @@ -0,0 +1,120 @@ +package code.api.UKOpenBanking + +import code.api.APIFailureNewStyle +import code.api.Constant +import code.api.util.APIUtil.{HTTPParam, createQueriesByHttpParams, fullBoxOrException, passesPsd2Aisp, unboxFull} +import code.api.util.{CallContext, OBPLimit, OBPQueryParam} +import code.api.util.ErrorMessages.UnknownError +import code.api.util.NewStyle +import code.api.util.newstyle.ViewNewStyle +import code.model.{BankAccountExtended, ModeratedTransaction, UserExtended} +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{AccountId, Bank, BankAccount, BankIdAccountId, TransactionAttribute, User, View, ViewId} +import net.liftweb.common.Full +import org.http4s.Request +import cats.effect.IO + +import scala.concurrent.Future + +/** + * Reading an account's transactions under a UK Open Banking consent. + * + * v3.1 and v4.0.1 expose the same read at two paths and serialise it with their own JSON factories, + * but everything before that last step is one procedure: check the consent, resolve Detail-or-Basic, + * turn the request into query params, narrow them by the directions the consent granted, fetch, and + * filter what came back. It lived twice, once per version, and the direction rule then had to be + * written twice as well -- the same shape that let an earlier fix land in one copy and not the other. + * + * The versions keep only what actually differs: their route, and the factory they yield through. + */ +object UKTransactionsQuery extends MdcLoggable { + + /** + * Everything the version-specific `yield` needs, once the shared work is done. + * + * `transactions` is already direction-filtered, and `attributes` was fetched for exactly those + * rows, so a caller cannot accidentally serialise the unfiltered list. + */ + case class Result( + account: BankAccount, + view: View, + transactions: List[ModeratedTransaction], + attributes: List[TransactionAttribute] + ) + + /** + * @param req the http4s request, read for its pagination/filter headers + * @param u the authenticated caller, whose consent scope decides the directions + */ + def read(req: Request[IO], u: User, cc: CallContext, accountId: AccountId): Future[Result] = { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) + for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) + (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) + (bank: Bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + view <- ViewNewStyle.checkViewsAccessAndReturnView( + detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) + params <- Future { + createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) + } map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + // Resolved before the query so the direction can shape it: the database has to apply the + // restriction and the page limit together, or the page is trimmed after the fact and a + // Credits-only consent on a debit-heavy account sees a short page it cannot tell from the end + // of the data. + grantsCredits = UKAmounts.grantsView(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID, account.bankId, accountId, u, cc) + grantsDebits = UKAmounts.grantsView(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID, account.bankId, accountId, u, cc) + directedParams = params ++ UKAmounts.directionQueryParam(grantsCredits, grantsDebits) + (transactions, _) <- BankAccountExtended(account) + .getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), directedParams) map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + // ReadTransactionsCredits / ReadTransactionsDebits restrict which rows the consent may see, + // not which fields, so they are applied here rather than through the view's can_* set. Kept + // alongside the query restriction on purpose: it is what holds the consent's scope when the + // connector ignored the param. + directedTransactions = UKAmounts.filterByGrantedDirections(transactions, grantsCredits, grantsDebits) + _ = warnIfPageWasTrimmed(transactions, directedTransactions, directedParams, cc) + (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( + account.bankId, + directedTransactions.map(_.id), + view.viewId, + Some(cc)) + } yield Result(account, view, directedTransactions, moderatedAttributes) + } + + /** + * Say so when the direction restriction had to be applied after the page limit. + * + * `OBPTransactionDirection` is translated by LocalMappedConnector; the remote connectors take a + * frozen outbound message (see RestConnector_vMar2019_FrozenTest) carrying only limit, offset and + * the date range, so they cannot receive it and return both directions. The filter above then + * removes rows from an already-limited page, and the TPP gets a short page indistinguishable from + * the end of the data -- a Credits-only consent on a debit-heavy account can see nothing at all. + * + * Nothing here can repair that without the connector's cooperation, and silently returning the + * short page is how the defect stayed invisible in the first place. So it is logged: a full page + * that lost rows to the filter is the exact signature, and it tells an operator which connector + * still needs to honour the param. + */ + private def warnIfPageWasTrimmed( + fetched: List[ModeratedTransaction], + kept: List[ModeratedTransaction], + params: List[OBPQueryParam], + cc: CallContext + ): Unit = { + val limit = params.collectFirst { case OBPLimit(value) => value }.getOrElse(Constant.Pagination.limit) + if (kept.size < fetched.size && fetched.size >= limit) { + logger.warn( + s"UK transactions: the direction restriction was applied after the page limit -- " + + s"${fetched.size - kept.size} of $limit rows removed from a full page for consent " + + s"${cc.consumer.map(_.consumerId.get).getOrElse("unknown")}. The connector in use did not " + + s"honour OBPTransactionDirection, so this page is short and the TPP cannot tell. " + + s"Implement the param in that connector to fix the pagination.") + } + } +} diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala index 33fc94c402..0933485bb9 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala @@ -1,11 +1,12 @@ package code.api.UKOpenBanking.v2_0_0 +import code.api.UKOpenBanking.UKAmounts + import java.util.Date import code.api.Constant import code.api.util.APIUtil.DateWithDayExampleObject import code.api.util.CustomJsonFormats -import com.openbankproject.commons.model.AmountOfMoneyJsonV121 import code.model.{ModeratedBankAccount, ModeratedTransaction} import code.model.toBankAccountExtended import com.openbankproject.commons.model.TransactionRequest @@ -51,8 +52,17 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { Issuer: String ) + // UK Open Banking spells this object {"Amount": "...", "Currency": "..."} -- + // OBActiveOrHistoricCurrencyAndAmount, both members capitalised. OBP's shared + // AmountOfMoneyJsonV121 spells the same two in lower case and is used by OBP's own endpoints, so + // it cannot be renamed; the UK responses take their own shape instead. + case class AmountUKOpenBankingJson( + Amount: String, + Currency: String + ) + case class BalanceUKOpenBankingJson( - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String ) @@ -61,7 +71,7 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { AccountId: String, TransactionId: String, TransactionReference: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Status: String, BookingDateTime: Date, @@ -84,13 +94,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { case class CreditLineJson( Included: Boolean, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, Type: String ) case class BalanceJsonUKV200( AccountId: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String, DateTime: Date, @@ -140,11 +150,12 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { AccountId = accountId, TransactionId = transaction.id.value, TransactionReference = transaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse("") , - amount= transaction.amount.getOrElse(BigDecimal(0)).toString() + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(transaction.amount) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicator(transaction.amount), Status = "Booked", BookingDateTime = transaction.startDate.get, ValueDateTime = transaction.finishDate.get, @@ -152,11 +163,11 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), Balance = BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse(""), - amount = transaction.balance + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(transaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(transaction.balance), Type = "InterimBooked" )) ) @@ -199,13 +210,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { val dataJson = DataJsonUKV200( List(BalanceJsonUKV200( AccountId = accountId, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""), moderatedAccount.balance), - CreditDebitIndicator = moderatedAccount.owners.getOrElse(null).head.name, + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance), moderatedAccount.currency.getOrElse("")), + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedAccount.balance), Type = "Credit", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""),moderatedAccount.balance), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance), moderatedAccount.currency.getOrElse("")), Type = "Pre-Agreed" ))))) @@ -221,13 +232,13 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats { val dataJson = DataJsonUKV200( accounts.map(account => BalanceJsonUKV200( AccountId = account.accountId.value, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), - CreditDebitIndicator = account.userOwners.headOption.getOrElse(null).name, + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "Credit", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), Type = "Pre-Agreed" ))))) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala index 03032bc919..225686d143 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310AccountAccess.scala @@ -5,14 +5,15 @@ import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, DateWithDayFormat} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, mockedDataText, passesPsd2Aisp, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.CallContext -import code.api.util.{ConsentJWT, JwtUtil} +import code.api.util.{Consent, ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents +import code.util.Helper import code.util.Helper.MdcLoggable import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -47,24 +48,54 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext for { - u <- cc.user.toOption match { - case Some(user) => Future.successful(user) - case None => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) - } + // Client-credentials lodging: require some authentication (consumer or user) but not a + // PSU specifically; reject only a fully anonymous request. The PSU is bound later at + // authorise time. Mirrors the Berlin Group native consent flow and the v4.0.1 handler. + _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) + Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + else Future.successful(()) + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. + createdByUser = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) consentJson <- Future.fromTry(scala.util.Try( com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) + // Separate step (not inlined into the saveUKConsent call below) so a bad date string + // fails here -> 400. NewStyle.function.tryons (not Future.fromTry) is required for + // that: ErrorResponseConverter only special-cases APIFailureNewStyle to preserve a set + // HTTP code -- tryons wraps failures that way, a bare Future.fromTry(Try(...)) doesn't + // and falls through to unknownErrorToResponse, i.e. 500. + (expirationDateTime, transactionFromDateTime, transactionToDateTime) <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should have valid ISO-8601 ExpirationDateTime/TransactionFromDateTime/TransactionToDateTime values ", 400, Some(cc)) { + ( + consentJson.Data.ExpirationDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionFromDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) + ) + } + // The standard requires the ASPSP to refuse malformed permission combinations with 400 + // rather than create a consent that can never be exercised -- see + // Consent.validateUKConsentPermissions for the rules and why they matter. + _ <- Consent.validateUKConsentPermissions(consentJson.Data.Permissions) match { + case Some(reason) => + Helper.booleanToFuture(s"$InvalidUKConsentPermissions$reason", 400, Some(cc))(false) + case None => Future.successful(true) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( - Some(u), + createdByUser, bankId = None, accountIds = None, consumerId = consumerId, permissions = consentJson.Data.Permissions, - expirationDateTime = DateWithDayFormat.parse(consentJson.Data.ExpirationDateTime), - transactionFromDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionFromDateTime), - transactionToDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionToDateTime), + expirationDateTime = expirationDateTime, + transactionFromDateTime = transactionFromDateTime, + transactionToDateTime = transactionToDateTime, apiStandard = Some("UKOpenBanking"), apiVersion = Some("3.1.0") )) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -83,11 +114,11 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "Status" : "${createdConsent.status}", "StatusUpdateDateTime" : "${createdConsent.statusUpdateDateTime}", "CreationDateTime" : "${createdConsent.creationDateTime}", - "TransactionToDateTime" : "${consentJson.Data.TransactionToDateTime}", - "ExpirationDateTime" : "${consentJson.Data.ExpirationDateTime}", + "TransactionToDateTime" : "${consentJson.Data.TransactionToDateTime.getOrElse("")}", + "ExpirationDateTime" : "${consentJson.Data.ExpirationDateTime.getOrElse("")}", "Permissions" : ${consentJson.Data.Permissions.mkString("[\"", "\",\"", "\"]")}, "ConsentId" : "${createdConsent.consentId}", - "TransactionFromDateTime" : "${consentJson.Data.TransactionFromDateTime}" + "TransactionFromDateTime" : "${consentJson.Data.TransactionFromDateTime.getOrElse("")}" } }""") } @@ -138,17 +169,28 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { }"""), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there + // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. + // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and + // 401s any request that carries no user -- so the endpoint only works today because OAuth2 + // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group + // twin (Http4sBGv13AIS.createConsent), which has always been UserOrApplication. + authMode = UserOrApplication, http4sPartialFunction = Some(createAccountAccessConsents) ) lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (_, cc) => + // Not withUserDelete: the standard has the AISP revoke its own consent with a + // client-credentials token, which carries no PSU. Consent.checkUKConsentAccess decides who + // may revoke it from whichever identity the session does carry. + EndpointHelpers.executeDelete(req) { cc => for { _ <- passesPsd2Aisp(Some(cc)) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -168,16 +210,21 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { EmptyBody, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // As with the POST that lodges the consent: revoking is a client-credentials call in the + // standard's AISP flow, so a PSU cannot be required. See Consent.checkUKConsentAccess. + authMode = UserOrApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV31Prefix` / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (_, cc) => + // Not withUser -- see the DELETE twin above. + EndpointHelpers.executeAndRespond(req) { cc => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } @@ -196,11 +243,11 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { "Status" : "${consent.status}", "StatusUpdateDateTime" : "${consent.statusUpdateDateTime}", "CreationDateTime" : "${consent.creationDateTime}", - "TransactionToDateTime" : "${consent.transactionToDateTime}", - "ExpirationDateTime" : "${consent.expirationDateTime}", + "TransactionToDateTime" : "${Option(consent.transactionToDateTime).getOrElse("")}", + "ExpirationDateTime" : "${Option(consent.expirationDateTime).getOrElse("")}", "Permissions" : ${consentViews.mkString("[\"", "\",\"", "\"]")}, "ConsentId" : "${consent.consentId}", - "TransactionFromDateTime" : "${consent.transactionFromDateTime}" + "TransactionFromDateTime" : "${Option(consent.transactionFromDateTime).getOrElse("")}" } }""") } @@ -242,6 +289,8 @@ object Http4sUKOBv310AccountAccess extends MdcLoggable { }"""), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access") :: Nil, + // As above: the AISP polls its own consent with a client-credentials token. + authMode = UserOrApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala index 5065276be6..c40f63c8c0 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Accounts.scala @@ -107,6 +107,8 @@ object Http4sUKOBv310Accounts extends MdcLoggable { val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) map { _.filter(_.accountId.value == accountId.value) } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala index 5d9b95801a..80826b6686 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Balances.scala @@ -28,7 +28,8 @@ import scala.collection.mutable.ArrayBuffer /** * UK Open Banking v3.1 — BalancesApi, migrated from Lift to http4s. * getAccountsAccountIdBalances: real business logic with UK consent check. - * getBalances: real business logic, no consent check (mirrors Lift). + * getBalances: real business logic, UK consent check (kept consistent with the v4.0.1 + * equivalent -- see Http4sUKOBv401AccountInfo.getBalances). */ object Http4sUKOBv310Balances extends MdcLoggable { type HttpF[A] = OptionT[IO, A] @@ -135,10 +136,22 @@ object Http4sUKOBv310Balances extends MdcLoggable { lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV31Prefix` / "balances" => EndpointHelpers.withUser(req) { (u, cc) => + val balancesViewId = ViewId(Constant.SYSTEM_READ_BALANCES_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) - } yield JSONFactory_UKOpenBanking_310.createBalancesJSON(accounts) + } yield { + // See the v4.0.1 twin: holding some view on an account does not make its balance readable, + // and this endpoint used to answer for every account the caller could see regardless of + // what the consent asked for. Filter on the same view the per-account endpoint checks. + val readable = accounts.filter { account => + code.api.util.APIUtil.checkViewAccessAndReturnView( + balancesViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).isDefined + } + JSONFactory_UKOpenBanking_310.createBalancesJSON(readable) + } } } resourceDocs += ResourceDoc( diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala index 682cc3cce9..edb8cfdb6b 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/Http4sUKOBv310Transactions.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.UKOpenBanking.{UKAmounts, UKTransactionsQuery} + import org.json4s._ import cats.data.{Kleisli, OptionT} import cats.effect.IO @@ -37,7 +39,8 @@ import scala.concurrent.Future * Http4sUKOBv310Statements (which takes precedence in the Lift priority order). * This file owns: * - getAccountsAccountIdTransactions: real business logic with UK consent check. - * - getTransactions: real business logic, bulk (no consent check, mirrors Lift). + * - getTransactions: real business logic, bulk, UK consent check (kept consistent with + * the v4.0.1 equivalent -- see Http4sUKOBv401AccountInfo.getTransactions). */ object Http4sUKOBv310Transactions extends MdcLoggable { type HttpF[A] = OptionT[IO, A] @@ -305,28 +308,11 @@ object Http4sUKOBv310Transactions extends MdcLoggable { case req @ GET -> `ukV31Prefix` / "accounts" / accountIdStr / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => val accountId = AccountId(accountIdStr) - val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) - val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) - for { - _ <- NewStyle.function.checkUKConsent(u, Some(cc)) - _ <- passesPsd2Aisp(Some(cc)) - (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) - (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) - view <- ViewNewStyle.checkViewsAccessAndReturnView(detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) - params <- Future { - createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) - } map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (transactions, _) <- BankAccountExtended(account).getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), params) map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( - account.bankId, - transactions.map(_.id), - view.viewId, - Some(cc)) - } yield JSONFactory_UKOpenBanking_310.createTransactionsJsonNew(account.bankId, transactions, moderatedAttributes, view) + // The read itself is shared with v4.0.1 -- see UKTransactionsQuery. Only the factory differs. + UKTransactionsQuery.read(req, u, cc, accountId) map { result => + JSONFactory_UKOpenBanking_310.createTransactionsJsonNew( + result.account.bankId, result.transactions, result.attributes, result.view) + } } } resourceDocs += ResourceDoc( @@ -574,13 +560,23 @@ object Http4sUKOBv310Transactions extends MdcLoggable { case req @ GET -> `ukV31Prefix` / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) (bank, _) <- NewStyle.function.getBank(BankId(defaultBankId), Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) allTxns <- Future { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) accounts.flatMap { bankAccount => (for { - view <- UserExtended(u).checkOwnerViewAccessAndReturnOwnerView(BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc)) + // The owner view, which this used to gate on, comes from holding the account and so + // says nothing about what a consent permits -- it let a consent that never asked for + // transactions read them, and moderated them as the owner rather than as the granted + // view. Gate on the transaction views the consent actually grants, exactly as the + // per-account endpoint above does. + view <- code.api.util.APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc)) + .or(code.api.util.APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc))) params = createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))).getOrElse(Nil) (transactions, _) <- BankAccountExtended(bankAccount).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc), params) } yield transactions).getOrElse(Nil) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala index 396aa2fdfc..6d371a675f 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v3_1_0/JSONFactory_UKOpenBanking_310.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.UKOpenBanking.UKAmounts + import org.json4s._ import java.util.Date @@ -7,7 +9,7 @@ import code.api.Constant import code.api.util.APIUtil.DateWithDayExampleObject import code.api.util.CustomJsonFormats import code.model.{ModeratedBankAccount, ModeratedBankAccountCore, ModeratedTransaction} -import com.openbankproject.commons.model.{AccountAttribute, AccountId, AmountOfMoneyJsonV121, BankAccount, BankId, TransactionAttribute, TransactionId, TransactionRequest, View} +import com.openbankproject.commons.model.{AccountAttribute, AccountId, BankAccount, BankId, TransactionAttribute, TransactionId, TransactionRequest, View} import org.json4s.JsonAST.JObject import scala.collection.immutable.List @@ -72,8 +74,18 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { Issuer: String ) + // UK Open Banking spells this object {"Amount": "...", "Currency": "..."} -- + // OBActiveOrHistoricCurrencyAndAmount, both members capitalised. OBP's shared + // AmountOfMoneyJsonV121 spells the same two in lower case and is used by OBP's own endpoints, so + // it cannot be renamed; the UK responses take their own shape instead, as v4.0.1 already does + // with AmountV401. + case class AmountUKOpenBankingJson( + Amount: String, + Currency: String + ) + case class BalanceUKOpenBankingJson( - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String = "ClosingAvailable" ) @@ -85,7 +97,7 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate: Int, ContractIdentification: String, QuotationDate: Date, - InstructedAmount: AmountOfMoneyJsonV121 + InstructedAmount: AmountUKOpenBankingJson ) case class CardInstrumentJson( @@ -132,13 +144,15 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { TransactionId: String, TransactionReference: String, StatementReference: List[String] = List("String"), - Amount: AmountOfMoneyJsonV121, - CreditDebitIndicator: String ="Credit", + Amount: AmountUKOpenBankingJson, + // No default: the direction has to be derived from the amount at every construction site + // (see UKAmounts). A default is how "Credit" ended up on every debit. + CreditDebitIndicator: String, Status: String ="Booked", BookingDateTime: Date, ValueDateTime: Date, AddressLine: String = "String", - ChargeAmount: AmountOfMoneyJsonV121, + ChargeAmount: AmountUKOpenBankingJson, TransactionInformation: String, CurrencyExchange:CurrencyExchangeJson, BankTransactionCode: BankTransactionCodeJson, @@ -165,13 +179,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { case class CreditLineJson( Included: Boolean, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, Type: String ) case class BalanceJsonUKV310( AccountId: String, - Amount: AmountOfMoneyJsonV121, + Amount: AmountUKOpenBankingJson, CreditDebitIndicator: String, Type: String, DateTime: Date, @@ -204,11 +218,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { Links: LinksV310, Risk: String ) + // The three datetimes are 0..1 (open-ended if absent) per the UK spec's OBReadConsent1 — + // shared by the v3.1.0 and v4.0.1 consent-creation handlers. case class ConsentPostBodyDataUKV310( - TransactionToDateTime: String, - ExpirationDateTime: String, + TransactionToDateTime: Option[String], + ExpirationDateTime: Option[String], Permissions: List[String], - TransactionFromDateTime: String + TransactionFromDateTime: Option[String] ) case class ConsentPostBodyUKV310( Data: ConsentPostBodyDataUKV310, @@ -311,12 +327,14 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { accountId, transaction.id.value, TransactionReference = transaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse("") , - amount= transaction.amount.getOrElse(BigDecimal(0)).toString()), + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(transaction.amount)), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(transaction.amount), BookingDateTime = transaction.startDate.get, ValueDateTime = transaction.finishDate.get, - ChargeAmount = AmountOfMoneyJsonV121(transaction.currency.getOrElse(""),"0"), + ChargeAmount = AmountUKOpenBankingJson("0", transaction.currency.getOrElse("")), TransactionInformation = transaction.description.getOrElse(""), CurrencyExchange = CurrencyExchangeJson( SourceCurrency = transaction.bankAccount.map(_.currency).flatten.getOrElse(""), @@ -325,16 +343,16 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate = 0, ContractIdentification = "string", QuotationDate = new Date(), - InstructedAmount = AmountOfMoneyJsonV121(transaction.bankAccount.map(_.currency).flatten.getOrElse(""),"")), + InstructedAmount = AmountUKOpenBankingJson("", transaction.bankAccount.map(_.currency).flatten.getOrElse(""))), BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), CardInstrument = CardInstrumentJson(), Balance =BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = transaction.currency.getOrElse(""), - amount = transaction.balance + Amount = AmountUKOpenBankingJson( + Currency = transaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(transaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(transaction.balance), Type = "InterimBooked" ) ) @@ -383,12 +401,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { accountId, moderatedTransaction.id.value, TransactionReference = moderatedTransaction.description.getOrElse(""), - Amount = AmountOfMoneyJsonV121( - currency = moderatedTransaction.currency.getOrElse("") , - amount= moderatedTransaction.amount.getOrElse(BigDecimal(0)).toString()), + Amount = AmountUKOpenBankingJson( + Currency = moderatedTransaction.currency.getOrElse("") , + Amount = UKAmounts.unsignedAmount(moderatedTransaction.amount)), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(moderatedTransaction.amount), BookingDateTime = moderatedTransaction.startDate.get, ValueDateTime = moderatedTransaction.finishDate.get, - ChargeAmount = AmountOfMoneyJsonV121(moderatedTransaction.currency.getOrElse(""),"0"), + ChargeAmount = AmountUKOpenBankingJson("0", moderatedTransaction.currency.getOrElse("")), TransactionInformation = moderatedTransaction.description.getOrElse(""), CurrencyExchange = CurrencyExchangeJson( SourceCurrency = moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""), @@ -397,16 +416,16 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { ExchangeRate = 0, ContractIdentification = "string", QuotationDate = new Date(), - InstructedAmount = AmountOfMoneyJsonV121(moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""),"")), + InstructedAmount = AmountUKOpenBankingJson("", moderatedTransaction.bankAccount.map(_.currency).flatten.getOrElse(""))), BankTransactionCode = BankTransactionCodeJson("",""), ProprietaryBankTransactionCode = TransactionCodeJson("Transfer", "AlphaBank"), CardInstrument = CardInstrumentJson(), Balance =BalanceUKOpenBankingJson( - Amount = AmountOfMoneyJsonV121( - currency = moderatedTransaction.currency.getOrElse(""), - amount = moderatedTransaction.balance + Amount = AmountUKOpenBankingJson( + Currency = moderatedTransaction.currency.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(moderatedTransaction.balance) ), - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedTransaction.balance), Type = "InterimBooked" ), MerchantDetails = getMerchantDetails(moderatedTransaction) @@ -434,13 +453,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { val dataJson = DataJsonUKV310( List(BalanceJsonUKV310( AccountId = accountId, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""), moderatedAccount.balance.getOrElse("")), - CreditDebitIndicator = "Credit", + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance.getOrElse("")), moderatedAccount.currency.getOrElse("")), + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedAccount.balance.getOrElse("")), Type = "ClosingAvailable", DateTime = null, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(moderatedAccount.currency.getOrElse(""),moderatedAccount.balance.getOrElse("")), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmountString(moderatedAccount.balance.getOrElse("")), moderatedAccount.currency.getOrElse("")), Type = "Pre-Agreed" ))))) @@ -465,13 +484,13 @@ object JSONFactory_UKOpenBanking_310 extends CustomJsonFormats { val dataJson = DataJsonUKV310( accounts.map(account => BalanceJsonUKV310( AccountId = account.accountId.value, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), - CreditDebitIndicator = "Credit", + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "ClosingAvailable", DateTime = account.lastUpdate, CreditLine = List(CreditLineJson( Included = true, - Amount = AmountOfMoneyJsonV121(account.currency, account.balance.toString()), + Amount = AmountUKOpenBankingJson(UKAmounts.unsignedAmount(account.balance), account.currency), Type = "Available" ))))) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala index 4fe1f01288..c40362d530 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401.scala @@ -2,11 +2,13 @@ package code.api.UKOpenBanking.v4_0_1 import cats.data.{Kleisli, OptionT} import cats.effect._ +import code.api.util.APIUtil import code.api.util.APIUtil.ResourceDoc import code.api.util.http4s.ResourceDocMiddleware import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.ApiVersion import org.http4s._ +import org.typelevel.ci.CIString import scala.collection.mutable.ArrayBuffer @@ -46,5 +48,19 @@ object Http4sUKOBv401 extends MdcLoggable { .orElse(Http4sUKOBv401Vrp.routes(req)) } - val wrappedRoutes: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + private val fapiInteractionIdHeader = CIString("x-fapi-interaction-id") + + // FAPI: every response carries x-fapi-interaction-id -- echoed from the request if the TPP sent + // one (for end-to-end tracing), otherwise generated as a fresh UUID so tracing works even when + // the TPP omits it. Applied as an outer middleware so it covers every UK v4.0.1 response, + // including error responses produced inside ResourceDocMiddleware. + private def withFapiInteractionId(routes: HttpRoutes[IO]): HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req => + val interactionId = req.headers.get(fapiInteractionIdHeader) + .map(_.head.value) + .getOrElse(APIUtil.generateUUID()) + routes(req).map(_.putHeaders(Header.Raw(fapiInteractionIdHeader, interactionId))) + } + + val wrappedRoutes: HttpRoutes[IO] = withFapiInteractionId(ResourceDocMiddleware.apply(resourceDocs)(allRoutes)) } diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index 3d02a40b4c..949d31bc27 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -1,20 +1,23 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.UKOpenBanking.{UKAmounts, UKTransactionsQuery} + import cats.data.{Kleisli, OptionT} import cats.effect.IO import code.api.APIFailureNewStyle import code.api.Constant import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, DateWithDayFormat} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, parseIso8601OrDayDate} import code.api.util.ApiTag import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, UnknownError} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, InvalidJsonFormat, InvalidUKConsentPermissions, UnknownError} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.ViewNewStyle -import code.api.util.{APIUtil, ConsentJWT, JwtUtil, NewStyle} +import code.api.util.{APIUtil, Consent, ConsentJWT, JwtUtil, NewStyle} import code.consent.Consents import code.model.{BankAccountExtended, UserExtended} +import code.util.Helper import code.util.Helper.MdcLoggable import code.views.Views import com.github.dwickern.macros.NameOf.nameOf @@ -94,24 +97,57 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext for { - u <- cc.user.toOption match { - case Some(user) => Future.successful(user) - case None => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) - } + // Spec Step 2: the TPP lodges the consent via a client-credentials grant -- authenticated + // as an app (consumer) but with no PSU yet. Require some authentication (a consumer or a + // user) but not a PSU specifically; reject only a fully anonymous request. The PSU is + // bound later at authorise time (mUserId stays null until then), mirroring the Berlin + // Group native consent flow (Http4sBGv13AIS.createConsent). A request with a real user + // (e.g. DirectLogin) still works -- createdByUser just carries it through. + _ <- if (cc.user.isEmpty && cc.consumer.isEmpty) + Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + else Future.successful(()) + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. + createdByUser = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) consentJson <- Future.fromTry(scala.util.Try( JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] )) + // Separate step (not inlined into the saveUKConsent call below) so a bad date string + // fails here -> 400. NewStyle.function.tryons (not Future.fromTry) is required for + // that: ErrorResponseConverter only special-cases APIFailureNewStyle to preserve a set + // HTTP code -- tryons wraps failures that way, a bare Future.fromTry(Try(...)) doesn't + // and falls through to unknownErrorToResponse, i.e. 500. + (expirationDateTime, transactionFromDateTime, transactionToDateTime) <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should have valid ISO-8601 ExpirationDateTime/TransactionFromDateTime/TransactionToDateTime values ", 400, Some(cc)) { + ( + consentJson.Data.ExpirationDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionFromDateTime.map(parseIso8601OrDayDate), + consentJson.Data.TransactionToDateTime.map(parseIso8601OrDayDate) + ) + } + // The standard requires the ASPSP to refuse malformed permission combinations with 400 + // rather than create a consent that can never be exercised -- see + // Consent.validateUKConsentPermissions for the rules and why they matter. + _ <- Consent.validateUKConsentPermissions(consentJson.Data.Permissions) match { + case Some(reason) => + Helper.booleanToFuture(s"$InvalidUKConsentPermissions$reason", 400, Some(cc))(false) + case None => Future.successful(true) + } consumerId = cc.consumer.map(_.consumerId.get) _ <- passesPsd2Aisp(Some(cc)) createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( - Some(u), + createdByUser, bankId = None, accountIds = None, consumerId = consumerId, permissions = consentJson.Data.Permissions, - expirationDateTime = DateWithDayFormat.parse(consentJson.Data.ExpirationDateTime), - transactionFromDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionFromDateTime), - transactionToDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionToDateTime), + expirationDateTime = expirationDateTime, + transactionFromDateTime = transactionFromDateTime, + transactionToDateTime = transactionToDateTime, apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") )) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -141,6 +177,13 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_createAccountAccessConsents), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // Consent lodging is a client-credentials call: the TPP is authenticated as an app, and there + // is no PSU yet. The handler above already says so; this makes the ResourceDoc say it too. + // Without it the doc defaults to UserOnly, which sends the middleware down anonymousAccess and + // 401s any request that carries no user -- so the endpoint only works today because OAuth2 + // token parsing auto-vivifies a user for a client-credentials token. Matches the Berlin Group + // twin (Http4sBGv13AIS.createConsent), which has always been UserOrApplication. + authMode = UserOrApplication, http4sPartialFunction = Some(createAccountAccessConsents) ) @@ -180,11 +223,15 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (_, cc) => + // Not withUser: the standard has the AISP poll its own consent with a client-credentials + // token, which carries no PSU. Consent.checkUKConsentAccess decides who may read it from + // whichever identity the session does carry. + EndpointHelpers.executeAndRespond(req) { cc => for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } @@ -195,9 +242,9 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { status = consent.status, statusUpdateDateTime = consent.statusUpdateDateTime.toString, permissions = consentViews, - expirationDateTime = consent.expirationDateTime.toString, - transactionFromDateTime = consent.transactionFromDateTime.toString, - transactionToDateTime = consent.transactionToDateTime.toString, + expirationDateTime = Option(consent.expirationDateTime).map(_.toString), + transactionFromDateTime = Option(consent.transactionFromDateTime).map(_.toString), + transactionToDateTime = Option(consent.transactionToDateTime).map(_.toString), selfPath = s"/aisp/account-access-consents/$consentId" ) } @@ -214,18 +261,23 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_getAccountAccessConsentsConsentId), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // Same reasoning as the POST that lodges the consent: the AISP polls it with a + // client-credentials token, so a PSU cannot be required. See Consent.checkUKConsentAccess. + authMode = UserOrApplication, http4sPartialFunction = Some(getAccountAccessConsentsConsentId) ) private val EX_deleteAccountAccessConsentsConsentId: String = """{}""" lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUserDelete(req) { (_, cc) => + // Not withUserDelete -- see the GET twin above. + EndpointHelpers.executeDelete(req) { cc => for { _ <- passesPsd2Aisp(Some(cc)) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, Some(cc), ConsentNotFound) } + _ <- Consent.assertUKConsentAccess(consent.userId, consent.consumerId, cc) _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { i => connectorEmptyResponse(i, Some(cc)) } @@ -243,6 +295,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { parseBody(EX_deleteAccountAccessConsentsConsentId), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Access Consents") :: Nil, + // As above: revoking is a client-credentials call in the standard's AISP flow. + authMode = UserOrApplication, http4sPartialFunction = Some(deleteAccountAccessConsentsConsentId) ) @@ -435,6 +489,8 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) map { _.filter(_.accountId.value == accountId.value) } @@ -2215,28 +2271,11 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountIdStr / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => val accountId = AccountId(accountIdStr) - val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) - val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) - for { - _ <- NewStyle.function.checkUKConsent(u, Some(cc)) - _ <- passesPsd2Aisp(Some(cc)) - (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) - (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) - view <- ViewNewStyle.checkViewsAccessAndReturnView(detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) - params <- Future { - createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) - } map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (transactions, _) <- BankAccountExtended(account).getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), params) map { x => - unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) - } - (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( - account.bankId, - transactions.map(_.id), - view.viewId, - Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(account.bankId, transactions, moderatedAttributes, view) + // The read itself is shared with v3.1 -- see UKTransactionsQuery. Only the factory differs. + UKTransactionsQuery.read(req, u, cc, accountId) map { result => + JSONFactory_UKOpenBanking_401.createTransactionsJsonNew( + result.account.bankId, accountId.value, result.transactions, result.attributes, result.view) + } } } resourceDocs += ResourceDoc( @@ -2304,10 +2343,23 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "balances" => EndpointHelpers.withUser(req) { (u, cc) => + val balancesViewId = ViewId(Constant.SYSTEM_READ_BALANCES_VIEW_ID) for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) - } yield JSONFactory_UKOpenBanking_401.createBalancesJSON(accounts) + } yield { + // Holding some view on an account is not the same as being allowed to read its balance: + // ReadBalances is a permission a consent may simply never have asked for. The per-account + // balances endpoint checks it, this one did not -- so it answered for every account the + // caller could see, whatever the consent said. Filter on the same view it does. + val readable = accounts.filter { account => + APIUtil.checkViewAccessAndReturnView( + balancesViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).isDefined + } + JSONFactory_UKOpenBanking_401.createBalancesJSON(readable) + } } } resourceDocs += ResourceDoc( @@ -3446,13 +3498,23 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { case req @ GET -> `ukV401Prefix` / "aisp" / "transactions" => EndpointHelpers.withUser(req) { (u, cc) => for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) (bank, _) <- NewStyle.function.getBank(BankId(defaultBankId), Some(cc)) availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) allTxns <- Future { + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) accounts.flatMap { bankAccount => (for { - view <- UserExtended(u).checkOwnerViewAccessAndReturnOwnerView(BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc)) + // The owner view, which this used to gate on, comes from holding the account and so + // says nothing about what a consent permits -- it let a consent that never asked for + // transactions read them, and moderated them as the owner rather than as the granted + // view. Gate on the transaction views the consent actually grants, exactly as the + // per-account endpoint does. + view <- APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc)) + .or(APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Full(u), Some(cc))) params = createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))).getOrElse(Nil) (transactions, _) <- BankAccountExtended(bankAccount).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc), params) } yield transactions).getOrElse(Nil) diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala index f8fb2ec4ce..4248923617 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -1,5 +1,7 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.UKOpenBanking.UKAmounts + import java.util.Date import code.api.Constant @@ -114,7 +116,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { TransactionId: String, TransactionReference: Option[String] = None, StatementReference: List[String] = Nil, - CreditDebitIndicator: String = "Credit", + // No default: the direction has to be derived from the amount at every construction site + // (see UKAmounts). A default is how "Credit" ended up on every debit. + CreditDebitIndicator: String, Status: String = "BOOK", TransactionMutability: String = "Mutable", BookingDateTime: Date, @@ -132,15 +136,20 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { // --------------------------------------------------------------------- // Consent // --------------------------------------------------------------------- + case class StatusReasonV401( + StatusReasonCode: String, + StatusReasonDescription: String + ) case class ConsentDataV401( ConsentId: String, CreationDateTime: String, Status: String, + StatusReason: List[StatusReasonV401], StatusUpdateDateTime: String, Permissions: List[String], - ExpirationDateTime: String, - TransactionFromDateTime: String, - TransactionToDateTime: String + ExpirationDateTime: Option[String], + TransactionFromDateTime: Option[String], + TransactionToDateTime: Option[String] ) case class ConsentResponseV401( Data: ConsentDataV401, @@ -215,13 +224,14 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createAccountBalanceJSON(moderatedAccount: ModeratedBankAccountCore): BalancesUKV401 = { val accountId = moderatedAccount.accountId.value + val rawBalance = moderatedAccount.balance.getOrElse("") val amount = AmountV401( - Amount = moderatedAccount.balance.getOrElse(""), + Amount = UKAmounts.unsignedAmountString(rawBalance), Currency = moderatedAccount.currency.getOrElse("") ) val balance = BalanceV401( AccountId = accountId, - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(rawBalance), Type = "ClosingAvailable", DateTime = None, Amount = amount, @@ -236,10 +246,10 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createBalancesJSON(accounts: List[BankAccount]): BalancesUKV401 = { val balances = accounts.map { account => - val amount = AmountV401(Amount = account.balance.toString(), Currency = account.currency) + val amount = AmountV401(Amount = UKAmounts.unsignedAmount(account.balance), Currency = account.currency) BalanceV401( AccountId = account.accountId.value, - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicator(account.balance), Type = "ClosingAvailable", DateTime = Some(account.lastUpdate), Amount = amount, @@ -271,8 +281,9 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { case _ => None } + // UK keeps the sign out of Amount and in CreditDebitIndicator; OBP holds one signed number. val amount = AmountV401( - Amount = moderatedTransaction.amount.getOrElse(BigDecimal(0)).toString(), + Amount = UKAmounts.unsignedAmount(moderatedTransaction.amount), Currency = moderatedTransaction.currency.getOrElse("") ) TransactionV401( @@ -283,10 +294,13 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { BookingDateTime = moderatedTransaction.startDate.get, ValueDateTime = moderatedTransaction.finishDate.get, Amount = amount, + CreditDebitIndicator = UKAmounts.creditDebitIndicator(moderatedTransaction.amount), Balance = Some(TransactionBalanceV401( - CreditDebitIndicator = "Credit", + CreditDebitIndicator = UKAmounts.creditDebitIndicatorOfString(moderatedTransaction.balance), Type = "InterimBooked", - Amount = AmountV401(Amount = moderatedTransaction.balance, Currency = moderatedTransaction.currency.getOrElse("")) + Amount = AmountV401( + Amount = UKAmounts.unsignedAmountString(moderatedTransaction.balance), + Currency = moderatedTransaction.currency.getOrElse("")) )), MerchantDetails = getMerchantDetails ) @@ -294,11 +308,11 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { def createTransactionsJsonNew( bankId: BankId, + accountId: String, moderatedTransactions: List[ModeratedTransaction], attributes: List[TransactionAttribute], view: View ): TransactionsUKV401 = { - val accountId = moderatedTransactions.headOption.flatMap(_.bankAccount.map(_.accountId.value)).orNull val transactions = moderatedTransactions.map(t => transactionJson(accountId, bankId, t, attributes, Some(view))) TransactionsUKV401( Data = TransactionDataV401(transactions), @@ -319,22 +333,49 @@ object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { ) } + // v4.0.1 uses ISO 20022-style four-letter status codes on the wire (AWAU/AUTH/RJCT/CANC/EXPD), + // whereas OBP stores the long enum names (AWAITINGAUTHORISATION/AUTHORISED/REJECTED/REVOKED/EXPIRED). + // Map at the serialization boundary only — storage keeps the long names. REVOKED maps to CANC + // because the spec's revoke-side status is CANC and OBP does not distinguish dashboard-cancel from + // AISP-DELETE-revoke. Unknown/other statuses pass through unchanged so nothing is silently hidden. + def ukConsentStatusCode(status: String): String = status.toUpperCase match { + case "AWAITINGAUTHORISATION" => "AWAU" + case "AUTHORISED" => "AUTH" + case "REJECTED" => "RJCT" + case "REVOKED" => "CANC" + case "EXPIRED" => "EXPD" + case _ => status + } + + // Minimal StatusReason per the current status, mirroring the spec's own example wording/codes + // (OBReadConsentResponse1). Richer per-transition reasons can follow later. + private def ukConsentStatusReason(fourLetterCode: String): List[StatusReasonV401] = fourLetterCode match { + case "AWAU" => List(StatusReasonV401("U036", "Waiting for completion of consent authorisation to be completed by user")) + case "AUTH" => List(StatusReasonV401("U110", "The account access consent has been successfully authorised")) + case "RJCT" => List(StatusReasonV401("U111", "The account access consent has been rejected")) + case "CANC" => List(StatusReasonV401("U112", "The account access consent has been cancelled")) + case "EXPD" => List(StatusReasonV401("U113", "The account access consent has passed its expiry date")) + case _ => Nil + } + def createConsentResponseJSON( consentId: String, creationDateTime: String, status: String, statusUpdateDateTime: String, permissions: List[String], - expirationDateTime: String, - transactionFromDateTime: String, - transactionToDateTime: String, + expirationDateTime: Option[String], + transactionFromDateTime: Option[String], + transactionToDateTime: Option[String], selfPath: String ): ConsentResponseV401 = { + val statusCode = ukConsentStatusCode(status) ConsentResponseV401( Data = ConsentDataV401( ConsentId = consentId, CreationDateTime = creationDateTime, - Status = status, + Status = statusCode, + StatusReason = ukConsentStatusReason(statusCode), StatusUpdateDateTime = statusUpdateDateTime, Permissions = permissions, ExpirationDateTime = expirationDateTime, diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala index 711e63b4e9..b7bc9acf82 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala @@ -9,12 +9,14 @@ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.model._ import code.api.berlin.group.v1_3.{BgSpecValidation, JSONFactory_BERLIN_GROUP_1_3, JvalueCaseClass} +import code.api.RequestHeader +import code.api.util.APIUtil import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, connectorEmptyResponse, createQueriesByHttpParams, fullBoxOrException, getHttpRequestUrlParam, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Aisp, unboxFull, unboxFullOrFail} import code.api.util.CallContext import code.api.util.ApiTag._ import code.api.util.CustomJsonFormats import code.api.util.ErrorMessages._ -import code.api.util.{ApiTag, NewStyle} +import code.api.util.{ApiTag, Consent, NewStyle} import code.api.util.newstyle.ViewNewStyle import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.consent.{ConsentStatus, Consents} @@ -58,7 +60,12 @@ object Http4sBGv13AIS extends MdcLoggable { Future { Helper.booleanToBox(u.hasViewAccess(BankIdAccountId(account.bankId, account.accountId), viewId, callContext)) } map { - unboxFullOrFail(_, callContext, s"$NoViewReadAccountsBerlinGroup ${viewId.value} userId : ${u.userId}. account : ${account.accountId}", 403) + // No user id in the message. Under consent authentication `u` is the consent's own shadow + // user -- an internal identifier the TPP has no business learning and cannot act on. What + // the refusal is actually about is the view and the account, both of which the caller + // already named. The user id stays in the logs for anyone diagnosing it. + unboxFullOrFail(_, callContext, + s"$NoViewReadAccountsBerlinGroup ${viewId.value}. account : ${account.accountId}", 403) } } @@ -68,10 +75,13 @@ object Http4sBGv13AIS extends MdcLoggable { EndpointHelpers.executeFutureCreated(req) { val cc = req.callContext val callContext = Some(cc) - val createdByUser: Option[User] = cc.user match { - case Full(user) => Some(user) - case _ => None - } + // A pure client-credentials token still resolves cc.user to an auto-vivified + // pseudo-user (idGivenByProvider == the calling consumer's own client key) rather than + // leaving it Empty -- that pseudo-user is not a PSU, so it must not become the + // consent's owner (it would permanently block the real PSU's authorise-time + // ConsentDoesNotMatchUser check). Only carry a genuine PSU session through. + val createdByUser: Option[User] = cc.user.toOption + .filterNot(u => cc.consumer.map(_.key.get).contains(u.idGivenByProvider)) for { _ <- passesPsd2Aisp(callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $PostConsentJson " @@ -306,6 +316,17 @@ object Http4sBGv13AIS extends MdcLoggable { val callContext = Some(cc) for { _ <- passesPsd2Aisp(callContext) + // The same ownership test its sibling GET /consents/CONSENTID applies twelve lines below. + // Without it any PSD2-AISP caller could list the authorisation ids of a consent lodged by + // somebody else -- the PUT that answers one is guarded, so this leaked identifiers rather + // than access, but the asymmetry between two neighbouring reads of the same consent was an + // oversight, not a decision. + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)") + } + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) @@ -341,9 +362,15 @@ object Http4sBGv13AIS extends MdcLoggable { val callContext = Some(cc) for { _ <- passesPsd2Aisp(callContext) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + // Same ownership test as the three sibling reads of this consent. The consent was already + // being fetched here purely to prove it exists, so the SCA status of anyone's consent was + // readable by any AISP that knew the id. + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, s"$ConsentNotFound ($consentId)", 403) } + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } (challenges, callContext) <- NewStyle.function.getChallengesByConsentId(consentId, callContext) } yield { val challengeStatus = challenges.filter(_.challengeId == authorisationId) @@ -363,6 +390,12 @@ object Http4sBGv13AIS extends MdcLoggable { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // Same ownership test as the three sibling reads of this consent. Without it the status of + // any consent was readable by any AISP holding its id -- which is enough to confirm the + // consent exists and to watch it move through authorisation. + _ <- booleanToFuture(failMsg = ConsentNotFound, failCode = 403, cc = callContext) { + consent.mConsumerId.get == cc.consumer.map(_.consumerId.get).getOrElse("None") + } } yield { JSONFactory_BERLIN_GROUP_1_3.ConsentStatusJsonV13(consent.status) } @@ -487,22 +520,69 @@ object Http4sBGv13AIS extends MdcLoggable { } } + /** + * The PSU-ID request header, resolved to the user id it names. + * + * Berlin Group makes the header conditional rather than mandatory, so absent is a conforming + * answer and gives None -- the caller may be identifying the PSU some other way, which + * Consent.resolveBerlinGroupPsu works out. A value the ASPSP cannot resolve is a different matter + * and is refused with the code the standard reserves for exactly it: PSU_CREDENTIALS_INVALID, 401, + * "PSU-ID cannot be found by ASPSP". + */ + private def resolvePsuIdHeader(cc: CallContext, callContext: Option[CallContext]): Future[Option[String]] = + Option(APIUtil.getRequestHeader(RequestHeader.`PSU-ID`, cc.requestHeaders)).map(_.trim).filter(_.nonEmpty) match { + case None => Future.successful(None) + case Some(psuId) => + Future(Consent.findPsuByPsuId(psuId)) map { psu => + Some(unboxFullOrFail(psu, callContext, UserNotFoundByProviderAndUsername, 401).userId) + } + } + // ── POST /consents/CONSENTID/authorisations (3 body-guard variants) ───── lazy val startConsentAuthorisationAll: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "consents" / consentId / "authorisations" => EndpointHelpers.executeFutureCreated(req) { val cc = req.callContext val callContext = Some(cc) - val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Aisp(callContext) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // Starting an authorisation is the entry to claiming the consent: the challenge minted + // here is what the PUT twin answers before binding a PSU. Without this, any authenticated + // caller could raise a challenge on any consent id and then answer their own. + _ <- Consent.checkBerlinGroupConsentAccess( + consent.userId, consent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + Consent.isScaFrontEnd(cc.consumer.map(_.consumerId.get))) match { + case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) + case None => Future.successful(true) + } + headerPsuUserId <- resolvePsuIdHeader(cc, callContext) + // Whose challenge this is, which is also where the OTP gets sent. Not the session's + // principal: in Berlin Group the caller is the TPP. See Consent.resolveBerlinGroupPsu. + psuUserId <- Consent.resolveBerlinGroupPsu( + consent.userId, Consent.genuinePsu(cc).map(_.userId), headerPsuUserId) match { + case Right(userId) => Future.successful(userId) + // A PSU-ID contradicting what the ASPSP already knows is an ownership refusal (403, + // like the guard above); no PSU identifiable at all is the standard's own + // PSU_CREDENTIALS_INVALID (401). booleanToFuture(false) always fails, so the mapped + // value is never reached -- it only lines the two branches up. + case Left(reason) => + val failCode = if (reason == ConsentDoesNotMatchUser) 403 else 401 + booleanToFuture(failMsg = reason, failCode = failCode, cc = callContext)(false).map(_ => "") + } + // Refuse here rather than at the PUT, even though binding happens there: the next step + // sends this person an OTP out of band. A consent naming accounts they do not hold can + // never legitimately bind to them, so minting the challenge would only deliver a code to + // someone the TPP nominated for an authorisation that must fail. + (psuForCheck, callContext) <- NewStyle.function.findByUserId(psuUserId, callContext) + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psuForCheck, consent, callContext) (challenges, callContext) <- NewStyle.function.createChallengesC2( - List(u.userId), + List(psuUserId), ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, None, getSuggestedDefaultScaMethod(), @@ -517,8 +597,12 @@ object Http4sBGv13AIS extends MdcLoggable { } yield { createStartConsentAuthorisationJson(consent, challenge) } - } else { - // mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(com.openbankproject.commons.util.JsonAliases.parse( """{ "scaStatus": "received", @@ -529,6 +613,13 @@ object Http4sBGv13AIS extends MdcLoggable { "scaStatus": {"href":"/v1.3/consents/qwer3456tzui7890/authorisations/123auth456"} } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -538,27 +629,55 @@ object Http4sBGv13AIS extends MdcLoggable { case req @ PUT -> `bgV13Prefix` / "consents" / consentId / "authorisations" / authorisationId => EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) - val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) if (checkTransactionAuthorisation(parsedJson)) { for { _ <- passesPsd2Aisp(callContext) - _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + storedConsent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { unboxFullOrFail(_, callContext, ConsentNotFound, 403) } + // updateConsentUser below overwrites mUserId unconditionally, so this is the check that + // decides who a consent ends up belonging to. See Consent.checkBerlinGroupConsentAccess. + _ <- Consent.checkBerlinGroupConsentAccess( + storedConsent.userId, storedConsent.consumerId, + Consent.genuinePsu(cc).map(_.userId), cc.consumer.map(_.consumerId.get), + Consent.isScaFrontEnd(cc.consumer.map(_.consumerId.get))) match { + case Some(reason) => booleanToFuture(failMsg = reason, failCode = 403, cc = callContext)(false) + case None => Future.successful(true) + } failMsg = s"$InvalidJsonFormat The Json body should be the $TransactionAuthorisation " updateJson <- NewStyle.function.tryons(failMsg, 400, callContext) { parsedJson.extract[TransactionAuthorisation] } - (_, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) - (challenge, callContext) <- NewStyle.function.validateChallengeAnswerC4( + (startedChallenge, callContext) <- NewStyle.function.getChallenge(authorisationId, callContext) + // The connector's validateChallengeAnswerC4 matches on challengeId alone and ignores the + // consentId it is handed, so without this a challenge minted on one consent could be + // answered on another's path -- and the ownership decision below reads off that + // challenge, so it has to be this consent's. + _ <- booleanToFuture( + failMsg = s"$InvalidChallengeChallengeId Current challengeId($authorisationId) does not belong to CONSENTID($consentId) ", + failCode = 400, cc = callContext)(startedChallenge.consentId.contains(consentId)) + // Who this consent binds to. The POST twin minted the challenge for a particular PSU and + // the OTP was delivered to that person, so the challenge is the record of whose + // authorisation this is -- the session is the TPP's and cannot say. + (psu, callContext) <- NewStyle.function.findByUserId(startedChallenge.expectedUserId, callContext) + // The binding point, so the holdings check is repeated here rather than trusted from the + // POST: the two calls are separate requests and an account can change hands between + // them. It runs before validateChallengeAnswerC4 and before the status update, because + // none of what follows is transactional -- a refusal after any of it would leave the + // consent half-claimed. + _ <- Consent.assertBerlinGroupConsentAccountsHeld(psu, storedConsent, callContext) + // Berlin Group Embedded has the TPP relay the PSU's OTP, so the identity the answer is + // validated against is the challenge's PSU rather than the principal on the token. The + // caller's own right to be here was settled by checkBerlinGroupConsentAccess above. + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE, None, Some(consentId), authorisationId, updateJson.scaAuthenticationData, SuppliedAnswerType.PLAIN_TEXT_VALUE, - callContext + callContext.map(_.copy(user = Full(psu))) ) consent <- challenge.scaStatus match { case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => @@ -572,13 +691,13 @@ object Http4sBGv13AIS extends MdcLoggable { consent.toList.size == 1 } _ <- Future { - val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) + val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(psu.userId) .map(_.map(i => BasicUserAuthContext(i.key, i.value))) ConsentAuthContextProvider.consentAuthContextProvider.vend.createOrUpdateConsentAuthContexts(consentId, authContexts.getOrElse(Nil)) } map { unboxFullOrFail(_, callContext, ConsentUserAuthContextCannotBeAdded) } - _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, u)) map { + _ <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, psu)) map { unboxFullOrFail(_, callContext, ConsentUserCannotBeAdded) } } yield { @@ -606,8 +725,11 @@ object Http4sBGv13AIS extends MdcLoggable { | "authoriseTransaction": {"href": "/psd2/v1/payments/1234-wertiq-983/authorisations/123auth456"} | } |}""".stripMargin)) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(com.openbankproject.commons.util.JsonAliases.parse( """{ | "scaStatus": "finalised", @@ -615,6 +737,13 @@ object Http4sBGv13AIS extends MdcLoggable { | "status": {"href":"/v1/payments/sepa-credit-transfers/qwer3456tzui7890/status"} | } |}""".stripMargin)) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -811,6 +940,13 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // Berlin Group's Embedded SCA step, and the TPP is the caller: the PSU either authenticated + // at the ASPSP under Redirect or handed its factors to the TPP under Embedded. Which PSU the + // challenge is for no longer comes from the session -- see Consent.resolveBerlinGroupPsu -- + // so the doc can now say what the call actually is, as its GET siblings already do. Left on + // the UserOnly default it would 401 a client-credentials caller the day OAuth2 token parsing + // stops auto-vivifying a user. + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -825,6 +961,7 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -839,6 +976,7 @@ using the extended forms as indicated above. startConsentAuthorisationResponse, List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(startConsentAuthorisationAll) ) @@ -885,6 +1023,7 @@ Maybe in a later version the access path will change. ), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -904,6 +1043,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -931,6 +1071,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) @@ -950,6 +1091,7 @@ Maybe in a later version the access path will change. | }""".stripMargin)), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + authMode = UserOrApplication, http4sPartialFunction = Some(updateConsentsPsuDataAll) ) } @@ -1161,6 +1303,17 @@ This function returns an array of hyperlinks to all generated authorisation sub- }""")), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // The TPP reads its own consent's authorisation sub-resources; no PSU is party to the call. + // The Implementation Guidelines put it plainly (§4.6, Authorisation Endpoints): the ASPSP + // "will give access to these sub-resources to the TPP by returning corresponding hyperlinks", + // and "the authorisation status would still result by submitting the command GET + // .../authorisations/authorisationId". In Berlin Group the PSU never calls the API at all -- + // it authenticates at the ASPSP under Redirect, or hands its factors to the TPP under + // Embedded. The handler already reflects that, taking no user; only the doc disagreed, and a + // doc left on the UserOnly default sends the middleware down anonymousAccess, which 401s a + // request carrying no user. Brings these into line with the consent endpoints in this same + // file, which have been UserOrApplication all along. + authMode = UserOrApplication, http4sPartialFunction = Some(getConsentAuthorisation) ) @@ -1179,6 +1332,8 @@ This method returns the SCA status of a consent initiation's authorisation sub-r }""")), List(AuthenticatedUserIsRequired, UnknownError), ApiTag("Account Information Service (AIS)") :: apiTagBerlinGroupM :: Nil, + // As above -- reading the SCA status is the TPP polling its own authorisation sub-resource. + authMode = UserOrApplication, http4sPartialFunction = Some(getConsentScaStatus) ) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala index d363d29a1a..d08ff72374 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -11,10 +11,12 @@ import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, getScaM import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats -import code.api.util.{ApiTag, CallContext, NewStyle} +import code.api.util.APIUtil.OBPReturnType +import code.api.util.{ApiTag, CallContext, Consent, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection} import code.fx.fx +import code.transactionrequests.TransactionRequests import code.util.Helper import code.util.Helper.{MdcLoggable, booleanToFuture} import com.github.dwickern.macros.NameOf.nameOf @@ -71,6 +73,46 @@ object Http4sBGv13PIS extends MdcLoggable { PaymentServiceTypes.withName(paymentService.replaceAll("-", "_")) }.isDefined + /** + * Fetch a payment the caller is entitled to address. + * + * Berlin Group names a payment by its id alone — there is no account in the path — so nothing in + * the route ties the payment to whoever is calling. Fetching one must therefore also establish + * that the caller is the party that lodged it; otherwise any authenticated TPP holding a paymentId + * could read another TPP's payment, list or start authorisations on it, or cancel it. Under + * NextGenPSD2 a payment initiation resource belongs to the TPP that created it, and only that TPP + * addresses it afterwards. + * + * Two things have to line up, because Berlin Group binds a payment to the TPP and the ASPSP + * separately knows which PSU it is for. + * + * - The TPP. The consumer that lodged the payment is recorded on it, and a caller presenting a + * different one is refused even when it is acting for the same PSU: one TPP's mandate over a + * payment is not another's. Payments lodged before the consumer was recorded carry none, and + * fall back to the person check alone rather than becoming unaddressable. + * - The person. A payment records the principal that lodged it and, when it was lodged under a + * consent, the PSU it was lodged for; a caller presents the same two. Any overlap is enough, so + * a payment lodged on a client-credentials token can still be authorised under the PSU's token + * and the other way round. A payment carrying neither identity belongs to nobody. + */ + private def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = + for { + (transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty) + callers = callContext.toSet[CallContext].flatMap(cc => cc.user.toOption.map(_.userId) ++ Consent.actingPsu(cc).map(_.userId)) + callingConsumer = callContext.flatMap(_.consumer.map(_.consumerId.get)) + // Read straight off the stored row rather than through the TransactionRequest model: which + // TPP lodged a payment is this guard's business, not something every REST connector needs on + // the wire, and that model's shape is a frozen contract. + lodgedByConsumer = TransactionRequests.transactionRequestProvider.vend + .getMappedTransactionRequest(TransactionRequestId(paymentId)) + .map(_.mConsumerId.get).toOption.filter(_.nonEmpty) + sameTpp = lodgedByConsumer.forall(lodgedBy => callingConsumer.contains(lodgedBy)) + _ <- Helper.booleanToFuture(s"$PaymentNotInitiatedByCaller Payment id: $paymentId.", 403, callContext) { + sameTpp && initiators.exists(callers) + } + } yield (transactionRequest, callContext) + /** * Shared business logic for all three initiate-payment variants (payments / periodic-payments / * bulk-payments). Mirrors `initiatePaymentImplementation` from the Lift builder; auth is handled @@ -145,7 +187,7 @@ object Http4sBGv13PIS extends MdcLoggable { transactionRequestTypes <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { transactionRequest.body.to_sepa_credit_transfers.get } @@ -189,7 +231,7 @@ object Http4sBGv13PIS extends MdcLoggable { failMsg = s"$TransactionRequestCannotBeCancelled Payment status: $mappedStatus. Only payments in RCVD, ACCP, PDNG, or CANC status can be cancelled.", cc = callContext ) { canBeCancelled == true } - (updatedTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (updatedTransactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) } yield { startSca.getOrElse(false) match { case true => Some(createCancellationTransactionRequestJson(updatedTransactionRequest)) @@ -219,7 +261,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.getChallenge(cancellationId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("None")) @@ -240,7 +282,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestBody <- NewStyle.function.tryons(s"${UnknownError} No data for Payment Body ", 400, callContext) { transactionRequest.body.to_sepa_credit_transfers.get } @@ -263,7 +305,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationsJson(challenges) @@ -284,6 +326,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.getChallengesByTransactionRequestId(paymentId, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.CancellationJsonV13(challenges.map(_.challengeId)) @@ -304,7 +347,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.getChallenge(authorisationId, callContext) } yield { json.parse(s"""{"scaStatus" : "${challenge.scaStatus.getOrElse("None")}"}""") @@ -326,7 +369,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) transactionRequestStatus = mapTransactionStatus(transactionRequest.status) transactionRequestAmount <- NewStyle.function.tryons(s"${InvalidNumber} transaction request amount cannot convert to a Decimal", 400, callContext) { BigDecimal(transactionRequest.body.to_sepa_credit_transfers.get.instructedAmount.amount) @@ -394,7 +437,7 @@ object Http4sBGv13PIS extends MdcLoggable { val callContext = Some(cc) val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Pisp(callContext) _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { @@ -403,7 +446,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenges, _) <- NewStyle.function.createChallengesC2( List(u.userId), ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, @@ -420,8 +463,12 @@ object Http4sBGv13PIS extends MdcLoggable { } yield { JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) } - } else { - // Mocked response for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(json.parse( """{ "challengeData": { @@ -433,6 +480,13 @@ object Http4sBGv13PIS extends MdcLoggable { } } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -445,7 +499,7 @@ object Http4sBGv13PIS extends MdcLoggable { val callContext = Some(cc) val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) val parsedJson = scala.util.Try(json.parse(cc.httpBody.getOrElse(""))).getOrElse(json.JNothing) - if (checkTransactionAuthorisation(parsedJson)) { + if (startsAuthorisation(parsedJson)) { for { _ <- passesPsd2Pisp(callContext) _ <- NewStyle.function.tryons(checkPaymentServerTypeError(paymentService), 404, callContext) { @@ -454,7 +508,7 @@ object Http4sBGv13PIS extends MdcLoggable { _ <- NewStyle.function.tryons(checkPaymentProductError(paymentProduct), 404, callContext) { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } - (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (transactionRequest, _) <- getOwnPaymentImpl(paymentId, callContext) _ <- Helper.booleanToFuture(failMsg = CannotStartTheAuthorisationProcessForTheCancellation, cc = callContext) { transactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString } @@ -476,8 +530,12 @@ object Http4sBGv13PIS extends MdcLoggable { challenge, paymentService, paymentProduct, paymentId ) } - } else { - // Mocked for updatePsuAuthentication and selectPsuAuthenticationMethod variants + } else if (checkUpdatePsuAuthentication(parsedJson) || checkSelectPsuAuthenticationMethod(parsedJson)) { + // Mocked for the updatePsuAuthentication and selectPsuAuthenticationMethod variants, which + // are Embedded-approach steps OBP does not implement. Guarded now: this was the + // unconditional final else, so any body the server could not recognise was answered with + // this example -- a fabricated authorisationId, returned 201, that matches no challenge. + // The TPP only discovers it at the PUT, where the id resolves to nothing. Future.successful(json.parse( """{ "scaStatus": "received", @@ -489,6 +547,13 @@ object Http4sBGv13PIS extends MdcLoggable { } } }""")) + } else { + // None of the recognised shapes. A malformed request has to be reported as one; handing + // back an id that was never minted only moves the failure somewhere harder to read. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of " + + s"updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -513,13 +578,13 @@ object Http4sBGv13PIS extends MdcLoggable { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } transactionRequestId = TransactionRequestId(paymentId) - (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + (existingTransactionRequest, _) <- getOwnPaymentImpl(transactionRequestId.value, callContext) _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUDataCancellation, cc = callContext) { existingTransactionRequest.status == TransactionRequestStatus.INITIATED.toString || existingTransactionRequest.status == TransactionRequestStatus.CANCELLATION_PENDING.toString || existingTransactionRequest.status == TransactionRequestStatus.COMPLETED.toString } - (_, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (_, _) <- getOwnPaymentImpl(paymentId, callContext) (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.BERLIN_GROUP_PAYMENT_CHALLENGE, Some(paymentId), @@ -569,8 +634,11 @@ object Http4sBGv13PIS extends MdcLoggable { "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} } }""")) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(json.parse( """{ "scaStatus": "finalised", @@ -578,6 +646,13 @@ object Http4sBGv13PIS extends MdcLoggable { "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} } }""")) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } @@ -603,7 +678,7 @@ object Http4sBGv13PIS extends MdcLoggable { TransactionRequestTypes.withName(paymentProduct.replaceAll("-", "_").toUpperCase) } transactionRequestId = TransactionRequestId(paymentId) - (existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transactionRequestId, callContext) + (existingTransactionRequest, _) <- getOwnPaymentImpl(transactionRequestId.value, callContext) _ <- Helper.booleanToFuture(failMsg = CannotUpdatePSUData, cc = callContext) { existingTransactionRequest.status == TransactionStatus.RCVD.code } @@ -657,8 +732,11 @@ object Http4sBGv13PIS extends MdcLoggable { "authoriseTransaction": {"href": "/psd2/v1.3/payments/1234-wertiq-983/authorisations/123auth456"} } }""")) - } else { - // authorisationConfirmation variant + } else if (checkAuthorisationConfirmation(parsedJson)) { + // authorisationConfirmation variant. Guarded by the checker that already existed for it: + // this was the unconditional final else, so a body matching none of the four shapes -- an + // empty one included -- was answered "scaStatus": "finalised", the terminal success state + // of strong customer authentication, for an authorisation nothing had happened to. Future.successful(json.parse( """{ "scaStatus": "finalised", @@ -666,6 +744,13 @@ object Http4sBGv13PIS extends MdcLoggable { "status": {"href":"/v1.3/payments/sepa-credit-transfers/qwer3456tzui7890/status"} } }""")) + } else { + // None of the four Berlin Group shapes. Malformed, and it has to say so: claiming an SCA + // outcome for a request the server could not read is worse than any of them. + Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat The Json body should be one of updatePsuAuthentication, " + + s"selectPsuAuthenticationMethod, transactionAuthorisation or authorisationConfirmation.", + failCode = 400, cc = callContext)(false).map(_ => json.parse("{}")) } } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 3a36351c17..d35c6a6dc8 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -724,7 +724,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def createStartConsentAuthorisationJson(consent: ConsentTrait, challenge: ChallengeTrait) : StartConsentAuthorisationJson = { StartConsentAuthorisationJson( scaStatus = challenge.scaStatus.map(_.toString).getOrElse("None"), - authorisationId = challenge.authenticationMethodId.getOrElse("None"), + authorisationId = challenge.challengeId, pushMessage = "started", //TODO Not implement how to fill this. _links = ScaStatusJsonV13(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/consents/${consent.consentId}/authorisations/${challenge.challengeId}")//TODO, Not sure, what is this for?? ) @@ -889,6 +889,27 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ JsonPost.extract[TransactionAuthorisation] }.isDefined + /** + * Whether this POST body means "start the authorisation process". + * + * Berlin Group hangs four request bodies off one path. An **empty body** is the plain start of an + * authorisation and the entry point of the Redirect approach; `updatePsuAuthentication` and + * `selectPsuAuthenticationMethod` are later Embedded-approach steps; `transactionAuthorisation` + * carries an SCA answer. + * + * The start handlers dispatch on the body's shape and only ever recognised the last of those, so + * an empty body fell to the same catch-all as the unimplemented variants and was answered with a + * hardcoded example -- HTTP 201 carrying the literal authorisationId "123auth456.". A TPP starting + * an authorisation the way the standard describes got a fabricated id back, and only found out at + * the PUT, where that id matched no challenge. It went unnoticed because OBP's own clients send + * `{"scaAuthenticationData": ""}`, so the one branch ever exercised was the one that worked. + * + * An empty body and a transactionAuthorisation body are the same request here: neither start + * handler reads scaAuthenticationData. The POST mints the challenge; the PUT twin answers it. + */ + def startsAuthorisation(JsonPost: JValue): Boolean = + JsonPost == JNothing || JsonPost == JObject(Nil) || checkTransactionAuthorisation(JsonPost) + def checkUpdatePsuAuthentication(JsonPost: JValue) = tryo { JsonPost.extract[UpdatePsuAuthentication] }.isDefined diff --git a/obp-api/src/main/scala/code/api/cache/Caching.scala b/obp-api/src/main/scala/code/api/cache/Caching.scala index 4a87556ce3..74dbee5a2f 100644 --- a/obp-api/src/main/scala/code/api/cache/Caching.scala +++ b/obp-api/src/main/scala/code/api/cache/Caching.scala @@ -57,38 +57,36 @@ object Caching extends MdcLoggable { } } - def getDynamicResourceDocCache(key: String) = { - use(JedisMethod.GET, (DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL)) - } - - def setDynamicResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL), Some(value)) - } + // Resource-doc / swagger caches. These go through the same fail-safe wrappers as the product + // caches below: the cache is an optimisation, and an unreachable Redis must degrade to a miss + // (recompute and serve) rather than turn every /resource-docs, /swagger and message-docs request + // into a 500. These are the documents API Explorer and Portal load on startup, so a Redis blip + // used to take the whole surface down. + def getDynamicResourceDocCache(key: String): Option[String] = + tryGet(DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL) - def getStaticResourceDocCache(key: String) = { - use(JedisMethod.GET, (STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL)) - } - - def setStaticResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL), Some(value)) - } + def setDynamicResourceDocCache(key: String, value: String): Unit = + trySet(DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL, value) - def getAllResourceDocCache(key: String) = { - use(JedisMethod.GET, (ALL_RESOURCE_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL)) - } - - def setAllResourceDocCache(key:String, value: String)= { - use(JedisMethod.SET, (ALL_RESOURCE_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_DYNAMIC_RESOURCE_DOCS_TTL), Some(value)) - } + def getStaticResourceDocCache(key: String): Option[String] = + tryGet(STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL) - def getStaticSwaggerDocCache(key: String) = { - use(JedisMethod.GET, (STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX + key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL)) - } - - def setStaticSwaggerDocCache(key:String, value: String)= { - use(JedisMethod.SET, (STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX+key).intern(), Some(GET_STATIC_RESOURCE_DOCS_TTL), Some(value)) - } - // Fail-safe wrappers around Redis.use for product caches. If Redis is unreachable (dev without a + def setStaticResourceDocCache(key: String, value: String): Unit = + trySet(STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL, value) + + def getAllResourceDocCache(key: String): Option[String] = + tryGet(ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL) + + def setAllResourceDocCache(key: String, value: String): Unit = + trySet(ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, key, GET_DYNAMIC_RESOURCE_DOCS_TTL, value) + + def getStaticSwaggerDocCache(key: String): Option[String] = + tryGet(STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL) + + def setStaticSwaggerDocCache(key: String, value: String): Unit = + trySet(STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, key, GET_STATIC_RESOURCE_DOCS_TTL, value) + + // Fail-safe wrappers around Redis.use. If Redis is unreachable (dev without a // running Redis, transient failure, etc.) we treat it as a miss and recompute instead of failing // the whole request. private def tryGet(prefix: String, key: String, ttlSeconds: Int): Option[String] = diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index db2143d8d0..486cee3f02 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -161,6 +161,7 @@ object Constant extends MdcLoggable { final val SYSTEM_READ_BALANCES_VIEW_ID = "ReadBalances" final val SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID = "ReadTransactionsBasic" final val SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID = "ReadTransactionsDebits" + final val SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID = "ReadTransactionsCredits" final val SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID = "ReadTransactionsDetail" // Berlin Group final val SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID = "ReadAccountsBerlinGroup" @@ -168,6 +169,11 @@ object Constant extends MdcLoggable { final val SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID = "ReadTransactionsBerlinGroup" final val SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID = "InitiatePaymentsBerlinGroup" + // A VRP mandate gets its own private custom view, named after the mandate. The prefix is how the + // consent conversion recognises a VRP consent, and how revocation finds the artefacts to release, + // so both ends read it from here rather than repeating the literal. + final val VRP_VIEW_ID_PREFIX = "_vrp-" + //This is used for the canRevokeAccessToViews_ and canGrantAccessToViews_ fields of SYSTEM_OWNER_VIEW_ID or SYSTEM_STANDARD_VIEW_ID. final val DEFAULT_CAN_GRANT_AND_REVOKE_ACCESS_TO_VIEWS = SYSTEM_OWNER_VIEW_ID:: @@ -182,6 +188,7 @@ object Constant extends MdcLoggable { SYSTEM_READ_BALANCES_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID:: + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID:: SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID:: SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID:: SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID:: @@ -632,6 +639,97 @@ object Constant extends MdcLoggable { CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) + // Design boundary for every SYSTEM_READ_*_VIEW_PERMISSION set below: these express what + // *fields* a view exposes, never a consent's time-boxing or access-frequency limit. BG's + // frequencyPerDay/recurringIndicator/validUntil and UK's TransactionFromDateTime/ToDateTime/ + // ExpirationDateTime belong on the consent record (see ConsentJWT in ConsentUtil.scala), not + // here — do not add a can_* string like "can_see_transactions_last_90_days" to narrow a view + // by date range or usage count; that's the consent's job, applied before the view is reached. + + // UK Open Banking v4.0.1 system views — previously all six shared the generic + // SYSTEM_VIEW_PERMISSION_COMMON set, so "Detail" granted nothing beyond "Basic" and a + // consent scoped to Balances alone still exposed full transaction/counterparty data. + // Mapped from the UK v4.0.1 spec's Detail Permissions table (see + // standards-permissions-research/uk-v401/account-and-transaction-api-profile.html). + final val SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_LABEL, + CAN_SEE_BANK_ACCOUNT_TYPE, + CAN_SEE_BANK_ACCOUNT_CURRENCY, + CAN_SEE_BANK_ACCOUNT_BANK_NAME + ) + + final val SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION = SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION ++ List( + CAN_SEE_BANK_ACCOUNT_IBAN, + CAN_SEE_BANK_ACCOUNT_NUMBER, + CAN_SEE_BANK_ACCOUNT_SWIFT_BIC, + CAN_SEE_BANK_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS + ) + + final val SYSTEM_READ_BALANCES_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_BALANCE, + CAN_QUERY_AVAILABLE_FUNDS + ) + + final val SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION = List( + CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT, + CAN_SEE_TRANSACTION_AMOUNT, + CAN_SEE_TRANSACTION_TYPE, + CAN_SEE_TRANSACTION_CURRENCY, + CAN_SEE_TRANSACTION_START_DATE, + CAN_SEE_TRANSACTION_FINISH_DATE, + CAN_SEE_TRANSACTION_STATUS + ) + + // ReadTransactionsDebits / ReadTransactionsCredits (UK v4.0.1 spec: independently-selectable + // Permissions codes) are direction-filtered, not field-filtered — a PSU who only grants + // ReadTransactionsCredits should see the same transaction fields as ReadTransactionsBasic, + // just restricted to credit-direction rows. Neither view widens or narrows field visibility, + // so both share Basic's permission set here. + // + // Enforcement mechanism (decided, not yet wired into the transactions endpoint): filter by + // which of the two view_ids the consent granted, resolved the same way + // Http4sUKOBv401AccountInfo.getAccountsAccountIdTransactions already resolves Basic-or-Detail + // via ViewNewStyle.checkViewsAccessAndReturnView — no new can_* permission string, since + // direction is a query-parameter-shaped concern, not a field-visibility one. Wiring this in + // requires the endpoint to also filter the returned transaction list by amount sign, which in + // turn depends on fixing JSONFactory_UKOpenBanking_401.transactionJson's separate, + // pre-existing CreditDebitIndicator hardcoding (always "Credit", never derived from the + // transaction) — tracked as a follow-up, out of scope for this view/permission gap. + final val SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + final val SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + + final val SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION = SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION ++ List( + CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT, + CAN_SEE_OTHER_ACCOUNT_IBAN, + CAN_SEE_OTHER_ACCOUNT_BANK_NAME, + CAN_SEE_OTHER_ACCOUNT_NUMBER, + CAN_SEE_OTHER_ACCOUNT_KIND, + CAN_SEE_OTHER_ACCOUNT_SWIFT_BIC, + CAN_SEE_OTHER_ACCOUNT_NATIONAL_IDENTIFIER, + CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS, + CAN_SEE_OTHER_BANK_ROUTING_SCHEME, + CAN_SEE_OTHER_BANK_ROUTING_ADDRESS + ) + + // Berlin Group NextGenPSD2 system views — these two previously had zero ViewPermission rows + // at all (pure membership gating), unlike ReadTransactionsBerlinGroup which already has a + // full permission set above. BG's access object is IBAN-keyed, so unlike UK's Basic/Detail + // split, the account identifier (IBAN) is included by default here. + final val SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_LABEL, + CAN_SEE_BANK_ACCOUNT_TYPE, + CAN_SEE_BANK_ACCOUNT_CURRENCY, + CAN_SEE_BANK_ACCOUNT_BANK_NAME, + CAN_SEE_BANK_ACCOUNT_IBAN + ) + + final val SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION = List( + CAN_SEE_BANK_ACCOUNT_BALANCE, + CAN_QUERY_AVAILABLE_FUNDS + ) + // Auditor system view: read-only on the account itself. The auditor can // see everything but cannot modify account data, counterparties, images, // locations, aliases, URLs, or initiate / approve payments. @@ -838,6 +936,12 @@ object RequestHeader { final lazy val `PSD2-CERT` = "PSD2-CERT" final lazy val `If-None-Match` = "If-None-Match" + // "Client ID of the PSU in the ASPSP client interface" -- the only place Berlin Group carries the + // PSU's identity, psuData being passwords only. Conditional rather than mandatory: the standard + // asks for it when the ASPSP does not already know who the PSU is (Implementation Guidelines + // V1.3.12, sections 7.1 p.195 and 7.2.1 p.206), which is why it stays out of BerlinGroupCheck's + // mandatory header list. + final lazy val `PSU-ID` = "PSU-ID" // Berlin Group final lazy val `PSU-Geo-Location` = "PSU-Geo-Location" // Berlin Group final lazy val `PSU-Device-Name` = "PSU-Device-Name" // Berlin Group final lazy val `PSU-Device-ID` = "PSU-Device-ID" // Berlin Group 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 272cba141d..a6d50d26fb 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -184,6 +184,21 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def rfc7231Date: SimpleDateFormat = rfc7231DateTL.get() + /** + * Parses a full ISO-8601 datetime with offset (e.g. "2020-01-01T00:00:00+00:00") or, failing + * that, a bare "yyyy-MM-dd" date (midnight UTC). Unlike DateWithDayFormat (a SimpleDateFormat + * with "yyyy-MM-dd"), this does not silently truncate a full datetime's time/offset — it parses + * the whole value or throws java.time.format.DateTimeParseException on genuinely malformed input. + */ + def parseIso8601OrDayDate(s: String): Date = { + try { + Date.from(java.time.OffsetDateTime.parse(s).toInstant) + } catch { + case _: java.time.format.DateTimeParseException => + Date.from(java.time.LocalDate.parse(s).atStartOfDay(java.time.ZoneOffset.UTC).toInstant) + } + } + val DateWithYearExampleString: String = "1100" val DateWithMonthExampleString: String = "1100-01" val DateWithDayExampleString: String = "1100-01-01" @@ -2791,21 +2806,24 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ consumerByCertificate } Consent.applyBerlinGroupRules(APIUtil.`getConsent-ID`(reqHeaders), cc.copy(consumer = consumerForConsent)) - } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's Consent + } else if (APIUtil.hasConsentJWT(reqHeaders)) { // Open Bank Project's or UK Open Banking's Consent val consentValue = APIUtil.getConsentJWT(reqHeaders) - Consent.getConsentJwtValueByConsentId(consentValue.getOrElse("")) match { + // Note: At this point we are getting the Consumer from the Consumer in the Consent. + // This may later be cross checked via the value in consumer_validation_method_for_consent. + // Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible. + val ccWithConsumer = cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey)) + Consent.getConsentByHeaderValue(consentValue.getOrElse("")) match { + // A UK consent normally travels as a consent_id claim inside an OAuth2 access token. It may + // also be presented on its own here, with no Authorization header -- the same shape Berlin + // Group and OBP-native clients use. applyUKRules applies every gate checkUKConsent does. + case Some(consent) if consent.apiStandard == Consent.ConsentStandardUK => + Consent.applyUKRules(consent, consentValue.getOrElse(""), ccWithConsumer) case Some(consent) => // JWT value obtained via "Consent-Id" request header - Consent.applyRules( - Some(consent.jsonWebToken), - // Note: At this point we are getting the Consumer from the Consumer in the Consent. - // This may later be cross checked via the value in consumer_validation_method_for_consent. - // Get the source of truth for Consumer (e.g. CONSUMER_CERTIFICATE) as early as possible. - cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey)) - ) + Consent.applyRules(Some(consent.jsonWebToken), ccWithConsumer) case _ => JwtUtil.checkIfStringIsJWTValue(consentValue.getOrElse("")).isDefined match { case true => // It's JWT obtained via "Consent-JWT" request header - Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), cc.copy(consumer = consumerByCertificate.orElse(consumerByConsumerKey))) + Consent.applyRules(APIUtil.getConsentJWT(reqHeaders), ccWithConsumer) case false => // Unrecognised consent value Future { (Failure(ErrorMessages.ConsentHeaderValueInvalid), None) } } @@ -2953,6 +2971,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ x => (x._1, x._2.map(_.copy(httpBody = body.toOption))) } map { // Inject logged in user into CallContext data x => (x._1, x._2.map(_.copy(user = x._1))) + } map { // A UK Open Banking consent presented as a token claim runs as that consent's shadow user + x => Consent.applyUKConsentPrincipalFromToken(x._1, x._2) } } @@ -3026,23 +3046,32 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } /** - * This function is used to introduce Rate Limit at an unauthorized endpoint + * The parts of a request that both [[JwsUtil.verifySignedRequest]] and + * [[BerlinGroupCheck.validate]] take, in the order they take them. + */ + private def requestPartsOf(result: (Box[User], Option[CallContext])) = { + val callContext = result._2 + ( + callContext.flatMap(_.httpBody), + callContext.map(_.verb).getOrElse("None"), + callContext.map(_.url).getOrElse("None"), + callContext.map(_.requestHeaders).getOrElse(Nil) + ) + } + + /** + * The pre-processing shared by [[anonymousAccess]] and [[applicationAccess]]: resolve the + * user and session, verify the signed request, run the Berlin Group checks and apply rate + * limiting. Each caller decides on its own what to make of the outcome. * @param cc The call context of an request - * @return Failure in case we exceeded rate limit */ - def anonymousAccess(cc: CallContext): OBPReturnType[Box[User]] = { - getUserAndSessionContextFuture(cc) map { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + private def accessPipeline(cc: CallContext): OBPReturnType[Box[User]] = { + getUserAndSessionContextFuture(cc) map { result => + val (body, verb, url, reqHeaders) = requestPartsOf(result) // Verify signed request JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) } flatMap { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) + val (body, verb, url, reqHeaders) = requestPartsOf(result) // Berlin Group checks BerlinGroupCheck.validate(body, verb, url, reqHeaders, result) } map { @@ -3052,7 +3081,16 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case Some(functionName) if excludeFunctions.exists(_ == functionName) => result case _ => RateLimitingUtil.underCallLimits(result) } - } map { + } + } + + /** + * This function is used to introduce Rate Limit at an unauthorized endpoint + * @param cc The call context of an request + * @return Failure in case we exceeded rate limit + */ + def anonymousAccess(cc: CallContext): OBPReturnType[Box[User]] = { + accessPipeline(cc) map { it => val callContext = it._2 @@ -3087,32 +3125,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * @return Tuple (User, Call Context) */ def applicationAccess(cc: CallContext): Future[(Box[User], Option[CallContext])] = - getUserAndSessionContextFuture(cc) map { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) - // Verify signed request if need be - JwsUtil.verifySignedRequest(body, verb, url, reqHeaders, result) - } flatMap { result => - val url = result._2.map(_.url).getOrElse("None") - val verb = result._2.map(_.verb).getOrElse("None") - val body = result._2.flatMap(_.httpBody) - val reqHeaders = result._2.map(_.requestHeaders).getOrElse(Nil) - // Berlin Group checks - BerlinGroupCheck.validate(body, verb, url, reqHeaders, result) - } map { - result => - val excludeFunctions = getPropsValue("rate_limiting.exclude_endpoints", "root,getOAuth2ServerWellKnown").split(",").toList - cc.resourceDocument.map(_.partialFunctionName) match { - case Some(functionName) if excludeFunctions.exists(_ == functionName) => result - case _ => RateLimitingUtil.underCallLimits(result) - } - } map { result => + accessPipeline(cc) map { result => result._1 match { + case Full(_) => // The user is known, so the application is too + result case Empty if result._2.flatMap(_.consumer).isDefined => // There is no error and Consumer is defined result - case _ => + case _ => // No Consumer, or a Failure whose reason we deliberately do not disclose here ( fullBoxOrException(result._1 ~> APIFailureNewStyle(ApplicationNotIdentified, 401, Some(cc.toLight))), result._2 @@ -4725,6 +4744,22 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ APIUtil.getPropsValue("email_domain_to_space_mappings").map(extractor).getOrElse(Nil) } + /** + * The Consumers the ASPSP declares to be its own SCA front end. + * + * Read from `sca_front_end_consumer_ids`. The Berlin-Group-specific key this started life as is + * still honoured, so an instance already configured for the Berlin Group redirect flow keeps + * working without being edited. + * + * Empty by default, which is what makes every use of it a no-op unless an ASPSP opts in. + */ + val scaFrontEndConsumerIds: List[String] = { + def read(key: String) = APIUtil.getPropsValue(key) + .map(_.split(",").toList.map(_.trim).filter(_.nonEmpty)) + .getOrElse(Nil) + (read("sca_front_end_consumer_ids") ++ read("berlin_group_sca_front_end_consumer_ids")).distinct + } + val skipConsentScaForConsumerIdPairs: List[ConsumerIdPair] = { def extractor(str: String) = try { val consumerIdPair = json.parse(str).extract[List[ConsumerIdPair]] diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 8bff206d57..f8c20b9d8e 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -61,6 +61,11 @@ case class CallContext( counterparty: Option[CounterpartyTrait] = None, // Set when the request is authenticated via a consent. Persisted on metric rows for search/audit. consentReferenceId: Option[String] = None, + // Set when a UK Open Banking consent authenticated this request via the Consent-Id / + // Consent-JWT header rather than a Bearer token. checkUKConsent short-circuits on this: + // the consent has already been fully validated (standard, status, expiry, consumer, + // signature) and the PSU resolved from MappedConsent.mUserId. + ukConsentId: Option[String] = None, // How the caller's certificate was established (PeerTrust.Resolution.mode): // "direct", "forwarded" or "none". Persisted on metric rows as certificate_trust. certificateTrust: Option[String] = None, @@ -72,16 +77,29 @@ case class CallContext( override def toString: String = SecureLogging.maskSensitive( s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})" ) + /** + * The human being this request is on behalf of, where one is known. + * + * `user` is not always a person: a consent resolves to a shadow user that exists only for that + * consent (Berlin Group, OBP-native, and -- since UK consents moved to the same model -- UK too). + * Anything that must name a human rather than a principal reads this instead: the CBS adapter, + * which tells the core banking system who is asking, and metric attribution. + */ + def humanUser: Box[User] = onBehalfOfUser.or(consenter).or(user) + //This is only used to connect the back adapter. not useful for sandbox mode. def toOutboundAdapterCallContext: OutboundAdapterCallContext= { for{ user <- this.user //If there is no user, then will go to `.openOr` method, to return anonymousAccess box. - username <- tryo(Some(user.name)) - currentResourceUserId <- tryo(Some(user.userId)) + // The adapter is told which human is asking. A shadow user has no name and no customer links, + // so sending it would make every consent-borne request look like a different, unknown caller. + psu <- this.humanUser + username <- tryo(Some(psu.name)) + currentResourceUserId <- tryo(Some(psu.userId)) consumerId = this.consumer.map(_.consumerId.get).openOr("") // if none, just return "" permission <- Views.views.vend.getPermissionForUser(user) views <- tryo(permission.views) - linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(user.userId)) + linkedCustomers <- tryo(CustomerX.customerProvider.vend.getCustomersByUserId(psu.userId)) likedCustomersBasic = if (linkedCustomers.isEmpty) None else Some(createInternalLinkedBasicCustomersJson(linkedCustomers)) userAuthContexts<- UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(user.userId) basicUserAuthContextsFromDatabase = if (userAuthContexts.isEmpty) None else Some(createBasicUserAuthContextJson(userAuthContexts)) @@ -130,8 +148,12 @@ case class CallContext( CallContextLight( gatewayLoginRequestPayload = this.gatewayLoginRequestPayload, gatewayLoginResponseHeader = this.gatewayLoginResponseHeader, - userId = this.user.map(_.userId).toOption, - userName = this.user.map(_.name).toOption, + // Metrics name the human, not the principal. A consent's shadow user would record a per-consent + // UUID and an empty username, which is what Berlin Group and OBP-native traffic has always + // looked like on the metrics table; the consent itself stays identifiable via + // consentReferenceId below. + userId = this.humanUser.map(_.userId).toOption, + userName = this.humanUser.map(_.name).toOption, consumerId = this.consumer.map(_.consumerId.get).toOption, appName = this.consumer.map(_.name.get).toOption, developerEmail = this.consumer.map(_.developerEmail.get).toOption, diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index 95706d8f3a..c00d16c945 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -196,18 +196,34 @@ object BerlinGroupCheck extends MdcLoggable { .getOrElse(forwardResult) } + /** + * Whether the PSU was behind this request, which is what `frequencyPerDay` counts: it is "the + * requested maximum frequency for an access without PSU involvement per day". + * + * NextGenPSD2 settles the question with one header. On every AIS read and consent-management call, + * `PSU-IP-Address` "shall be contained if and only if this request was actively initiated by the + * PSU" (psd2-api v1.3, parameter `PSU-IP-Address_conditionalForAis`). Omitting it is therefore the + * TPP's declaration that no PSU is involved, and that is the case the daily limit governs. + * + * This used to be read the other way round: only a request carrying a sentinel value counted, so a + * TPP that simply sent nothing — the very shape the spec reserves for unattended access — was never + * counted at all, and each TPP decided whether its own daily limit applied to it. + * + * The two sentinels are still honoured, for a TPP that sends the header unconditionally and marks + * the no-PSU case in the value rather than by omission. + */ def isTppRequestsWithoutPsuInvolvement(requestHeaders: List[HTTPParam]): Boolean = { - val psuIpAddress = getHeaderValue(RequestHeader.`PSU-IP-Address`, requestHeaders) - val psuDeviceId = getHeaderValue(RequestHeader.`PSU-Device-ID`, requestHeaders) - val psuDeviceNAme = getHeaderValue(RequestHeader.`PSU-Device-Name`, requestHeaders) - if(psuIpAddress == "0.0.0.0" || psuDeviceId == "no-psu-involved" || psuDeviceNAme == "no-psu-involved") { - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuIpAddress: $psuIpAddress") - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuDeviceId: $psuDeviceId") - logger.debug(s"isTppRequestsWithoutPsuInvolvement.psuDeviceNAme: $psuDeviceNAme") - true - } else { - false - } + def valueOf(name: String): Option[String] = + requestHeaders.find(_.name.equalsIgnoreCase(name)).map(_.values.mkString.trim).filter(_.nonEmpty) + + val psuIpAddress = valueOf(RequestHeader.`PSU-IP-Address`) + val markedAsUnattended = psuIpAddress.contains("0.0.0.0") || + valueOf(RequestHeader.`PSU-Device-ID`).contains("no-psu-involved") || + valueOf(RequestHeader.`PSU-Device-Name`).contains("no-psu-involved") + + val withoutPsu = psuIpAddress.isEmpty || markedAsUnattended + logger.debug(s"isTppRequestsWithoutPsuInvolvement: $withoutPsu (PSU-IP-Address: $psuIpAddress)") + withoutPsu } def validate(body: Box[String], verb: String, url: String, reqHeaders: List[HTTPParam], forwardResult: (Box[User], Option[CallContext])): OBPReturnType[Box[User]] = { diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index c3de4f1fc1..d8b015e8a7 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -57,6 +57,11 @@ object BerlinGroupError { case "401" if message.contains("OBP-20203") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20206") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20207") => "PSU_CREDENTIALS_INVALID" + // The table above defines this code as "PSU-ID cannot be found by ASPSP", which is both of + // these: a PSU-ID naming nobody (OBP-20027), and an authorisation with no PSU identifiable at + // all (OBP-35039). + case "401" if message.contains("OBP-20027") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-35039") => "PSU_CREDENTIALS_INVALID" case "401" if message.contains("OBP-20204") => "TOKEN_EXPIRED" case "401" if message.contains("OBP-20215") => "TOKEN_INVALID" diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 4b294c1774..28ad965831 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -22,7 +22,10 @@ import code.model.Consumer import code.model.dataAccess.BankAccountRouting import code.scheduler.ConsentScheduler.currentDate import code.users.Users +import code.util.Helper import code.util.Helper.MdcLoggable +import code.counterpartylimit.CounterpartyLimitProvider +import code.metadata.counterparties.Counterparties import code.views.Views import com.nimbusds.jwt.JWTClaimsSet import com.openbankproject.commons.ExecutionContext.Implicits.global @@ -38,8 +41,19 @@ import net.liftweb.util.Props import java.text.SimpleDateFormat import java.util.Date import scala.collection.immutable.{List, Nil} -import scala.concurrent.Future - +import scala.concurrent.{Await, Future} +import scala.concurrent.duration._ +// Not scala.util.Failure: net.liftweb.common._ above already binds that name to Box's failure case. +import scala.util.{Success, Try} + +// Design boundary (not enforced by the compiler — keep it that way by convention): consent-layer +// attributes belong on the consent record, never as a View can_* permission. BG's +// frequencyPerDay/recurringIndicator/validUntil and UK's TransactionFromDateTime/ToDateTime/ +// ExpirationDateTime (exp/nbf above) already live here for that reason. Do not add a can_* string +// like "can_see_transactions_last_90_days" to a system view's permission set to express a +// consent's time-boxing or access-frequency limit — that's a property of *this* consent, not of +// the account's view. See MapperViews.applyDefaultsForSystemView / Constant.SYSTEM_READ_*_VIEW_PERMISSION +// for where view-layer can_* sets are defined. case class ConsentJWT(createdByUserId: String, sub: String, // An identifier for the user, unique among all OBP-API users and never reused iss: String, // The Issuer Identifier for the Issuer of the response. @@ -381,16 +395,78 @@ object Consent extends MdcLoggable { } + /** + * Materialise a consent's views as AccountAccess rows, always under ALL_CONSUMERS. + * + * Every standard wants this and none wants anything else: each consent resolves to its own shadow + * user (createXxxConsentJWT gives every consent a random `sub`, which applyConsentRules and + * resolveUKConsentPrincipal turn into a distinct user), so a consent's rows are isolated by + * construction and there is nothing for a consumer to disambiguate. UK used to be the exception -- + * it granted to the real PSU and passed the consent's own consumerId -- but it moved to the shadow + * user like the others, which left the parameter with no caller and the scaladoc describing an + * arrangement that no longer existed. + * + * ==Do not reintroduce consumer scoping here without changing revokeConsentAccountAccess== + * + * That is why the parameter is gone rather than merely unused. AccountAccess.consumer_id holds the + * literal string ALL_CONSUMERS, not a wildcard, and every lookup matches it by equality + * (MapperViews.accessGrantedToUserForConsumer, User.hasAccountAccess). revokeConsentAccountAccess + * sweeps a revoked consent's rows by asking for exactly ALL_CONSUMERS, so a row written under a + * real consumer id would have been invisible to it and would have outlived the consent that + * created it -- access that no longer has a consent behind it, left in the table with nothing to + * remove it. Keeping the branch as dead code kept that trap armed for whoever used it next. + */ private def grantAccessToViews(user: User, consent: ConsentJWT): Box[User] = { + val consumerId = Constant.ALL_CONSUMERS + val wanted: List[BankIdAccountIdViewId] = consent.views.map { view => + BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id), ViewId(view.view_id)) + }.distinct + + // Reconcile rather than revoke-then-regrant. This runs on every request that presents the + // consent, so deleting a row and putting it back left a window in which the row did not exist: + // a second concurrent request for the same consent would delete the row the first had just + // re-granted, and the first would then fail its own access check with a 403 it could do nothing + // about. Two requests could also insert the same row at once and collide on the unique index. + // Touching only the difference means a steady-state request writes nothing at all. + val held: List[BankIdAccountIdViewId] = Views.views.vend.accessGrantedToUserForConsumer(user, consumerId) + val wantedSet = wanted.toSet + // A failed revoke leaves the consent holding a view it no longer declares, which is the one + // direction of this reconciliation that matters: a grant that fails denies access the consent + // asked for, and the caller sees that immediately, but a revoke that fails keeps access the + // consent gave up, and nothing downstream can tell. The result used to be discarded outright, + // so the only symptom was data the consent no longer covered still being served. + // + // Logged rather than returned. Failing the request would make a consent stuck in this state + // unusable altogether -- its remaining, legitimate views included -- and the operator would have + // no way back short of deleting rows by hand. The narrower reading is that the consent still + // holds everything it declares, plus one view it should have lost; refusing everything is a + // worse answer to that than serving it and saying so loudly. + // + // canRevokeOwnerAccess is the realistic trigger (MapperViews): it refuses to drop an `owner` + // row when no other principal holds one on that account, which an OBP-native consent can reach + // because createConsentJWT takes its views from whatever the PSU already holds, `owner` included. for { - view <- consent.views + staleAccess <- held.filterNot(wantedSet.contains) } yield { - val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) - Views.views.vend.revokeAccess(bankIdAccountIdViewId, user) + Views.views.vend.revokeAccess(staleAccess, user) match { + case Full(true) => // gone + case other => + logger.warn( + s"grantAccessToViews: could not revoke ${staleAccess.viewId.value} on " + + s"${staleAccess.bankId.value}/${staleAccess.accountId.value} from user ${user.userId}, " + + s"which consent ${consent.jti} no longer declares. The access is still held: $other") + } } + + val heldSet = held.toSet val result: List[Box[View]] = { for { - view <- consent.views + // A view that is already granted and still wanted needs nothing done to it, and nothing + // looked up either: on a consent that has been used before -- the common case, since this + // runs on every request -- this loop does no work at all. + view <- consent.views.filterNot { view => + heldSet.contains(BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id), ViewId(view.view_id))) + } } yield { val bankIdAccountIdViewId = BankIdAccountIdViewId(BankId(view.bank_id), AccountId(view.account_id),ViewId(view.view_id)) Views.views.vend.systemView(ViewId(view.view_id)) match { @@ -571,6 +647,22 @@ object Consent extends MdcLoggable { } } + /** + * Resolve the stored consent behind either spelling of the OBP consent header: a bare consent id + * in Consent-Id, or the consent JWT itself in Consent-JWT (whose jti is the consent id). + * + * Returns the row whatever standard created it -- the caller decides which gate to apply. Nothing + * here is authentication: the value is unverified caller input until one of the applyXxxRules + * functions has checked it. + */ + def getConsentByHeaderValue(headerValue: String): Option[MappedConsent] = { + getConsentJwtValueByConsentId(headerValue) // Consent-Id: a bare consent id + .orElse { // Consent-JWT: the consent JWT itself + JwtUtil.getOptionalClaim("jti", headerValue) + .flatMap(jti => Consents.consentProvider.vend.getConsentByConsentId(jti).toOption) + } + } + private def copyAuthContextOfConsentToUser(consentId: String, userId: String, newUser: Boolean): Box[List[UserAuthContext]] = { if(newUser) { val authContexts = ConsentAuthContextProvider.consentAuthContextProvider.vend.getConsentAuthContextsBox(consentId) @@ -615,8 +707,24 @@ object Consent extends MdcLoggable { } } + /** + * Whether this pass through the authentication pipeline is the one that will actually serve the + * request, and so the one that should spend a frequencyPerDay access. + * + * The pipeline runs many times per HTTP request: each API version wraps its own routes in its + * own ResourceDocMiddleware, and a middleware whose index holds no matching doc still runs + * best-effort authentication before falling through to the next one + * (ResourceDocMiddleware.scala, the `case None` branch). Only the middleware that matched a + * ResourceDoc attaches it to the CallContext, so its presence is exactly the distinction. + * + * Without this, one request spent an access per middleware in the chain, which drove the + * counter to frequencyPerDay before the request was served: a consent asking for four accesses + * a day got none, answering 429 to its very first call. + */ + def isTheServingPass: Boolean = callContext.resourceDocument.isDefined + def checkFrequencyPerDay(storedConsent: consent.ConsentTrait) = { - if(BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { + if(isTheServingPass && BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { def isSameDay(date1: Date, date2: Date): Boolean = { val fmt = new SimpleDateFormat("yyyyMMdd") fmt.format(date1).equals(fmt.format(date2)) @@ -665,7 +773,7 @@ object Consent extends MdcLoggable { logger.debug(s"End of com.openbankproject.commons.util.JsonAliases.parse(jsonAsString).extract[ConsentJWT].checkConsent.consentBox: $consent") consentBox match { // Check is it Consent-JWT expired case (Full(true)) => // OK - if(BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { + if(isTheServingPass && BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(callContext.requestHeaders)) { // Update MappedConsent.usesSoFarTodayCounter field val consentUpdatedBox = Consents.consentProvider.vend.updateBerlinGroupConsent(consentId, currentCounterState + 1) logger.debug(s"applyBerlinGroupConsentRulesCommon.consentUpdatedBox: $consentUpdatedBox") @@ -711,6 +819,348 @@ object Consent extends MdcLoggable { case (None, _) => Future((Failure(ErrorMessages.ConsentHeaderNotFound), Some(callContext))) } } + /** + * Authenticate a request that presents a UK Open Banking consent as its sole credential, in a + * Consent-Id / Consent-JWT request header, with no Authorization header at all. + * + * UK Open Banking itself binds a consent to an OAuth2 access token (the consent_id claim that + * checkUKConsent reads). This is an OBP extension alongside that: it lets a client exercise a UK + * consent the way Berlin Group and OBP-native clients already exercise theirs. The token path is + * unaffected. + * + * Every gate checkUKConsent applies is applied here too -- standard, AUTHORISED status, not-before + * / expiry, and the consumer match -- so the header path is never the weaker of the two. The + * user-binding check that checkUKConsent performs (consent.mUserId == the token's user) has no + * analogue here and needs none: there is no independently-authenticated user to disagree with, + * because the PSU *is* resolved from the consent's own mUserId. + * + * Signature verification is stricter than the token path needs. On the token path OAuth2Login has + * already verified the access token's signature before checkUKConsent runs; here the caller may + * hand us the consent JWT itself, so it is verified against the consent's stored secret. + * + * The principal is the consent's own shadow user (see resolveUKConsentShadowUser); the PSU the + * consent belongs to is carried alongside it on the CallContext as `consenter`. + */ + def applyUKRules(storedConsent: MappedConsent, + consentHeaderValue: String, + callContext: CallContext): Future[(Box[User], Option[CallContext])] = Future { + val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty = "consents.allowed", defaultValue = false) + if (!allowed) { + (Failure(ErrorMessages.ConsentDisabled), Some(callContext)) + } else { + val result: Box[(User, CallContext)] = for { + // A consent may only be exercised by its own standard. + _ <- assertConsentStandard(storedConsent, ConsentStandardUK) match { + case Some(failure) => failure + case None => Full(true) + } + _ <- if (storedConsent.status.toUpperCase == ConsentStatus.AUTHORISED.toString) Full(true) + else Failure(s"${ErrorMessages.ConsentStatusIssue}${ConsentStatus.AUTHORISED.toString}.") + currentTimeMillis = System.currentTimeMillis + _ <- if (currentTimeMillis >= storedConsent.creationDateTime.getTime) Full(true) + else Failure(ErrorMessages.ConsentNotBeforeIssue) + // A null expirationDateTime means the consent never expires (0..1 per spec, open-ended if + // absent -- see createUKConsentJWT). + _ <- if (!Option(storedConsent.expirationDateTime).exists(_.getTime < currentTimeMillis)) Full(true) + else Failure(ErrorMessages.ConsentExpiredIssue) + // Only meaningful when the caller supplied the JWT itself; a bare consent id carries + // nothing to forge, since the JWT is then read straight from our own row. + _ <- if (!JwtUtil.checkIfStringIsJWTValue(consentHeaderValue).isDefined) Full(true) + else if (verifyHmacSignedJwt(consentHeaderValue, storedConsent)) Full(true) + else Failure(ErrorMessages.ConsentVerificationIssue) + consentJwt <- { + implicit val dateFormats = CustomJsonFormats.formats + JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + } ?~! ErrorMessages.ConsentNotFound + _ <- checkConsumerIsActiveAndMatchedUK( + consentJwt, + callContext.consumer.map(_.consumerId.get) + ) + // The PSU bound to the consent by updateConsentUser during the authorise ceremony. A + // consent that was never authorised has no user, and the status gate above already + // rejected it -- this is the belt to that braces. + psu <- Users.users.vend.getUserByUserId(storedConsent.userId) ?~! ErrorMessages.ConsentNotFound + principal <- resolveUKConsentPrincipal(storedConsent, consentJwt, psu) + } yield { + (principal, callContext.copy( + // The PSU stays reachable for everything that needs a human: the CBS adapter, metric + // attribution, and CallContext.effectiveHumanUserId. + consenter = Full(psu), + ukConsentId = Some(storedConsent.consentId), + consentReferenceId = Some(storedConsent.consentReferenceId) + )) + } + + result match { + case Full((user, updatedCallContext)) => (Full(user), Some(updatedCallContext)) + case failure@Failure(_, _, _) => (failure, Some(callContext)) + case _ => (Failure(ErrorMessages.ConsentNotFound), Some(callContext)) + } + } + } + + /** + * Drop the account access a consent's shadow user holds. + * + * Revoking or expiring a consent only ever flipped a status column, which was enough while the + * status gate was the only thing standing between the caller and the data. It leaves the granted + * AccountAccess rows in the table forever, so anything that reads them without going through the + * consent gate -- the account-permissions listing, a future endpoint, an operator looking at the + * database -- still sees a revoked consent's access as live. A shadow user's rows belong to + * exactly one consent, so for the first time they can be cleaned up without guessing. + * + * Safe for a consent whose shadow user was never minted (one that predates account binding and + * still runs as the PSU): the lookup simply finds nothing. It must never fall back to the PSU -- + * that would delete access the PSU holds in their own right. + * + * The ALL_CONSUMERS below is an exact match on the column, not a wildcard, so this is complete + * only because grantAccessToViews writes nothing else. The two have to stay in step: see the + * warning on grantAccessToViews before giving a consent's rows a real consumer id. + */ + def revokeConsentAccountAccess(consent: code.consent.ConsentTrait): Unit = { + implicit val dateFormats = CustomJsonFormats.formats + val consentJwtBox = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map(parse(_).extract[ConsentJWT]) + + val revoked = for { + consentJwt <- consentJwtBox + shadowUser <- Users.users.vend.getUserByProviderId(provider = consentJwt.iss, idGivenByProvider = consentJwt.sub) + } yield { + val (dropped, stuck) = Views.views.vend.accessGrantedToUserForConsumer(shadowUser, Constant.ALL_CONSUMERS) + .map(access => access -> Views.views.vend.revokeAccessToViewForUserAndConsumer(access, shadowUser, Constant.ALL_CONSUMERS)) + .partition(_._2 == Full(true)) + (dropped.size, stuck) + } + revoked match { + case Full((count, stuck)) => + if (count > 0) { + logger.info(s"revokeConsentAccountAccess: dropped $count account access rows for consent ${consent.consentId}") + } + // revokeAccessToViewForUserAndConsumer can refuse -- canRevokeOwnerAccess will not drop the + // last `owner` row on an account -- so counting the attempts rather than the successes + // reported rows as gone while they were still there. On a revoked consent that is the worst + // version of this to get wrong: the access outlives the consent entirely, and nothing else + // ever comes back for it. + for ((access, outcome) <- stuck) { + logger.warn( + s"revokeConsentAccountAccess: could not revoke ${access.viewId.value} on " + + s"${access.bankId.value}/${access.accountId.value} for revoked consent " + + s"${consent.consentId}. The access outlives the consent: $outcome") + } + case _ => + } + + // Deliberately outside the comprehension above, and after it. Outside, because a VRP consent + // that never reached SCA has no shadow user, and its mandate still has to be released -- an + // abandoned mandate is exactly the case that used to accumulate. After, because the view can + // only be removed once every access row pointing at it is gone, the shadow user's included. + consentJwtBox.foreach(releaseVrpMandateArtefacts(consent, _)) + } + + /** + * Release the artefacts a VRP mandate created, when the consent that owns them is revoked. + * + * Converting a VRP consent-request builds a private custom view named `_vrp-`, grants it to + * the PSU, hangs a counterparty off it and gives that counterparty a limit. Together they are the + * mandate: the view carries CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, and the limit is how much + * may be paid under it. Revoking the consent used to drop only the shadow user's access, so the + * PSU kept a live standing payment authority for a mandate they had just cancelled -- and one set + * of these accumulated on the account for every mandate ever requested, revoked or abandoned. + * + * Each artefact is named after the view, and the view belongs to exactly one consent, so this can + * be undone without guessing. What gets released, and what deliberately does not: + * + * - the PSU's grant on the view, which is the authority itself; + * - the counterparty's limit, which is the amount that authority was good for; + * - the view, but only once no access row is left pointing at it -- removeCustomView refuses + * otherwise, so a view some other principal still holds is left alone rather than orphaning it. + * + * The counterparty row stays. It is a payee record that settled transactions refer to, and + * deleting it would take history with it; with the view and the limit gone it grants nothing. + */ + private def releaseVrpMandateArtefacts(consent: code.consent.ConsentTrait, consentJwt: ConsentJWT): Unit = { + val vrpViews = consentJwt.views.filter(_.view_id.startsWith(Constant.VRP_VIEW_ID_PREFIX)) + if (vrpViews.nonEmpty) { + Users.users.vend.getUserByUserId(consent.userId) match { + case Full(psu) => + vrpViews.foreach { consentView => + val bankId = BankId(consentView.bank_id) + val accountId = AccountId(consentView.account_id) + val viewId = ViewId(consentView.view_id) + + Views.views.vend.revokeAccessToViewForUserAndConsumer( + BankIdAccountIdViewId(bankId, accountId, viewId), psu, Constant.ALL_CONSUMERS) + + // A Failure here is not "this view has no counterparties" -- treating it as such would + // skip the deletions and still report success below. + Counterparties.counterparties.vend.getCounterparties(bankId, accountId, viewId) match { + case Full(counterparties) => + counterparties.foreach { counterparty => + // Awaited so the deletion's outcome is observed rather than left in an unwatched + // Future -- but caught, because the caller has already committed the revoke. The + // status flip and the shadow user's access are gone by the time this runs, and a + // second attempt is refused with ConsentAlreadyRevoked, so letting a timeout or a + // connector failure escape would turn a completed revoke into a 500 and abandon + // the views still queued behind this one. + Try(Await.result( + CounterpartyLimitProvider.counterpartyLimit.vend.deleteCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), + 10.seconds)) match { + case Success(deleted) if deleted.isDefined => // released + case other => logger.warn( + s"releaseVrpMandateArtefacts: could not delete the limit on ${viewId.value} " + + s"for counterparty ${counterparty.counterpartyId}: $other") + } + } + case other => + logger.warn(s"releaseVrpMandateArtefacts: could not list counterparties on " + + s"${viewId.value} for consent ${consent.consentId}, limits left in place: $other") + } + + Views.views.vend.removeCustomView(viewId, BankIdAccountId(bankId, accountId)) match { + case Full(_) => + logger.info(s"releaseVrpMandateArtefacts: released ${viewId.value} for consent ${consent.consentId}") + case other => + // Something still holds the view. Its authority is gone either way; say so and stop. + logger.info(s"releaseVrpMandateArtefacts: kept ${viewId.value} for consent ${consent.consentId}: $other") + } + } + case _ => + logger.warn(s"releaseVrpMandateArtefacts: no PSU on consent ${consent.consentId}, mandate views left in place") + } + } + } + + + /** + * The Bearer-token half of the shadow-user resolution. + * + * A UK consent normally travels as a `consent_id` claim inside an OAuth2 access token. On that + * path the request is authenticated as the PSU long before any UK code runs, and checkUKConsent -- + * which validates the consent -- executes inside the endpoint, by which point the principal is + * already fixed. So the swap has to happen here, at the end of authentication, where the + * CallContext the endpoint will see is still being assembled. + * + * This performs no validation and cannot widen anything: it either narrows the principal to the + * consent's shadow user or leaves the request exactly as it was. checkUKConsent still runs + * afterwards and still rejects a consent that is the wrong standard, revoked, expired, bound to a + * different PSU, or held by a different consumer -- with the same status codes as before. + */ + def applyUKConsentPrincipalFromToken(user: Box[User], + callContext: Option[CallContext]): (Box[User], Option[CallContext]) = { + def swap: Option[(Box[User], Option[CallContext])] = for { + cc <- callContext + // The Consent-Id / Consent-JWT header path resolved the principal already, in applyUKRules. + if cc.ukConsentId.isEmpty + psu <- user.toOption + accessToken <- cc.authReqHeaderField.toOption.map(_.replaceFirst("Bearer\\s+", "").trim) + if JwtUtil.checkIfStringIsJWTValue(accessToken).isDefined + consentId <- JwtUtil.getOptionalClaim("consent_id", accessToken) + storedConsent <- Consents.consentProvider.vend.getConsentByConsentId(consentId).toOption + if storedConsent.apiStandard == ConsentStandardUK + consentJwt <- { + implicit val dateFormats = CustomJsonFormats.formats + JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken).map(parse(_).extract[ConsentJWT]).toOption + } + principal <- resolveUKConsentPrincipal(storedConsent, consentJwt, psu).toOption + } yield { + (Full(principal), Some(cc.copy( + user = Full(principal), + consenter = Full(psu), + consentReferenceId = Some(storedConsent.consentReferenceId) + ))) + } + // Anything unexpected -- an unparseable token, a consent row that has gone -- leaves the request + // as it is rather than failing authentication for a reason the caller cannot act on. + scala.util.Try(swap).toOption.flatten.getOrElse((user, callContext)) + } + + /** + * The user a UK consent's data access runs as: the consent's own shadow user. + * + * Every consent JWT carries a random UUID in `sub` (createUKConsentJWT, same as Berlin Group and + * OBP-native). Berlin Group and OBP-native resolve that UUID to a user of its own and grant it the + * consent's views, so one consent's AccountAccess rows can never be another's. UK used to skip + * that and grant to the real PSU instead, which is why two consents held by the same TPP for the + * same PSU wrote to one set of rows and rewrote each other -- and why a consent could read + * accounts it never named. Resolving to the shadow user makes the isolation structural: the rows + * belong to an identity that exists only for this consent. + * + * It also closes the bypasses for free. The shadow user holds no entitlements (a UK consent JWT + * carries `entitlements = Nil`), so account firehose and the ABAC fallback -- both of which are + * consulted before the AccountAccess check in APIUtil.hasAccountAccess -- can never answer for it. + * And it holds no `owner` view, so the account-listing query (a bare WHERE user_id = ?) returns + * exactly the consent's accounts without the query having to learn about consents at all. + * + * The views are re-granted from the JWT on every request, as Berlin Group does: the JWT is the + * source of truth, and the rows are only its materialisation. Narrowing a consent therefore takes + * effect immediately, with no sweep of anything. + * + * ==A consent that names no account grants no access== + * + * A consent authorised before grantUKConsentAccountAccess existed still carries the + * `(null, null, permission)` placeholder views createUKConsentJWT writes at creation time. Nothing + * created today can look like that: the authorise endpoint rejects an empty account_ids with 400, + * so every consent it accepts names real accounts. These are old rows and nothing else. + * + * Such a consent used to fall back to running as the PSU. That is the widest possible reading of a + * consent that selected nothing -- the PSU's own AccountAccess rows govern, so the TPP saw + * everything the PSU can see, and the consent's declared Permissions constrained none of it. The + * fallback was chosen to avoid breaking rows that predated the feature, but "we cannot tell which + * accounts this consent covers" is a reason to serve nothing, not a reason to serve everything. + * + * So it is refused. Re-authorising binds the consent to accounts and it works again; the error + * says so. `uk_consent_allow_unbound_legacy` restores the old behaviour for an operator who needs + * a migration window and accepts what it means -- default false, and it warns on every use. + */ + private def resolveUKConsentPrincipal(storedConsent: MappedConsent, consentJwt: ConsentJWT, psu: User): Box[User] = { + val namesRealAccounts = consentJwt.views.exists { view => + Option(view.bank_id).exists(_.trim.nonEmpty) && Option(view.account_id).exists(_.trim.nonEmpty) + } + if (!namesRealAccounts) { + if (APIUtil.getPropsAsBoolValue(nameOfProperty = "uk_consent_allow_unbound_legacy", defaultValue = false)) { + logger.warn( + s"UK consent ${storedConsent.consentId} names no real account in its JWT views -- it predates " + + s"account binding. uk_consent_allow_unbound_legacy is set, so it runs as the PSU: this consent's " + + s"declared Permissions place no limit on what it can read. Re-authorise it to bind it to accounts, " + + s"then unset the property.") + Full(psu) + } else { + logger.warn( + s"UK consent ${storedConsent.consentId} names no real account in its JWT views -- it predates " + + s"account binding, so there is nothing it can be scoped to and it is refused. Re-authorise it to " + + s"bind it to accounts. Set uk_consent_allow_unbound_legacy=true to serve it as the PSU meanwhile, " + + s"which places no limit on what it can read.") + Failure(ErrorMessages.ConsentNamesNoAccount) + } + } else { + for { + shadowUser <- getOrCreateUKConsentShadowUser(storedConsent, consentJwt) + _ <- grantAccessToViews(shadowUser, consentJwt) + } yield shadowUser + } + } + + /** + * Get (or first mint) the shadow user for a UK consent, copying the consent's snapshot of the + * PSU's auth context onto it the first time -- the connectors read those key/value pairs to + * identify the customer, so a shadow user without them would be a different caller to the CBS. + */ + private def getOrCreateUKConsentShadowUser(storedConsent: MappedConsent, consentJwt: ConsentJWT): Box[User] = { + // Reuses the shared get-or-create rather than doing its own find-then-insert: this runs on + // every request, so the very first two requests for a new consent race, and only the shared one + // recovers from the resulting unique-index violation by re-reading. + val (user, isNew) = Users.users.vend.getOrCreateUserByProviderId( + provider = consentJwt.iss, + idGivenByProvider = consentJwt.sub, + consentId = Some(storedConsent.consentId), + name = None, + email = None) + for { + shadowUser <- user ?~! ErrorMessages.CannotGetOrCreateUser + _ = if (isNew) copyAuthContextOfConsentToUser(storedConsent.consentId, shadowUser.userId, newUser = true) + } yield shadowUser + } + def applyRulesOldStyle(consentId: Option[String], callContext: CallContext): (Box[User], CallContext) = { val allowed = APIUtil.getPropsAsBoolValue(nameOfProperty="consents.allowed", defaultValue=false) (consentId, allowed) match { @@ -1046,14 +1496,463 @@ object Consent extends MdcLoggable { Full(CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret)) } + /** + * The permission codes UK Open Banking defines for an account-access-consent. + * + * Taken from the Account and Transaction API profile's permission table. Codes outside this set + * are not UK permission codes at all, which is a different thing from a code this ASPSP happens + * not to implement -- see validateUKConsentPermissions. + */ + private val ukPermissionCodes: Set[String] = Set( + "ReadAccountsBasic", "ReadAccountsDetail", + "ReadBalances", + "ReadBeneficiariesBasic", "ReadBeneficiariesDetail", + "ReadDirectDebits", + "ReadOffers", + "ReadPAN", + "ReadParty", "ReadPartyPSU", + "ReadProducts", + "ReadScheduledPaymentsBasic", "ReadScheduledPaymentsDetail", + "ReadStandingOrdersBasic", "ReadStandingOrdersDetail", + "ReadStatementsBasic", "ReadStatementsDetail", + "ReadTransactionsBasic", "ReadTransactionsCredits", + "ReadTransactionsDebits", "ReadTransactionsDetail" + ) + + /** + * Check a UK account-access-consent's Permissions array against the combinations the standard + * forbids, returning the reason it must be refused or None when it is well formed. + * + * The Account and Transaction API profile requires the ASPSP to reject these with 400, and the + * rules are not arbitrary: every AIS endpoint other than /accounts is /accounts/{AccountId}/..., + * so a consent without an account-read permission can never discover the ids it would need and + * is a dead end -- it authorises data the AISP has no way to reach. Accepting one produces a + * consent that looks authorised and returns an empty account list forever. + * + * Deliberately not enforced: "a permission code that is not supported by the ASPSP". That rule + * is about the endpoint subset an ASPSP publishes, and OBP publishes no such list, so rejecting + * on it would be guesswork. A code that is not a UK permission code at all is still refused. + * + * Requesting both a Basic and its corresponding Detail code is explicitly allowed: the profile + * calls it duplication but forbids rejecting on that basis alone. + */ + def validateUKConsentPermissions(permissions: List[String]): Option[String] = { + val granted = permissions.toSet + val transactionDepth = Set("ReadTransactionsBasic", "ReadTransactionsDetail") + val transactionDirection = Set("ReadTransactionsCredits", "ReadTransactionsDebits") + + if (permissions.isEmpty) { + Some("The Permissions array must not be empty.") + } else { + val unknown = granted.diff(ukPermissionCodes) + if (unknown.nonEmpty) { + Some(s"Unknown permission code(s): ${unknown.toList.sorted.mkString(", ")}.") + } else if (granted.intersect(Set("ReadAccountsBasic", "ReadAccountsDetail")).isEmpty) { + Some("The Permissions array must contain at least one of ReadAccountsBasic and ReadAccountsDetail.") + } else if (granted.intersect(transactionDepth).nonEmpty && + granted.intersect(transactionDirection).isEmpty) { + Some("A Permissions array containing ReadTransactionsBasic or ReadTransactionsDetail must also " + + "contain at least one of ReadTransactionsCredits and ReadTransactionsDebits.") + } else if (granted.intersect(transactionDirection).nonEmpty && + granted.intersect(transactionDepth).isEmpty) { + Some("A Permissions array containing ReadTransactionsCredits or ReadTransactionsDebits must also " + + "contain at least one of ReadTransactionsBasic and ReadTransactionsDetail.") + } else { + None + } + } + } + + /** + * Decide whether a caller may read or revoke a UK account-access-consent, returning the reason to + * refuse with 403 or None when it is allowed. + * + * The standard names one caller, and it is not the PSU. In the Endpoints table of + * account-access-consents, in both v3.1 and v4.0.1, GET and DELETE carry Grant Type "Client + * Credentials", and the prose repeats it: "Prior to calling the API, the AISP must have an access + * token issued by the ASPSP using a client credentials grant." GET retrieves a consent "that they + * have created"; DELETE is what the AISP does after the PSU has revoked consent with the AISP. + * So for a standard caller there is no PSU in the session at all, and the Consumer the consent was + * lodged under is the whole of the authorisation: that AISP may read and revoke it whatever its + * status, and no other Consumer may. + * + * The user check is kept for a caller that does present a PSU. OBP allows credentials the standard + * does not describe here, and for those the stricter rule applies: a session acting as one PSU + * cannot reach another PSU's consent, and the lodging TPP cannot use a PSU session to do it + * either. That path is a superset of the standard, never a way around it -- the PSU comparison + * only ever narrows, and a caller with no PSU never reaches it. + * + * Blank ids are treated as absent: a consent that was never authorised stores no user id, and one + * lodged before consumer binding existed stores no consumer id. + * + * Kept here rather than inline so the four endpoints that need it (v3.1 and v4.0.1, GET and + * DELETE) share one definition, and so the rule can be tested without standing up a request -- + * same reasoning as validateUKConsentPermissions above. + */ + def checkUKConsentAccess( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String], + callerIsScaFrontEnd: Boolean + ): Option[String] = { + val stillUnclaimed = Option(consentUserId).map(_.trim).forall(_.isEmpty) + // The ASPSP's own approval screen has to read the consent to show the PSU what they are being + // asked to grant, and it arrives under its own Consumer rather than the TPP's -- so the lodging + // Consumer comparison refuses precisely the caller whose whole job is to inform the PSU, and the + // screen renders with no permissions, no status and no expiry. + // + // Narrow on purpose. It applies only while the consent is still unclaimed, which is the only + // window the approval screen exists for; once a PSU is bound, the PSU half below governs and a + // declared front end gets no further than anyone else. And it is inert unless an ASPSP has + // declared a front end at all. + if (callerIsScaFrontEnd && stillUnclaimed) None + else psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + } + + /** + * Decide whether a caller may drive a Berlin Group consent's authorisation sub-resources -- + * starting one, and answering it, the two steps that bind the consent to a PSU -- returning the + * reason to refuse with 403 or None when it is allowed. + * + * The Consumer half is the standard's own blanket rule, not a UK import. The Implementation + * Guidelines state it once for the whole API, in section 4.11 "API Access Methods" (p.24): "all + * methods submitted by a TPP, which are addressing dynamically created resources in this API, may + * only apply to resources which have been created by the same TPP before." A consent and its + * authorisation sub-resources are exactly such dynamically created resources, so the Consumer the + * consent was lodged under is what identifies a legitimate caller. Two endpoints in this family + * already enforce that inline -- deleteConsent and getConsentInformation -- and only the + * authorisation pair was left without it. + * + * Note who that caller is. In Berlin Group the PSU does not call the API: under the Redirect + * approach it authenticates at the ASPSP, and under Embedded it hands its authentication factors + * to the TPP, which relays them. So on this pair the session is the TPP's, and a Consumer check is + * the substantive one -- which is why callerUserId must be a genuine PSU (see genuinePsu) rather + * than whatever principal the token resolved to. + * + * The PSU half covers re-binding. updateConsentUser overwrites mUserId unconditionally, so without + * it a second party could take over a consent another PSU has already authorised. The standard + * leaves PSU identity to the ASPSP to enforce and says so where it defines the PSU-ID header: "the + * ASPSP might check whether PSU-ID and token match, according to ASPSP documentation". Where the + * caller does present a genuine PSU and the consent already names one, they must be the same. + * + * This lands on the same rule as checkUKConsentAccess, and the agreement is worth stating because + * the two derivations are not the same. UK's rests on a per-endpoint claim -- its Endpoints table + * marks these calls Client Credentials, so no PSU is party to them. Berlin Group's rests on the + * blanket same-TPP rule above plus PSU binding happening at SCA time. Different premises, same + * conclusion, so they share one implementation rather than one being copied onto the other. + * + * With one exception, and it is in the wording of the rule itself: it binds "methods submitted by + * a TPP". Under the Redirect approach the PSU authenticates at the ASPSP, so these calls arrive + * from the ASPSP's own front end -- not a TPP, and never the Consumer that lodged the consent. + * Applying the rule there refuses the only caller Redirect has, and the scaRedirect ceremony + * cannot complete at all. + * + * Nothing in the request tells that front end apart from a second TPP holding a PSU session -- + * both are an authenticated person arriving under a Consumer that did not lodge the consent -- so + * the ASPSP declares its own, and callerIsScaFrontEnd is that declaration reaching this rule. See + * APIUtil.scaFrontEndConsumerIds. It is not a way past the PSU half: a consent already + * bound to someone still only re-binds to them. + * + * What it is emphatically not is a substitute for checking that the claiming PSU has anything to + * do with the accounts the consent names. A Consumer comparison could never have provided that -- + * the TPP that lodged the consent passes it by definition, and is the party the access accrues to + * -- so that check lives on its own in assertBerlinGroupConsentAccountsHeld, which the + * authorisation pair applies before anything is written. + */ + def checkBerlinGroupConsentAccess( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String], + callerIsScaFrontEnd: Boolean + ): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + (present(consentUserId), callerUserId.flatMap(present)) match { + case (Some(psu), Some(caller)) if psu != caller => Some(ErrorMessages.ConsentDoesNotMatchUser) + case (Some(_), Some(_)) => None + // Only while the consent is still unclaimed. A caller with no PSU used to reach this too, so a + // declared front end presenting client credentials could drive the authorisation of a consent + // already bound to somebody else -- the opposite of what the paragraph above promises. + case (None, _) if callerIsScaFrontEnd => None + case _ => psuOrLodgingTppRefusal(consentUserId, consentConsumerId, callerUserId, callerConsumerId) + } + } + + /** + * Whether the Consumer making this call is one the ASPSP declared as its own SCA front end. + * + * Not Berlin-Group-specific: the same front end drives the UK approval screen, and the same + * difficulty applies there -- nothing in the request distinguishes the ASPSP's own screen from a + * second TPP holding a PSU session, so the ASPSP has to say which Consumer is its own. + */ + def isScaFrontEnd(callerConsumerId: Option[String]): Boolean = + callerConsumerId.map(_.trim).filter(_.nonEmpty) + .exists(APIUtil.scaFrontEndConsumerIds.contains) + + /** + * The rule shared by checkUKConsentAccess and checkBerlinGroupConsentAccess: a consent bound to a + * PSU belongs to that PSU, and otherwise belongs to the Consumer that lodged it. + * + * Blank ids are treated as absent: a consent that was never authorised stores no user id, and one + * lodged before consumer binding existed stores no consumer id. + * + * Private on purpose. Each standard states its own case in its own scaladoc above; this is only + * the mechanism they turned out to share, and it carries no argument of its own. + */ + private def psuOrLodgingTppRefusal( + consentUserId: String, + consentConsumerId: String, + callerUserId: Option[String], + callerConsumerId: Option[String] + ): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + (present(consentUserId), callerUserId.flatMap(present)) match { + case (Some(psu), Some(caller)) => + // The consent belongs to a PSU and the caller is acting as one: they must be the same PSU. + if (psu == caller) None else Some(ErrorMessages.ConsentDoesNotMatchUser) + case _ => + // Either the consent has no PSU yet, or the caller is not acting as one. Either way the + // Consumer that lodged it is what identifies a legitimate caller. + val owner = present(consentConsumerId) + if (owner.forall(id => callerConsumerId.flatMap(present).contains(id))) None + else Some(ErrorMessages.ConsentDoesNotMatchConsumer) + } + } + + /** + * Decide whether a caller may read an OBP-native consent through GET /user/current/consents/CONSENT_ID, + * returning the reason to refuse or None when it is allowed. + * + * OBP-native answers to no external standard, so the contract is OBP's own API surface, and that + * surface is explicit about the subject: this endpoint is /user/current/..., while its sibling + * /consumer/current/consents/CONSENT_ID is the Consumer-scoped read. Two endpoints, two subjects. + * So the comparison here is against the human the request is on behalf of -- CallContext.humanUser, + * not CallContext.userId, which returns the authenticated principal and under consent + * authentication is the per-consent shadow user rather than the PSU. checkUKConsent already + * resolves the human this way for the same comparison. + * + * A consent with no PSU yet stays readable, and that is deliberate rather than an oversight + * inherited from the previous guard. This endpoint is where the PSU inspects a consent before + * deciding to authorise it, in both the Berlin Group and UK journeys, and the app doing the + * inspecting is the PSU's own -- a different Consumer from the TPP that lodged the consent. Adding + * the Consumer fallback checkUKConsentAccess uses would therefore break the journey, not tighten + * it; that is where OBP-native's rule genuinely parts company with the standards', and why this is + * its own function rather than a second caller of theirs. + * + * What that leaves open, stated plainly: an unbound consent's metadata can be read by any + * authenticated caller who knows its consent id. Claiming one is a separate matter and is gated + * where the binding happens. + * + * Refuses with ConsentNotFound rather than a distinct message, preserving the endpoint's existing + * 404 so it does not tell a stranger that a consent id exists. + */ + def checkObpConsentUserAccess(consentUserId: String, callerHumanUserId: Option[String]): Option[String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + present(consentUserId) match { + case Some(psu) if !callerHumanUserId.flatMap(present).contains(psu) => + Some(ErrorMessages.ConsentNotFound) + case _ => None + } + } + + /** + * The PSU behind a request, where the session really carries one. + * + * A pure client-credentials token still resolves to a user: OAuth2.getOrCreateResourceUser maps + * the JWT sub onto idGivenByProvider, and in that grant the sub is the caller's own client id, so + * an auto-vivified pseudo-user appears where the code expects a person. Ownership checks must not + * mistake it for a PSU -- doing so would refuse a legitimate TPP poll by comparing the TPP's own + * pseudo-identity against the consent's real owner. + * + * Berlin Group consent lodging filters the same way inline (see createConsent); that site was left + * duplicated deliberately while ConsentUtil was being edited on another branch. This is that + * extraction, used by the checks added here. + */ + def genuinePsu(callContext: CallContext): Option[User] = + callContext.user.toOption.filterNot(u => callContext.consumer.map(_.key.get).contains(u.idGivenByProvider)) + + /** + * Refuse a Berlin Group consent authorisation unless the PSU claiming it holds every account the + * consent names, returning the reason to refuse or None. + * + * A Berlin Group consent carries its accounts from the moment it is created: the TPP lists IBANs + * in the access object and createBerlinGroupConsentJWT resolves each one to a (bank_id, + * account_id) view before any PSU is involved. Nothing until now checked that the PSU who + * eventually authorises it has anything to do with those accounts, and the consequence was + * reachable rather than theoretical -- a consent naming another customer's IBAN, authorised by a + * PSU who does not hold it, bound and then served that account's details and balances to the TPP. + * It is the same gap UK closed at its own authorise step, and the same error answers it. + * + * Read off the JWT rather than the access object because the JWT is what the read path will + * actually grant: applyConsentRules materialises exactly these views for the consent's shadow + * user. Checking anything else would leave the two able to disagree. + * + * A consent whose JWT names no account yet is not refused here. That is the availableAccounts + * ("allAccounts") shape, whose views are materialised later and against the PSU's own holdings, + * so there is nothing for this check to compare and nothing it could wrongly let through. + */ + def assertBerlinGroupConsentAccountsHeld( + psu: User, + storedConsent: consent.ConsentTrait, + callContext: Option[CallContext] + ): Future[Box[Unit]] = Future { + implicit val dateFormats: Formats = CustomJsonFormats.formats + val consentAccounts: List[(String, String)] = JwtUtil.getSignedPayloadAsJson(storedConsent.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT]) + .map(_.views.map(v => (v.bank_id, v.account_id)).distinct) + .getOrElse(Nil) + .filter { case (bankId, accountId) => bankId != null && accountId != null } + + val notHeld: List[String] = consentAccounts + .groupBy(_._1) + .toList + .flatMap { case (bankId, pairs) => + val held = AccountHolders.accountHolders.vend + .getAccountsHeld(BankId(bankId), psu) + .map(_.accountId.value) + pairs.map(_._2).filterNot(held.contains) + } + + (notHeld.isEmpty, notHeld): (Boolean, List[String]) + } flatMap { case (allHeld: Boolean, notHeld: List[String]) => + Helper.booleanToFuture( + s"${ErrorMessages.ConsentAccountNotHeldByUser} Account(s): ${notHeld.mkString(", ")}", + 403, callContext)(allHeld) + } + + /** + * The PSU a caller is acting as, or None when it is acting only as itself. + * + * genuinePsu answers this for every credential except one: a request authenticated by the consent + * itself. applyUKRules swaps callContext.user to the consent's shadow user and sets aside the real + * PSU on consenter, and a shadow user's idGivenByProvider is a random UUID rather than the + * consumer key, so genuinePsu waves it through as if it were a person. An ownership check handed + * that principal compares the shadow user against the consent's owner and can never match -- + * checkUKConsent already reads consenter for exactly this reason, and says so at its own PSU + * comparison. + * + * So: the PSU the swap set aside if there is one, otherwise whatever genuine PSU the session + * carries. Both absent is the standard's own AISP call -- a client-credentials token with no PSU + * anywhere -- and None is the answer that lets checkUKConsentAccess fall through to the Consumer + * rule, which is the whole of the authorisation there. + */ + def actingPsu(callContext: CallContext): Option[User] = + callContext.consenter.toOption.orElse(genuinePsu(callContext)) + + /** + * The whole guard the four UK consent-by-id endpoints apply: resolve who the caller is acting as, + * put that to checkUKConsentAccess, and refuse with 403 when it says so. + * + * The rule and the identity it is asked about belong together. Keeping them apart is what let the + * four sites settle on callContext.user -- a value that is never absent on a request reaching + * them, so the rule's own "caller with no PSU" branch was unreachable from every one of them, + * however well that branch was tested in isolation. + */ + def assertUKConsentAccess( + consentUserId: String, + consentConsumerId: String, + callContext: CallContext + ): Future[Box[Unit]] = { + val refusal = checkUKConsentAccess( + consentUserId, consentConsumerId, + actingPsu(callContext).map(_.userId), callContext.consumer.map(_.consumerId.get), + isScaFrontEnd(callContext.consumer.map(_.consumerId.get))) + // booleanToFuture only reads failMsg when the statement is false, so the empty default is never + // the message anyone sees. + Helper.booleanToFuture(refusal.getOrElse(""), 403, Some(callContext))(refusal.isEmpty) + } + + /** + * Resolve the PSU a Berlin Group consent authorisation is for, returning that PSU's user id or the + * reason to refuse. + * + * The session cannot answer this, and the reason is not a formality. Berlin Group has the TPP make + * these calls, not the PSU: under Redirect the PSU authenticates at the ASPSP, under Embedded it + * hands its factors to the TPP, which relays them (Implementation Guidelines V1.3.12, section + * 6.1.1.4, p.123: "the TPP is transmitting the authentication data of the customer, e.g. an OTP"). + * So on an Embedded call the token is the TPP's, and a client-credentials token still resolves to + * a user -- the caller's own auto-vivified pseudo-identity, see genuinePsu. Minting the challenge + * against that principal names the TPP, and the challenge answer is delivered to whoever the + * challenge names: createChallengeInternal sends it to getEmailsByUserId / getPhoneNumbersByUserId + * of the minted user. The OTP would go to the TPP and never reach the PSU. + * + * Where the standard does put the PSU's identity is the PSU-ID header. It is not in the body: + * psuData carries password, encryptedPassword, additionalPassword and additionalEncryptedPassword, + * and no identifier at all. On the start-authorisation call PSU-ID "shall be transmitted if this + * Request is indicated by 'startAuthorisationWithPsuIdentification' or + * 'startAuthorisationWithPsuAuthentication' ... and this field has not yet been transmitted + * before" (section 7.1, p.195); on the update call it is "contained if not yet contained in a + * pre-ceeding request" (section 7.2.1, p.206). Both make it conditional on the ASPSP not already + * knowing, which is exactly the order used here: + * + * 1. the consent's own PSU, once SCA has bound one -- the strongest form of "already transmitted"; + * 2. a genuine PSU in the session, which is the Redirect approach, where the PSU really is the + * caller; + * 3. the PSU-ID header, which is Embedded, the TPP naming the PSU on the PSU's behalf. + * + * With none of the three there is no one to mint the challenge for and nowhere to send the OTP, so + * the call is refused. That is an ordinary outcome rather than an attack being repelled: a + * conforming client-credentials call that omitted the header lands here. + * + * A header that disagrees with 1 or 2 is refused rather than resolved by precedence. The standard + * sanctions the check where it defines the header -- "the ASPSP might check whether PSU-ID and + * token match, according to ASPSP documentation" (section 6.3.1, p.134) -- and what it closes is + * specific: without it the lodging TPP could name a third party on a consent already bound to + * someone else, and have that person's OTP mailed to them. + * + * What this deliberately does not do is verify a first factor. psuData.password is still not + * checked anywhere in the Berlin Group path, so PSU-ID is an assertion by the TPP and not proof of + * anything. It is the OTP, delivered out of band to the PSU this resolves to, that actually binds + * the consent -- which is why resolving it correctly is what makes the unverified assertion safe, + * and why getting it wrong was the defect rather than a tidiness problem. + */ + def resolveBerlinGroupPsu( + consentUserId: String, + sessionPsuId: Option[String], + headerPsuId: Option[String] + ): Either[String, String] = { + def present(s: String): Option[String] = Option(s).map(_.trim).filter(_.nonEmpty) + + val alreadyKnown = present(consentUserId).orElse(sessionPsuId.flatMap(present)) + + (alreadyKnown, headerPsuId.flatMap(present)) match { + case (Some(known), Some(named)) if known != named => Left(ErrorMessages.ConsentDoesNotMatchUser) + case (Some(known), _) => Right(known) + case (None, Some(named)) => Right(named) + case (None, None) => Left(ErrorMessages.BerlinGroupPsuNotIdentified) + } + } + + /** + * Resolve a Berlin Group PSU-ID header value -- "Client ID of the PSU in the ASPSP client + * interface" -- to the user it names. + * + * Local users first, since that is what the header means at an ASPSP running its own identity + * store. A federated PSU carries its issuer as provider rather than the local one, so a username + * that is not local is looked up across providers and accepted only when exactly one user answers + * to it. Two would make the header ambiguous, and choosing between them is not the ASPSP's to do + * on the PSU's behalf. + */ + def findPsuByPsuId(psuId: String): Box[User] = + Users.users.vend.getUserByProviderAndUsername(Constant.localIdentityProvider, psuId) or { + Users.users.vend.getUsersByUsername(psuId) match { + case theOnlyOne :: Nil => Full(theOnlyOne) + case _ => Empty + } + } + def createUKConsentJWT( user: Option[User], bankId: Option[String], accountIds: Option[List[String]], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], secret: String, consentId: String, consumerId: Option[String] @@ -1062,7 +1961,12 @@ object Consent extends MdcLoggable { val createdByUserId = user.map(_.userId).getOrElse("None") val currentConsumerId = Consumer.findAll(By(Consumer.createdByUserId, createdByUserId)).map(_.consumerId.get).headOption.getOrElse("") val currentTimeInSeconds = System.currentTimeMillis / 1000 - val validUntilTimeInSeconds = expirationDateTime.getTime() / 1000 + // No ExpirationDateTime means the consent never expires (UK spec: 0..1, open-ended if absent). + // Use Long.MaxValue rather than e.g. "now" (the convention createBerlinGroupConsentJWT falls + // back to for its own optional validUntil) since that would make the JWT read as already + // expired -- wrong for "no limit". Nothing currently reads this exp claim for UK consents + // (checkUKConsent doesn't check expiry at all yet), so this only matters once that's added. + val validUntilTimeInSeconds = expirationDateTime.map(_.getTime / 1000).getOrElse(Long.MaxValue) // Write Consent's Auth Context to the DB user map { u => val authContexts = UserAuthContextProvider.userAuthContextProvider.vend.getUserAuthContextsBox(u.userId) @@ -1118,6 +2022,81 @@ object Consent extends MdcLoggable { CertificateUtil.jwtWithHmacProtection(jwtClaims, secret) } + /** + * Binds a UK Open Banking account-access consent to the PSU-selected accounts. + * + * createUKConsentJWT (called from POST /account-access-consents, before any account is known) + * writes every granted permission as ConsentView(bank_id=null, account_id=null, view_id=permission) + * — a row that can never match a real account (see User.hasAccountAccess: plain equality on + * bank_id/account_id, no wildcard). Call this once the PSU has selected which accounts the + * consent applies to (currently: the UK authorise step, since OBP has no separate + * ASPSP-hosted account-selection UI) to replace those dead rows with real per-account + * ConsentViews — mirroring how updateViewsOfBerlinGroupConsentJWT resolves BG's IBAN-keyed access + * into real accounts. + * + * The JWT it writes is the consent's whole scope: resolveUKConsentPrincipal re-derives the + * consent's AccountAccess from it on every request. So this grants nothing itself, and must keep + * running as the real PSU -- the account-holdership check below is the security control that stops + * a consent being bound to somebody else's accounts, and only the PSU holds accounts. + */ + def grantUKConsentAccountAccess(user: User, + bankId: BankId, + accountIds: List[String], + consent: MappedConsent, + callContext: Option[CallContext]): Future[Box[MappedConsent]] = { + implicit val dateFormats = CustomJsonFormats.formats + val payloadToUpdate: Box[ConsentJWT] = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[ConsentJWT]) + + val permissions: List[String] = payloadToUpdate match { + case Full(consentJwt) => consentJwt.views.map(_.view_id).distinct + case _ => Nil + } + + val accountChecks: List[Future[Box[BankAccount]]] = accountIds.distinct.map { accountId => + Connector.connector.vend.checkBankAccountExists(bankId, AccountId(accountId), callContext).map(_._1) + } + + Future.sequence(accountChecks).map { boxes => + val error = s"$BankAccountNotFound BankId(${bankId.value})" + val validatedAccountIds: List[String] = boxes.map(_.openOrThrowException(error)).map(_.accountId.value) + + // Existence alone is not enough: only bind the consent to accounts the authorising PSU + // actually holds -- otherwise any authenticated user could authorise a consent naming an + // arbitrary existing account_id and be granted every consented view on it. + val heldAccountIds: Set[String] = AccountHolders.accountHolders.vend + .getAccountsHeld(bankId, user) + .map(_.accountId.value) + val notHeld: List[String] = validatedAccountIds.filterNot(heldAccountIds.contains) + + val newViews: List[ConsentView] = for { + accountId <- validatedAccountIds + permission <- permissions + } yield ConsentView(bank_id = bankId.value, account_id = accountId, view_id = permission, None) + + if (notHeld.nonEmpty) { + Failure(s"$ConsentAccountNotHeldByUser Account(s): ${notHeld.mkString(", ")}") + } else if (newViews.isEmpty) { + Empty + } else { + val updatedPayload = payloadToUpdate.map(_.copy(views = newViews)) + val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload)) + val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson) + val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret) + // Writing the JWT is the whole job. Nothing is granted here. + // + // This used to eagerly write AccountAccess rows for the PSU, and then sweep away the rows + // an earlier, wider consent had left behind -- necessary only because those rows were keyed + // to the PSU and so were shared by every consent that PSU had granted. Data access now runs + // as the consent's own shadow user (resolveUKConsentPrincipal), which re-derives its views + // from this JWT on every request, so a consent's rows are its own and narrowing one takes + // effect the moment this JWT is written. There is nothing left to sweep, and an eager grant + // to the PSU would only put back the rows that caused the problem. + Consents.consentProvider.vend.setJsonWebToken(consent.consentId, jwt) + } + } + } + private def checkConsumerIsActiveAndMatchedUK(consent: ConsentJWT, consumerIdOfLoggedInUser: Option[String]): Box[Boolean] = { Consumers.consumers.vend.getConsumerByConsumerId(consent.aud) match { case Full(consumerFromConsent) if consumerFromConsent.isActive.get == true => // Consumer is active @@ -1191,9 +2170,25 @@ object Consent extends MdcLoggable { * consent_id and exercise a consent they never authorised. */ def checkUKConsent(user: User, calContext: Option[CallContext]): Box[Boolean] = { + // The request may instead have been authenticated by the consent itself, presented in a + // Consent-Id / Consent-JWT header with no Authorization header at all. applyUKRules has then + // already run every check below -- and resolved this very user from the consent -- so there is + // nothing left to re-derive from a token that does not exist. + if (calContext.flatMap(_.ukConsentId).isDefined) return Full(true) + + // No Authorization header, and applyUKRules did not run either. The usual way to arrive here + // is a consent of another standard: the dispatcher routes it into that standard's branch, + // which authenticates the request and leaves ukConsentId unset, and then a UK endpoint asks + // this question. That is an ordinary refusal and the mirror of what the Berlin Group side + // answers for a UK consent, so it gets the same code. Throwing instead surfaced it as + // OBP-50000 Unknown Error at 500 -- a server fault for a request that was merely not + // entitled. val accessToken = calContext.flatMap(_.authReqHeaderField) - .map(_.replaceFirst("Bearer\\s+", "")) - .getOrElse(throw new RuntimeException("Not found http request header 'Authorization', it is mandatory.")) + .map(_.replaceFirst("Bearer\\s+", "")) match { + case Some(token) => token + case None => + return Failure(s"$ConsentDoesNotMatchStandard Required: $ConsentStandardUK.") + } val boxedConsent: Box[MappedConsent] = JwtUtil.getOptionalClaim("consent_id", accessToken) match { case Some(consentId) => Consents.consentProvider.vend.getConsentByConsentId(consentId) @@ -1208,7 +2203,17 @@ object Consent extends MdcLoggable { System.currentTimeMillis match { case currentTimeMillis if currentTimeMillis < c.creationDateTime.getTime => Failure(ErrorMessages.ConsentNotBeforeIssue) - case _ if c.mUserId.get != user.userId => + // A null expirationDateTime means the consent never expires (0..1 per spec, + // open-ended if absent -- see createUKConsentJWT). ConsentScheduler.expiredUKConsents + // proactively flips long-past-expiry consents to EXPIRED, but that runs on an + // interval; this reactive check closes the gap immediately regardless of timing. + case currentTimeMillis if Option(c.expirationDateTime).exists(_.getTime < currentTimeMillis) => + Failure(ErrorMessages.ConsentExpiredIssue) + // The consent must belong to the PSU the access token authenticated. Data access runs + // as the consent's shadow user (applyUKConsentPrincipalFromToken), so compare against + // the PSU that swap set aside rather than against the principal -- a shadow user's id + // can never equal mUserId. `user` is the fallback for a request the swap left alone. + case _ if c.mUserId.get != calContext.flatMap(_.consenter.toOption).getOrElse(user).userId => Failure(ErrorMessages.ConsentDoesNotMatchUser) case _ => val consumerIdOfLoggedInUser: Option[String] = calContext.flatMap(_.consumer.map(_.consumerId.get)) diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index a34e402eca..d77de18175 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -772,6 +772,10 @@ object ErrorMessages { val UserAuthContextUpdateRequestAllowedScaMethods = "OBP-35034: Unsupported as SCA method. " val ConsentIdClaimMissing = "OBP-35035: The access token is not bound to a Consent. The identity provider must include a consent_id claim in access tokens issued via the consent authorisation flow. " val ConsentDoesNotMatchStandard = "OBP-35036: The Consent was created by a different API standard than the endpoint using it. A consent may only be used by endpoints of the standard that created it. " + val ConsentAccountNotHeldByUser = "OBP-35037: One or more of the specified account_ids is not held by the current user. A consent may only be authorised for accounts the authorising user holds. " + val InvalidUKConsentPermissions = "OBP-35038: The Permissions array is not a valid combination for UK Open Banking. " + val BerlinGroupPsuNotIdentified = "OBP-35039: The PSU this authorisation is for cannot be identified. Send the PSU-ID header, or authenticate as the PSU. " + val ConsentNamesNoAccount = "OBP-35040: The Consent names no account, so it grants no access. It was authorised before consents were bound to accounts; re-authorise it to select which accounts it applies to. " //Authorisations val AuthorisationNotFound = "OBP-36001: Authorisation not found. Please specify valid values for PAYMENT_ID and AUTHORISATION_ID. " @@ -893,6 +897,7 @@ object ErrorMessages { val OpenCorridorPublishFailed = "OBP-40055: Could not publish the Open Corridor message to the bank's broker or no reply arrived in time." val OpenCorridorSettlementAddressMissing = "OBP-40056: The creditor bank has no settlement address registered in its Open Corridor broker registration, so the settlement instruction cannot be addressed." val OpenCorridorDisabled = "OBP-40057: Open Corridor is not enabled on this API instance. Set open_corridor_enabled=true in the props." + val PaymentNotInitiatedByCaller = "OBP-40058: The addressed payment was not initiated by you. " // Exceptions (OBP-50XXX) val UnknownError = "OBP-50000: Unknown Error." val FutureTimeoutException = "OBP-50001: Future Timeout Exception." diff --git a/obp-api/src/main/scala/code/api/util/OBPParam.scala b/obp-api/src/main/scala/code/api/util/OBPParam.scala index 6afeabfd7f..4067525668 100644 --- a/obp-api/src/main/scala/code/api/util/OBPParam.scala +++ b/obp-api/src/main/scala/code/api/util/OBPParam.scala @@ -24,6 +24,43 @@ case class OBPOffset(value: Int) extends OBPQueryParam case class OBPFromDate(value: Date) extends OBPQueryParam case class OBPToDate(value: Date) extends OBPQueryParam case class OBPOrdering(field: Option[String], order: OBPOrder) extends OBPQueryParam +/** + * Restrict a transaction query to one direction, so the database applies the restriction and any + * page limit together. + * + * `credits = true` keeps money in, `false` keeps money out. Zero counts as a credit, which is what + * UK Open Banking states and what UKAmounts.creditDebitIndicator implements -- the two have to + * agree, or a row could be labelled one direction in the response and selected as the other. + * + * A connector that does not honour this returns more rows than asked for, never fewer, so callers + * that use it to enforce a consent's scope must still filter what comes back. + */ +case class OBPTransactionDirection(credits: Boolean) extends OBPQueryParam +object OBPTransactionDirection { + + /** + * Where a credit starts, in the smallest currency unit. Inclusive, because zero is a credit. + * + * Lives beside the param because two enforcements have to agree on it: the connector's SQL + * predicate and the endpoint's filter over the rows that came back. A connector holding its own + * literal could drift from the filter, and a row would then be selected by one and dropped by the + * other. + */ + val creditFloorInSmallestUnit = 0L + + /** + * Whether the restriction this param expresses keeps a row of the given amount. + * + * The in-memory statement of what the connector's query does, so a test can hold the two sides of + * the rule against each other without standing up a database. Not a substitute for reading real + * rows through the real query -- uk_direction_paging.py does that -- but it pins the boundary. + */ + def admits(param: OBPQueryParam, amountInSmallestUnit: Long): Boolean = param match { + case OBPTransactionDirection(true) => amountInSmallestUnit >= creditFloorInSmallestUnit + case OBPTransactionDirection(false) => amountInSmallestUnit < creditFloorInSmallestUnit + case _ => true + } +} case class OBPConsumerId(value: String) extends OBPQueryParam case class OBPSortBy(value: String) extends OBPQueryParam case class OBPAzp(value: String) extends OBPQueryParam diff --git a/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala index fc076c4575..4c2f77ec21 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/AppsPage.scala @@ -41,6 +41,7 @@ object AppsPage { "public_obp_opey_url" -> Set("status", "health"), "public_obp_api_explorer_url" -> Set("status", "health"), "public_obp_mcp_url" -> Set("status", "health", "ready"), + "public_obp_hola_url" -> Set("status", "health"), ) private def probesFor(key: String): List[String] = diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index db1e41ac6c..2a1a21bbf4 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -24,8 +24,30 @@ object StatusPage extends MdcLoggable { props } - private def gitCommit: String = - Option(gitProps.getProperty("git.commit.id")).getOrElse("unknown") + private def gitProp(key: String): String = + Option(gitProps.getProperty(key)).map(_.trim).filter(_.nonEmpty).getOrElse("unknown") + + private def gitCommit: String = gitProp("git.commit.id") + + /** + * The branch and the moment the running artifact was built. Both come from the same stamp as the + * commit, and both were unusable until the stamp stopped being generated by + * git-commit-id-maven-plugin: in a git worktree it named the main checkout's branch, and it + * refused to rewrite the file when only the build time had changed. Absent from older stamps, so + * they degrade to "unknown" rather than failing. + */ + private def gitBranch: String = gitProp("git.branch") + + private def gitBuildTime: String = gitProp("git.build.time") + + /** + * True when the build had uncommitted changes. Reported alongside the commit because the stamp + * names the last commit, not what was compiled: a build from a modified working tree names a + * revision whose source never produced this artifact, and checking that id out would not + * reproduce it. Absent from older stamps, so it defaults to false rather than failing. + */ + private def gitDirty: Boolean = + Option(gitProps.getProperty("git.dirty")).exists(_.trim.equalsIgnoreCase("true")) private def apiInstanceId: String = code.api.Constant.ApiInstanceId @@ -159,6 +181,9 @@ object StatusPage extends MdcLoggable { | "status": "$status", | "api_instance_id": "$apiInstanceId", | "git_commit": "$gitCommit", + | "git_branch": "${jsonEscape(gitBranch)}", + | "git_dirty": $gitDirty, + | "git_build_time": "${jsonEscape(gitBuildTime)}", | "uptime_seconds": $uptimeSeconds, | "checks": { | "database": "${checks.database}", @@ -211,7 +236,16 @@ object StatusPage extends MdcLoggable { |

Instance

| | - | + | + | + | | |
api_instance_id$apiInstanceId
git_commit$gitCommit
git_commit$gitCommit${ + if (gitDirty) + """ dirty""" + + """
""" + + "built from a working tree with uncommitted changes — this names the last " + + "commit, not necessarily what is running
" + else "" + }
git_branch${htmlEscape(gitBranch)}
git_build_time${htmlEscape(gitBuildTime)}
uptime_seconds$uptimeSeconds
| diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala index 40fcd87a1e..3a2f20d3e7 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala @@ -962,7 +962,7 @@ object Http4s500 { val isVrpConsent = (viewsFromJwtToken.length == 1) && viewsFromJwtToken.head.bank_id.nonEmpty && viewsFromJwtToken.head.account_id.nonEmpty && - viewsFromJwtToken.head.view_id.startsWith("_vrp-") + viewsFromJwtToken.head.view_id.startsWith(VRP_VIEW_ID_PREFIX) if (isVrpConsent) { val bId = BankId(viewsFromJwtToken.head.bank_id) val aId = AccountId(viewsFromJwtToken.head.account_id) @@ -1083,7 +1083,7 @@ object Http4s500 { } (bankId, accountId, viewId, counterpartyId) <- if (isVRPConsentRequest) { val postConsentRequestJsonV510 = json.parse(createdConsentRequest.payload).extract[code.api.v5_1_0.PostVRPConsentRequestJsonV510] - val vrpViewId = s"_vrp-${UUID.randomUUID.toString}".dropRight(5) + val vrpViewId = s"${VRP_VIEW_ID_PREFIX}${UUID.randomUUID.toString}".dropRight(5) val targetPermissions = List( CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY, CAN_GET_COUNTERPARTY, diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 1f172a3e95..70d4ad7a49 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -64,7 +64,7 @@ import com.openbankproject.commons.model.{ } import com.openbankproject.commons.model.enums.{AtmAttributeType, ChallengeType, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, StrongCustomerAuthenticationStatus, SuppliedAnswerType, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} -import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.common.{Box, Empty, Failure, Full} import com.openbankproject.commons.util.json import com.openbankproject.commons.util.JsonAliases.prettyRender import org.json4s.{Extraction, Formats} @@ -87,7 +87,9 @@ import scala.language.{higherKinds, implicitConversions} // UK Open Banking consent SCA (see authoriseUKConsentChallenge / authoriseUKConsent): // the challenge-start endpoint returns this; the authorise endpoint consumes the answer. case class UKConsentScaChallengeJsonV510(challenge_id: String, sca_status: String, sca_method: String) -case class PostUKConsentAuthoriseJsonV510(challenge_id: String, answer: String) +// account_ids: the accounts the PSU is selecting for this consent's granted permissions — +// see the Gap 4 remediation note above authoriseUKConsent (bankId comes from the URL BANK_ID). +case class PostUKConsentAuthoriseJsonV510(challenge_id: String, answer: String, account_ids: List[String]) object Http4s510 { @@ -104,6 +106,17 @@ object Http4s510 { val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + // Statuses from which a UK Open Banking consent may be (re-)authorised. AWAITINGAUTHORISATION + // is the initial authorise; AUTHORISED and REVOKED (wire: CANC) are the re-authentication + // cases per the UK spec ("re-authenticate ... if the account-access-consent has a Status of + // AUTH or CANC and the ExpirationDateTime has not elapsed"). EXPIRED and REJECTED are terminal + // -- the TPP must create a new consent rather than re-authenticate. + private val ukReAuthableStatuses: Set[String] = Set( + ConsentStatus.AWAITINGAUTHORISATION.toString, + ConsentStatus.AUTHORISED.toString, + ConsentStatus.REVOKED.toString + ) + // Used by lifted consumer-management endpoint descriptions. private def consumerDisabledText(): String = { if (APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) == false) { @@ -4281,8 +4294,18 @@ object Http4s510 { user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) - _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to start SCA (current: ${consent.status}).", 400, Some(cc)) { - consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue one of ${ukReAuthableStatuses.mkString(", ")} to start SCA (current: ${consent.status}).", 400, Some(cc)) { + ukReAuthableStatuses.contains(consent.status.toUpperCase) + } + // Re-authentication is only allowed before the consent's ExpirationDateTime (a null + // ExpirationDateTime = never expires); an expired consent must be recreated. + _ <- Helper.booleanToFuture(s"$ConsentExpiredIssue", 400, Some(cc)) { + Option(consent.expirationDateTime).forall(_.getTime >= System.currentTimeMillis) + } + // A consent already bound to a PSU may only be (re-)authorised by that same PSU -- + // otherwise a different user of the same consumer could hijack it. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId } (challenges, _) <- NewStyle.function.createChallengesC2( List(user.userId), @@ -4322,7 +4345,7 @@ object Http4s510 { |""", EmptyBody, UKConsentScaChallengeJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "received", "SMS"), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, ConsentExpiredIssue, ConsentDoesNotMatchUser, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsentChallenge) @@ -4337,24 +4360,42 @@ object Http4s510 { // flip it to AUTHORISED — the missing "authorisation binding" step of the UK flow. // User-authenticated but role-free (the account holder is approving their own consent). val authoriseUKConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentId / "authorise" => + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "consents" / consentId / "authorise" => EndpointHelpers.executeFuture(req) { implicit val cc: code.api.util.CallContext = req.callContext for { user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) - // Only a consent still awaiting authorisation can be authorised. This status - // is UK-specific (Berlin Group uses "received", OBP uses INITIATED), so the - // guard also effectively scopes this endpoint to the UK flow. - _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to be authorised (current: ${consent.status}).", 400, Some(cc)) { - consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + // The initial authorisation (AWAITINGAUTHORISATION) and re-authentication of an + // already-authorised or dashboard-revoked (wire: CANC) consent are both allowed -- + // see ukReAuthableStatuses. EXPIRED/REJECTED are terminal. These statuses are + // UK-specific (Berlin Group uses "received", OBP uses INITIATED), so the guard also + // effectively scopes this endpoint to the UK flow. + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue one of ${ukReAuthableStatuses.mkString(", ")} to be authorised (current: ${consent.status}).", 400, Some(cc)) { + ukReAuthableStatuses.contains(consent.status.toUpperCase) + } + // Re-authentication is only allowed before the consent's ExpirationDateTime (a null + // ExpirationDateTime = never expires); an expired consent must be recreated. + _ <- Helper.booleanToFuture(s"$ConsentExpiredIssue", 400, Some(cc)) { + Option(consent.expirationDateTime).forall(_.getTime >= System.currentTimeMillis) + } + // A consent already bound to a PSU may only be (re-)authorised by that same PSU -- + // otherwise a different user of the same consumer could hijack it. + _ <- Helper.booleanToFuture(s"$ConsentDoesNotMatchUser", 403, Some(cc)) { + Option(consent.userId).forall(_.isBlank) || consent.userId == user.userId } // Verify the SCA challenge answer before authorising (dynamic linking to this consent). // The challenge must have been started via POST .../authorise/challenge. authJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostUKConsentAuthoriseJsonV510 ", 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[PostUKConsentAuthoriseJsonV510] } + // The PSU must select at least one account for the consented permissions to bind to — + // see grantUKConsentAccountAccess (Gap 4 remediation: previously the consent's + // Permissions were never bound to a real account and had zero enforcement effect). + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat The Json body should be the $PostUKConsentAuthoriseJsonV510 (account_ids must not be empty) ", 400, Some(cc)) { + authJson.account_ids.nonEmpty + } (_, _) <- NewStyle.function.getChallenge(authJson.challenge_id, Some(cc)) (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( ChallengeType.OBP_CONSENT_CHALLENGE, @@ -4367,6 +4408,37 @@ object Http4s510 { _ <- Helper.booleanToFuture(s"$InvalidChallengeAnswer", 403, Some(cc)) { challenge.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) } + // Bind the consented permissions to the PSU-selected accounts, replacing the + // (bank_id=null, account_id=null) dead views createUKConsentJWT wrote at consent + // creation time. + // + // This runs before anything is written because it is the last step that can still + // refuse the request: it rejects an account_id the PSU does not hold + // (ConsentAccountNotHeldByUser) or one that does not exist at this bank. It used to run + // after updateConsentUser, and a refused authorisation therefore left the consent bound + // to whoever attempted it -- status still AWAITINGAUTHORISATION, mUserId now the + // caller. The ConsentDoesNotMatchUser guard above then locked the real PSU out of their + // own consent, and the lodging TPP lost it too, with no way back through the API. A + // consent id travels to the browser in the authorisation redirect, so a single failing + // request from anyone who had seen one was enough. Nothing here is transactional, so + // ordering is what has to carry it: refuse first, commit afterwards. + // + // Not connectorEmptyResponse: that turns every Box into InvalidConnectorResponse at + // 400, so the refusal reached the TPP as "OBP-50200 Connector cannot return the data + // we requested. connectorEmptyResponse <- OBP-35037 ..." -- an authorisation decision + // presented as a connector fault, with the reason buried behind a cause it has + // nothing to do with. A Failure here carries its own message and is the same kind of + // answer as the ConsentDoesNotMatchUser guard above, so it gets the same 403. Only a + // genuinely empty Box is a connector problem. + _ <- Consent.grantUKConsentAccountAccess(user, BankId(bankIdStr), authJson.account_ids, consent, Some(cc)) + .flatMap { + case Full(granted) => Future.successful(granted) + case Failure(reason, _, _) => + // booleanToFuture(false) always fails, so the mapped value is never reached -- + // it only lines the branches up to one type. + Helper.booleanToFuture(reason, 403, Some(cc))(false).map(_ => consent) + case Empty => Future.successful(connectorEmptyResponse(Empty: Box[MappedConsent], Some(cc))) + } // Bind the PSU as the consent's user in the DB (mUserId). consentAfterBind <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) .map(i => connectorEmptyResponse(i, Some(cc))) @@ -4375,11 +4447,13 @@ object Http4s510 { // rather than the client_credentials pseudo-user the consent was lodged under. // Despite its name, updateUserIdOfBerlinGroupConsentJWT only rewrites // createdByUserId (via ConsentJWT.copy) — the UK permission views are preserved. + // updateConsentUser re-reads the row from the database, so the JWT copied here is the + // one grantUKConsentAccountAccess just wrote, views and all. updatedJwt <- Future(Consent.updateUserIdOfBerlinGroupConsentJWT(user.userId, consentAfterBind, Some(cc))) .map(i => connectorEmptyResponse(i, Some(cc))) - _ <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) + consentWithUser <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) .map(i => connectorEmptyResponse(i, Some(cc))) - updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED)) + updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentWithUser.consentId, ConsentStatus.AUTHORISED)) .map(i => connectorEmptyResponse(i, Some(cc))) } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) } @@ -4395,22 +4469,25 @@ object Http4s510 { |Authorise a UK Open Banking account-access consent as the current (PSU) user, after SCA. | |The TPP first lodges the intent via `POST /account-access-consents`; the consent is - |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user. The PSU then - |starts SCA via `POST .../authorise/challenge` and submits the resulting `challenge_id` - |plus the OTP `answer` here. On a valid answer this binds the consent to the PSU and - |transitions it to ${ConsentStatus.AUTHORISED}, so subsequent UK data calls whose access - |token carries the `consent_id` claim pass the consent check. + |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user and no bound + |accounts. The PSU then starts SCA via `POST .../authorise/challenge` and submits the + |resulting `challenge_id` plus the OTP `answer`, together with the `account_ids` the PSU + |is selecting for this consent, here. On a valid answer this binds the consent to the PSU + |and to those accounts — every permission the consent declared is granted on each selected + |account — and transitions it to ${ConsentStatus.AUTHORISED}, so subsequent UK data calls + |whose access token carries the `consent_id` claim pass the consent check and are scoped to + |exactly the accounts and permissions the PSU approved. | |${userAuthenticationMessage(true)} | |""", - PostUKConsentAuthoriseJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "123"), + PostUKConsentAuthoriseJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "123", List("8ca8a7e4-6d05-4b21-a165-c02c39d77e55")), ConsentJsonV310( "9d429899-24f5-42c8-8565-943ffa6a7945", "eyJhbGciOiJIUzI1NiJ9.eyJ2aWV3cyI6W119.signature", "AUTHORISED" ), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidJsonFormat, InvalidChallengeAnswer, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, ConsentExpiredIssue, ConsentDoesNotMatchUser, InvalidJsonFormat, InvalidChallengeAnswer, $BankAccountNotFound, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsent) @@ -4577,8 +4654,13 @@ object Http4s510 { for { consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) .map(unboxFullOrFail(_, Some(cc), ConsentNotFound, 404)) - _ <- Helper.booleanToFuture(failMsg = ConsentNotFound, failCode = 404, cc = Some(cc)) { - consent.mUserId == cc.userId + // cc.humanUser, not cc.userId: under consent authentication the principal is the + // per-consent shadow user, so comparing it against the consent's PSU never matched and + // the PSU got a 404 for their own consent. See Consent.checkObpConsentUserAccess for + // why an unbound consent stays readable. + _ <- Consent.checkObpConsentUserAccess(consent.userId, cc.humanUser.toOption.map(_.userId)) match { + case Some(reason) => Helper.booleanToFuture(failMsg = reason, failCode = 404, cc = Some(cc))(false) + case None => Future.successful(true) } } yield JSONFactory510.getConsentInfoJson(consent) } diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index c94c30b2f2..b0d0c2aa50 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -712,13 +712,28 @@ object LocalMappedConnector extends Connector with MdcLoggable { .map(transaction => (transaction, callContext)) } - override def getTransactionsLegacy(bankId: BankId, accountId: AccountId, callContext: Option[CallContext], queryParams: List[OBPQueryParam]) = { - - // TODO Refactor this. No need for database lookups etc. + /** + * The OBPQueryParams a transaction read carries, as Mapper query params. + * + * One copy for both transaction reads below. They had identical translations, and the direction + * restriction had to be written into each of them -- a rule that decides what a consent may see + * should exist once, not once per caller that remembers to add it. + * + * The direction restriction is pushed into the query rather than applied to the rows afterwards, + * so the database narrows and paginates in the same pass; filtering an already-limited page hands + * the caller a short page it cannot distinguish from the end of the data. Zero counts as a credit, + * matching UKAmounts.creditDebitIndicator -- `amount` is signed and in the smallest currency unit, + * so its sign is all this needs. + */ + private def transactionQueryParams(queryParams: List[OBPQueryParam]): Seq[QueryParam[MappedTransaction]] = { val limit = queryParams.collect { case OBPLimit(value) => MaxRows[MappedTransaction](value) }.headOption val offset = queryParams.collect { case OBPOffset(value) => StartAt[MappedTransaction](value) }.headOption val fromDate = queryParams.collect { case OBPFromDate(date) => By_>=(MappedTransaction.tFinishDate, date) }.headOption val toDate = queryParams.collect { case OBPToDate(date) => By_<=(MappedTransaction.tFinishDate, date) }.headOption + val direction = queryParams.collect { + case OBPTransactionDirection(true) => By_>=(MappedTransaction.amount, OBPTransactionDirection.creditFloorInSmallestUnit) + case OBPTransactionDirection(false) => By_<(MappedTransaction.amount, OBPTransactionDirection.creditFloorInSmallestUnit) + }.headOption val ordering = queryParams.collect { //we don't care about the intended sort field and only sort on finish date for now case OBPOrdering(_, direction) => @@ -727,8 +742,13 @@ object LocalMappedConnector extends Connector with MdcLoggable { case OBPDescending => OrderBy(MappedTransaction.tFinishDate, Descending) } } + Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, direction.toSeq, ordering.toSeq).flatten + } + + override def getTransactionsLegacy(bankId: BankId, accountId: AccountId, callContext: Option[CallContext], queryParams: List[OBPQueryParam]) = { - val optionalParams: Seq[QueryParam[MappedTransaction]] = Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, ordering.toSeq).flatten + // TODO Refactor this. No need for database lookups etc. + val optionalParams: Seq[QueryParam[MappedTransaction]] = transactionQueryParams(queryParams) val mapperParams = Seq(By(MappedTransaction.bank, bankId.value), By(MappedTransaction.account, accountId.value)) ++ optionalParams def getTransactionsCached(bankId: BankId, accountId: AccountId, optionalParams: Seq[QueryParam[MappedTransaction]]): Box[List[Transaction]] @@ -761,20 +781,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getTransactionsCore(bankId: BankId, accountId: AccountId, queryParams: List[OBPQueryParam], callContext: Option[CallContext]): OBPReturnType[Box[List[TransactionCore]]] = { // TODO Refactor this. No need for database lookups etc. - val limit = queryParams.collect { case OBPLimit(value) => MaxRows[MappedTransaction](value) }.headOption - val offset = queryParams.collect { case OBPOffset(value) => StartAt[MappedTransaction](value) }.headOption - val fromDate = queryParams.collect { case OBPFromDate(date) => By_>=(MappedTransaction.tFinishDate, date) }.headOption - val toDate = queryParams.collect { case OBPToDate(date) => By_<=(MappedTransaction.tFinishDate, date) }.headOption - val ordering = queryParams.collect { - //we don't care about the intended sort field and only sort on finish date for now - case OBPOrdering(_, direction) => - direction match { - case OBPAscending => OrderBy(MappedTransaction.tFinishDate, Ascending) - case OBPDescending => OrderBy(MappedTransaction.tFinishDate, Descending) - } - } - - val optionalParams: Seq[QueryParam[MappedTransaction]] = Seq(limit.toSeq, offset.toSeq, fromDate.toSeq, toDate.toSeq, ordering.toSeq).flatten + val optionalParams: Seq[QueryParam[MappedTransaction]] = transactionQueryParams(queryParams) val mapperParams = Seq(By(MappedTransaction.bank, bankId.value), By(MappedTransaction.account, accountId.value)) ++ optionalParams def getTransactionsCached(bankId: BankId, accountId: AccountId, optionalParams: Seq[QueryParam[MappedTransaction]]): Box[List[TransactionCore]] @@ -879,23 +886,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getBankAccountByRoutingLegacy(bankId: Option[BankId], scheme: String, address: String, callContext: Option[CallContext]): Box[(BankAccount, Option[CallContext])] = { - // OBP-family schemes (OBP / OBP_ACCOUNT_ID) are implicit self-identifiers - // — address IS the account_id. Resolve directly against the BankAccount - // table without touching BankAccountRouting. - if (isImplicitOBPAccountScheme(scheme)) { - bankId match { - case Some(bankId) => - getBankAccountCommon(bankId, AccountId(address), callContext) - case None => - // No bank context — accept only when the account_id is globally unique. - MappedBankAccount.findAll(By(MappedBankAccount.theAccountId, address)) match { - case account :: Nil => Full((account, callContext)) - case Nil => Empty - case _ => - Failure(s"$AccountRoutingNotUnique (scheme: $scheme, address: $address)") - } - } - } else { + def byRoutingTable: Box[(MappedBankAccount, Option[CallContext])] = { def handleRouting(routing: List[BankAccountRouting]): Box[(MappedBankAccount, Option[CallContext])] = { if (routing.size > 1) { // Handle more than 1 occurrence // Routing MUST be unique @@ -917,6 +908,43 @@ object LocalMappedConnector extends Connector with MdcLoggable { handleRouting(routing) } } + + // OBP-family schemes (OBP / OBP_ACCOUNT_ID) are implicit self-identifiers — address IS the + // account_id — so they resolve directly against the BankAccount table. + // + // But that is not the only thing an OBP-scheme address can be. A bank may also *register* an + // `OBP` routing whose address is something other than the account id, and BankAccountRouting + // stores it like any other. Treating the implicit reading as the only one made those accounts + // unreachable through every endpoint that resolves by routing: the account was right there in + // the table, and the answer was "Bank Account not found". + // + // So try the implicit reading first, and fall back to the registered routing when it finds + // nothing. The implicit reading still wins where both would match, which is what happened + // before, so no address that resolves today resolves differently now. + if (isImplicitOBPAccountScheme(scheme)) { + val implicitly = bankId match { + case Some(bankId) => + getBankAccountCommon(bankId, AccountId(address), callContext) + case None => + // No bank context — accept only when the account_id is globally unique. + MappedBankAccount.findAll(By(MappedBankAccount.theAccountId, address)) match { + case account :: Nil => Full((account, callContext)) + case Nil => Empty + case _ => + Failure(s"$AccountRoutingNotUnique (scheme: $scheme, address: $address)") + } + } + implicitly match { + // Nothing answers to the implicit reading, so try a registered routing. + case Empty => byRoutingTable + // A hit, or an ambiguity. `or` would have replaced the ambiguity with whatever the routing + // table said, which for an ambiguous address is nothing -- turning "this address matches + // several accounts" into a bare "not found". Keep what the implicit reading concluded. + case decided => decided + } + } else { + byRoutingTable + } } override def getBankAccountByRouting(bankId: Option[BankId], scheme: String, address: String, callContext: Option[CallContext]): OBPReturnType[Box[BankAccount]] = Future { @@ -1166,10 +1194,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { && (bankAccountRoutings.account.scheme.equalsIgnoreCase("OBP") || bankAccountRoutings.account.scheme.equalsIgnoreCase("OBP_ACCOUNT_ID"))){ for{ (_, callContext) <- NewStyle.function.getBank(BankId(bankAccountRoutings.bank.address), callContext) - (account, callContext) <- NewStyle.function.checkBankAccountExists( - BankId(bankAccountRoutings.bank.address), - AccountId(bankAccountRoutings.account.address), - callContext) + bankId = BankId(bankAccountRoutings.bank.address) + // The OBP scheme reads two ways -- the address is normally the account id, but a bank may + // also have registered an OBP routing whose address is something else. Ask the resolver + // that knows both rather than assuming the first, and fall back to checkBankAccountExists + // when neither answers, so a genuinely unknown account still reports itself the same way. + (account, callContext) <- getBankAccountByRoutingLegacy( + Some(bankId), bankAccountRoutings.account.scheme, bankAccountRoutings.account.address, callContext + ) match { + case Full((resolved, cc)) => Future.successful((resolved, cc)) + case _ => NewStyle.function.checkBankAccountExists( + bankId, AccountId(bankAccountRoutings.account.address), callContext) + } } yield { (account, callContext) } @@ -1204,13 +1240,16 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getCoreBankAccountsLegacy(bankIdAccountIds: List[BankIdAccountId], callContext: Option[CallContext]): Box[(List[CoreAccount], Option[CallContext])] = { Full( bankIdAccountIds - .map(bankIdAccountId => + // Tolerate stale account access: a user can hold a view/grant on an account that has + // since been deleted (a dangling BankIdAccountId). Skip such accounts instead of + // throwing, which would otherwise 500 an entire "list my accounts" call because of one + // orphaned grant. Callers that need a specific account use getBankAccount(single). + .flatMap(bankIdAccountId => getBankAccountLegacy( bankIdAccountId.bankId, bankIdAccountId.accountId, callContext - ).map(_._1) - .openOrThrowException(s"${ErrorMessages.BankAccountNotFound} current BANK_ID(${bankIdAccountId.bankId}) and ACCOUNT_ID(${bankIdAccountId.accountId})")) + ).map(_._1).toList) .map(account => CoreAccount( account.accountId.value, diff --git a/obp-api/src/main/scala/code/consent/ConsentProvider.scala b/obp-api/src/main/scala/code/consent/ConsentProvider.scala index 51b63906d1..3932ed12bc 100644 --- a/obp-api/src/main/scala/code/consent/ConsentProvider.scala +++ b/obp-api/src/main/scala/code/consent/ConsentProvider.scala @@ -48,9 +48,9 @@ trait ConsentProvider { accountIds: Option[List[String]],//for UK Open Banking endpoints, there is no accountIds there. consumerId: Option[String], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], //0..1 per spec: None = open-ended / never expires + transactionFromDateTime: Option[Date], //0..1 per spec: None = no restriction on history start + transactionToDateTime: Option[Date], //0..1 per spec: None = no restriction on history end apiStandard: Option[String], apiVersion: Option[String] ): Box[ConsentTrait] diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 8e0cb38918..c281d3103c 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -267,9 +267,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo accountIds: Option[List[String]],//for UK Open Banking endpoints, there is no accountIds there. consumerId: Option[String], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], apiStandard: Option[String], apiVersion: Option[String] ) ={ @@ -279,9 +279,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo .mUserId(user.map(_.userId).getOrElse(null)) .mConsumerId(consumerId.getOrElse(null)) .mStatus(ConsentStatus.AWAITINGAUTHORISATION.toString) - .mExpirationDateTime(expirationDateTime) - .mTransactionFromDateTime(transactionFromDateTime) - .mTransactionToDateTime(transactionToDateTime) + .mExpirationDateTime(expirationDateTime.orNull) + .mTransactionFromDateTime(transactionFromDateTime.orNull) + .mTransactionToDateTime(transactionToDateTime.orNull) .mStatusUpdateDateTime(now) .mApiVersion(apiVersion.getOrElse(null)) .mApiStandard(apiStandard.getOrElse(null)) @@ -291,9 +291,9 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo bankId: Option[String], accountIds: Option[List[String]], permissions: List[String], - expirationDateTime: Date, - transactionFromDateTime: Date, - transactionToDateTime: Date, + expirationDateTime: Option[Date], + transactionFromDateTime: Option[Date], + transactionToDateTime: Option[Date], secret = consent.secret, consentId = consent.consentId, consumerId: Option[String] @@ -373,7 +373,13 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo // already revoked makes this a 0-row no-op, so we never resurrect or double-revoke. val rows = code.bankconnectors.DoobieConsentStatusQueries .conditionalRevoke(consent.id.get, ConsentStatus.REVOKED.toString) - if (rows == 1) MappedConsent.find(By(MappedConsent.mConsentId, consentId)) + if (rows == 1) { + // Every revoke endpoint funnels through here, so this is the one place that has to give + // the granted access back. The status flip alone leaves the AccountAccess rows live in + // the table for anything that reads them without asking the consent first. + code.api.util.Consent.revokeConsentAccountAccess(consent) + MappedConsent.find(By(MappedConsent.mConsentId, consentId)) + } else Failure(ErrorMessages.ConsentAlreadyRevoked) case Empty => Empty ?~! ErrorMessages.ConsentNotFound @@ -388,10 +394,14 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo case Full(consent) if consent.status == ConsentStatus.terminatedByTpp.toString => Failure(ErrorMessages.ConsentAlreadyRevoked) case Full(consent) => - tryo(consent - .mStatus(ConsentStatus.terminatedByTpp.toString) - .mLastActionDate(now) - .saveMe()) + tryo { + val terminated = consent + .mStatus(ConsentStatus.terminatedByTpp.toString) + .mLastActionDate(now) + .saveMe() + code.api.util.Consent.revokeConsentAccountAccess(terminated) + terminated + } case Empty => Empty ?~! ErrorMessages.ConsentNotFound case Failure(msg, _, _) => diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala index ed88cb1cf6..a0ebb6e109 100644 --- a/obp-api/src/main/scala/code/model/OAuth.scala +++ b/obp-api/src/main/scala/code/model/OAuth.scala @@ -647,6 +647,9 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ override def defaultValue : Long = APIUtil.getPropsAsLongValue("rate_limiting_per_month", -1) } object clientCertificate extends MappedString(this, 4000) + // FAPI 1.0 Advanced: URL where this client publishes its JWKS, used to verify + // signed request objects and private_key_jwt client assertions (OBP-OIDC). + object jwksUri extends MappedString(this, 500) object company extends MappedString(this, 100) { override def displayName = "Company:" } diff --git a/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala b/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala index 08d343d5eb..eee5069a61 100644 --- a/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala +++ b/obp-api/src/main/scala/code/sandbox/OBPDataImport.scala @@ -409,6 +409,7 @@ trait OBPDataImport extends MdcLoggable { val readBalancesView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_BALANCES_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsBasicView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsDebitsView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID).asInstanceOf[Box[ViewType]] + val readTransactionsCreditsView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID).asInstanceOf[Box[ViewType]] val readTransactionsDetailView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID).asInstanceOf[Box[ViewType]] // Berlin Group val readAccountsBerlinGroupView = Views.views.vend.getOrCreateSystemView(SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID).asInstanceOf[Box[ViewType]] @@ -427,9 +428,10 @@ trait OBPDataImport extends MdcLoggable { readAccountsBasicView, readAccountsDetailView, readBalancesView, - readTransactionsBasicView, - readTransactionsDebitsView, - readTransactionsDetailView, + readTransactionsBasicView, + readTransactionsDebitsView, + readTransactionsCreditsView, + readTransactionsDetailView, readAccountsBerlinGroupView, readBalancesBerlinGroupView, readTransactionsBerlinGroupView, diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index d44d222899..5ad7405406 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -1,7 +1,7 @@ package code.scheduler import code.api.berlin.group.ConstantsBG -import code.api.util.APIUtil +import code.api.util.{APIUtil, Consent} import code.consent.{ConsentStatus, MappedConsent} import code.util.Helper.MdcLoggable import com.openbankproject.commons.util.{ApiStandards, ApiVersion} @@ -50,6 +50,17 @@ object ConsentScheduler extends MdcLoggable { } else { logger.warn("|---> Skipping expiredObpConsents task: obp_expired_consents_interval_in_seconds set to 0") } + + // UK Open Banking. checkUKConsent (ConsentUtil.scala) also reactively rejects an expired + // AUTHORISED consent on every request regardless of this task's timing -- this proactive + // sweep just keeps the stored status accurate for GET/dashboard purposes. + val ukExpiredInterval = APIUtil.getPropsAsIntValue("uk_open_banking_expired_consents_interval_in_seconds", 601) + if (ukExpiredInterval > 0) { + SchedulerUtil.startTask(interval = ukExpiredInterval, () => expiredUKConsents(), initialDelay) + initialDelay = initialDelay + 10 + } else { + logger.warn("|---> Skipping expiredUKConsents task: uk_open_banking_expired_consents_interval_in_seconds set to 0") + } } @@ -76,7 +87,11 @@ object ConsentScheduler extends MdcLoggable { newStatus = ConsentStatus.rejected.toString, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -116,7 +131,11 @@ object ConsentScheduler extends MdcLoggable { consentPrimaryKey = consent.id.get, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -150,7 +169,11 @@ object ConsentScheduler extends MdcLoggable { newStatus = ConsentStatus.EXPIRED.toString, newNote = newNote ) - if (rows > 0) logger.warn(message) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } else logger.debug(s"|---> Skipped stale update for OBP consent ${consent.id}: status already changed") } match { case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) @@ -162,5 +185,45 @@ object ConsentScheduler extends MdcLoggable { case Success(_) => logger.debug("|---> Task executed successfully") } } + private def expiredUKConsents(): Unit = { + Try { + logger.debug("|---> Checking for expired UK Open Banking consents...") + + // A null mExpirationDateTime (never set -- 0..1 per spec, open-ended if absent) never + // matches By_< against a real Date, so perpetual consents are correctly never selected here. + val expiredConsents = MappedConsent.findAll( + By(MappedConsent.mStatus, ConsentStatus.AUTHORISED.toString), + By(MappedConsent.mApiStandard, Consent.ConsentStandardUK), + By_<(MappedConsent.mExpirationDateTime, new Date()) + ) + + logger.debug(s"|---> Found ${expiredConsents.size} expired consents") + + expiredConsents.foreach { consent => + Try { + val message = s"|---> Changed status from ${consent.status} to ${ConsentStatus.EXPIRED.toString} for consent ID: ${consent.id}" + val newNote = s"$currentDate\n$message\n" + Option(consent.note).getOrElse("") + val rows = code.bankconnectors.DoobieConsentSchedulerQueries.conditionallyUpdateStatus( + consentPrimaryKey = consent.id.get, + guardStatus = ConsentStatus.AUTHORISED.toString, + newStatus = ConsentStatus.EXPIRED.toString, + newNote = newNote + ) + if (rows > 0) { + // An expired consent must give its granted access back, exactly as a revoked one does. + Consent.revokeConsentAccountAccess(consent) + logger.warn(message) + } + else logger.debug(s"|---> Skipped stale update for UK consent ${consent.id}: status already changed") + } match { + case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex) + case Success(_) => // Already logged + } + } + } match { + case Failure(ex) => logger.error("Error in expiredUKConsents!", ex) + case Success(_) => logger.debug("|---> Task executed successfully") + } + } } diff --git a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala index 43512ad87d..c0b83269c0 100644 --- a/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala +++ b/obp-api/src/main/scala/code/transactionrequests/MappedTransactionRequestProvider.scala @@ -170,6 +170,7 @@ object MappedTransactionRequestProvider extends TransactionRequestProvider with .mApiStandard(apiStandard.getOrElse(null)) .mUserId(callContext.flatMap(_.user.map(_.userId)).getOrElse(null)) .mOnBehalfOfUserId(callContext.flatMap(cc => cc.onBehalfOfUser.or(cc.consenter).map(_.userId)).getOrElse(null)) + .mConsumerId(callContext.flatMap(_.consumer.map(_.consumerId.get)).getOrElse(null)) // Explicit originator fields (FATF Rec 16, OPEN_CORRIDOR_PROMISE type only — null otherwise). .mOriginator_Name(explicitOriginator.map(_.name).getOrElse(null)) @@ -296,6 +297,7 @@ class MappedTransactionRequest extends LongKeyedMapper[MappedTransactionRequest] object mUserId extends MappedString(this, 100) object mOnBehalfOfUserId extends MappedString(this, 100) + object mConsumerId extends MappedString(this, 100) def updateStatus(newStatus: String) = { mStatus.set(newStatus) diff --git a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala index 0bd3f4304a..58424cca5a 100644 --- a/obp-api/src/main/scala/code/users/DoobieUserQueries.scala +++ b/obp-api/src/main/scala/code/users/DoobieUserQueries.scala @@ -134,6 +134,14 @@ object DoobieUserQueries { Fragment.empty } + // Users a consent minted for itself are excluded here exactly as LiftUsers.getUsersCommon + // excludes them from the older list endpoints — see the comment there for why they are not + // people. That filter was only ever applied to the Lift path, so this newer search endpoint + // still listed them. Applied in SQL for the same reason: filtering after LIMIT/OFFSET returns + // short pages. + val notMintedByAConsentFilter: Fragment = + fr"AND (ru.createdbyconsentid IS NULL OR ru.createdbyconsentid = '')" + // role_name/bank_id via EXISTS subquery against mappedentitlement — keeps // the outer row count correct (no duplicates when a user has many entitlements). val roleFilter: Fragment = roleName match { @@ -160,6 +168,7 @@ object DoobieUserQueries { deletedFilter ++ lockedFilter ++ roleFilter ++ + notMintedByAConsentFilter ++ orderBy ++ fr"LIMIT $limit OFFSET $offset") .query[UserSearchRow] diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index f117f9f110..1a5cd589f3 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -111,6 +111,10 @@ object LiftUsers extends Users with MdcLoggable{ } } + override def getUsersByUsername(userName: String): List[User] = { + ResourceUser.findAll(By(ResourceUser.name_, userName)) + } + override def getUserByEmail(email: String): Box[List[ResourceUser]] = { Full(ResourceUser.findAll(By(ResourceUser.email, email))) } @@ -177,7 +181,22 @@ object LiftUsers extends Users with MdcLoggable{ Some(By(ResourceUser.IsDeleted, false)) // There is no query parameter "is_deleted" ) - val optionalParams: Seq[QueryParam[ResourceUser]] = Seq(limit.toSeq, offset.toSeq, deleted.toSeq).flatten + // Users a consent minted for itself are not people and do not belong in a list of people: they + // have no username and no email, there is one of them for every consent ever granted, and they + // outnumber real users by orders of magnitude on any busy instance. They stay reachable by id + // and through the account-access data; they just do not pad out this list. + // + // Filtered in SQL rather than after the fact, so it composes with the limit/offset above: a + // filter applied to an already-paginated result returns short pages, which is exactly the + // defect the ?locked= path below has. + // + // The v6.0.0 search path applies the same predicate -- see DoobieUserQueries.getUsers. + val notMintedByAConsent = BySql[ResourceUser]( + "(createdbyconsentid IS NULL OR createdbyconsentid = '')", + IHaveValidatedThisSQL("hongwei", "2026-08-01")) + + val optionalParams: Seq[QueryParam[ResourceUser]] = + Seq(limit.toSeq, offset.toSeq, deleted.toSeq, Seq(notMintedByAConsent)).flatten def getAllResourceUsers(): List[ResourceUser] = ResourceUser.findAll(optionalParams: _*) diff --git a/obp-api/src/main/scala/code/users/Users.scala b/obp-api/src/main/scala/code/users/Users.scala index 56bc3b4b00..786c4bc970 100644 --- a/obp-api/src/main/scala/code/users/Users.scala +++ b/obp-api/src/main/scala/code/users/Users.scala @@ -31,16 +31,27 @@ trait Users { def getUserByProviderId(provider : String, idGivenByProvider : String) : Box[User] def getUserByProviderIdFuture(provider : String, idGivenByProvider : String) : Future[Box[User]] def getOrCreateUserByProviderIdFuture(provider : String, idGivenByProvider : String, consentId: Option[String], name: Option[String], email: Option[String]) : Future[(Box[User], Boolean)] + // The synchronous form of the above, for callers already inside a Box for-comprehension. Carries + // the same duplicate-key recovery: two concurrent first requests both find nothing and both + // insert, so the loser re-reads instead of failing. Second element is true when the user was + // created by this call. + def getOrCreateUserByProviderId(provider : String, idGivenByProvider : String, consentId: Option[String], name: Option[String], email: Option[String]) : (Box[User], Boolean) //resourceuser has two ids: id(Long)and userid_(String), this method use userid_(String) def getUserByUserId(userId : String) : Box[User] def getUserByUserIdFuture(userId : String) : Future[Box[User]] def getUsersByUserIdsFuture(userIds : List[String]) : Future[List[User]] - // find ResourceUser by Resourceuser username + // find ResourceUser by Resourceuser username def getUserByProviderAndUsername(provider: String, userName: String) : Box[User] def getUserByProviderAndUsernameFuture(provider: String, username: String): Future[Box[User]] + // Every user answering to this username, whichever provider they came from. Username is only + // unique per provider, so this can return more than one; callers that need a single user must say + // what they do with an ambiguous answer. Added for Berlin Group PSU-ID resolution, where the + // header names a username and the PSU may be federated rather than local. + def getUsersByUsername(userName: String) : List[User] + def getUserByEmail(email: String) : Box[List[ResourceUser]] def getUserByEmailFuture(email: String) : Future[List[(ResourceUser, Box[List[Entitlement]])]] def getUsersByEmail(email: String) : Future[List[(ResourceUser, Box[List[Entitlement]], Option[List[UserAgreement]])]] diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 93812381e3..4f90ed8295 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -130,22 +130,28 @@ object MapperViews extends Views with MdcLoggable { Full(Permission(user, getViewsForUser(user))) } // This is an idempotent function - private def getOrGrantAccessToViewCommon(user: User, viewDefinition: View, bankId: String, accountId: String): Box[View] = { + // + // consumerId defaults to ALL_CONSUMERS, which is what account ownership wants: holding an account + // is not something any one application owns. A grant that IS owned by one application -- a consent + // exercised by a specific TPP -- passes that TPP's consumerId instead, so it becomes a row of its + // own rather than sharing (and overwriting) everybody else's. User.hasAccountAccess already reads + // it that way: consumer-specific row first, ALL_CONSUMERS as the fallback. + private def getOrGrantAccessToViewCommon(user: User, viewDefinition: View, bankId: String, accountId: String, consumerId: String = ALL_CONSUMERS): Box[View] = { if (AccountAccess.findByUniqueIndex( BankId(bankId), - AccountId(accountId), + AccountId(accountId), viewDefinition.viewId, - user.userPrimaryKey, - ALL_CONSUMERS).isEmpty) { + user.userPrimaryKey, + consumerId).isEmpty) { logger.debug(s"getOrGrantAccessToViewCommon AccountAccess.create" + - s"user(UserId(${user.userId}), ViewId(${viewDefinition.viewId.value}), bankId($bankId), accountId($accountId), consumerId($ALL_CONSUMERS)") + s"user(UserId(${user.userId}), ViewId(${viewDefinition.viewId.value}), bankId($bankId), accountId($accountId), consumerId($consumerId)") // SQL Insert AccountAccessList val saved = AccountAccess.create. user_fk(user.userPrimaryKey.value). bank_id(bankId). account_id(accountId). view_id(viewDefinition.viewId.value). - consumer_id(ALL_CONSUMERS). + consumer_id(consumerId). save if (saved) { //logger.debug("saved AccountAccessList") @@ -190,6 +196,32 @@ object MapperViews extends Views with MdcLoggable { } } + // The consumer-scoped counterparts of the two grants above, mirroring the revoke...ForConsumer + // pair further down. Access granted through one application's consent belongs to that application + // alone: keeping it on its own row is what stops a second TPP's consent from rewriting it, and + // what makes narrowing a consent narrow only that consent. + def grantAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view: View, user: User, consumerId: String): Box[View] = { + { view.isPublic && !allowPublicViews } match { + case true => Failure(PublicViewsNotAllowedOnThisInstance) + case false => getOrGrantAccessToViewCommon(user, view, bankId.value, accountId.value, consumerId) + } + } + + def grantAccessToCustomViewForConsumer(bankIdAccountIdViewId: BankIdAccountIdViewId, user: User, consumerId: String): Box[View] = { + val viewDefinition = ViewDefinition.findCustomView( + bankIdAccountIdViewId.bankId.value, + bankIdAccountIdViewId.accountId.value, + bankIdAccountIdViewId.viewId.value) + + viewDefinition match { + case Full(v) => + if (v.isPublic && !allowPublicViews) Failure(PublicViewsNotAllowedOnThisInstance) + else getOrGrantAccessToViewCommon(user, v, bankIdAccountIdViewId.bankId.value, bankIdAccountIdViewId.accountId.value, consumerId) + case _ => + Empty ~> APIFailure(s"View ${bankIdAccountIdViewId.viewId} not found", 404) + } + } + def grantAccessToMultipleViews(views: List[BankIdAccountIdViewId], user: User, callContext: Option[CallContext]): Box[List[View]] = { val viewDefinitions: List[(ViewDefinition, BankIdAccountIdViewId)] = views.map { uid => ViewDefinition.findCustomView(uid.bankId.value,uid.accountId.value, uid.viewId.value).map((_, uid)) @@ -316,6 +348,53 @@ object MapperViews extends Views with MdcLoggable { } } + /** + * Revoke exactly one grant: this user's, under this consumer, on this view. + * + * revokeAccess matches on (bank, account, view, user) and so cannot tell two applications' grants + * apart; revokeAccessTo*ForConsumer match on (bank, account, view, consumer) and so cannot tell + * two users apart -- on a joint account they would delete whichever row came back first. Undoing + * one consent's grant needs both, hence AccountAccess's full unique index. + */ + def revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId: BankIdAccountIdViewId, user: User, consumerId: String): Box[Boolean] = { + def accountAccessRow = AccountAccess.findByUniqueIndex( + bankIdAccountIdViewId.bankId, + bankIdAccountIdViewId.accountId, + bankIdAccountIdViewId.viewId, + user.userPrimaryKey, + consumerId + ) ?~! CannotFindAccountAccess + + val isRevokedCustomViewAccess = + for { + _ <- ViewDefinition.findCustomView( + bankIdAccountIdViewId.bankId.value, + bankIdAccountIdViewId.accountId.value, + bankIdAccountIdViewId.viewId.value) + accountAccess <- accountAccessRow + } yield { + accountAccess.delete_! + } + + val isRevokedSystemViewAccess = + for { + systemViewDefinition <- ViewDefinition.findSystemView(bankIdAccountIdViewId.viewId.value) + accountAccess <- accountAccessRow + _ <- canRevokeOwnerAccessAsBox(bankIdAccountIdViewId.bankId, bankIdAccountIdViewId.accountId, systemViewDefinition, user) + } yield { + accountAccess.delete_! + } + + isRevokedCustomViewAccess or isRevokedSystemViewAccess + } + + def accessGrantedToUserForConsumer(user: User, consumerId: String): List[BankIdAccountIdViewId] = { + AccountAccess.findAll( + By(AccountAccess.user_fk, user.userPrimaryKey.value), + By(AccountAccess.consumer_id, consumerId) + ).map(row => BankIdAccountIdViewId(BankId(row.bank_id.get), AccountId(row.account_id.get), ViewId(row.view_id.get))) + } + //returns Full if deletable, Failure if not def canRevokeOwnerAccessAsBox(bankId: BankId, accountId: AccountId, viewDefinition : ViewDefinition, user : User) : Box[Unit] = { if(canRevokeOwnerAccess(bankId: BankId, accountId: AccountId, viewDefinition, user)) Full(Unit) @@ -782,8 +861,17 @@ object MapperViews extends Views with MdcLoggable { SYSTEM_VIEW_PERMISSION_COMMON ) entity.isFirehose_(true) - case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID | - SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID => + case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION + ) entity case SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID => ViewPermission.resetViewPermissions( @@ -803,18 +891,58 @@ object MapperViews extends Views with MdcLoggable { SYSTEM_AUDITOR_VIEW_PERMISSION ) entity - case SYSTEM_ACCOUNTANT_VIEW_ID | - SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID | - SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID | - SYSTEM_READ_BALANCES_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + case SYSTEM_ACCOUNTANT_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_VIEW_PERMISSION_COMMON ) entity + // UK Open Banking v4.0.1 system views — each gets the can_* set matching its place in + // the spec's Basic/Detail permission hierarchy (see Constant.SYSTEM_READ_*_VIEW_PERMISSION + // definitions). Previously all six shared SYSTEM_VIEW_PERMISSION_COMMON, so "Detail" + // granted nothing beyond "Basic" and Balances alone exposed full transaction data. + case SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_BALANCES_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_BALANCES_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION + ) + entity + case SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION + ) + entity case _ => entity } diff --git a/obp-api/src/main/scala/code/views/Views.scala b/obp-api/src/main/scala/code/views/Views.scala index 4a462272bb..36a76b67d0 100644 --- a/obp-api/src/main/scala/code/views/Views.scala +++ b/obp-api/src/main/scala/code/views/Views.scala @@ -36,6 +36,17 @@ trait Views { def revokeAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view : View, consumerId : String) : Box[Boolean] def revokeAccessToCustomViewForConsumer(view : View, consumerId : String) : Box[Boolean] + // Grant/revoke a single application's access, rather than access shared by every application. + // Used by the consent flows: what one TPP's consent grants is that TPP's alone, so narrowing or + // re-authorising it must not touch another TPP's grants on the same account. + def grantAccessToSystemViewForConsumer(bankId: BankId, accountId: AccountId, view : View, user : User, consumerId : String) : Box[View] + def grantAccessToCustomViewForConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[View] + def revokeAccessToViewForUserAndConsumer(bankIdAccountIdViewId : BankIdAccountIdViewId, user : User, consumerId : String) : Box[Boolean] + // Everything a user currently holds under one application's scope. The consent flows reconcile + // against this: a consent's granted views are whatever its JWT says, so the rows that back it are + // brought to match rather than deleted and rewritten. + def accessGrantedToUserForConsumer(user : User, consumerId : String) : List[BankIdAccountIdViewId] + def customView(viewId : ViewId, bankAccountId: BankIdAccountId) : Box[View] def systemView(viewId : ViewId) : Box[View] def customViewFuture(viewId : ViewId, bankAccountId: BankIdAccountId) : Future[Box[View]] diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql index 76bc983f3e..20f2b9a0a0 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_admin_clients.sql @@ -28,6 +28,7 @@ name ,logourl ,userauthenticationurl ,clientcertificate +,jwksuri ,company ,key_c ,isactive diff --git a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql index 4dbfbf1f66..aa0c2bd842 100644 --- a/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql +++ b/obp-api/src/main/scripts/sql/OIDC/cre_v_oidc_clients.sql @@ -25,7 +25,9 @@ SELECT name as client_name, 'code' as response_types, 'client_secret_post' as token_endpoint_auth_method, - createdat as created_at + createdat as created_at, + jwksuri as jwks_uri, + clientcertificate as client_certificate FROM consumer WHERE isactive = true -- Only expose active consumers to OIDC service ORDER BY client_name; diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala new file mode 100644 index 0000000000..7882dfafb3 --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/UKAmountsTest.scala @@ -0,0 +1,144 @@ +package code.api.UKOpenBanking + +import code.api.util.OBPTransactionDirection +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} + +/** + * UK Open Banking splits a signed amount in two: `Amount` is unsigned (its pattern, + * `^\d{1,13}$|^\d{1,13}\.\d{1,5}$`, admits no sign) and the direction sits beside it in + * `CreditDebitIndicator`. OBP holds one signed BigDecimal, so every UK response has to split it. + * + * The factories used to hardcode `"Credit"` and pass the signed number straight through, which + * reported a debit of 25 as a credit of -25 — both halves wrong at once. These scenarios pin the + * split so neither half can drift back. + */ +class UKAmountsTest extends FeatureSpec with Matchers with GivenWhenThen { + + feature("UK Open Banking - splitting a signed amount into magnitude and direction") { + + scenario("a negative amount is a debit, reported as its magnitude") { + UKAmounts.creditDebitIndicator(BigDecimal("-25.00")) should be("Debit") + UKAmounts.unsignedAmount(BigDecimal("-25.00")) should be("25.00") + } + + scenario("a positive amount is a credit, unchanged") { + UKAmounts.creditDebitIndicator(BigDecimal("1209.06")) should be("Credit") + UKAmounts.unsignedAmount(BigDecimal("1209.06")) should be("1209.06") + } + + scenario("zero is a credit, as the standard states explicitly") { + UKAmounts.creditDebitIndicator(BigDecimal(0)) should be("Credit") + UKAmounts.unsignedAmount(BigDecimal(0)) should be("0") + } + + scenario("a missing amount is treated as zero, not as an error") { + UKAmounts.creditDebitIndicator(None: Option[BigDecimal]) should be("Credit") + UKAmounts.unsignedAmount(None: Option[BigDecimal]) should be("0") + UKAmounts.creditDebitIndicator(Some(BigDecimal("-1"))) should be("Debit") + UKAmounts.unsignedAmount(Some(BigDecimal("-1"))) should be("1") + } + + scenario("an amount OBP already holds as a string splits the same way") { + UKAmounts.creditDebitIndicatorOfString("-25.00") should be("Debit") + UKAmounts.unsignedAmountString("-25.00") should be("25.00") + UKAmounts.creditDebitIndicatorOfString("1209.06") should be("Credit") + UKAmounts.unsignedAmountString("1209.06") should be("1209.06") + } + + scenario("a value that is not a number is passed through rather than turned into a fabricated zero") { + UKAmounts.unsignedAmountString("") should be("") + UKAmounts.unsignedAmountString("not-a-number") should be("not-a-number") + UKAmounts.creditDebitIndicatorOfString("") should be("Credit") + } + + scenario("granting both directions, or neither, restricts nothing") { + // Neither is the plain ReadTransactionsBasic/Detail case; both is a TPP asking for everything. + for (amount <- List(BigDecimal("-25"), BigDecimal("25"), BigDecimal(0))) { + UKAmounts.admitsDirection(Some(amount), grantsCredits = false, grantsDebits = false) should be(true) + UKAmounts.admitsDirection(Some(amount), grantsCredits = true, grantsDebits = true) should be(true) + } + } + + scenario("granting only Credits admits credits and excludes debits") { + UKAmounts.admitsDirection(Some(BigDecimal("25")), grantsCredits = true, grantsDebits = false) should be(true) + UKAmounts.admitsDirection(Some(BigDecimal("-25")), grantsCredits = true, grantsDebits = false) should be(false) + // Zero is a credit, so a Credits-only consent sees it. + UKAmounts.admitsDirection(Some(BigDecimal(0)), grantsCredits = true, grantsDebits = false) should be(true) + } + + scenario("granting only Debits admits debits and excludes credits") { + UKAmounts.admitsDirection(Some(BigDecimal("-25")), grantsCredits = false, grantsDebits = true) should be(true) + UKAmounts.admitsDirection(Some(BigDecimal("25")), grantsCredits = false, grantsDebits = true) should be(false) + UKAmounts.admitsDirection(Some(BigDecimal(0)), grantsCredits = false, grantsDebits = true) should be(false) + } + + scenario("what a response labels Debit is what a Debits-only consent admits") { + // The two must agree, or a row could be labelled one direction and filtered as the other. + for (amount <- List(BigDecimal("-0.01"), BigDecimal("0"), BigDecimal("0.01"), BigDecimal("-1000"))) { + val labelledDebit = UKAmounts.creditDebitIndicator(amount) == "Debit" + UKAmounts.admitsDirection(Some(amount), grantsCredits = false, grantsDebits = true) should be(labelledDebit) + UKAmounts.admitsDirection(Some(amount), grantsCredits = true, grantsDebits = false) should be(!labelledDebit) + } + } + + scenario("a scale that would render in scientific notation still comes out plain") { + // BigDecimal("1E+3").toString is "1E+3", which the Amount pattern rejects. + UKAmounts.unsignedAmount(BigDecimal("1E+3")) should be("1000") + UKAmounts.unsignedAmount(BigDecimal("-1E+3")) should be("1000") + UKAmounts.unsignedAmountString("1E+3") should be("1000") + } + + scenario("the query restriction matches the directions granted") { + // Both or neither is no restriction, so no param is added at all. + UKAmounts.directionQueryParam(grantsCredits = true, grantsDebits = true) should be(Nil) + UKAmounts.directionQueryParam(grantsCredits = false, grantsDebits = false) should be(Nil) + UKAmounts.directionQueryParam(grantsCredits = true, grantsDebits = false) should + be(List(OBPTransactionDirection(credits = true))) + UKAmounts.directionQueryParam(grantsCredits = false, grantsDebits = true) should + be(List(OBPTransactionDirection(credits = false))) + } + + scenario("the query restriction and the post-filter agree on every amount") { + // They are two enforcements of one rule -- the database narrows, the filter is authoritative. + // If they disagreed, a row could be selected by one and dropped by the other. + // + // The boundary comes from OBPTransactionDirection -- the same value LocalMappedConnector + // builds its SQL predicate from -- rather than a copy written here. A local copy agrees with + // itself no matter what the connector does, so it could not detect the drift it exists to + // catch. + for (amount <- List(BigDecimal("-1000"), BigDecimal("-0.01"), BigDecimal(0), BigDecimal("0.01"))) { + for ((credits, debits) <- List((true, false), (false, true))) { + val smallestUnit = (amount * 100).toLongExact + val queryWouldKeep = UKAmounts.directionQueryParam(credits, debits) match { + case List(param) => OBPTransactionDirection.admits(param, smallestUnit) + case _ => true + } + withClue(s"amount $amount credits=$credits debits=$debits: ") { + UKAmounts.admitsDirection(Some(amount), credits, debits) should be(queryWouldKeep) + } + } + } + } + + scenario("an amount the view withheld is admitted by neither direction") { + // None here is "the moderating view did not grant CAN_SEE_TRANSACTION_AMOUNT", not "zero". + // creditDebitIndicator still labels it Credit for rendering, but as a permission test that + // would hand every debit to a Credits-only consent, so the restriction must refuse instead. + UKAmounts.admitsDirection(None, grantsCredits = true, grantsDebits = false) should be(false) + UKAmounts.admitsDirection(None, grantsCredits = false, grantsDebits = true) should be(false) + // With no restriction in force there is nothing to refuse. + UKAmounts.admitsDirection(None, grantsCredits = true, grantsDebits = true) should be(true) + UKAmounts.admitsDirection(None, grantsCredits = false, grantsDebits = false) should be(true) + } + + scenario("every produced Amount matches the standard's unsigned pattern") { + val pattern = "^\\d{1,13}$|^\\d{1,13}\\.\\d{1,5}$".r + List("-25.00", "25.00", "0", "-0.5", "1209.06", "-1234567890123").foreach { input => + val produced = UKAmounts.unsignedAmountString(input) + withClue(s"input $input produced $produced: ") { + pattern.findFirstIn(produced).isDefined should be(true) + } + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala index e369ca5706..20da0faa34 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310AisTests.scala @@ -1,5 +1,10 @@ package code.api.UKOpenBanking.v3_1_0 +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserOrApplication, buildOperationId} +import code.api.util.ErrorMessages.ConsentDoesNotMatchConsumer +import code.consent.Consents +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion import org.scalatest.Tag /** @@ -20,6 +25,24 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { val acc = testAccountId1.value + // A pending (not yet authorised -- no bound PSU) consent, created by "consumer" (the OAuth1 + // consumer backing user1). For the cross-consumer regression tests: user2 authenticates with a + // *different* consumer (consumer2, see DefaultUsers), so this simulates a second TPP trying to + // reach a first TPP's still-pending consent before any PSU has authorised it. + private def createPendingConsentForConsumer1(): String = + Consents.consentProvider.vend.saveUKConsent( + user = None, + bankId = None, + accountIds = None, + consumerId = Some(testConsumer.consumerId.get), + permissions = List("ReadAccountsBasic"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("3.1.0") + ).openOrThrowException("test consent creation failed").consentId + // ── AccountAccessApi ─────────────────────────────────────────────── feature("UKOB v3.1 POST /account-access-consents") { scenario("authenticated", UKOpenBankingV310) { @@ -29,6 +52,15 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { postUnauthed("{}", "account-access-consents").code should equal(401) } + // See the twin scenario in UKOpenBankingV401AccountInfoTests for why this is pinned: lodging is + // a client-credentials call with no PSU, and the ResourceDoc default (UserOnly) would 401 it as + // soon as a client-credentials token stops auto-vivifying a user. + scenario("ResourceDoc declares UserOrApplication so a PSU-less TPP call is not rejected", UKOpenBankingV310) { + val docs = ResourceDoc.getResourceDocs( + List(buildOperationId(ApiVersion.ukOpenBankingV31, "createAccountAccessConsents"))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } } feature("UKOB v3.1 DELETE /account-access-consents/CONSENT_ID") { scenario("authenticated", UKOpenBankingV310) { @@ -38,6 +70,21 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { deleteUnauthed("account-access-consents", "fake-consent-id").code should equal(401) } + // Cross-consumer regression (currently RED): a pending consent (no bound PSU yet) is + // currently revokable by ANY authenticated party -- the ownership check added to + // Http4sUKOBv310AccountAccess.deleteAccountAccessConsentsConsentId only guards against a + // *different bound user*, not a *different consumer*. deleteAuthedAsUser2 authenticates as + // consumer2 (see DefaultUsers), a different OAuth1 consumer than the one that created this + // pending consent (testConsumer/consumer). Once fixed, this must be 403 + // ConsentDoesNotMatchConsumer, and the consent must be left untouched. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, and consent is left untouched", UKOpenBankingV310) { + val consentId = createPendingConsentForConsumer1() + val response = deleteAuthedAsUser2("account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + + Consents.consentProvider.vend.getConsentByConsentId(consentId).isDefined should equal(true) + } } feature("UKOB v3.1 GET /account-access-consents/CONSENT_ID") { scenario("authenticated", UKOpenBankingV310) { @@ -47,6 +94,16 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("account-access-consents", "fake-consent-id").code should equal(401) } + // Cross-consumer regression (currently RED): same root cause as the DELETE gap above -- + // a pending consent is currently readable by ANY authenticated party. getAuthedAsUser2 + // authenticates as consumer2, a different OAuth1 consumer than the one that created this + // pending consent. Once fixed, this must be 403 ConsentDoesNotMatchConsumer. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, not 200", UKOpenBankingV310) { + val consentId = createPendingConsentForConsumer1() + val response = getAuthedAsUser2("account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + } } // ── AccountsApi ──────────────────────────────────────────────────── @@ -80,8 +137,11 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { } } feature("UKOB v3.1 GET /balances") { - scenario("authenticated -> 200", UKOpenBankingV310) { - getAuthed("balances").code should equal(200) + scenario("authenticated", UKOpenBankingV310) { + // DATA-DEPENDENT: checkUKConsent + passesPsd2Aisp (Issue B fix -- kept consistent + // with v4.0.1's getBalances, see UKOpenBankingV401AccountInfoTests). This OAuth1-signed + // test request carries no Bearer JWT, so checkUKConsent deterministically 403s here. + getAuthed("balances").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("balances").code should equal(401) @@ -270,8 +330,11 @@ class UKOpenBankingV310AisTests extends UKOpenBankingV310ServerSetup { } } feature("UKOB v3.1 GET /transactions") { - scenario("authenticated -> 200", UKOpenBankingV310) { - getAuthed("transactions").code should equal(200) + scenario("authenticated", UKOpenBankingV310) { + // DATA-DEPENDENT: checkUKConsent + passesPsd2Aisp (Issue B fix -- kept consistent + // with v4.0.1's getTransactions, see UKOpenBankingV401AccountInfoTests). This OAuth1-signed + // test request carries no Bearer JWT, so checkUKConsent deterministically 403s here. + getAuthed("transactions").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV310) { getUnauthed("transactions").code should equal(401) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala new file mode 100644 index 0000000000..648dedf66e --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ConsentPermissionsTests.scala @@ -0,0 +1,69 @@ +package code.api.UKOpenBanking.v3_1_0 + +import code.api.util.ErrorMessages.InvalidUKConsentPermissions +import com.openbankproject.commons.model.ErrorMessage +import org.scalatest.Tag + +// v3.1 lodges account-access consents through its own handler, so the permission-combination rules +// have to be asserted here as well as on v4.0.1 -- the rule function being shared is not by itself +// evidence that this endpoint calls it. The rules themselves, and why an unusable consent is worth +// refusing outright, are covered in UKOpenBankingV401ConsentPermissionsTests. +class UKOpenBankingV310ConsentPermissionsTests extends UKOpenBankingV310ServerSetup { + + object UKOpenBankingV310ConsentPermissions extends Tag("UKOpenBankingV310ConsentPermissions") + + // v3.1's ConsentPostBodyUKV310 types Risk as a String, not an object. + private def body(permissions: String): String = + s"""{ + | "Data": { + | "Permissions": $permissions, + | "ExpirationDateTime": "2030-01-01", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": "" + |}""".stripMargin + + feature("UKOB v3.1 POST /account-access-consents rejects invalid Permissions") { + + scenario("no account-read permission -> 400 with the OBP error code", + UKOpenBankingV310ConsentPermissions) { + val response = postAuthed( + body("""["ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "account-access-consents") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(InvalidUKConsentPermissions) + } + + scenario("empty Permissions array -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed(body("[]"), "account-access-consents").code should equal(400) + } + + scenario("transaction depth without a direction -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsBasic"]"""), + "account-access-consents").code should equal(400) + } + + scenario("a transaction direction without a depth -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsCredits"]"""), + "account-access-consents").code should equal(400) + } + + scenario("unknown permission code -> 400", UKOpenBankingV310ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadEverything"]"""), + "account-access-consents").code should equal(400) + } + + scenario("a valid combination is still created -> 201", UKOpenBankingV310ConsentPermissions) { + val response = postAuthed( + body("""["ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsCredits"]"""), + "account-access-consents") + response.code should equal(201) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal( + List("ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsCredits")) + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala index 51f81712ed..7bfbb6fee9 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v3_1_0/UKOpenBankingV310ServerSetup.scala @@ -22,9 +22,16 @@ trait UKOpenBankingV310ServerSetup extends ServerSetupWithTestData with DefaultU def getAuthed(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET <@ (user1)) def getUnauthed(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET) + // For IDOR regression tests: user2 authenticated (a different user AND a different OAuth1 + // consumer than user1, see DefaultUsers), acting on a resource (e.g. a consent) that belongs + // to a different user/consumer. + def getAuthedAsUser2(segments: String*): APIResponse = makeGetRequest(v31(segments: _*).GET <@ (user2)) + def postAuthed(body: String, segments: String*): APIResponse = makePostRequest(v31(segments: _*).POST <@ (user1), body) def postUnauthed(body: String, segments: String*): APIResponse = makePostRequest(v31(segments: _*).POST, body) def deleteAuthed(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE <@ (user1)) def deleteUnauthed(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE) + + def deleteAuthedAsUser2(segments: String*): APIResponse = makeDeleteRequest(v31(segments: _*).DELETE <@ (user2)) } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index 4f7302c51f..ef6d535c66 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -1,10 +1,21 @@ package code.api.UKOpenBanking.v4_0_1 -import code.api.util.APIUtil.DateWithDayFormat -import code.consent.Consents +import code.api.Constant +import code.api.util.APIUtil.{DateWithDayFormat, ResourceDoc, UserOrApplication, buildOperationId} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser, ConsentExpiredIssue, ConsentIdClaimMissing} +import code.api.util.{CallContext, CertificateUtil, Consent} +import code.consent.{ConsentStatus, Consents} +import code.model.UserExtended +import code.views.Views +import com.openbankproject.commons.model.{BankIdAccountId, ErrorMessage, ViewId} +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Failure, Full} import org.json4s._ import org.scalatest.Tag +import scala.concurrent.Await +import scala.concurrent.duration._ + // Test suite for UK Open Banking Read/Write v4.0.1 (AccountInfo). // // The 9 endpoints wired to real connector data (see Http4sUKOBv401AccountInfo) @@ -12,13 +23,17 @@ import org.scalatest.Tag // seeded data -> 200/201/204 with real field values, error paths (unknown // consent/account), and a full consent create -> get -> delete -> get lifecycle. // -// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions -// call NewStyle.function.checkUKConsent, which requires the Bearer access token -// to be a JWT carrying a consent_id claim bound to an AUTHORISED consent of the -// calling user+consumer (see code.api.util.ConsentUtil.checkUKConsent). The test -// framework's DirectLogin tokens carry no consent_id, so (mirroring -// UKOpenBankingV310AisTests' precedent for the same three v3.1 endpoints) only -// "unauthenticated -> 401" and "authenticated -> not 401" are asserted for those three. +// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions call +// NewStyle.function.checkUKConsent (code.api.util.ConsentUtil.checkUKConsent), which reads +// the `consent_id` claim off the Bearer access token — no external identity-provider call. +// These OAuth1-signed test requests carry no Bearer JWT, so the claim lookup deterministically +// fails with a 403 ConsentIdClaimMissing (see the scenario comments on those three features). +// getBalances / getTransactions (the account-aggregate variants) share the same +// checkUKConsent guard but only assert "unauthenticated -> 401" / "authenticated -> +// not 401" (mirroring UKOpenBankingV310AisTests' precedent): they previously skipped +// the consent check entirely and returned real data straight from a DirectLogin +// token, which let a token with no bound consent reach 500 instead of the 403 +// OBP-35035 every other AISP data endpoint gives it. // // The remaining 80 endpoints are still static spec-faithful stubs; their tests // are unchanged (two scenarios: authenticated -> fixed code, unauthenticated -> 401). @@ -46,9 +61,9 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { accountIds = None, consumerId = None, permissions = consentPermissions, - expirationDateTime = DateWithDayFormat.parse("2030-01-01"), - transactionFromDateTime = DateWithDayFormat.parse("2020-01-01"), - transactionToDateTime = DateWithDayFormat.parse("2030-01-01"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed") @@ -64,13 +79,31 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { accountIds = None, consumerId = None, permissions = consentPermissions, - expirationDateTime = DateWithDayFormat.parse("2030-01-01"), - transactionFromDateTime = DateWithDayFormat.parse("2020-01-01"), - transactionToDateTime = DateWithDayFormat.parse("2030-01-01"), + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), apiStandard = standard, apiVersion = Some("4.0.1") ).openOrThrowException("test consent creation failed").consentId + // A pending (not yet authorised -- no bound PSU) consent, created by "consumer" (the OAuth1 + // consumer backing user1). For the cross-consumer regression tests: user2 authenticates with a + // *different* consumer (consumer2, see DefaultUsers), so this simulates a second TPP trying to + // reach a first TPP's still-pending consent before any PSU has authorised it. + private def createPendingConsentForConsumer1(): String = + Consents.consentProvider.vend.saveUKConsent( + user = None, + bankId = None, + accountIds = None, + consumerId = Some(testConsumer.consumerId.get), + permissions = consentPermissions, + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("test consent creation failed").consentId + // ── Cross-standard exercise boundary (ConsentUtil.assertConsentStandard) ── feature("A consent may only be exercised by the standard that created it") { import code.api.util.Consent @@ -102,10 +135,75 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { consentId should not be empty Consents.consentProvider.vend.getConsentByConsentId(consentId).isDefined should equal(true) (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) + // v4.0.1 four-letter status codes on the wire (not the stored long name) + StatusReason. + (response.body \ "Data" \ "Status").extract[String] should equal("AWAU") + (response.body \ "Data" \ "StatusReason" \ "StatusReasonCode").extract[List[String]] should equal(List("U036")) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) } + // Lodging a consent is a client-credentials call: the TPP is authenticated as an app and no PSU + // exists yet. UserOnly (the ResourceDoc default) sends ResourceDocMiddleware down anonymousAccess, + // which 401s a request carrying no user. Pinned here because nothing else would notice the doc + // silently reverting to the default -- the endpoint would keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. + scenario("ResourceDoc declares UserOrApplication so a PSU-less TPP call is not rejected", UKOpenBankingV401AccountInfo) { + val docs = ResourceDoc.getResourceDocs( + List(buildOperationId(ApiVersion.ukOpenBankingV401, "createAccountAccessConsents"))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } + scenario("all three datetime fields omitted -> 201, open-ended (no expiry/date restriction)", UKOpenBankingV401AccountInfo) { + val bodyWithoutDates = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"] + | }, + | "Risk": {} + |}""".stripMargin + val response = postAuthed(bodyWithoutDates, "aisp", "account-access-consents") + response.code should equal(201) + val consentId = (response.body \ "Data" \ "ConsentId").extract[String] + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + // MappedDateTime fields left unset by a null write come back as Java null, not a sentinel date. + consent.expirationDateTime should equal(null) + consent.transactionFromDateTime should equal(null) + consent.transactionToDateTime should equal(null) + } + scenario("full ISO-8601 datetime with time and offset is preserved, not truncated to a bare date", UKOpenBankingV401AccountInfo) { + val bodyWithFullDatetime = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"], + | "ExpirationDateTime": "2030-06-15T13:45:30+02:00", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + val response = postAuthed(bodyWithFullDatetime, "aisp", "account-access-consents") + response.code should equal(201) + val consentId = (response.body \ "Data" \ "ConsentId").extract[String] + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + // 2030-06-15T13:45:30+02:00 == 2030-06-15T11:45:30Z -- if the parser truncated to the bare + // date (the pre-fix DateWithDayFormat behaviour), this would be 2030-06-15T00:00:00Z instead. + consent.expirationDateTime.getTime should equal( + java.time.OffsetDateTime.parse("2030-06-15T13:45:30+02:00").toInstant.toEpochMilli) + } + scenario("malformed datetime -> 400, not 500", UKOpenBankingV401AccountInfo) { + val bodyWithBadDate = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"], + | "ExpirationDateTime": "not-a-date", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + postAuthed(bodyWithBadDate, "aisp", "account-access-consents").code should equal(400) + } } feature("UKOB v4.0.1 GET /aisp/account-access-consents/CONSENT_ID") { scenario("authenticated with real consent -> 200 real data", UKOpenBankingV401AccountInfo) { @@ -114,6 +212,8 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { response.code should equal(200) (response.body \ "Data" \ "ConsentId").extract[String] should equal(consentId) (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) + // freshly-created consent is AWAITINGAUTHORISATION → wire code AWAU + (response.body \ "Data" \ "Status").extract[String] should equal("AWAU") } scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { getAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) @@ -121,6 +221,31 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } + // IDOR regression (currently RED): the endpoint only checks that the consent exists, never + // that the caller owns it (see Http4sUKOBv401AccountInfo.getAccountAccessConsentsConsentId, + // which discards the resolved user via `(_, cc)` before the lookup). Any authenticated party + // can currently read any other party's consent details -- this must become a 403 + // ConsentDoesNotMatchUser once the ownership check is added, mirroring the identity contract + // already enforced at consent authorise time (Http4s510: consent.userId == user.userId). + scenario("authenticated as a different user than the consent owner -> 403, not 200", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() // owned by resourceUser1 + val response = getAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchUser) + } + // Cross-consumer regression (currently RED): a pending consent (no bound PSU yet) is + // currently readable by ANY authenticated party, because the ownership check + // (Option(consent.userId).forall(_.isBlank) || ...) deliberately lets pending consents + // through -- it only guards against a *different bound user*, not a *different consumer*. + // getAuthedAsUser2 authenticates as consumer2 (see DefaultUsers), a different OAuth1 + // consumer than the one that created this pending consent (testConsumer/consumer). Once + // fixed, a different consumer must get 403 ConsentDoesNotMatchConsumer here. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, not 200", UKOpenBankingV401AccountInfo) { + val consentId = createPendingConsentForConsumer1() + val response = getAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + } } feature("UKOB v4.0.1 DELETE /aisp/account-access-consents/CONSENT_ID") { scenario("full consent lifecycle: create -> get -> delete -> get", UKOpenBankingV401AccountInfo) { @@ -132,7 +257,8 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { val afterDelete = getAuthed("aisp", "account-access-consents", consentId) afterDelete.code should equal(200) - (afterDelete.body \ "Data" \ "Status").extract[String] should equal("REVOKED") + // stored status is REVOKED, but the v4.0.1 wire format reports the spec's CANC code + (afterDelete.body \ "Data" \ "Status").extract[String] should equal("CANC") } scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { deleteAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) @@ -140,39 +266,268 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { deleteUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } + // IDOR regression (currently RED, most severe of the two): deleteAccountAccessConsentsConsentId + // (Http4sUKOBv401AccountInfo) resolves the consent by id and calls + // Consents.consentProvider.vend.revoke(consentId) directly -- no ownership check at all. Any + // authenticated party can currently revoke any other party's consent. Once fixed this must be + // a 403 ConsentDoesNotMatchUser, and -- critically -- the consent's stored status must be left + // untouched (still AWAITINGAUTHORISATION), proving the rejected delete had zero side effect. + scenario("authenticated as a different user than the consent owner -> 403, and consent is left untouched", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() // owned by resourceUser1 + val response = deleteAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchUser) + + val stillThere = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + stillThere.status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + // Cross-consumer regression (currently RED, most severe of this pair): a pending consent + // (no bound PSU yet) is currently revokable by ANY authenticated party -- same root cause + // as the GET cross-consumer gap above (the ownership check treats "no bound user yet" as + // "anyone may proceed"). getAuthedAsUser2/deleteAuthedAsUser2 authenticate as consumer2, a + // different OAuth1 consumer than the one that created this pending consent. Once fixed, + // this must be 403 ConsentDoesNotMatchConsumer, and the consent must be left untouched. + scenario("authenticated as a different consumer than the creator, pending consent -> 403, and consent is left untouched", UKOpenBankingV401AccountInfo) { + val consentId = createPendingConsentForConsumer1() + val response = deleteAuthedAsUser2("aisp", "account-access-consents", consentId) + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(ConsentDoesNotMatchConsumer) + + val stillThere = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + stillThere.status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + } + // ── Consent.grantUKConsentAccountAccess (Gap 4 fix) ─────────────────── + // Regression test for the previously-unverified scenario: before this fix, + // createUKConsentJWT wrote every permission as ConsentView(bank_id=null, + // account_id=null, view_id=permission) — a row that could never match a real + // account (see User.hasAccountAccess: plain bank_id/account_id equality, no + // wildcard) — so a UK consent's declared Permissions had zero effect on what + // could actually be read. This exercises the fix at the same access-check + // layer checkViewAccessAndReturnView (and therefore every UK data endpoint) + // relies on, since the full HTTP path requires a Bearer JWT with a consent_id + // claim that this OAuth1-signed test suite cannot mint (see the comment above + // "GET /aisp/accounts" below). + feature("UKOB v4.0.1 Consent.grantUKConsentAccountAccess binds permissions to the selected account only") { + scenario("the consent's scope lands in its JWT, and the PSU gains nothing", UKOpenBankingV401AccountInfo) { + val userExtended = UserExtended(resourceUser1) + val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + + // Baseline: ServerSetupWithTestData's default view grants don't include the UK read views. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + + val consentId = createRealConsent() // permissions = List("ReadAccountsBasic") only + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + + val result = Await.result( + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, List(acc), consent, None), + 10.seconds) + result.isDefined should equal(true) + + // The JWT is the consent's scope: binding replaces the (null, null, permission) placeholders + // createUKConsentJWT wrote with the real account, for the declared permission and no other. + // What that scope is worth at request time is pinned in UKOpenBankingV401ConsentScopingTests, + // which drives the whole applyUKRules path. + val boundViews = { + implicit val formats = code.api.util.CustomJsonFormats.formats + val updated = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + code.api.util.JwtUtil.getSignedPayloadAsJson(updated.jsonWebToken) + .map(com.openbankproject.commons.util.JsonAliases.parse(_).extract[code.api.util.ConsentJWT]) + .openOrThrowException("consent jwt").views + } + boundViews.map(v => (v.bank_id, v.account_id, v.view_id)) should equal( + List((testBankId1.value, acc, Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID))) + + // And the PSU is left exactly as they were. Binding used to write AccountAccess rows under + // the PSU, which is what made every consent that PSU granted share one set of rows; the + // consent's access now belongs to a principal of its own. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_BALANCES_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + } + } + + // ── Consent.grantUKConsentAccountAccess must reject accounts the PSU does not hold ── + // Regression test for the account-ownership gap: grantUKConsentAccountAccess only verified + // that the requested account_ids *exist* (checkBankAccountExists), never that the PSU + // authorising the consent actually holds them. Without this check, any authenticated user + // could authorise a UK consent naming ANY existing account_id (e.g. testAccountId1, held by + // resourceUser1) and be granted every consented view on it -- an account-access IDOR. This + // mirrors the existing "binds permissions to the selected account only" scenario above but + // asserts on account holder-ship (AccountHolders.getAccountsHeld) rather than permission scope. + feature("UKOB v4.0.1 Consent.grantUKConsentAccountAccess rejects an account the PSU does not hold") { + scenario("resourceUser2 tries to authorise a consent naming resourceUser1's account -> rejected, no access granted", UKOpenBankingV401AccountInfo) { + val userExtended = UserExtended(resourceUser2) + val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + + // testAccountId1 is held by resourceUser1 (ServerSetupWithTestData.beforeEach), not resourceUser2. + code.accountholders.AccountHolders.accountHolders.vend + .getAccountsHeld(testBankId1, resourceUser2) + .contains(bankIdAccountId) should equal(false) + + val consentId = createRealConsent() // permissions = List("ReadAccountsBasic") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent") + + val result = Await.result( + Consent.grantUKConsentAccountAccess(resourceUser2, testBankId1, List(acc), consent, None), + 10.seconds) + result.isDefined should equal(false) + result match { + case Failure(msg, _, _) => msg should include("OBP-35037") + case other => fail(s"expected Failure(OBP-35037...), got $other") + } + + // No AccountAccess row must have been created for resourceUser2 on this account. + userExtended.hasAccountAccess( + Views.views.vend.getOrCreateSystemView(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID).openOrThrowException("view"), + bankIdAccountId, None) should equal(false) + } + } + + // Regression coverage for Gap 14 (UK consents previously never expired): checkUKConsent must + // reactively reject an AUTHORISED consent whose ExpirationDateTime has passed, independent of + // ConsentScheduler's proactive sweep timing. Exercised by calling Consent.checkUKConsent + // directly with a hand-built CallContext carrying a self-signed Bearer JWT with a consent_id + // claim -- JwtUtil.getOptionalClaim only parses the JWT structurally (SignedJWT.parse), it does + // not verify the signature, so this doesn't need to be signed with the real shared secret. This + // sidesteps the suite-wide limitation noted above (OAuth1-signed test requests carry no real + // Bearer JWT) for the one scenario that specifically needs one. + feature("UKOB v4.0.1 Consent.checkUKConsent rejects an authorised consent past its ExpirationDateTime") { + scenario("expired consent -> Failure(ConsentExpiredIssue), not silently accepted", UKOpenBankingV401AccountInfo) { + val consent = Consents.consentProvider.vend.saveUKConsent( + user = Some(resourceUser1), + bankId = None, + accountIds = None, + consumerId = None, + permissions = consentPermissions, + expirationDateTime = Some(new java.util.Date(System.currentTimeMillis() - 60000L)), // 1 minute ago + transactionFromDateTime = None, + transactionToDateTime = None, + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("consent creation failed") + Consents.consentProvider.vend.updateConsentUser(consent.consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.AUTHORISED) + + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .claim("consent_id", consent.consentId) + .build() + val jwt = CertificateUtil.jwtWithHmacProtection(claimsSet) + val callContext = CallContext(authReqHeaderField = Full(s"Bearer $jwt")) + + Consent.checkUKConsent(resourceUser1, Some(callContext)) match { + case Failure(msg, _, _) => msg.contains(ConsentExpiredIssue) should equal(true) + case other => fail(s"expected Failure(ConsentExpiredIssue), got $other") + } + } } + + // Gap 12 (re-authentication) security guards on POST /obp/v5.1.0/banks/BANK_ID/consents/ + // CONSENT_ID/authorise/challenge -- the first step of the ceremony, so it can be exercised + // without a completed SCA/OTP. The successful re-auth path (start challenge -> answer OTP -> + // stays AUTHORISED) needs the full OTP ceremony, which has no test harness in this repo yet + // (the authorise endpoints shipped with no coverage), so it's not asserted here; these cover + // the security-relevant rejection branches the re-auth relaxation introduces. + private def scaChallengeRequest(consentId: String) = + (baseRequest / "obp" / "v5.1.0" / "banks" / testBankId1.value / "consents" / consentId / "authorise" / "challenge").POST + // Option, because the state that matters for the authorise guards is the one a TPP actually + // lodges: no PSU bound yet. saveUKConsent writes mUserId straight from this. + private def createUKConsent(user: Option[com.openbankproject.commons.model.User], expiration: Option[java.util.Date]): String = { + val consent = Consents.consentProvider.vend.saveUKConsent( + user = user, bankId = None, accountIds = None, consumerId = None, + permissions = consentPermissions, expirationDateTime = expiration, + transactionFromDateTime = None, transactionToDateTime = None, + apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") + ).openOrThrowException("consent creation failed") + consent.consentId + } + feature("UKOB v4.0.1 re-authentication guards on the SCA challenge endpoint") { + scenario("challenge-start on an already-authorised consent bound to a different PSU -> 403", UKOpenBankingV401AccountInfo) { + val consentId = createUKConsent(Some(resourceUser1), Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + // user2 != the bound resourceUser1 -> hijack guard rejects + val response = makePostRequest(scaChallengeRequest(consentId) <@ (user2), "") + response.code should equal(403) + response.body.extract[ErrorMessage].message.contains(ConsentDoesNotMatchUser) should equal(true) + } + scenario("challenge-start on an authorised consent past its ExpirationDateTime -> 400 ConsentExpiredIssue", UKOpenBankingV401AccountInfo) { + val consentId = createUKConsent(Some(resourceUser1), Some(new java.util.Date(System.currentTimeMillis() - 60000L))) + Consents.consentProvider.vend.updateConsentUser(consentId, resourceUser1) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + val response = makePostRequest(scaChallengeRequest(consentId) <@ (user1), "") + response.code should equal(400) + response.body.extract[ErrorMessage].message.contains(ConsentExpiredIssue) should equal(true) + } + // Note: the terminal-status rejection (EXPIRED/REJECTED can't be re-authed) is enforced by + // ukReAuthableStatuses not containing those; it isn't asserted via a third HTTP scenario here + // because a second same-user OAuth1 call within this block collides on the test harness's + // nonce/timestamp replay check (a harness artifact -- production UK uses OAuth2 Bearer). + } + + // Gap 15: every UK v4.0.1 response carries x-fapi-interaction-id (FAPI tracing). Uses a stub + // endpoint that returns 200 so the assertion is purely about the header, independent of consent. + feature("UKOB v4.0.1 x-fapi-interaction-id response header") { + scenario("generated as a UUID when the request omits it", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", "fake-accountid", "beneficiaries") + response.code should equal(200) + val interactionId = response.headers.map(_.get("x-fapi-interaction-id")).orNull + interactionId should not be null + interactionId should not be empty + // a generated value is a UUID + java.util.UUID.fromString(interactionId).toString should equal(interactionId) + } + scenario("echoed verbatim when the request supplies it", UKOpenBankingV401AccountInfo) { + val supplied = "test-interaction-id-12345" + val response = makeGetRequest( + v401("aisp", "accounts", "fake-accountid", "beneficiaries").GET <@ (user1), + List(("x-fapi-interaction-id", supplied))) + response.code should equal(200) + response.headers.map(_.get("x-fapi-interaction-id")).orNull should equal(supplied) + } + } + // ── AccountsApi ──────────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (consent_id claim — see class doc above). + // checkUKConsent extracts the `consent_id` claim from the Bearer access token (no external + // Hydra call since Consent.checkUKConsent dropped the Hydra dependency). These OAuth1-signed + // test requests carry no Bearer JWT at all, so the claim lookup deterministically fails -> + // 403 ConsentIdClaimMissing, mirroring "authenticated but no bound consent" in production. feature("UKOB v4.0.1 GET /aisp/accounts") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts").code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID") { - scenario("authenticated with granted read view -> 200 real account data", UKOpenBankingV401AccountInfo) { + // Issue A fix: this endpoint now runs checkUKConsent before the account lookup, matching + // its sibling /balances and /transactions endpoints below. This OAuth1-signed test suite + // carries no Bearer JWT, so the consent check deterministically 403s here -- the previous + // "200 real account data" scenarios (dropped) actually reached real data with zero consent + // enforcement, which was Issue A itself. + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { grantUKReadViews(testAccountId1, resourceUser1) val response = getAuthed("aisp", "accounts", acc) - response.code should equal(200) - val accounts = (response.body \ "Data" \ "Account").children - accounts should not be empty - (accounts.head \ "AccountId").extract[String] should equal(acc) - (accounts.head \ "Currency").extract[String] should equal("EUR") - } - scenario("authenticated with fake account id -> 200 empty Account list", UKOpenBankingV401AccountInfo) { - val response = getAuthed("aisp", "accounts", "fake-accountid") - response.code should equal(200) - (response.body \ "Data" \ "Account").children should be(empty) + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc).code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/balances") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", acc, "balances").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", acc, "balances") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc, "balances").code should equal(401) @@ -275,10 +630,12 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } // ── TransactionsApi ──────────────────────────────────────────────── - // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (consent_id claim — see class doc above). + // See the "no external Hydra call" note above feature("UKOB v4.0.1 GET /aisp/accounts"). feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/transactions") { - scenario("authenticated", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", acc, "transactions").code should not equal (401) + scenario("authenticated without a consent-bound token -> 403", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", acc, "transactions") + response.code should equal(403) + response.body.extract[ErrorMessage].message.trim should equal(ConsentIdClaimMissing.trim) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts", acc, "transactions").code should equal(401) @@ -286,23 +643,10 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } // ── BalancesApi ──────────────────────────────────────────────────── + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (see class doc above). feature("UKOB v4.0.1 GET /aisp/balances") { - scenario("authenticated with real account -> 200 real balance", UKOpenBankingV401AccountInfo) { - val response = getAuthed("aisp", "balances") - response.code should equal(200) - // resourceUser1 owns accounts on several test banks (see TestConnectorSetup. - // createAccountRelevantResources), so testAccountId1's entry isn't necessarily - // first — find it rather than assume position 0. - val balances = (response.body \ "Data" \ "Balance").children - balances should not be empty - val myBalance = balances.find(b => (b \ "AccountId").extract[String] == acc) - myBalance shouldBe defined - (myBalance.get \ "Amount" \ "Currency").extract[String] should equal("EUR") - } - scenario("authenticated with no private accounts -> 200 empty Balance list", UKOpenBankingV401AccountInfo) { - val response = getAuthedAsUser2("aisp", "balances") - response.code should equal(200) - (response.body \ "Data" \ "Balance").children should be(empty) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "balances").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "balances").code should equal(401) @@ -372,26 +716,73 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "statements").code should equal(401) } } + // DATA-DEPENDENT: checkUKConsent requires a consent-bound token (see class doc above). feature("UKOB v4.0.1 GET /aisp/transactions") { - scenario("authenticated with seeded transactions -> 200 real data", UKOpenBankingV401AccountInfo) { - val seeded = seedTransactions(testAccountId1) - val response = getAuthed("aisp", "transactions") - response.code should equal(200) - // resourceUser1 owns accounts on several test banks, but only testAccountId1 - // has seeded transactions here, so every returned entry should belong to it. - val transactions = (response.body \ "Data" \ "Transaction").children - transactions should not be empty - transactions.foreach(t => (t \ "AccountId").extract[String] should equal(acc)) - (transactions.head \ "Amount" \ "Currency").extract[String] should equal("EUR") - transactions.map(t => (t \ "TransactionId").extract[String]) should contain (seeded.head.id.value) - } - scenario("authenticated with no private accounts -> 200 empty Transaction list", UKOpenBankingV401AccountInfo) { - val response = getAuthedAsUser2("aisp", "transactions") - response.code should equal(200) - (response.body \ "Data" \ "Transaction").children should be(empty) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "transactions").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "transactions").code should equal(401) } } + + // A refused authorisation must leave the consent exactly as it found it. + // + // POST .../consents/CONSENT_ID/authorise used to bind the PSU (updateConsentUser) before + // grantUKConsentAccountAccess had decided whether the request was acceptable at all, and nothing + // in the sequence is transactional. A rejected attempt therefore claimed the consent for whoever + // made it: status still AWAITINGAUTHORISATION, mUserId now the caller. From there the + // ConsentDoesNotMatchUser guard at the top of the same endpoint locked the genuine PSU out of + // their own consent, and the lodging TPP lost its GET/DELETE as well, with no way back through + // the API. Consent ids reach the browser in the authorisation redirect, so one failing request + // from anyone who had seen one was enough to destroy it. + // + // The endpoint asserts the outcome; the DB assertions after it are the actual regression -- a + // 400 alone was always true, and was true while the consent was being claimed. + private def authoriseRequest(consentId: String) = + (baseRequest / "obp" / "v5.1.0" / "banks" / testBankId1.value / "consents" / consentId / "authorise").POST + private def storedConsent(consentId: String) = + Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException(s"consent $consentId") + // An unbound consent stores mUserId as null rather than "", so read it defensively -- the + // assertion is "nobody", and both spellings mean that. + private def boundPsuOf(consentId: String): String = + Option(storedConsent(consentId).userId).map(_.trim).getOrElse("") + + feature("UKOB v4.0.1 a refused authorisation does not claim the consent") { + scenario("account_ids naming an account the PSU does not hold -> refused, consent left unbound", UKOpenBankingV401AccountInfo) { + // The dummy SCA answer is `123` only when this is set; test props leave it unset, so the + // challenge would otherwise refuse before the account check is ever reached. Same as the + // Berlin Group authorisation scenarios do. + setPropsValues("suggested_default_sca_method" -> "DUMMY") + // Lodged the way a TPP lodges one: no PSU yet. + val consentId = createUKConsent(None, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + boundPsuOf(consentId) should equal("") + + // testAccountId1 belongs to resourceUser1, so user2 authorising against it is the refusal. + code.accountholders.AccountHolders.accountHolders.vend + .getAccountsHeld(testBankId1, resourceUser2) + .contains(BankIdAccountId(testBankId1, testAccountId1)) should equal(false) + + val challenge = makePostRequest(scaChallengeRequest(consentId) <@ (user2), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + + val refused = makePostRequest(authoriseRequest(consentId) <@ (user2), + s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"123"}""") + // 403, the same answer the ConsentDoesNotMatchUser guard earlier in this endpoint gives: + // both are "you may not authorise this", not "your request was malformed". + refused.code should equal(403) + val message = refused.body.extract[ErrorMessage].message + message should include("OBP-35037") + // And it has to arrive as itself. Passing this Box through connectorEmptyResponse reported + // an authorisation decision as InvalidConnectorResponse, burying the reason behind a cause + // it has nothing to do with. + message should not include "OBP-50200" + message should not include "connectorEmptyResponse" + + // The regression: before the reorder both of these came back naming resourceUser2. + boundPsuOf(consentId) should equal("") + storedConsent(consentId).status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + } } diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala new file mode 100644 index 0000000000..9e21e3dd4b --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentAccessTests.scala @@ -0,0 +1,260 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.util.APIUtil.{ResourceDoc, UserOrApplication, buildOperationId} +import code.api.util.{CallContext, Consent} +import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import code.model.UserX +import code.model.dataAccess.ResourceUser +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Empty, Failure, Full} +import net.liftweb.util.Helpers.randomString +import org.scalatest.Tag + +// Who may read or revoke a UK account-access-consent. +// +// The standard names one caller and it is not the PSU: GET and DELETE on account-access-consents +// carry Grant Type "Client Credentials" in both v3.1 and v4.0.1, so a standard caller has no PSU in +// the session and the Consumer the consent was lodged under decides everything. The rule used to be +// four copies of the same inline pair of checks across v3.1/v4.0.1 GET/DELETE, keyed on the caller's +// user id -- which silently assumed a user is always present. +// +// Those PSU-less combinations are covered here explicitly, including the one whose outcome changes: +// an authorised consent reached with no PSU used to be refused with ConsentDoesNotMatchUser and is +// now decided by the Consumer, which is what the standard asks for. +// +// The user check still applies to a caller that does present a PSU -- OBP allows credentials the +// standard does not describe here -- so those combinations are covered too, unchanged. +// +// The rule function is unit-tested rather than driven over HTTP because the test framework signs +// with OAuth1, which always attaches a user -- there is no way to make a genuinely PSU-less request +// from here. The ResourceDoc auth mode is pinned separately, since that is what lets such a request +// reach the handler at all. +class UKOpenBankingV401ConsentAccessTests extends UKOpenBankingV401ServerSetup { + + object UKOpenBankingV401ConsentAccess extends Tag("UKOpenBankingV401ConsentAccess") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + private val tpp = "lodging-consumer-id" + private val otherTpp = "second-consumer-id" + + // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's + // getOrCreateResourceUser turns `sub` into idGivenByProvider -- so the token resolves cc.user to an + // auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. The + // OAuth1-signed harness cannot mint that token, so build the same shape directly. + private lazy val pseudoUserOfConsumer: ResourceUser = + getOrCreateUser(idGivenByProvider = testConsumer.key.get, name = testConsumer.key.get) + + // What applyUKRules puts on cc.user for a request authenticated by the consent itself: a user + // minted from the consent JWT's `sub`, which is a random UUID per consent. Nothing about it says + // "not a person" -- that is precisely why genuinePsu cannot filter it and consenter is needed. + private lazy val shadowUserOfConsent: ResourceUser = + getOrCreateUser(idGivenByProvider = s"uk-consent-shadow-${randomString(16)}", name = "") + + private def getOrCreateUser(idGivenByProvider: String, name: String): ResourceUser = + UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = idGivenByProvider) + .map(_.asInstanceOf[ResourceUser]) + .getOrElse { + UserX.createResourceUser( + provider = defaultProvider, + providerId = Some(idGivenByProvider), + createdByConsentId = None, + name = Some(name), + email = Some(s"${randomString(10)}@example.com"), + userId = None, + company = None + ).openOrThrowException(s"test user creation failed for $idGivenByProvider") + } + + feature("Consent.checkUKConsentAccess") { + + // The ASPSP's own approval screen arrives under its own Consumer, never the TPP's, so the + // lodging-Consumer comparison refuses exactly the caller whose job is to show the PSU what they + // are being asked to grant -- and the screen renders with no permissions, status or expiry. + scenario("a declared SCA front end may read a consent nobody has claimed yet", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should equal(None) + Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = true) should equal(None) + } + + scenario("but not once a PSU has claimed it", UKOpenBankingV401ConsentAccess) { + // The window the approval screen exists for has closed; from here the PSU half governs, and a + // declared front end gets no further than anyone else would. + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchUser)) + Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("and an undeclared caller is still refused on an unclaimed consent", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("the PSU a consent is bound to may use it", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a different PSU may not use a bound consent", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("the PSU check wins over the Consumer once a consent is bound", UKOpenBankingV401ConsentAccess) { + // Even the Consumer that lodged it cannot act as another PSU. + Consent.checkUKConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("an unbound consent may be used by the Consumer that lodged it", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("an unbound consent may not be used by a second TPP", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + // The client-credentials cases: no PSU in the session at all. + scenario("a PSU-less call may use an unbound consent it lodged", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess("", tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a PSU-less call may use a bound consent it lodged", UKOpenBankingV401ConsentAccess) { + // The one combination whose outcome changes, and the reason: this is how the standard has the + // AISP poll and revoke its own consent after the PSU has authorised it. It used to be refused. + Consent.checkUKConsentAccess(psu, tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a PSU-less call from a second TPP is still refused", UKOpenBankingV401ConsentAccess) { + // Dropping the user check does not open the consent to everyone: the Consumer still decides. + Consent.checkUKConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkUKConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call with no Consumer at all is refused", UKOpenBankingV401ConsentAccess) { + Consent.checkUKConsentAccess(psu, tpp, None, None, callerIsScaFrontEnd = false) should equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("blank ids count as absent, not as a value to match", UKOpenBankingV401ConsentAccess) { + // A consent lodged before consumer binding existed stores no consumer id; nothing identifies a + // wrong caller, so it cannot be refused on that basis. + Consent.checkUKConsentAccess("", "", None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkUKConsentAccess(null, null, None, None, callerIsScaFrontEnd = false) should equal(None) + // A blank caller user id is not a PSU either -- it must not accidentally match a blank binding. + Consent.checkUKConsentAccess(psu, tpp, Some(" "), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + } + + // The rule above is only ever as good as the identity handed to it, and that is where this family + // was actually failing: every combination with `None` for the caller is tested there, and no + // caller could produce one. cc.user is never Empty on a request that reaches these handlers -- a + // client-credentials token auto-vivifies a pseudo-user, and consent-header authentication swaps in + // the consent's shadow user -- so the rule was being asked about the wrong person and an + // authorised consent answered 403 ConsentDoesNotMatchUser to both callers the standard describes. + // + // Consent.actingPsu is the missing step. These pin the four shapes a caller can arrive in, and the + // last scenario pins the composition, which is the part that regressed rather than either half. + feature("Consent.actingPsu") { + + scenario("a session with no user at all is acting as nobody", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext(user = Empty, consumer = Full(testConsumer))) should equal(None) + } + + scenario("a client-credentials caller is acting only as itself", UKOpenBankingV401ConsentAccess) { + // The AISP call the standard describes. None is the right answer, not a missing one: it is + // what lets checkUKConsentAccess fall through to the Consumer rule. + Consent.actingPsu( + CallContext(user = Full(pseudoUserOfConsumer), consumer = Full(testConsumer))) should equal(None) + } + + scenario("a real person authenticated in the session is the PSU", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext(user = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + scenario("under consent-header authentication the PSU is the one the swap set aside", UKOpenBankingV401ConsentAccess) { + // applyUKRules leaves the consent's shadow user on `user` and the real PSU on `consenter`. A + // shadow user's idGivenByProvider is a random UUID rather than the consumer key, so genuinePsu + // alone waves it through -- this is the case that needs consenter. + val consentHeaderContext = CallContext( + user = Full(shadowUserOfConsent), consenter = Full(resourceUser1), consumer = Full(testConsumer)) + + Consent.actingPsu(consentHeaderContext).map(_.userId) should equal(Some(resourceUser1.userId)) + Consent.genuinePsu(consentHeaderContext).map(_.userId) should equal(Some(shadowUserOfConsent.userId)) + } + + scenario("the consenter outranks the session principal whenever both are present", UKOpenBankingV401ConsentAccess) { + Consent.actingPsu(CallContext( + user = Full(resourceUser2), consenter = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + scenario("the composition the endpoints perform lets both standard callers through", UKOpenBankingV401ConsentAccess) { + // A consent bound to resourceUser1 and lodged by testConsumer, reached the two ways a TPP can + // reach it. Both used to be refused with ConsentDoesNotMatchUser. + val bound = resourceUser1.userId + val lodger = testConsumer.consumerId.get + + val viaClientCredentials = + CallContext(user = Full(pseudoUserOfConsumer), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaClientCredentials).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should equal(None) + + val viaConsentHeader = CallContext( + user = Full(shadowUserOfConsent), consenter = Full(resourceUser1), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaConsentHeader).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should equal(None) + + // And it still narrows: a session acting as a different PSU cannot reach the consent, which is + // the whole reason the user half is kept. + val viaOtherPsu = CallContext(user = Full(resourceUser2), consumer = Full(testConsumer)) + Consent.checkUKConsentAccess( + bound, lodger, Consent.actingPsu(viaOtherPsu).map(_.userId), Some(lodger), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + } + } + + // A consent of another standard reaching a UK endpoint is a refusal, not a server fault. + // checkUKConsent used to throw when there was no Authorization header, which is exactly the + // shape such a request has: the dispatcher routes the consent into its own standard's branch, + // that branch authenticates the request, and ukConsentId is never set. The uncaught throw came + // back as OBP-50000 Unknown Error at 500. + feature("Consent.checkUKConsent refuses rather than throws when no UK consent is in play") { + + scenario("a request with neither a UK consent nor an Authorization header is refused", UKOpenBankingV401ConsentAccess) { + val result = Consent.checkUKConsent(resourceUser1, Some(CallContext())) + result match { + case Failure(msg, _, _) => msg should include("OBP-35036") + case other => fail(s"expected a Failure naming the standard mismatch, got $other") + } + } + + scenario("a request the consent header already settled is still waved through", UKOpenBankingV401ConsentAccess) { + // applyUKRules sets ukConsentId once it has run every gate, and this short-circuit is what + // keeps consent-header authentication working -- the refusal above must not reach it. + Consent.checkUKConsent(resourceUser1, Some(CallContext(ukConsentId = Some("any-consent-id")))) should + equal(Full(true)) + } + } + + feature("consent-by-id ResourceDocs accept a client-credentials caller") { + // Without this the docs default to UserOnly, which sends ResourceDocMiddleware down + // anonymousAccess and 401s any request carrying no user -- so the rule above would never be + // reached. Pinned because nothing else would notice a revert: these endpoints keep working for + // as long as OAuth2 token parsing auto-vivifies a user for a client-credentials token. + for (name <- List("getAccountAccessConsentsConsentId", "deleteAccountAccessConsentsConsentId")) { + scenario(s"v4.0.1 $name declares UserOrApplication", UKOpenBankingV401ConsentAccess) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV401, name))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } + scenario(s"v3.1 $name declares UserOrApplication", UKOpenBankingV401ConsentAccess) { + val docs = ResourceDoc.getResourceDocs(List(buildOperationId(ApiVersion.ukOpenBankingV31, name))) + docs should not be empty + docs.foreach(_.authMode should equal(UserOrApplication)) + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala new file mode 100644 index 0000000000..bc6379347d --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentPermissionsTests.scala @@ -0,0 +1,132 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.util.Consent +import code.api.util.ErrorMessages.InvalidUKConsentPermissions +import com.openbankproject.commons.model.ErrorMessage +import org.scalatest.Tag + +// The Account and Transaction API profile lists permission combinations the ASPSP "must reject +// with a 400 response code". OBP accepted all of them, so a consent could be created that no +// AISP can ever use: every AIS endpoint other than /accounts is /accounts/{AccountId}/..., and a +// consent with no account-read permission can never discover the ids it would need. Such a +// consent authorises AND returns an empty account list forever, with no error to explain it. +// +// Both the pure rule function and the HTTP surface are covered: the function is where the rules +// live, the endpoint is where the 400 has to actually come out. +class UKOpenBankingV401ConsentPermissionsTests extends UKOpenBankingV401ServerSetup { + + object UKOpenBankingV401ConsentPermissions extends Tag("UKOpenBankingV401ConsentPermissions") + + private def body(permissions: String): String = + s"""{ + | "Data": { + | "Permissions": $permissions, + | "ExpirationDateTime": "2030-01-01", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + + feature("Consent.validateUKConsentPermissions") { + + scenario("an empty array is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions(Nil).isDefined should equal(true) + } + + scenario("a code that is not a UK permission code is refused", UKOpenBankingV401ConsentPermissions) { + val reason = Consent.validateUKConsentPermissions(List("ReadAccountsBasic", "ReadEverything")) + reason.isDefined should equal(true) + reason.get should include("ReadEverything") + } + + scenario("an array with no account-read permission is refused", UKOpenBankingV401ConsentPermissions) { + // The combination that motivated this work: authorises fine, then /aisp/accounts is empty + // forever because no account is readable. + val reason = Consent.validateUKConsentPermissions( + List("ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits")) + reason.isDefined should equal(true) + reason.get should include("ReadAccountsBasic") + } + + scenario("either account-read permission satisfies the requirement", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions(List("ReadAccountsBasic")) should equal(None) + Consent.validateUKConsentPermissions(List("ReadAccountsDetail")) should equal(None) + } + + scenario("transaction depth without a direction is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsBasic")).isDefined should equal(true) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDetail")).isDefined should equal(true) + } + + scenario("a transaction direction without a depth is refused", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsCredits")).isDefined should equal(true) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDebits")).isDefined should equal(true) + } + + scenario("depth paired with either direction is accepted", UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsBasic", "ReadTransactionsCredits")) should equal(None) + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadTransactionsDetail", "ReadTransactionsDebits")) should equal(None) + } + + scenario("requesting both Basic and Detail is allowed, not rejected as duplication", + UKOpenBankingV401ConsentPermissions) { + // The profile calls this duplication but forbids rejecting on that basis alone. + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadAccountsDetail")) should equal(None) + Consent.validateUKConsentPermissions(List( + "ReadAccountsBasic", "ReadAccountsDetail", + "ReadTransactionsBasic", "ReadTransactionsDetail", + "ReadTransactionsCredits", "ReadTransactionsDebits")) should equal(None) + } + + scenario("permissions unrelated to the combination rules pass alongside a valid base", + UKOpenBankingV401ConsentPermissions) { + Consent.validateUKConsentPermissions( + List("ReadAccountsBasic", "ReadBalances", "ReadProducts", "ReadPAN")) should equal(None) + } + } + + feature("UKOB v4.0.1 POST /aisp/account-access-consents rejects invalid Permissions") { + + scenario("no account-read permission -> 400 with the OBP error code", + UKOpenBankingV401ConsentPermissions) { + val response = postAuthed( + body("""["ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "aisp", "account-access-consents") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(InvalidUKConsentPermissions) + } + + scenario("empty Permissions array -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed(body("[]"), "aisp", "account-access-consents").code should equal(400) + } + + scenario("transaction depth without a direction -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadTransactionsBasic"]"""), + "aisp", "account-access-consents").code should equal(400) + } + + scenario("unknown permission code -> 400", UKOpenBankingV401ConsentPermissions) { + postAuthed( + body("""["ReadAccountsBasic", "ReadEverything"]"""), + "aisp", "account-access-consents").code should equal(400) + } + + scenario("a valid combination is still created -> 201", UKOpenBankingV401ConsentPermissions) { + val response = postAuthed( + body("""["ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits"]"""), + "aisp", "account-access-consents") + response.code should equal(201) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal( + List("ReadAccountsBasic", "ReadBalances", "ReadTransactionsBasic", "ReadTransactionsDebits")) + } + } +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala new file mode 100644 index 0000000000..38ec27640d --- /dev/null +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ConsentScopingTests.scala @@ -0,0 +1,284 @@ +package code.api.UKOpenBanking.v4_0_1 + +import code.api.Constant +import code.api.util.APIUtil.DateWithDayFormat +import code.api.util.{CallContext, Consent} +import code.consent.{ConsentStatus, Consents} +import code.entitlement.Entitlement +import code.model.UserExtended +import code.views.Views +import code.views.system.AccountAccess +import com.openbankproject.commons.model.{BankIdAccountId, User, ViewId} +import net.liftweb.common.Full +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * What a UK consent's declared scope is actually worth, once more than one consent exists. + * + * A UK consent's data access runs as the consent's own shadow user: the consent JWT carries a + * random UUID in `sub`, and applyUKRules resolves that to a user that exists only for this consent + * and grants it exactly the views the JWT names (see Consent.resolveUKConsentPrincipal). Berlin + * Group and OBP-native have always worked this way; UK used to grant to the real PSU instead, which + * meant every consent that PSU had granted wrote to one shared set of AccountAccess rows and + * silently rewrote each other's. + * + * The properties worth pinning down, in the order they were lost historically: + * - narrowing: a consent that names fewer permissions, or fewer accounts, must have less; + * - independence: two consents live at once under the SAME TPP must not see each other's scope -- + * this is the one that was still open when access was keyed on the PSU; + * - isolation: one TPP's authorisation must not rewrite another TPP's access; + * - and the scope must be all the principal has: no account ownership, no roles, so none of the + * checks that run before the AccountAccess lookup (firehose, ABAC) can answer for it. + * + * Asserted at the UserExtended.hasAccountAccess layer, driven through the real applyUKRules + * entry point, because that is exactly what APIUtil.checkViewAccessAndReturnView -- and therefore + * every UK data endpoint -- consults. + */ +class UKOpenBankingV401ConsentScopingTests extends UKOpenBankingV401ServerSetup { + + object UKConsentScoping extends Tag("UKConsentScoping") + + private val acc = testAccountId1.value + private val otherAcc = testAccountId0.value + private val bankIdAccountId = BankIdAccountId(testBankId1, testAccountId1) + private val otherBankIdAccountId = BankIdAccountId(testBankId1, testAccountId0) + + private val ReadAccountsBasic = Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID + private val ReadBalances = Constant.SYSTEM_READ_BALANCES_VIEW_ID + private val FirehoseRole = code.api.util.ApiRole.canUseAccountFirehoseAtAnyBank.toString + + private def systemView(viewId: String) = + Views.views.vend.getOrCreateSystemView(viewId).openOrThrowException(s"could not create system view $viewId") + + /** + * Create a UK consent held by `consumerId`, bind it to accounts, and mark it AUTHORISED -- i.e. + * everything the POST /consents/CONSENT_ID/authorise endpoint does once SCA has passed. + */ + private def authoriseConsentFor(consumerId: String, + permissions: List[String], + accountIds: List[String] = List(acc)): String = { + val consentId = Consents.consentProvider.vend.saveUKConsent( + user = Some(resourceUser1), + bankId = None, + accountIds = None, + consumerId = Some(consumerId), + permissions = permissions, + expirationDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + transactionFromDateTime = Some(DateWithDayFormat.parse("2020-01-01")), + transactionToDateTime = Some(DateWithDayFormat.parse("2030-01-01")), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("test consent creation failed").consentId + + // The seven UK permission views are not seeded in the test DB (Boot only creates + // owner/auditor/accountant/... unless additional_system_views is set, and test.default.props + // does not set it), so they have to exist before the grant can bind them. + permissions.foreach(systemView) + + reAuthorise(consentId, accountIds) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + consentId + } + + /** Re-run the authorise-time binding on a consent that already exists and is still live -- the + * PSU re-authenticating against a consent the TPP never revoked. */ + private def reAuthorise(consentId: String, accountIds: List[String]): Unit = { + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException(s"consent $consentId not found") + Await.result( + Consent.grantUKConsentAccountAccess(resourceUser1, testBankId1, accountIds, consent, None), + 10.seconds) + } + + /** + * Authenticate a request the way a caller presenting this consent would, and hand back the + * principal it resolves to plus the CallContext the endpoint would see. This is the real + * Consent-Id / Consent-JWT header path, gates and all. + */ + private def authenticateWith(consentId: String, consumer: code.model.Consumer): (User, CallContext) = { + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException(s"consent $consentId not found") + val (user, callContext) = Await.result( + Consent.applyUKRules(consent, "", CallContext(consumer = Full(consumer))), + 10.seconds) + (user.openOrThrowException(s"consent $consentId did not authenticate: $user"), + callContext.getOrElse(CallContext(consumer = Full(consumer)))) + } + + /** Access as it is evaluated for a request arriving with `consentId` from `consumer`. */ + private def canRead(viewId: String, + consentId: String, + consumer: code.model.Consumer, + account: BankIdAccountId = bankIdAccountId): Boolean = { + val (principal, callContext) = authenticateWith(consentId, consumer) + UserExtended(principal).hasAccountAccess(systemView(viewId), account, Some(callContext)) + } + + feature("A UK consent is authoritative for the permissions it declares") { + scenario("a consent that did not ask for a permission does not have it", UKConsentScoping) { + val wide = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + canRead(ReadAccountsBasic, wide, testConsumer) should equal(true) + canRead(ReadBalances, wide, testConsumer) should equal(true) + + // Same TPP, same PSU, same account -- but this consent never asked for balances. + val narrow = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + canRead(ReadAccountsBasic, narrow, testConsumer) should equal(true) + canRead(ReadBalances, narrow, testConsumer) should equal(false) + } + + } + + feature("A UK consent is authoritative for the accounts it names") { + scenario("a consent does not reach an account it never named", UKConsentScoping) { + val both = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + accountIds = List(acc, otherAcc)) + canRead(ReadAccountsBasic, both, testConsumer, otherBankIdAccountId) should equal(true) + + val onlyOne = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances), + accountIds = List(acc)) + canRead(ReadAccountsBasic, onlyOne, testConsumer) should equal(true) + canRead(ReadAccountsBasic, onlyOne, testConsumer, otherBankIdAccountId) should equal(false) + canRead(ReadBalances, onlyOne, testConsumer, otherBankIdAccountId) should equal(false) + } + + scenario("re-authorising one consent with fewer accounts narrows it", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc, otherAcc)) + canRead(ReadAccountsBasic, consentId, testConsumer, otherBankIdAccountId) should equal(true) + + // The PSU re-authorises and drops otherAcc from the selection. Nothing sweeps anything: the + // JWT no longer names that account, so the next request simply does not re-grant it. + reAuthorise(consentId, List(acc)) + canRead(ReadAccountsBasic, consentId, testConsumer) should equal(true) + canRead(ReadAccountsBasic, consentId, testConsumer, otherBankIdAccountId) should equal(false) + } + } + + feature("Two live consents held by the same TPP are scoped independently") { + scenario("re-authorising the wider consent does not widen the narrower one", UKConsentScoping) { + val wide = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc, otherAcc)) + val narrow = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc)) + + canRead(ReadAccountsBasic, narrow, testConsumer, otherBankIdAccountId) should equal(false) + + // Both consents are live and both belong to the same TPP and the same PSU. While access was + // keyed on the PSU they shared one set of rows, so re-authorising the wider one handed the + // narrower one an account it never named. Each now has a principal of its own. + reAuthorise(wide, List(acc, otherAcc)) + + canRead(ReadAccountsBasic, wide, testConsumer, otherBankIdAccountId) should equal(true) + canRead(ReadAccountsBasic, narrow, testConsumer, otherBankIdAccountId) should equal(false) + } + } + + feature("One TPP's UK consent does not rewrite another TPP's access") { + scenario("a second consumer authorising a narrower consent leaves the first consumer's access intact", UKConsentScoping) { + val first = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic, ReadBalances)) + val second = authoriseConsentFor(testConsumer2.consumerId.get, List(ReadAccountsBasic)) + + canRead(ReadBalances, second, testConsumer2) should equal(false) + canRead(ReadBalances, first, testConsumer) should equal(true) + } + } + + feature("A UK consent's principal has the consent's scope and nothing else") { + scenario("it holds no account ownership and no roles, so no check above the view lookup can answer for it", UKConsentScoping) { + // Give the PSU the role that lets account firehose bypass the AccountAccess check entirely. + // APIUtil.hasAccountAccess consults firehose (and then ABAC) BEFORE the view lookup, so if the + // consent ran as the PSU this role would make its declared scope meaningless. + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, FirehoseRole) + + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + val (principal, _) = authenticateWith(consentId, testConsumer) + + principal.userId should not equal resourceUser1.userId + Entitlement.entitlement.vend.getEntitlementsByUserId(principal.userId) + .getOrElse(Nil) shouldBe empty + + // ...and no ownership of any account, so the owner view is not reachable either. + AccountAccess.findByUniqueIndex( + testBankId1, testAccountId1, ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + principal.userPrimaryKey, Constant.ALL_CONSUMERS + ).isDefined should equal(false) + } + + scenario("account ownership is left alone: the PSU keeps the owner view", UKConsentScoping) { + authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + + // owner comes from holding the account, not from any consent. Nothing in the consent flow + // writes or removes a row for the PSU any more, so it is untouched. + AccountAccess.findByUniqueIndex( + testBankId1, testAccountId1, ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + resourceUser1.userPrimaryKey, Constant.ALL_CONSUMERS + ).isDefined should equal(true) + } + } + + /** + * The consent normally arrives as a `consent_id` claim inside the OAuth2 access token, not in a + * Consent-Id header. On that path the request is authenticated as the PSU before any UK code + * runs, so the principal is swapped at the end of authentication instead + * (Consent.applyUKConsentPrincipalFromToken). Everything above drives the header path; this + * drives the token one, since it is the path the standard actually specifies. + * + * The token is self-signed: JwtUtil.getOptionalClaim parses structurally and does not verify, and + * the real access token would have been verified by OAuth2Login long before this point. + */ + private def bearerContextFor(consentId: String, consumer: code.model.Consumer): CallContext = { + val claims = new com.nimbusds.jwt.JWTClaimsSet.Builder().claim("consent_id", consentId).build() + CallContext( + user = Full(resourceUser1), + consumer = Full(consumer), + authReqHeaderField = Full(s"Bearer ${code.api.util.CertificateUtil.jwtWithHmacProtection(claims)}")) + } + + feature("A UK consent presented in an access token resolves the same way as one in a header") { + scenario("the principal is swapped, the PSU is kept, and the scope is the consent's", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic), + accountIds = List(acc)) + + val (principal, callContext) = + Consent.applyUKConsentPrincipalFromToken(Full(resourceUser1), Some(bearerContextFor(consentId, testConsumer))) + val resolved = principal.openOrThrowException("token path did not resolve a principal") + val cc = callContext.getOrElse(fail("token path dropped the CallContext")) + + resolved.userId should not equal resourceUser1.userId + // The PSU has to survive the swap: checkUKConsent compares the consent's owner against it, + // and the CBS adapter and metrics both name it. + cc.consenter.map(_.userId) should equal(Full(resourceUser1.userId)) + + UserExtended(resolved).hasAccountAccess(systemView(ReadAccountsBasic), bankIdAccountId, Some(cc)) should equal(true) + UserExtended(resolved).hasAccountAccess(systemView(ReadBalances), bankIdAccountId, Some(cc)) should equal(false) + UserExtended(resolved).hasAccountAccess(systemView(ReadAccountsBasic), otherBankIdAccountId, Some(cc)) should equal(false) + + // And the consent's ownership check still passes, because it is asked about the PSU. + Consent.checkUKConsent(resolved, Some(cc)).isDefined should equal(true) + } + + scenario("a token with no consent claim is left exactly as it is", UKConsentScoping) { + val plain = CallContext(user = Full(resourceUser1), consumer = Full(testConsumer)) + val (principal, callContext) = Consent.applyUKConsentPrincipalFromToken(Full(resourceUser1), Some(plain)) + principal.map(_.userId) should equal(Full(resourceUser1.userId)) + callContext.flatMap(_.consenter.toOption) should equal(None) + } + } + + feature("Revoking a UK consent takes its access away") { + scenario("the granted rows are gone, not merely unreachable", UKConsentScoping) { + val consentId = authoriseConsentFor(testConsumer.consumerId.get, List(ReadAccountsBasic)) + val (principal, _) = authenticateWith(consentId, testConsumer) + Views.views.vend.accessGrantedToUserForConsumer(principal, Constant.ALL_CONSUMERS) should not be empty + + Consents.consentProvider.vend.revoke(consentId) + + // Revocation used to flip a status column and leave the granted rows in the table for good. + Views.views.vend.accessGrantedToUserForConsumer(principal, Constant.ALL_CONSUMERS) shouldBe empty + } + } + +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala index 38b56ea712..db3d71bc99 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala @@ -78,4 +78,8 @@ trait UKOpenBankingV401ServerSetup extends ServerSetupWithTestData with DefaultU def deleteAuthed(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE <@ (user1)) def deleteUnauthed(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE) + + // For IDOR regression tests: user2 authenticated, but acting on a resource (e.g. a consent) + // that belongs to a different user (typically resourceUser1). + def deleteAuthedAsUser2(segments: String*): APIResponse = makeDeleteRequest(v401(segments: _*).DELETE <@ (user2)) } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index f9d5a6b697..d21600e1a6 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -3,26 +3,37 @@ package code.api.berlin.group.v1_3 import org.json4s._ import code.api.Constant import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID} +import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ import code.api.berlin.group.v1_3.Http4sBGv13AIS import code.api.util.APIUtil import code.api.util.APIUtil.OAuth._ +import code.api.berlin.group.v1_3.model.ScaStatusResponse +import code.api.util.Consent import code.api.util.ErrorMessages._ import code.api.v4_0_0.PostViewJsonV400 -import code.consent.ConsentStatus -import code.model.dataAccess.BankAccountRouting +import code.consent.{ConsentStatus, ConsentTrait, Consents} +import code.model.TokenType.Access +import code.model.UserX +import code.model.dataAccess.{BankAccountRouting, ResourceUser} import code.setup.{APIResponse, DefaultUsers} +import code.token.Tokens import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.model.enums.AccountRoutingScheme import org.json4s.native.Serialization.write import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan import org.scalatest.Tag import java.time.LocalDate import java.time.format.DateTimeFormatter +import java.util.Date +import scala.concurrent.Await +import scala.concurrent.duration._ -class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 with DefaultUsers { +class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures { object getAccountList extends Tag(nameOf(Http4sBGv13AIS.getAccountList)) @@ -58,11 +69,6 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit object updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod extends Tag("updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod") object updateConsentsPsuDataUpdateAuthorisationConfirmation extends Tag("updateConsentsPsuDataUpdateAuthorisationConfirmation") - def getNextMonthDate(): String = { - val nextMonthDate = LocalDate.now().plusMonths(1) - nextMonthDate.format(DateTimeFormatter.ISO_LOCAL_DATE) - } - feature(s"BG v1.3 - $getAccountList") { scenario("Not Authentication User, test failed ", BerlinGroupV1_3, getAccountList) { val requestGet = (V1_3_BG / "accounts").GET @@ -762,6 +768,117 @@ class AccountInformationServiceAISApiTest extends BerlinGroupServerSetupV1_3 wit val responseStartConsentAuthorisation = makePutRequest(requestStartConsentAuthorisation, """{"confirmationCode":"confirmationCode"}""") responseStartConsentAuthorisation.code should be (200) } - } + } + + feature(s"BG v1.3 - unclaimed consent SCA (regression: GET /obp/v5.1.0/user/current/consents/CONSENT_ID 404 before SCA, wrong authorisationId from ${startConsentAuthorisationTransactionAuthorisation.name})") { + scenario("Unclaimed consent: viewable pre-SCA by any user, authorisable, and claimed by the answering PSU on correct OTP", BerlinGroupV1_3, startConsentAuthorisationTransactionAuthorisation, updateConsentsPsuDataTransactionAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + + val createdConsent = createUnclaimedBerlinGroupConsent() + Option(createdConsent.userId).forall(_.isBlank) should be (true) + val consentId = createdConsent.consentId + + Then("A different logged-in user can GET the unclaimed consent — this used to 404 (Bug A)") + val requestGetConsent = (baseRequest / "obp" / "v5.1.0" / "user" / "current" / "consents" / consentId).GET <@ (user2) + val responseGetConsent = makeGetRequest(requestGetConsent) + responseGetConsent.code should be (200) + + Then(s"We test the $startConsentAuthorisationTransactionAuthorisation") + val requestStartConsentAuthorisation = (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1) + val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") + responseStartConsentAuthorisation.code should be (201) + val authorisationId = responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("The returned authorisationId must resolve on GET — this used to be the wrong field (Bug C)") + val requestGetConsentScaStatus = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).GET <@ (user1) + val responseGetConsentScaStatus = makeGetRequest(requestGetConsentScaStatus) + responseGetConsentScaStatus.code should be (200) + + Then(s"We submit the correct OTP to $updateConsentsPsuDataTransactionAuthorisation and the consent becomes valid, owned by the answering PSU") + val requestUpdatePsuData = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (user1) + val responseUpdatePsuData = makePutRequest(requestUpdatePsuData, """{"scaAuthenticationData":"123"}""") + responseUpdatePsuData.code should be (200) + responseUpdatePsuData.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val updatedConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + updatedConsent.userId should be (resourceUser1.userId) + updatedConsent.status should be (ConsentStatus.valid.toString) + } + + scenario("Unclaimed consent: an incorrect OTP is rejected with 400 and the consent stays unclaimed (documents that updateConsentUser in updateConsentsPsuDataAll is never reached on a failed challenge answer, unrelated to this fix)", BerlinGroupV1_3, updateConsentsPsuDataTransactionAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + + val createdConsent = createUnclaimedBerlinGroupConsent() + val consentId = createdConsent.consentId + + val requestStartConsentAuthorisation = (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1) + val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") + responseStartConsentAuthorisation.code should be (201) + val authorisationId = responseStartConsentAuthorisation.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("We submit a wrong OTP") + val requestUpdatePsuData = (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (user1) + val responseUpdatePsuData = makePutRequest(requestUpdatePsuData, """{"scaAuthenticationData":"wrong-otp"}""") + responseUpdatePsuData.code should be (400) + responseUpdatePsuData.body.extract[ErrorMessagesBG].tppMessages.head.text should include ("OBP-40016") + + Then("The consent is not claimed — validateChallengeAnswerC4 fails the Box before updateConsentUser runs") + val updatedConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + Option(updatedConsent.userId).forall(_.isBlank) should be (true) + updatedConsent.status should be (ConsentStatus.received.toString) + } + } + + feature(s"BG v1.3 - $createConsent consent ownership") { + scenario("A consent lodged on a client-credentials session is left unowned, not bound to the consumer's own pseudo-user", BerlinGroupV1_3, createConsent) { + val requestPost = (V1_3_BG / "consents").POST <@ (clientCredentialsSession) + val response: APIResponse = makePostRequest(requestPost, write(bgConsentPostBody())) + + Then("We should get a 201") + response.code should equal(201) + val consentId = response.body.extract[PostConsentResponseJson].consentId + + Then("The consent must be left unowned — a pseudo-user owner is neither blank nor the PSU, so it " + + "would fail the mUserId guard on GET /obp/v5.1.0/user/current/consents/CONSENT_ID and hide the " + + "consent from the real PSU at SCA time (OBP-35001)") + val createdConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + Option(createdConsent.userId).forall(_.isBlank) should be (true) + createdConsent.status should be (ConsentStatus.received.toString) + } + + scenario("A consent lodged on a genuine PSU session is still owned by that PSU", BerlinGroupV1_3, createConsent) { + val requestPost = (V1_3_BG / "consents").POST <@ (user1) + val response: APIResponse = makePostRequest(requestPost, write(bgConsentPostBody())) + + Then("We should get a 201") + response.code should equal(201) + val consentId = response.body.extract[PostConsentResponseJson].consentId + + Then("Filtering out the consumer's pseudo-user must not drop a real PSU session (DirectLogin/OAuth1)") + val createdConsent = Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("test consent lookup failed") + createdConsent.userId should be (resourceUser1.userId) + } + } + + // Reading a consent's authorisation sub-resources is the TPP polling its own consent -- in Berlin + // Group the PSU never calls the API at all, it authenticates at the ASPSP under Redirect or hands + // its factors to the TPP under Embedded. Both handlers already reflect that, taking no user. The + // ResourceDocs did not, and a doc left on the UserOnly default sends the middleware down + // anonymousAccess, which 401s a request carrying no user. + // + // Pinned because nothing else would notice a revert: these keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. The consent endpoints in the same file have been UserOrApplication all along, so + // this is also a consistency guard within the family. + feature("BG v1.3 - consent authorisation sub-resources accept a client-credentials caller") { + for (name <- List(nameOf(Http4sBGv13AIS.getConsentAuthorisation), nameOf(Http4sBGv13AIS.getConsentScaStatus))) { + scenario(s"$name declares UserOrApplication", BerlinGroupV1_3) { + val docs = APIUtil.ResourceDoc.getResourceDocs( + List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) + docs should not be empty + docs.foreach(_.authMode should equal(APIUtil.UserOrApplication)) + } + } + } } \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala new file mode 100644 index 0000000000..67adbf111e --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala @@ -0,0 +1,136 @@ +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{ConsentAccessAccountsJson, ConsentAccessJson, PostConsentJson} +import code.api.util.APIUtil.OAuth._ +import code.api.util.Consent +import code.consent.{ConsentTrait, Consents} +import code.model.TokenType.Access +import code.model.UserX +import code.model.dataAccess.{BankAccountRouting, ResourceUser} +import code.setup.DefaultUsers +import code.token.Tokens +import com.openbankproject.commons.model.enums.AccountRoutingScheme +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Fixtures shared by the Berlin Group consent suites: a consent body, a consent lodged without a + * PSU, and a session shaped like a client_credentials caller's. + * + * Extracted here because more than one suite needs them and two copies would have to be kept in + * agreement -- the client-credentials session in particular encodes a non-obvious fact about how + * OAuth2 token parsing behaves, and a copy that drifted from it would quietly stop testing the + * thing it was written for. + */ +trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with DefaultUsers { + + def getNextMonthDate(): String = + LocalDate.now().plusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE) + + /** One account, addressed by the first IBAN routing in the test data. */ + def bgConsentPostBody(): PostConsentJson = { + val acountRoutingIban = BankAccountRouting + .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)).head + PostConsentJson( + access = ConsentAccessJson( + accounts = Option(List(ConsentAccessAccountsJson( + iban = Some(acountRoutingIban.accountRouting.address), + bban = None, + pan = None, + maskedPan = None, + msisdn = None, + currency = None, + ))), + balances = None, + transactions = None, + availableAccounts = None, + allPsd2 = None + ), + recurringIndicator = true, + validUntil = getNextMonthDate(), + frequencyPerDay = 4, + combinedServiceIndicator = Some(false) + ) + } + + /** + * An unclaimed (PSU-less) consent lodged under testConsumer, built straight through the provider + * -- the shape POST /consents leaves behind for a client_credentials caller. + */ + def createUnclaimedBerlinGroupConsent(): ConsentTrait = { + val postJsonBody = bgConsentPostBody() + val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) + + val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( + user = None, + consumer = Some(testConsumer), + recurringIndicator = postJsonBody.recurringIndicator, + validUntil = validUntilDate, + frequencyPerDay = postJsonBody.frequencyPerDay, + combinedServiceIndicator = postJsonBody.combinedServiceIndicator.getOrElse(false), + apiStandard = Some(ConstantsBG.berlinGroupVersion1.apiStandard), + apiVersion = Some(ConstantsBG.berlinGroupVersion1.apiShortVersion) + ).openOrThrowException("test consent creation failed") + + val consentJWT = Await.result( + Consent.createBerlinGroupConsentJWT( + None, + postJsonBody, + createdConsent.secret, + createdConsent.consentId, + Some(testConsumer.consumerId.get), + Some(validUntilDate), + None + ), + 10.seconds + ).openOrThrowException("test consent JWT creation failed") + Consents.consentProvider.vend.setJsonWebToken(createdConsent.consentId, consentJWT) + + createdConsent + } + + // A client_credentials token carries the caller's own client id in `sub`, and OAuth2's + // getOrCreateResourceUser turns `sub` into idGivenByProvider — so such a token resolves cc.user to + // an auto-vivified pseudo-user keyed on the consumer's client key rather than leaving it Empty. + // The OAuth1-signed test harness cannot mint that token, so build the same CallContext shape + // directly: a user whose idGivenByProvider IS testConsumer's client key, plus an access token for + // it issued under testConsumer. Signing with this pair gives the endpoint exactly what a + // client_credentials TPP gives it — cc.user.idGivenByProvider == cc.consumer.key. + lazy val pseudoUserOfTestConsumer: ResourceUser = + UserX.findByProviderId(provider = defaultProvider, idGivenByProvider = testConsumer.key.get) + .map(_.asInstanceOf[ResourceUser]) + .getOrElse { + UserX.createResourceUser( + provider = defaultProvider, + providerId = Some(testConsumer.key.get), + createdByConsentId = None, + name = Some(testConsumer.key.get), + email = Some("pseudo.user.of.test.consumer@example.com"), + userId = None, + company = Some("Tesobe GmbH") + ).openOrThrowException("test pseudo user creation failed") + } + + lazy val pseudoUserToken = Tokens.tokens.vend.createToken( + Access, + Some(testConsumer.id.get), + Some(pseudoUserOfTestConsumer.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new Date(System.currentTimeMillis())), + None + ).openOrThrowException("test pseudo user token creation failed") + + // Same consumer as user1, different token: cc.consumer is testConsumer, cc.user is the pseudo-user. + lazy val clientCredentialsSession = Some(consumer, Token(pseudoUserToken.key.get, pseudoUserToken.secret.get)) +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala new file mode 100644 index 0000000000..43ffe95d84 --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupV13ConsentAccessTests.scala @@ -0,0 +1,519 @@ +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.ConstantsBG +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ +import code.api.berlin.group.v1_3.model.ScaStatusResponse +import code.api.util.APIUtil +import code.api.util.APIUtil.OAuth._ +import code.api.util.{CallContext, Consent} +import code.api.util.ErrorMessages.{BerlinGroupPsuNotIdentified, ConsentDoesNotMatchConsumer, ConsentDoesNotMatchUser} +import code.consent.{ConsentStatus, Consents} +import code.model.TokenType.Access +import code.token.Tokens +import code.transactionChallenge.Challenges +import net.liftweb.common.{Empty, Full} +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan +import org.scalatest.Tag + +import java.util.Date + +/** + * Who may drive a Berlin Group consent's authorisation sub-resources. + * + * Two endpoints decide who a consent ends up belonging to: POST /consents/CONSENTID/authorisations + * mints the SCA challenge, and PUT .../AUTHORISATIONID answers it and writes the PSU onto the + * consent row. Neither had any ownership guard, so any authenticated caller could raise a challenge + * on any consent id and then answer their own -- claiming a consent lodged by a different TPP, or + * re-binding one another PSU had already authorised, since updateConsentUser overwrites mUserId + * unconditionally. + * + * The rule and its derivation from the standard live in Consent.checkBerlinGroupConsentAccess. This + * suite pins both halves of it, and pins the two ways it must NOT bite: the lodging TPP's own PSU + * completing SCA, and the lodging TPP polling on a client-credentials session, where cc.user is an + * auto-vivified pseudo-user rather than a person. + */ +class BerlinGroupV13ConsentAccessTests extends BerlinGroupConsentFixtures { + + object BerlinGroupV13ConsentAccess extends Tag("BerlinGroupV13ConsentAccess") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + private val tpp = "lodging-consumer-id" + private val otherTpp = "second-consumer-id" + + // The rule is unit-tested as well as driven over HTTP because the interesting caller shapes -- a + // session with no PSU at all -- cannot be produced by an OAuth1-signed test request, which always + // attaches a user. Same reasoning as UKOpenBankingV401ConsentAccessTests. + feature("Consent.checkBerlinGroupConsentAccess") { + + scenario("the TPP that lodged an unowned consent may authorise it", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a second TPP may not authorise a consent it did not lodge", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call may drive a consent its own Consumer lodged", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a PSU-less call from a second TPP is still refused", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("a PSU-less call with no Consumer at all is refused", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, None, None, callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + + scenario("the PSU a consent is already bound to may re-authorise it", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + + scenario("a different PSU may not re-bind a consent that is already owned", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(tpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("the PSU check wins over the Consumer once a consent is bound", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("blank ids count as absent, not as a value to match", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(null, null, None, None, callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(" ", tpp, Some(psu), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(" "), Some(tpp), callerIsScaFrontEnd = false) should equal(None) + } + } + + // The Redirect approach: the PSU authenticates at the ASPSP, so these calls arrive from the + // ASPSP's own front end under its own Consumer -- never the one that lodged the consent. The + // same-TPP rule would refuse the only caller Redirect has, which is what blocked the scaRedirect + // ceremony outright. Nothing in the request separates that front end from a second TPP holding a + // PSU session, so it is declared rather than inferred; these pin that the declaration is the only + // thing that changes, and that it does not reach the PSU half. + feature("Consent.checkBerlinGroupConsentAccess and the ASPSP's declared SCA front end") { + + scenario("a declared front end may start an authorisation on a consent it did not lodge", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + Consent.checkBerlinGroupConsentAccess("", tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(None) + } + + scenario("a declared front end still cannot re-bind another PSU's consent", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(otherPsu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(Some(ConsentDoesNotMatchUser)) + } + + scenario("a declared front end acting for the consent's own PSU is fine", BerlinGroupV13ConsentAccess) { + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(psu), Some(otherTpp), callerIsScaFrontEnd = true) should + equal(None) + } + + scenario("the declaration is by consumer id and nothing else", BerlinGroupV13ConsentAccess) { + // Empty config is the default, and it must leave the same-TPP rule applying to everyone. + Consent.isScaFrontEnd(Some(otherTpp)) should equal(false) + Consent.isScaFrontEnd(None) should equal(false) + Consent.isScaFrontEnd(Some(" ")) should equal(false) + } + } + + // Pinned rather than left to the scaladoc because the Berlin Group authorisation handlers are + // being reworked on top of this, and the case that matters there is the one that looks like an + // absence: per the standard the caller of these endpoints is the TPP, with the PSU's factors + // travelling in the body rather than in the session, so None is the normal answer for a + // conforming call -- not a failure to defend against. checkBerlinGroupConsentAccess is written + // for that: a caller with no PSU skips the PSU comparison and is judged on its Consumer alone. + // A refusal tells the TPP which view and which account it was refused, and that is all it + // should tell them. Under consent authentication the principal is the consent's own shadow + // user, so putting its id in the message hands the TPP an internal identifier it cannot act + // on and was never party to. + feature("BG v1.3 - a view refusal does not disclose the internal principal") { + scenario("the refusal names the view and the account, and no user id", BerlinGroupV13ConsentAccess) { + // user2 holds no Berlin Group view on testAccountId1, so this is a real refusal. + val response = makeGetRequest((V1_3_BG / "accounts" / testAccountId1.value / "balances").GET <@ (user2)) + response.code should equal(403) + val body = response.body.toString + body should include("OBP-20060") + body should not include "userId :" + body should not include resourceUser2.userId + } + } + + feature("Consent.genuinePsu") { + + scenario("a session with no user at all has no PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Empty, consumer = Full(testConsumer))) should equal(None) + } + + scenario("the Consumer's own pseudo-identity is not a PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu( + CallContext(user = Full(pseudoUserOfTestConsumer), consumer = Full(testConsumer))) should equal(None) + } + + scenario("a real person authenticated in the session is a PSU", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Full(resourceUser1), consumer = Full(testConsumer))) + .map(_.userId) should equal(Some(resourceUser1.userId)) + } + + // Degenerate, and it fails closed rather than open: with no Consumer identified there is no key + // to compare against, so the pseudo-user survives the filter -- but callerConsumerId is None + // too, so a bound consent is refused on the PSU half and an unbound one on the Consumer half. + scenario("with no Consumer on the call there is no key to filter against", BerlinGroupV13ConsentAccess) { + Consent.genuinePsu(CallContext(user = Full(pseudoUserOfTestConsumer), consumer = Empty)) + .map(_.userId) should equal(Some(pseudoUserOfTestConsumer.userId)) + + Consent.checkBerlinGroupConsentAccess(psu, tpp, Some(pseudoUserOfTestConsumer.userId), None, callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchUser)) + Consent.checkBerlinGroupConsentAccess("", tpp, Some(pseudoUserOfTestConsumer.userId), None, callerIsScaFrontEnd = false) should + equal(Some(ConsentDoesNotMatchConsumer)) + } + } + + // The interesting cases here are absences -- no PSU in the session, no header, or both -- and an + // OAuth1-signed test request always attaches a user, so the rule is pinned directly as well as + // driven over HTTP. Same reasoning as the two blocks above. + feature("Consent.resolveBerlinGroupPsu") { + + scenario("a consent that already names a PSU answers for itself", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, None, None) should equal(Right(psu)) + Consent.resolveBerlinGroupPsu(psu, None, Some(psu)) should equal(Right(psu)) + } + + scenario("an unbound consent takes the PSU from the session, which is the Redirect approach", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", Some(psu), None) should equal(Right(psu)) + } + + scenario("with no PSU in the session the PSU-ID header names one, which is Embedded", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", None, Some(psu)) should equal(Right(psu)) + } + + // Not a defensive branch: a conforming client-credentials call that omitted the header lands + // here, and there is genuinely no one to mint the challenge for or send the OTP to. + scenario("with none of the three there is nobody to authorise for", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu("", None, None) should equal(Left(BerlinGroupPsuNotIdentified)) + Consent.resolveBerlinGroupPsu(" ", None, Some(" ")) should equal(Left(BerlinGroupPsuNotIdentified)) + } + + // "the ASPSP might check whether PSU-ID and token match" -- Implementation Guidelines V1.3.12, + // section 6.3.1, p.134. Refused rather than resolved by precedence: otherwise a lodging TPP + // could name a third party and have the bound PSU's OTP mailed to them instead. + scenario("a PSU-ID contradicting what the ASPSP already knows is refused", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, None, Some(otherPsu)) should equal(Left(ConsentDoesNotMatchUser)) + Consent.resolveBerlinGroupPsu("", Some(psu), Some(otherPsu)) should equal(Left(ConsentDoesNotMatchUser)) + } + + scenario("the consent outranks the session when both are present", BerlinGroupV13ConsentAccess) { + Consent.resolveBerlinGroupPsu(psu, Some(psu), None) should equal(Right(psu)) + } + } + + // resourceUser2 under testConsumer: a genuine second PSU of the *lodging* TPP, which is the only + // way to reach the PSU half of the rule over HTTP -- user2 in the shared fixtures carries + // testConsumer2, so it would be refused on the Consumer half first. + private lazy val secondPsuOfTestConsumerToken = Tokens.tokens.vend.createToken( + Access, + Some(testConsumer.id.get), + Some(resourceUser2.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new Date(System.currentTimeMillis())), + None + ).openOrThrowException("test second PSU token creation failed") + + private lazy val secondPsuOfTestConsumerSession = + Some(consumer, Token(secondPsuOfTestConsumerToken.key.get, secondPsuOfTestConsumerToken.secret.get)) + + private def startAuthorisation( + consentId: String, + session: Option[(Consumer, Token)], + headers: List[(String, String)] = Nil + ) = + makePostRequest( + (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (session), + """{"scaAuthenticationData":""}""", + headers) + + private def answerAuthorisation( + consentId: String, + authorisationId: String, + session: Option[(Consumer, Token)], + otp: String, + headers: List[(String, String)] = Nil + ) = + makePutRequest( + (V1_3_BG / "consents" / consentId / "authorisations" / authorisationId).PUT <@ (session), + s"""{"scaAuthenticationData":"$otp"}""", + headers: _*) + + private def psuIdHeader(userName: String) = List(("PSU-ID", userName)) + + feature("BG v1.3 - a consent's authorisation sub-resources answer only to the TPP that lodged it") { + + scenario("A second TPP cannot start an authorisation on a consent it did not lodge", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("user2 carries testConsumer2, which did not lodge this consent") + val response = startAuthorisation(consentId, user2) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchConsumer) + + Then("The consent is untouched: no PSU was bound and no challenge was minted for the caller") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + consent.status should be (ConsentStatus.received.toString) + } + + scenario("A second TPP cannot answer an authorisation the lodging TPP started", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("Answering it from a second TPP's session is refused before the OTP is even checked") + val response = answerAuthorisation(consentId, authorisationId, user2, "123") + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchConsumer) + + Then("The consent stays unclaimed") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + } + + scenario("A second PSU of the lodging TPP cannot re-bind a consent another PSU authorised", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("resourceUser1 authorises it") + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + answerAuthorisation(consentId, authorisationId, user1, "123").code should equal(200) + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + + Then("resourceUser2, on the same Consumer, is refused on the PSU half of the rule") + val response = startAuthorisation(consentId, secondPsuOfTestConsumerSession) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchUser) + + Then("The consent still belongs to the PSU that authorised it") + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + } + } + + feature("BG v1.3 - the guard does not bite the flows Berlin Group actually describes") { + + scenario("The lodging TPP's PSU completes SCA and the consent is bound to them", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + val answered = answerAuthorisation(consentId, authorisationId, user1, "123") + answered.code should equal(200) + answered.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + consent.userId should be (resourceUser1.userId) + consent.status should be (ConsentStatus.valid.toString) + } + + // The discriminating case for Consent.genuinePsu. A client_credentials token resolves cc.user to + // a pseudo-user keyed on the caller's own client key, not to a person. Compare that against the + // consent's real owner and a legitimate TPP poll on its own bound consent turns into a 403 -- + // which is exactly what Berlin Group's Redirect approach does, the PSU having authenticated at + // the ASPSP rather than through the TPP. + scenario("The lodging TPP may still drive a bound consent on a client-credentials session", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + answerAuthorisation(consentId, authorisationId, user1, "123").code should equal(200) + + Then("A client-credentials session of the same Consumer is not mistaken for a foreign PSU") + val response = startAuthorisation(consentId, clientCredentialsSession) + response.code should equal(201) + } + } + + /** + * Embedded SCA: the TPP calls, the PSU is named in the PSU-ID header. + * + * These endpoints used to take the challenge's owner from the session principal. Under a + * client_credentials token that principal is the TPP's own auto-vivified pseudo-user, and the + * challenge answer is delivered to whoever the challenge names -- createChallengeInternal mails it + * to getEmailsByUserId(userId) -- so the OTP went to the TPP and the consent bound to the TPP. + * + * The standard puts the PSU's identity in the PSU-ID header: "Shall be transmitted if this Request + * is indicated by startAuthorisationWithPsuIdentification ... and this field has not yet been + * transmitted before" (Implementation Guidelines V1.3.12, section 7.1 Start Authorisation Process, + * p.195). It is not in the body: the psuData object carries passwords only and no identifier at + * all. + */ + feature("BG v1.3 - an Embedded SCA challenge belongs to the PSU, not to the TPP relaying it") { + + scenario("A client-credentials TPP completes SCA for the PSU it names in PSU-ID", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("The TPP starts the authorisation on its own client-credentials session") + val started = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader(resourceUser1Name)) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + Then("The challenge is minted for the named PSU, so the OTP reaches them and not the TPP") + Challenges.ChallengeProvider.vend.getChallenge(authorisationId) + .openOrThrowException("test challenge lookup failed") + .expectedUserId should be (resourceUser1.userId) + + Then("The TPP relays the PSU's OTP and the consent binds to the PSU") + val answered = answerAuthorisation(consentId, authorisationId, clientCredentialsSession, "123") + answered.code should equal(200) + answered.body.extract[ScaStatusResponse].scaStatus should be ("valid") + + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + consent.userId should be (resourceUser1.userId) + consent.status should be (ConsentStatus.valid.toString) + } + + scenario("With no PSU in the session and no PSU-ID there is nobody to mint the challenge for", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val response = startAuthorisation(consentId, clientCredentialsSession) + response.code should equal(401) + response.body.extract[ErrorMessagesBG].tppMessages.head.code should be ("PSU_CREDENTIALS_INVALID") + + Then("The consent is untouched") + val consent = Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed") + Option(consent.userId).forall(_.isBlank) should be (true) + consent.status should be (ConsentStatus.received.toString) + } + + scenario("A PSU-ID the ASPSP cannot resolve is refused", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val response = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader("no-such-psu-at-this-aspsp")) + response.code should equal(401) + response.body.extract[ErrorMessagesBG].tppMessages.head.code should be ("PSU_CREDENTIALS_INVALID") + + Option(Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId).forall(_.isBlank) should be (true) + } + + // "It might be contained even if an OAuth2 based authentication was performed in a pre-step. In + // this case the ASPSP might check whether PSU-ID and token match, according to ASPSP + // documentation" -- Implementation Guidelines V1.3.12, section 6.3.1, p.134. Taken up here, and + // extended to the consent's own PSU, which is the same fact recorded a step earlier. + scenario("A PSU-ID naming someone other than the consent's PSU cannot redirect the OTP", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + Then("resourceUser1 authorises it") + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + answerAuthorisation( + consentId, started.body.extract[StartConsentAuthorisationJson].authorisationId, user1, "123" + ).code should equal(200) + + Then("The lodging TPP may not now name a different PSU") + val response = startAuthorisation(consentId, clientCredentialsSession, psuIdHeader(resourceUser2Name)) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(ConsentDoesNotMatchUser) + + Consents.consentProvider.vend.getConsentByConsentId(consentId) + .openOrThrowException("test consent lookup failed").userId should be (resourceUser1.userId) + } + + // The consent already records who it belongs to, which is the "not yet contained in a pre-ceeding + // request" case the standard makes PSU-ID conditional on (section 7.2.1, p.206). + scenario("A bound consent needs no PSU-ID: the consent itself already names the PSU", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(consentId, user1) + started.code should equal(201) + answerAuthorisation( + consentId, started.body.extract[StartConsentAuthorisationJson].authorisationId, user1, "123" + ).code should equal(200) + + val restarted = startAuthorisation(consentId, clientCredentialsSession) + restarted.code should equal(201) + Challenges.ChallengeProvider.vend + .getChallenge(restarted.body.extract[StartConsentAuthorisationJson].authorisationId) + .openOrThrowException("test challenge lookup failed") + .expectedUserId should be (resourceUser1.userId) + } + + scenario("A challenge minted on one consent cannot be answered on another", BerlinGroupV13ConsentAccess) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val firstConsentId = createUnclaimedBerlinGroupConsent().consentId + val secondConsentId = createUnclaimedBerlinGroupConsent().consentId + + val started = startAuthorisation(firstConsentId, user1) + started.code should equal(201) + val authorisationId = started.body.extract[StartConsentAuthorisationJson].authorisationId + + val response = answerAuthorisation(secondConsentId, authorisationId, user1, "123") + response.code should equal(400) + + Then("Neither consent was bound") + Option(Consents.consentProvider.vend.getConsentByConsentId(secondConsentId) + .openOrThrowException("test consent lookup failed").userId).forall(_.isBlank) should be (true) + } + } + + // The GET siblings were brought to UserOrApplication on their own; these two were held back + // because a doc's auth mode says nothing useful until the handler has an answer to which PSU an + // authorisation is for. They now do, and the answer does not come from the session, so the docs + // can state what these calls have always been: the TPP acting as itself. + // + // Pinned because nothing else would notice a revert -- these keep working for as long as OAuth2 + // token parsing auto-vivifies a user for a client-credentials token, and start 401ing the day + // that stops. + feature("BG v1.3 - the consent authorisation pair accepts a client-credentials caller") { + val authorisationDocs = List( + "startConsentAuthorisationTransactionAuthorisation", + "startConsentAuthorisationUpdatePsuAuthentication", + "startConsentAuthorisationSelectPsuAuthenticationMethod", + "updateConsentsPsuDataTransactionAuthorisation", + "updateConsentsPsuDataUpdatePsuAuthentication", + "updateConsentsPsuDataUpdateSelectPsuAuthenticationMethod", + "updateConsentsPsuDataUpdateAuthorisationConfirmation" + ) + for (name <- authorisationDocs) { + scenario(s"$name declares UserOrApplication", BerlinGroupV13ConsentAccess) { + val docs = APIUtil.ResourceDoc.getResourceDocs( + List(APIUtil.buildOperationId(ConstantsBG.berlinGroupVersion1, name))) + docs should not be empty + docs.foreach(_.authMode should equal(APIUtil.UserOrApplication)) + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala index fe162244ad..6b0ccce4c1 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala @@ -10,7 +10,11 @@ import code.api.util.APIUtil.OAuth._ import code.api.util.APIUtil.extractErrorMessageCode import code.api.util.ErrorMessages._ import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} +import code.model.TokenType import code.setup.{APIResponse, DefaultUsers} +import code.token.Tokens +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, TransactionRequestTypes} @@ -758,11 +762,127 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with "NON_EXISTING_PAYMENT_ID" / "cancellation-authorisations").POST <@ (user1) val response: APIResponse = makeGetRequest(requestGet) - Then("We should get a 200 ") - response.code should equal(200) - val payment = response.body.extract[CancellationJsonV13] - payment.cancellationIds should be equals(0) + Then("We should get a 400 - the payment has to exist before its cancellation authorisations can be listed") + response.code should equal(400) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (InvalidTransactionRequestId) } } + // Berlin Group addresses a payment by its id alone. Nothing in the route ties the payment to the + // caller, so without an explicit check a paymentId is a bearer token: whoever holds it can read + // the payment, list and start authorisations on it, and cancel it. The payment records who lodged + // it; these scenarios hold every payment-scoped route to that record. + feature("test the BG v1.3 - a payment is only addressable by the party that initiated it") { + scenario("a second TPP can neither read, authorise, nor cancel a payment it did not initiate", BerlinGroupV1_3, PIS, initiatePayment) { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + val ibanFrom = accountsRoutingIban.head + val ibanTo = accountsRoutingIban.last + + def balanceOf(routing: BankAccountRouting) = MappedBankAccount.find( + By(MappedBankAccount.bank, routing.bankId.value), + By(MappedBankAccount.theAccountId, routing.accountId.value)) + .map(_.balance).openOrThrowException("Can not be empty here") + + grantAccountAccess(ibanFrom) + + // Over the challenge threshold, so the payment stays in RCVD awaiting SCA — the state in which + // a hijacked authorisation would actually move money. + val initiatePaymentJson = + s"""{ + | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "2001" }, + | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorName": "70charname" + }""".stripMargin + + When("user1 initiates a payment") + val responseInitiate: APIResponse = makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1), + initiatePaymentJson) + responseInitiate.code should equal(201) + val paymentId = responseInitiate.body.extract[InitiatePaymentResponseJson].paymentId + + val fromBalanceBefore = balanceOf(ibanFrom) + val toBalanceBefore = balanceOf(ibanTo) + + val payment = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId + + Then("user2 is refused on every payment-scoped route") + val refusals = List( + "read the payment" -> makeGetRequest((payment).GET <@ (user2)), + "read its status" -> makeGetRequest((payment / "status").GET <@ (user2)), + "list its authorisations" -> makeGetRequest((payment / "authorisations").GET <@ (user2)), + "list its cancellation authorisations" -> makeGetRequest((payment / "cancellation-authorisations").GET <@ (user2)), + "start an authorisation on it" -> makePostRequest((payment / "authorisations").POST <@ (user2), """{"scaAuthenticationData":"123"}"""), + "cancel it" -> makeDeleteRequest((payment).DELETE <@ (user2)) + ) + refusals.foreach { case (what, response) => + withClue(s"user2 was allowed to $what: ") { + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (PaymentNotInitiatedByCaller) + } + } + + And("no money moved") + balanceOf(ibanFrom) should equal(fromBalanceBefore) + balanceOf(ibanTo) should equal(toBalanceBefore) + + And("user1, who initiated it, still can address it") + val ownerResponse = makeGetRequest((payment / "status").GET <@ (user1)) + ownerResponse.code should equal(200) + } + scenario("a second TPP acting for the same PSU is refused too", BerlinGroupV1_3, PIS, initiatePayment) { + val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + val ibanFrom = accountsRoutingIban.head + val ibanTo = accountsRoutingIban.last + + grantAccountAccess(ibanFrom) + + val initiatePaymentJson = + s"""{ + | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "2001" }, + | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorName": "70charname" + }""".stripMargin + + When("the PSU lodges a payment through one TPP") + val responseInitiate: APIResponse = makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1), + initiatePaymentJson) + responseInitiate.code should equal(201) + val paymentId = responseInitiate.body.extract[InitiatePaymentResponseJson].paymentId + val payment = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId + + Then("the same PSU acting through a second TPP still cannot address it") + // The person matches and only the TPP differs -- the case a person-only check waves through, + // and the reason the payment has to record which consumer lodged it. + val response = makeGetRequest((payment / "status").GET <@ (samePsuUnderSecondConsumer)) + response.code should equal(403) + response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith (PaymentNotInitiatedByCaller) + + And("the TPP that lodged it still can") + makeGetRequest((payment / "status").GET <@ (user1)).code should equal(200) + } + } + // resourceUser1's own token, issued under a second consumer: same person, different TPP. + // DefaultUsers has no such pair -- user2 and user3 change the person as well as the consumer. + private lazy val samePsuUnderSecondConsumer = { + val token = Tokens.tokens.vend.createToken( + TokenType.Access, + Some(testConsumer2.id.get), + Some(resourceUser1.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new java.util.Date(System.currentTimeMillis())), + None + ).openOrThrowException("test token creation failed") + Some(consumer2, Token(token.key.get, token.secret.get)) + } + + } \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala index 9845c9be3b..7ed98ce8a3 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -83,6 +83,29 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser http4sServer.port should equal(APIUtil.getPropsAsIntValue("http4s.test.port", 8087)) } + // The point of /status is telling an operator which build is running. It reported the + // wrong branch and a frozen build time for any build made from a git worktree, so pin + // what it reports to the stamp the artifact actually carries (scripts/write_git_properties.sh + // writes it; APIUtil.gitCommit reads the same file from the classpath). + scenario("GET /status reports the build stamp this artifact carries", Http4sServerIntegrationTag) { + Given("HTTP4S test server is running") + + When("We request the status page as JSON") + val (status, body) = makeHttp4sGetRequest("/status", Map("Accept" -> "application/json")) + + Then("We should get a status response") + status should (equal(200) or equal(503)) + + And("It should carry the commit the running classes were built from") + val json = parse(body) + (json \ "git_commit").extract[String] should equal(APIUtil.gitCommit) + + And("It should also name the branch, the dirty flag and the build time") + (json \ "git_branch").extract[String] should not be empty + (json \ "git_build_time").extract[String] should not be empty + List(JBool(true), JBool(false)) should contain(json \ "git_dirty") + } + scenario("Server handles 404 for unknown routes", Http4sServerIntegrationTag) { Given("HTTP4S test server is running") diff --git a/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala b/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala new file mode 100644 index 0000000000..80535b185a --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/BerlinGroupPsuInvolvementTest.scala @@ -0,0 +1,54 @@ +package code.api.util + +import code.api.berlin.group.v1_3.BerlinGroupServerSetupV1_3 +import code.api.util.APIUtil.HTTPParam +import org.scalatest.Tag + +/** + * `frequencyPerDay` limits "an access without PSU involvement per day", so everything turns on how + * the ASPSP decides a PSU was not involved. NextGenPSD2 makes that a property of one header: + * `PSU-IP-Address` "shall be contained if and only if this request was actively initiated by the + * PSU". These scenarios pin that reading, in particular that a request carrying no PSU header at all + * counts against the limit — the case a TPP running unattended actually produces. + */ +class BerlinGroupPsuInvolvementTest extends BerlinGroupServerSetupV1_3 { + + object PsuInvolvement extends Tag("BerlinGroupPsuInvolvement") + + private def headers(pairs: (String, String)*): List[HTTPParam] = + pairs.map { case (name, value) => HTTPParam(name, List(value)) }.toList + + feature("Berlin Group - deciding whether the PSU was behind a request") { + + scenario("a request carrying no PSU-IP-Address is unattended", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("X-Request-ID" -> "5d8a7e2c-3c1f-4f7a-9a6e-1b0d2f3a4b5c")) should be(true) + } + + scenario("an empty or blank PSU-IP-Address is no address at all", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> " ")) should be(true) + } + + scenario("a request carrying the PSU's address was initiated by the PSU", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "192.168.8.78")) should be(false) + } + + scenario("the header name is matched case-insensitively, as HTTP requires", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("psu-ip-address" -> "192.168.8.78")) should be(false) + } + + scenario("the sentinel values still mark an unattended request", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement(headers("PSU-IP-Address" -> "0.0.0.0")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-ID" -> "no-psu-involved")) should be(true) + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-Name" -> "no-psu-involved")) should be(true) + } + + scenario("a real device id alongside a PSU address does not make the request unattended", PsuInvolvement) { + BerlinGroupCheck.isTppRequestsWithoutPsuInvolvement( + headers("PSU-IP-Address" -> "192.168.8.78", "PSU-Device-ID" -> "99435c7e-ad88-49ec-a2ad-99ddcb1f7721")) should be(false) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala new file mode 100644 index 0000000000..47386f389d --- /dev/null +++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsentOwnershipTests.scala @@ -0,0 +1,153 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2019, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH +Osloerstrasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + */ +package code.api.v5_1_0 + +import org.json4s._ +import code.api.{Constant, RequestHeader} +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole._ +import code.api.util.Consent +import code.api.util.ErrorMessages._ +import code.api.v3_1_0.{ConsentJsonV310, PostConsentChallengeJsonV310, PostConsentEntitlementJsonV310, PostConsentViewJsonV310} +import code.api.v5_1_0.OBPAPI5_1_0.Implementations5_1_0 +import code.entitlement.Entitlement +import code.setup.PropsReset +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * Who the OBP-native consent read answers to. + * + * GET /obp/v5.1.0/user/current/consents/CONSENT_ID compared the consent's PSU against + * CallContext.userId -- the authenticated principal. Under Consent-Id / Consent-JWT authentication + * that principal is the per-consent shadow user, never the human, so the comparison could not match + * and the PSU was told their own consent did not exist. The subject is now CallContext.humanUser, + * the accessor the codebase already keeps for exactly this distinction. + * + * The rule, and why a consent with no PSU yet stays readable by anyone, is in + * Consent.checkObpConsentUserAccess. + */ +class ConsentOwnershipTests extends V510ServerSetup with PropsReset { + + object VersionOfApi extends Tag(ApiVersion.v5_1_0.toString) + object CreateConsent extends Tag(nameOf(Implementations5_1_0.createConsent)) + object ConsentOwnership extends Tag("ConsentOwnership") + + private val psu = "psu-user-id" + private val otherPsu = "someone-else-user-id" + + feature("Consent.checkObpConsentUserAccess") { + + scenario("the PSU a consent is bound to may read it", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, Some(psu)) should equal(None) + } + + scenario("a different human may not read a bound consent", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, Some(otherPsu)) should equal(Some(ConsentNotFound)) + } + + scenario("a caller with no human at all may not read a bound consent", ConsentOwnership) { + Consent.checkObpConsentUserAccess(psu, None) should equal(Some(ConsentNotFound)) + } + + // Deliberate, and load-bearing: this endpoint is where a PSU inspects a consent before deciding + // to authorise it, and the app doing the inspecting belongs to the PSU, not to the TPP that + // lodged the consent. See the Berlin Group SCA regression in AccountInformationServiceAISApiTest. + scenario("a consent with no PSU yet is readable", ConsentOwnership) { + Consent.checkObpConsentUserAccess("", Some(psu)) should equal(None) + Consent.checkObpConsentUserAccess(null, None) should equal(None) + Consent.checkObpConsentUserAccess(" ", Some(otherPsu)) should equal(None) + } + } + + private val validHeaderConsumerKey = + List((RequestHeader.`Consumer-Key`, user1.map(_._1.key).getOrElse("SHOULD_NOT_HAPPEN"))) + + private lazy val bankId = randomBankId + private lazy val bankAccount = randomPrivateAccount(bankId) + private lazy val entitlements = List(PostConsentEntitlementJsonV310("", CanGetAnyUser.toString())) + private lazy val views = List(PostConsentViewJsonV310(bankId, bankAccount.id, Constant.SYSTEM_OWNER_VIEW_ID)) + private lazy val postConsentImplicitJsonV310 = SwaggerDefinitionsJSON.postConsentImplicitJsonV310 + .copy(entitlements = entitlements) + .copy(consumer_id = Some(testConsumer.consumerId.get)) + .copy(views = views) + + // Lodge an OBP-native consent for resourceUser1 and take it through SCA, so it ends up ACCEPTED + // and usable as a credential. Returns (consentId, consentJWT). + private def acceptedConsentOfUser1(): (String, String) = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + val request = (v5_1_0_Request / "my" / "consents" / "IMPLICIT").POST <@ (user1) + val response = makePostRequest(request, write(postConsentImplicitJsonV310), validHeaderConsumerKey) + response.code should equal(201) + val consentId = response.body.extract[ConsentJsonV310].consent_id + val jwt = response.body.extract[ConsentJsonV310].jwt + + val challengeRequest = (v5_1_0_Request / "banks" / bankId / "consents" / consentId / "challenge").POST <@ (user1) + val challengeResponse = makePostRequest( + challengeRequest, write(PostConsentChallengeJsonV310(answer = Consent.challengeAnswerAtTestEnvironment))) + challengeResponse.code should equal(201) + + (consentId, jwt) + } + + feature("Consent-authenticated reads of GET /user/current/consents/CONSENT_ID") { + + scenario("The PSU may read their own consent when the consent itself is the credential", CreateConsent, VersionOfApi, ConsentOwnership) { + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") + val (consentId, jwt) = acceptedConsentOfUser1() + + When("The consent is presented as the credential, so cc.user is its shadow user") + val request = (v5_1_0_Request / "user" / "current" / "consents" / consentId).GET + val response = makeGetRequest( + request, List((RequestHeader.`Consent-JWT`, jwt)) ::: validHeaderConsumerKey) + + Then("The read resolves the human behind the consent and succeeds -- this used to be a 404") + response.code should equal(200) + (response.body \ "consent_id").extract[String] should equal(consentId) + + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_CERTIFICATE") + } + + scenario("Another user still cannot read a consent bound to someone else", CreateConsent, VersionOfApi, ConsentOwnership) { + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_KEY_VALUE") + val (consentId, _) = acceptedConsentOfUser1() + + When("user2 asks for a consent that belongs to resourceUser1") + val response = makeGetRequest((v5_1_0_Request / "user" / "current" / "consents" / consentId).GET <@ (user2)) + + Then("It is refused as not found, so the id is not confirmed to a stranger") + response.code should equal(404) + response.body.extract[ErrorMessage].message should include(ConsentNotFound) + + setPropsValues("consumer_validation_method_for_consent" -> "CONSUMER_CERTIFICATE") + } + } +} diff --git a/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala index b98fcf953d..cc57ae70ca 100644 --- a/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala +++ b/obp-api/src/test/scala/code/api/v5_1_0/VRPConsentRequestTest.scala @@ -32,7 +32,15 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.{accountRoutingJsonV121 import code.api.v5_0_0.ConsentJsonV500 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole._ +import code.api.Constant +import com.openbankproject.commons.model.{AccountId, BankId, ViewId} import code.api.util.Consent +import code.consent.Consents +import code.counterpartylimit.CounterpartyLimitProvider +import code.metadata.counterparties.Counterparties +import code.views.system.{AccountAccess, ViewDefinition} +import scala.concurrent.Await +import scala.concurrent.duration._ import code.api.util.ErrorMessages._ import code.api.util.ExampleValue.counterpartyNameExample import code.api.v2_1_0.{CounterpartyIdJson, TransactionRequestBodyCounterpartyJSON} @@ -234,6 +242,51 @@ class VRPConsentRequestTest extends V510ServerSetup with PropsReset{ response.body.extract[TransactionRequestWithChargeJSON400].status shouldBe("COMPLETED") } + + scenario("Revoking a VRP consent releases the mandate it created", ApiEndpoint1, ApiEndpoint3, VersionOfApi) { + When("the PSU creates a VRP consent request and converts it") + val createConsentResponse = makePostRequest(createVRPConsentRequestUrl, write(postVRPConsentRequestMonthlyGuardJson)) + createConsentResponse.code should equal(201) + val consentRequestId = createConsentResponse.body.extract[ConsentRequestResponseJson].consent_request_id + + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + val createConsentByRequestResponse = makePostRequest(createConsentByConsentRequestIdEmail(consentRequestId), write("")) + createConsentByRequestResponse.code should equal(201) + val consentJson = createConsentByRequestResponse.body.extract[ConsentJsonV500] + val consentId = consentJson.consent_id + val access = consentJson.account_access.getOrElse(fail("the converted consent named no account access")) + val bankId = BankId(access.bank_id) + val accountId = AccountId(access.account_id) + val viewId = ViewId(access.view_id) + viewId.value should startWith(Constant.VRP_VIEW_ID_PREFIX) + + Then("the mandate exists: the PSU holds the view, and its counterparty has a limit") + ViewDefinition.findCustomView(bankId.value, accountId.value, viewId.value).isDefined should be(true) + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, viewId).size should be > 0 + val counterparties = Counterparties.counterparties.vend.getCounterparties(bankId, accountId, viewId).getOrElse(Nil) + counterparties.size should be > 0 + counterparties.foreach { counterparty => + Await.result(CounterpartyLimitProvider.counterpartyLimit.vend.getCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), 10.seconds + ).isDefined should be(true) + } + + When("the consent is revoked") + Consents.consentProvider.vend.revoke(consentId).isDefined should be(true) + + Then("nothing of the mandate is left to pay with") + // The authority the PSU held, the amount it was good for, and the view that carried it. + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, viewId).size should be(0) + counterparties.foreach { counterparty => + Await.result(CounterpartyLimitProvider.counterpartyLimit.vend.getCounterpartyLimit( + bankId.value, accountId.value, viewId.value, counterparty.counterpartyId), 10.seconds + ).isDefined should be(false) + } + ViewDefinition.findCustomView(bankId.value, accountId.value, viewId.value).isDefined should be(false) + + And("the PSU's own access to the account is untouched") + AccountAccess.findAllByBankIdAccountIdViewId(bankId, accountId, ViewId(Constant.SYSTEM_OWNER_VIEW_ID)).size should be > 0 + } scenario("We will call the Create (IMPLICIT), Get and Delete endpoints with user credentials ", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, ApiEndpoint5, ApiEndpoint6, VersionOfApi) { When(s"We try $ApiEndpoint1 v5.1.0") val createConsentResponse = makePostRequest(createVRPConsentRequestUrl, write(postVRPConsentRequestMonthlyGuardJson)) diff --git a/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala b/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala new file mode 100644 index 0000000000..60ab856bd8 --- /dev/null +++ b/obp-api/src/test/scala/code/bankconnectors/ObpAccountRoutingResolutionTest.scala @@ -0,0 +1,110 @@ +package code.bankconnectors + +import code.api.Constant +import code.model.dataAccess.BankAccountRouting +import code.setup.{DefaultUsers, ServerSetupWithTestData} +import com.openbankproject.commons.model.{AccountId, AccountRoutingJsonV121, BankAccountRoutings, BankId, BankRoutingJson, BranchRoutingJsonV141} +import net.liftweb.mapper.By +import scala.concurrent.Await +import scala.concurrent.duration._ +import org.scalatest.Tag + +/** + * The `OBP` account-routing scheme means two things at once, and resolution has to honour both. + * + * It is an implicit self-identifier: an address under it is normally the account id itself, with no + * row in bankaccountrouting. But a bank may also *register* an `OBP` routing whose address is + * something else entirely, and that row is stored like any other scheme's. Reading only the implicit + * meaning left those accounts unreachable through every endpoint that resolves by routing — the row + * was in the table, and the answer was "Bank Account not found", which is what blocked converting a + * consent-request that named an account that way. + */ +class ObpAccountRoutingResolutionTest extends ServerSetupWithTestData with DefaultUsers { + + object ObpRouting extends Tag("ObpAccountRoutingResolution") + + // "OBP" is the overloaded one, accepted in both bank- and account-routing contexts. + private val obpScheme = "OBP" + + feature("Resolving an account by an OBP-scheme routing") { + + scenario("an address that is the account id resolves, with and without a bank", ObpRouting) { + val account = createAccountRelevantResource(Some(resourceUser1), testBankId1, testAccountId1, "EUR") + + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(account.bankId), obpScheme, account.accountId.value, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + + } + + scenario("without a bank, an account id shared by several banks is reported as ambiguous", ObpRouting) { + // The fixture gives more than one bank an account called testAccount1, so with no bank context + // the address matches several accounts. That has to stay an ambiguity: falling through to the + // routing table would find nothing there and answer a bare "not found" instead. + createAccountRelevantResource(Some(resourceUser1), testBankId1, testAccountId1, "EUR") + createAccountRelevantResource(Some(resourceUser1), testBankId2, testAccountId1, "EUR") + + val result = Connector.connector.vend.getBankAccountByRoutingLegacy( + None, obpScheme, testAccountId1.value, None) + result.isDefined should equal(false) + result.toString should include("OBP-31075") + } + + scenario("a registered OBP routing whose address is not the account id resolves too", ObpRouting) { + val account = createAccountRelevantResource(Some(resourceUser1), testBankId2, AccountId("testAccountObpRouting"), "EUR") + val registeredAddress = "some-bank-chosen-obp-address" + + BankAccountRouting.create + .BankId(account.bankId.value) + .AccountId(account.accountId.value) + .AccountRoutingScheme(obpScheme) + .AccountRoutingAddress(registeredAddress) + .saveMe() + + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(account.bankId), obpScheme, registeredAddress, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + + Connector.connector.vend.getBankAccountByRoutingLegacy( + None, obpScheme, registeredAddress, None + ).map(_._1.accountId) should equal(net.liftweb.common.Full(account.accountId)) + } + + scenario("the plural-routings resolver honours a registered OBP routing too", ObpRouting) { + // getBankAccountByRoutings has its own copy of the implicit-OBP shortcut, and had the same + // blind spot. This is the path the VRP consent-request creation takes. + val account = createAccountRelevantResource(Some(resourceUser1), testBankId1, AccountId("testAccountPluralRouting"), "EUR") + val registeredAddress = "another-bank-chosen-obp-address" + + BankAccountRouting.create + .BankId(account.bankId.value) + .AccountId(account.accountId.value) + .AccountRoutingScheme(obpScheme) + .AccountRoutingAddress(registeredAddress) + .saveMe() + + val routings = BankAccountRoutings( + bank = BankRoutingJson(obpScheme, account.bankId.value), + account = BranchRoutingJsonV141(obpScheme, registeredAddress), + branch = AccountRoutingJsonV121("", "") + ) + val resolved = Await.result( + Connector.connector.vend.getBankAccountByRoutings(routings, None), 20.seconds)._1 + resolved.map(_.accountId) should equal(net.liftweb.common.Full(account.accountId)) + } + + scenario("an address that is neither still resolves to nothing", ObpRouting) { + Connector.connector.vend.getBankAccountByRoutingLegacy( + Some(BankId(testBankId1.value)), obpScheme, "no-such-address-anywhere", None + ).isDefined should equal(false) + } + } + + override def afterEach(): Unit = { + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingAddress, "some-bank-chosen-obp-address")).foreach(_.delete_!) + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingAddress, "another-bank-chosen-obp-address")).foreach(_.delete_!) + super.afterEach() + } +} diff --git a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md index 89d2384424..7e87bc2f38 100644 --- a/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md +++ b/obp-api/src/test/scala/code/concurrency/CONCURRENCY_HAZARDS.md @@ -252,7 +252,8 @@ confirmed before each fix, all green after. | **H6** | ″ | `ObpLookupSystem.obpLookupSystem` unguarded var | `@volatile` + synchronized init (structural reflection test) | | **M9** | ″ | `ObpActorSystem` actor-system vars | `@volatile` + synchronized init (structural reflection test) | | **M2** | `ConcurrentBusinessStatusRaceTest` | `AccountAccessRequest.updateStatus` no terminal guard | conditional `UPDATE … WHERE status='INITIATED'` (`DoobieBusinessStatusQueries`) | -| **M3** | ″ | `MappedAccountApplication.updateStatus` in-memory ACCEPTED guard | optimistic CAS `UPDATE … WHERE mstatus=` | +| **M3** | ″ | `MappedAccountApplication.updateStatus` in-memory ACCEPTED guard | conditional `UPDATE … WHERE mstatus='REQUESTED'` (`DoobieBusinessStatusQueries`) | +| **M3b** | ″ | ″ — the first CAS guarded on the *loaded* status, so a serialised second decision matched its own read and overwrote the first | same fixed-`REQUESTED` guard; deterministic sequential reproduction | | **M4** | ″ | `MappedChallengeProvider.validateChallenge` non-CAS success flip | CAS `UPDATE … SET successful_c=true WHERE challengeid=? AND successful_c=false` | **M1** (`Http4s510.updateTransactionRequestStatus` lacked the row lock that `Http4s400` has) is fixed diff --git a/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala b/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala index 16605c28ba..9090cb1380 100644 --- a/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala +++ b/obp-api/src/test/scala/code/concurrency/ConcurrentBusinessStatusRaceTest.scala @@ -40,7 +40,15 @@ import scala.concurrent.duration._ * the guard, and both write — the last one wins non-deterministically. A legitimate ACCEPTED * transition can be overwritten by a concurrent REJECTED. * - * EXPECTED TO FAIL (M2, M3) until a conditional UPDATE WHERE status='' is used. + * M3b (testable, deterministic): + * The sequential form of M3, and the reason M3 stayed intermittently red after the conditional + * UPDATE was introduced. That UPDATE guarded on the status the caller had just loaded, so it only + * caught the interleaving where both callers read the same old value. When the two calls serialise, + * the second one loads what the first wrote and its guard matches — the decision is overwritten + * with no error. M3b reproduces that with two ordinary sequential calls, no threads involved. + * + * All four are fixed. M2/M3/M3b/M4 now guard on a fixed starting state + * (INITIATED / REQUESTED / successful_c=false) so each decision can only be taken once. * Tagged ConcurrencyRace. */ class ConcurrentBusinessStatusRaceTest extends ConcurrentRaceSetup { @@ -128,6 +136,43 @@ class ConcurrentBusinessStatusRaceTest extends ConcurrentRaceSetup { } } + scenario("M3b: a REJECTED AccountApplication must not be silently re-decided as ACCEPTED", ConcurrencyRace) { + Given("an AccountApplication in REQUESTED state") + val appId = UUID.randomUUID.toString + MappedAccountApplication.create + .mAccountApplicationId(appId) + .mCode(ProductCode("__conc_m3b_product").value) + .mUserId(resourceUser1.userId) + .mCustomerId(UUID.randomUUID.toString) + .mStatus("REQUESTED") + .saveMe() + + When("it is REJECTED and then a second decision tries to ACCEPT it") + // The deterministic (sequential) form of the M3 race. M3's threads only both write when the + // barrier releases them close enough together to both read REQUESTED; when they serialise, the + // second call reads the status the first one wrote. A guard keyed on that freshly-read status + // matches its own read and lets the overwrite through, so the outcome below is exactly what M3 + // observes intermittently — reproduced here with no threads and no timing dependency. + val rejected = Await.result(MappedAccountApplicationProvider.updateStatus(appId, "REJECTED"), 10.seconds) + val accepted = Await.result(MappedAccountApplicationProvider.updateStatus(appId, "ACCEPTED"), 10.seconds) + + val finalStatus = MappedAccountApplication + .find(By(MappedAccountApplication.mAccountApplicationId, appId)) + .map(_.status).getOrElse("missing") + + Then("only the first decision may take effect — the application stays REJECTED") + withClue( + s"rejected=$rejected accepted=$accepted finalStatus=$finalStatus: " + + s"the decision on an account application is one-shot — it may only be taken from REQUESTED. " + + s"Accepting an already-REJECTED application also creates a bank account, so a silent " + + s"re-decision is not recoverable — " + ) { + rejected shouldBe a[Full[_]] + accepted shouldBe a[Failure] + finalStatus should equal("REJECTED") + } + } + // M1 (Http4s510 updateTransactionRequestStatus lacks the row lock that Http4s400 has) is fixed at // the endpoint: it now calls DoobieTransactionRequestQueries.lockTransactionRequest within the // request transaction. It has no provider-level reproduction here because the FOR UPDATE lock only diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index 35f58da174..9ea83e2934 100644 --- a/obp-api/src/test/scala/code/views/MappedViewsTest.scala +++ b/obp-api/src/test/scala/code/views/MappedViewsTest.scala @@ -95,7 +95,39 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{ MapperViews.factoryResetSystemView(ViewId("does-not-exist")) shouldBe Empty } + // Regression coverage for the UK Open Banking / Berlin Group views-permissions gap + // remediation (Gap 1, 2, 5): each of these views previously either shared the generic + // SYSTEM_VIEW_PERMISSION_COMMON set (so "Detail" granted nothing beyond "Basic") or had no + // ViewPermission rows at all (the two BG views). Assert each view's allowed_actions match + // its target set exactly — no more, no less. + scenario("UK and Berlin Group system views have exact, differentiated can_* permission sets") { + // UK/BG views are opt-in (created on demand), not unconditionally present like auditor — + // getOrCreateSystemView creates them fresh with current code defaults; afterEach's + // ViewDefinition.bulkDelete_!! guarantees no stale permissions leak in between scenarios. + def actionsOf(viewId: String): Set[String] = + MapperViews.getOrCreateSystemView(viewId) + .openOrThrowException(s"$viewId should be a known system view") + .allowed_actions.toSet + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Constant.SYSTEM_READ_BALANCES_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_CREDITS_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) should equal(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID) should equal(Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_PERMISSION.toSet) + actionsOf(Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID) should equal(Constant.SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_PERMISSION.toSet) + + Then("Detail must be a strict superset of Basic (never narrower), for both Accounts and Transactions") + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet should contain allElementsOf Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_PERMISSION.toSet.size should be > Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_PERMISSION.toSet.size + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet should contain allElementsOf Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_PERMISSION.toSet.size should be > Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_PERMISSION.toSet.size + + Then("Balances must not carry transaction- or counterparty-visibility permissions") + actionsOf(Constant.SYSTEM_READ_BALANCES_VIEW_ID) should equal(Set(Constant.CAN_SEE_BANK_ACCOUNT_BALANCE, Constant.CAN_QUERY_AVAILABLE_FUNDS)) + } } diff --git a/pom.xml b/pom.xml index c460355604..c572a82851 100644 --- a/pom.xml +++ b/pom.xml @@ -43,12 +43,28 @@ obp-api + - - scala-tools.releases - Scala-Tools Dependencies Repository for Releases - https://oss.sonatype.org/content/repositories/releases/ - git-OpenBankProject OpenBankProject Git based repo @@ -60,14 +76,6 @@ - - - org.sonatype.oss.groups.public - Sonatype Public - https://oss.sonatype.org/content/groups/public - - - org.apache.maven.plugins diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 56c8d5107f..fd952014ca 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -294,6 +294,17 @@ run_shard() { # ports (both test servers bind a real socket; see the port-allocation block). # Tests only, no recompile (the compile already happened in the pre-compile step). # ${TIMEOUT_BIN} 1200: hard-kill after 20 min to prevent Pekko non-daemon threads from hanging. + # OBP_API_INSTANCE_ID feeds Constant.getGlobalCacheNamespacePrefix, which prefixes every + # Redis cache key with "{api_instance_id}_{runmode}_". Ports and the H2 database are already + # isolated per run, but Redis is not: every checkout on this machine talks to the same + # 127.0.0.1:6379. A plain "shard_${n}" is therefore identical in every checkout, so two + # concurrent runs share one key namespace -- and LocalMappedConnectorTestSetup.wipeTestData + # deletes that whole namespace after EVERY test. Run A's teardown was wiping run B's live + # rate-limit counters, once per test. Nothing failed because the rate-limit suites seed their + # counters immediately before asserting, but that is luck, not isolation. Mixing in the + # already-allocated random port makes the namespace unique per run, so a teardown only ever + # deletes its own keys. Keys are still cleaned up: wipeTestData removes the whole prefix at + # the end of every test, so unique namespaces do not accumulate garbage in the shared Redis. MAVEN_OPTS="$MVN_OPTS" \ OBP_TESTS_PORT="${port}" \ OBP_HOSTNAME="http://localhost:${port}" \ @@ -301,7 +312,7 @@ run_shard() { OBP_MAIL_TEST_MODE="true" \ OBP_DYNAMIC_CODE_SANDBOX_PERMISSIONS='[new java.net.NetPermission("specifyStreamHandler"), new java.lang.reflect.ReflectPermission("suppressAccessChecks"), new java.lang.RuntimePermission("getenv.*"), new java.util.PropertyPermission("cglib.useCache", "read"), new java.util.PropertyPermission("net.sf.cglib.test.stressHashCodes", "read"), new java.util.PropertyPermission("cglib.debugLocation", "read"), new java.lang.RuntimePermission("accessDeclaredMembers"), new java.lang.RuntimePermission("getClassLoader")]' \ OBP_ALLOW_USER_GENERATED_SCALA_CODE="true" \ - OBP_API_INSTANCE_ID="shard_${n}" \ + OBP_API_INSTANCE_ID="shard_${n}_${port}" \ "$TIMEOUT_BIN" 1200 mvn scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ "-DwildcardSuites=${filter}" \ > "$log" 2>&1 diff --git a/scripts/write_git_properties.sh b/scripts/write_git_properties.sh new file mode 100755 index 0000000000..12f806e8e3 --- /dev/null +++ b/scripts/write_git_properties.sh @@ -0,0 +1,124 @@ +#!/bin/bash +################################################################################ +# Write the build stamp (git.properties) that /status reports. +# +# Usage: write_git_properties.sh +# +# Why a script and not git-commit-id-maven-plugin: that plugin bundles JGit 6.7, +# which has no `commondir` support, so it cannot read a linked worktree's gitdir. +# Its GitDirLocator.resolveWorktree() works around that by collapsing +#
/.git/worktrees/ back to
/.git — meaning every build run from +# a worktree stamped the MAIN checkout's branch and commit. On top of that its +# PropertiesFileGenerator skips rewriting whenever the only changed key is +# git.build.time, so the stamp froze at the first build. The git CLI is +# worktree-aware by construction and we rewrite unconditionally, so both go away. +# +# Never fails the build: with no git binary or no repository we still emit a +# well-formed stamp with git.commit.id=unknown (the plugin behaved the same way +# via failOnNoGitDirectory=false, and source-tarball builds rely on it). +################################################################################ + +set -u + +OUT="${1:-}" +if [[ -z "$OUT" ]]; then + echo "write_git_properties.sh: output file argument is required" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Resolve the repository from the script's own location, not the caller's cwd: +# Maven invokes this from a module directory, and `git` must run against the +# working copy that owns this script. +REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" + +g() { + # Echo a git command's first line, empty string on any failure. + git -C "$REPO_ROOT" "$@" 2>/dev/null | head -1 || true +} + +if [[ -n "$REPO_ROOT" ]]; then + BRANCH="$(g rev-parse --abbrev-ref HEAD)" + COMMIT_ID="$(g rev-parse HEAD)" + COMMIT_ABBREV="$(g rev-parse --short=7 HEAD)" + COMMIT_TIME="$(g log -1 --format=%cI)" + COMMIT_USER_NAME="$(g log -1 --format=%an)" + COMMIT_USER_EMAIL="$(g log -1 --format=%ae)" + COMMIT_MESSAGE_SHORT="$(g log -1 --format=%s)" + DESCRIBE="$(g describe --always --dirty --abbrev=7)" + TAGS="$(git -C "$REPO_ROOT" tag --points-at HEAD 2>/dev/null | paste -sd, - || true)" + REMOTE_URL="$(g config --get remote.origin.url)" + BUILD_USER_NAME="$(g config --get user.name)" + BUILD_USER_EMAIL="$(g config --get user.email)" + # Tracked modifications only, matching what git-commit-id reported: untracked + # scratch files (build logs, editor state) are always present in a working + # checkout and would pin the flag to true, which is exactly the "renders the + # same either way" problem 6727bd372 set out to fix. + if [[ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ]]; then + DIRTY="true" + else + DIRTY="false" + fi +else + BRANCH="" + COMMIT_ID="unknown" + COMMIT_ABBREV="unknown" + COMMIT_TIME="" + COMMIT_USER_NAME="" + COMMIT_USER_EMAIL="" + COMMIT_MESSAGE_SHORT="" + DESCRIBE="" + TAGS="" + REMOTE_URL="" + BUILD_USER_NAME="" + BUILD_USER_EMAIL="" + DIRTY="false" +fi + +[[ -z "$COMMIT_ID" ]] && COMMIT_ID="unknown" +[[ -z "$COMMIT_ABBREV" ]] && COMMIT_ABBREV="unknown" + +# BSD date (macOS) has no %:z, so put the colon into the numeric offset by hand +# and keep the ISO-8601 shape the previous stamps used. +BUILD_TIME="$(date +%Y-%m-%dT%H:%M:%S%z | sed -E 's/([+-][0-9]{2})([0-9]{2})$/\1:\2/')" +BUILD_HOST="$(hostname 2>/dev/null || echo unknown)" + +# java.util.Properties treats \ : = # ! specially in values; escape what can occur +# here. Timestamps and URLs carry ':', commit subjects can carry anything. +esc() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/:/\\:/g' -e 's/=/\\=/g' -e 's/#/\\#/g' -e 's/!/\\!/g' +} + +OUT_DIR="$(dirname "$OUT")" +mkdir -p "$OUT_DIR" + +{ + echo "#Generated by scripts/write_git_properties.sh" + echo "git.branch=$(esc "$BRANCH")" + echo "git.build.host=$(esc "$BUILD_HOST")" + echo "git.build.time=$(esc "$BUILD_TIME")" + echo "git.build.user.email=$(esc "$BUILD_USER_EMAIL")" + echo "git.build.user.name=$(esc "$BUILD_USER_NAME")" + echo "git.commit.id=$(esc "$COMMIT_ID")" + echo "git.commit.id.abbrev=$(esc "$COMMIT_ABBREV")" + echo "git.commit.id.describe=$(esc "$DESCRIBE")" + echo "git.commit.message.short=$(esc "$COMMIT_MESSAGE_SHORT")" + echo "git.commit.time=$(esc "$COMMIT_TIME")" + echo "git.commit.user.email=$(esc "$COMMIT_USER_EMAIL")" + echo "git.commit.user.name=$(esc "$COMMIT_USER_NAME")" + echo "git.dirty=$DIRTY" + echo "git.remote.origin.url=$(esc "$REMOTE_URL")" + echo "git.tags=$(esc "$TAGS")" +} > "$OUT" + +# Legacy stamps written by git-commit-id into the source tree. They are build +# output (gitignored), and obp-commons' copy would otherwise keep shipping a +# stale git.properties that can shadow this one on the runtime classpath. +if [[ -n "$REPO_ROOT" ]]; then + rm -f "$REPO_ROOT/obp-api/src/main/resources/git.properties" \ + "$REPO_ROOT/obp-commons/src/main/resources/git.properties" \ + "$REPO_ROOT/obp-commons/target/classes/git.properties" 2>/dev/null || true +fi + +exit 0