From 38733142ff9e226a8fe5979960db0c03ed9cabfa Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 29 Apr 2026 07:00:08 +0200 Subject: [PATCH 1/6] capture release ideas --- ...PTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md diff --git a/ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md b/ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md new file mode 100644 index 0000000000..ce04e027bd --- /dev/null +++ b/ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md @@ -0,0 +1,180 @@ +# Draft v2: `CAPTURE` and `RELEASE` Transaction Request Types + +**Status**: design draft, not yet implemented. Section 6 is the open question that needs to be resolved before code lands. + +## Background + +OBP already has a `HOLD` transaction request type (`POST /banks/{BANK_ID}/accounts/{ACCOUNT_ID}/owner/transaction-request-types/HOLD/transaction-requests`, see `obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala:180`). HOLD moves funds from a parent account into an auto-created `HOLDING`-type sub-account, linked back via the `RELEASER_ACCOUNT_ID` account attribute. + +What's missing: + +- A `CAPTURE` step that commits held funds to a counterparty (turns the reservation into a real transfer to the recipient). +- A `RELEASE` step that returns held funds to the parent account (cancels the reservation). + +Both are needed to support trading-style settlement sagas (offer/order → match → capture vs cancel → release), where funds must be reserved before a counterparty is known. + +This document drafts those two new types. + +--- + +## Changes since v1 + +- Path uses generic `{ACCOUNT_ID}` (not a HOLDING-specific path segment) — same as every other transaction-request type. +- Destination uses ACCOUNT shape (`{bank_id, account_id}`), no counterparty resolution. +- `RELEASE` destination comes from the referenced HOLD's `from_account_id`, not from the holding account's `RELEASER_ACCOUNT_ID` attribute. +- The `hold_transaction_request_id` field is **flagged as still under discussion** — see §6. + +--- + +## 1. Endpoints + +``` +POST /obp/v6.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/owner/transaction-request-types/CAPTURE/transaction-requests +POST /obp/v6.0.0/banks/{BANK_ID}/accounts/{ACCOUNT_ID}/owner/transaction-request-types/RELEASE/transaction-requests +``` + +`{ACCOUNT_ID}` is the source of the transfer — i.e., the HOLDING sub-account that received the funds when the HOLD ran. The path doesn't enforce "type = HOLDING" explicitly; the integrity check falls out of the body validation (§6). + +## 2. `CAPTURE` body — `TransactionRequestBodyCaptureJsonV600` + +```json +{ + "hold_transaction_request_id": "abc-123-...", + "to": { + "bank_id": "gh.29.uk", + "account_id": "seller-fiat-account" + }, + "value": { "currency": "EUR", "amount": "250.00" }, + "description": "Settlement of trade trade-789" +} +``` + +| Field | Required | Purpose | +|---|---|---| +| `hold_transaction_request_id` | TBD (§6) | Linkage to originating HOLD | +| `to.bank_id` / `to.account_id` | yes | In-bank ACCOUNT-style destination | +| `value.currency` / `value.amount` | yes | Amount and currency to capture | +| `description` | no | Free-form note | + +## 3. `RELEASE` body — `TransactionRequestBodyReleaseJsonV600` + +```json +{ + "hold_transaction_request_id": "abc-123-...", + "value": { "currency": "EUR", "amount": "250.00" }, + "description": "Offer offer-456 cancelled by user" +} +``` + +No `to` field. Two options for resolving the destination, depending on §6: + +- **If we keep `hold_transaction_request_id`**: destination = the referenced HOLD's `from_account_id`. +- **If we drop it**: destination = the source account's `RELEASER_ACCOUNT_ID` attribute (only viable resolution path without the linkage). + +If `value.amount` is omitted, the server releases the full remaining balance (definition of "remaining" depends on §6). + +## 4. Response + +`transactionRequestWithChargeJSON400` — same as every other transaction-request type. If we keep §6, attach two attributes to the resulting transaction request: + +- `hold_transaction_request_id = abc-123-...` +- `hold_purpose = capture` *or* `hold_purpose = release` + +## 5. Validation + +Common to both types: + +1. `{ACCOUNT_ID}` exists and the caller has the required view permission (same as every other transaction-request). +2. Standard body validation (positive amount, valid currency, etc.). +3. `value.amount` must not exceed the source account's available balance. (Already enforced by the underlying transfer machinery.) + +§6-dependent (only if we keep `hold_transaction_request_id`): + +4. The HOLD referenced exists, was a HOLD, has status `COMPLETED`. +5. The HOLD's destination = `{ACCOUNT_ID}` in the URL. (Otherwise `HoldDoesNotMatchAccount`.) +6. `value.currency` matches the HOLD's currency. +7. Sum of completed `CAPTURE` + `RELEASE` against this HOLD plus `value.amount` ≤ HOLD amount. (`CaptureExceedsHoldRemaining` / `ReleaseExceedsHoldRemaining`.) + +## 6. Open question: do we need `hold_transaction_request_id`? + +This is the question that's still under discussion. Two designs: + +### Design A — keep the linkage + +CAPTURE/RELEASE bodies carry `hold_transaction_request_id`. The resulting transaction stores it as an attribute. Per-HOLD "remaining balance" is computed on demand: + +``` +remaining(hold_id) = hold.amount + − Σ amount of CAPTURE txns linked to hold_id, status=COMPLETED + − Σ amount of RELEASE txns linked to hold_id, status=COMPLETED +``` + +**What this gives us** + +- Server-enforced invariant: a HOLD cannot be over-captured or over-released; partial fills compose cleanly. +- The optional `GET .../transaction-requests/{HOLD_ID}/balance` helper makes sense. +- Audit/regulatory readers see "this transfer was the capture of HOLD X" without having to reason from context. +- Distinguishes CAPTURE/RELEASE from "ordinary transfer that happens to leave a HOLDING account" at the data-model level. + +**What it costs** + +- Each operation does one extra lookup (HOLD row + sum-of-related-attributes). +- Concurrent CAPTUREs against the same HOLD need transactional balance-check (already true for any debit transfer, but now the check is per-HOLD too). +- Schema-level additions: just two new transaction-request attributes (`hold_transaction_request_id`, `hold_purpose`) — no new table. + +### Design B — drop the linkage + +CAPTURE/RELEASE are typed by intent only. The HOLDING account is a fungible bucket; capture/release just transfer in or out of it. No per-HOLD tracking. RELEASE's destination has to come from the HOLDING account's `RELEASER_ACCOUNT_ID` attribute (so we'd need that attribute to be load-bearing again). + +**What this gives us** + +- Simpler API. Bodies match existing ACCOUNT-type transaction requests one-for-one. +- One source of truth for held funds: the HOLDING account's balance. +- Trading orchestrator (or any consumer) tracks per-HOLD bookkeeping itself if it cares. + +**What it costs** + +- The system can't tell you "how much of HOLD X is still held" — only "how much is in the HOLDING account in total." Multiple HOLDs against the same parent become indistinguishable post-hoc. +- The CAPTURE/RELEASE types reduce to "labelled transfers from a HOLDING account": almost no semantic gain over the existing ACCOUNT type beyond the label itself. +- `RELEASER_ACCOUNT_ID` attribute on the holding account becomes mandatory and load-bearing for RELEASE to work. + +### Recommendation + +**Design A** — keep `hold_transaction_request_id`. Without it, the new types add little over plain ACCOUNT transfers, and the per-HOLD invariant ("captured + released ≤ original HOLD amount") is exactly the kind of guarantee that belongs in the API, not in every consumer. Cost is two attributes and a sum-aggregation query — well-bounded. + +**But** — if the view is that the trading orchestrator (or any consumer) is the rightful owner of per-HOLD bookkeeping and OBP-API should stay primitive, Design B is internally consistent and a smaller commitment. + +## 7. Other open questions + +1. **API version**: land in v6.0.0 (alongside HOLD) or v7.0.0 (gets idempotency middleware + new patterns automatically)? Mild preference for v7.0.0. +2. **Entitlements**: `canCaptureHoldAtOneBank` / `canReleaseHoldAtOneBank` (plus AnyBank variants), or piggy-back on existing transaction-request entitlements? Probably new ones for clarity. +3. **HOLD expiry**: should an unconsumed HOLD auto-release after a TTL, or stay parked until the orchestrator releases it? (Ties into §6: auto-release only really makes sense in Design A where "remaining" is a first-class concept.) +4. **`GET .../balance` helper**: pure read endpoint exposing the per-HOLD remaining computation. Only meaningful in Design A. +5. **Naming**: `hold_transaction_request_id` vs `original_transaction_request_id` — keep specific or go generic for future use? + +--- + +## 8. How the trading orchestrator uses these + +Per-trade settlement saga (one trade, two HOLDs — buyer's fiat HOLD, seller's token HOLD): + +``` +1. CAPTURE buyer's fiat HOLD, to=seller's fiat account, amount=trade.value +2. CAPTURE seller's token HOLD, to=buyer's token account, amount=trade.qty + On any failure: release the unconsumed remainder, mark trade FAILED. +``` + +Cancel an unfilled offer: + +``` +1. RELEASE the HOLD (no amount → full remaining balance). +``` + +Partial fill: + +``` +1. CAPTURE the matched portion. +2. (Optional) RELEASE the unmatched portion if the offer is being closed. +``` + +Each step is an ordinary transaction-request-create call; idempotent via the `Idempotency-Key` header in the v7 idempotency middleware; auditable via the attached `hold_transaction_request_id` + `hold_purpose` attributes (Design A) or the source-account balance and type-on-the-record (Design B). From e3e537aa710413cf3736cb73adda212f5d4e0a3d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 30 Apr 2026 08:01:31 +0200 Subject: [PATCH 2/6] Auth rate Limiting and related --- obp-api/src/main/protobuf/signal.proto | 82 +++++++++++ .../main/scala/code/api/GatewayLogin.scala | 11 ++ .../main/scala/code/api/OBPRestHelper.scala | 16 ++- .../scala/code/api/cache/RedisMessaging.scala | 2 + obp-api/src/main/scala/code/api/dauth.scala | 12 +- .../src/main/scala/code/api/directlogin.scala | 3 + .../main/scala/code/api/openidconnect.scala | 4 + .../main/scala/code/api/util/APIUtil.scala | 24 +++- .../scala/code/api/util/AuthRateLimiter.scala | 87 ++++++++++++ .../scala/code/api/util/ErrorMessages.scala | 6 + .../code/api/util/RateLimitingUtil.scala | 47 ++++--- .../scala/code/api/util/RemoteIpUtil.scala | 77 ++++++++++ .../scala/code/api/util/http4s/AppsPage.scala | 28 +++- .../code/api/util/http4s/Http4sSupport.scala | 14 +- .../scala/code/api/v6_0_0/APIMethods600.scala | 12 +- .../code/model/dataAccess/AuthUser.scala | 14 +- .../code/api/util/AuthRateLimiterTest.scala | 133 ++++++++++++++++++ 17 files changed, 527 insertions(+), 45 deletions(-) create mode 100644 obp-api/src/main/protobuf/signal.proto create mode 100644 obp-api/src/main/scala/code/api/util/AuthRateLimiter.scala create mode 100644 obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala create mode 100644 obp-api/src/test/scala/code/api/util/AuthRateLimiterTest.scala diff --git a/obp-api/src/main/protobuf/signal.proto b/obp-api/src/main/protobuf/signal.proto new file mode 100644 index 0000000000..81e01427cb --- /dev/null +++ b/obp-api/src/main/protobuf/signal.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; +package code.obp.grpc.signal.g1; + +import "google/protobuf/timestamp.proto"; + +// Mirrors SignalMessageJsonV600. The payload field carries the JSON-encoded +// message body verbatim — proto has no native JValue, and round-tripping +// through google.protobuf.Struct can reorder/lose nesting. +message SignalMessage { + string message_id = 1; + string channel_name = 2; + string sender_consumer_id = 3; + string sender_user_id = 4; + string to_user_id = 5; // empty string = broadcast + google.protobuf.Timestamp timestamp = 6; + string message_type = 7; + string payload_json = 8; // JSON-encoded payload +} + +// Mirrors SignalChannelInfoJsonV600 +message SignalChannelInfo { + string channel_name = 1; + int64 message_count = 2; + int64 ttl_seconds = 3; +} + +// --- Publish: 1:1 with POST /signal/channels/{name}/messages --- + +message PublishRequest { + string channel_name = 1; + string to_user_id = 2; // empty = broadcast + string message_type = 3; + string payload_json = 4; // JSON-encoded payload +} + +message PublishResponse { + string message_id = 1; + string channel_name = 2; + google.protobuf.Timestamp timestamp = 3; + int64 channel_message_count = 4; +} + +// --- Fetch: 1:1 with GET /signal/channels/{name}/messages --- +// Privacy filter applied server-side: caller sees broadcasts plus messages +// to/from themselves. Same logic as REST. + +message FetchRequest { + string channel_name = 1; + int32 offset = 2; + int32 limit = 3; +} + +message FetchResponse { + string channel_name = 1; + repeated SignalMessage messages = 2; + int64 total_count = 3; + bool has_more = 4; +} + +// --- ListChannels: 1:1 with GET /signal/channels --- +// Returns broadcast-visible channels only, matching REST behaviour. + +message ListChannelsRequest {} + +message ListChannelsResponse { + repeated SignalChannelInfo channels = 1; +} + +// --- Subscribe: live-only stream of new messages --- +// No catch-up, no replay. Late joiners ask other agents. +// Privacy filter applied server-side, same as REST Fetch. + +message SubscribeRequest { + string channel_name = 1; +} + +service SignalChannelsService { + rpc Publish(PublishRequest) returns (PublishResponse); + rpc Fetch(FetchRequest) returns (FetchResponse); + rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse); + rpc Subscribe(SubscribeRequest) returns (stream SignalMessage); +} diff --git a/obp-api/src/main/scala/code/api/GatewayLogin.scala b/obp-api/src/main/scala/code/api/GatewayLogin.scala index 4603bce01e..e6f5b86032 100755 --- a/obp-api/src/main/scala/code/api/GatewayLogin.scala +++ b/obp-api/src/main/scala/code/api/GatewayLogin.scala @@ -238,6 +238,12 @@ object GatewayLogin extends RestHelper with MdcLoggable { def getOrCreateResourceUser(jwtPayload: String, callContext: Option[CallContext]) : Box[(User, Option[String], Option[CallContext])] = { val username = getFieldFromPayloadJson(jwtPayload, "login_user_name") logger.debug("login_user_name: " + username) + // Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props. + // In shadow mode trips are logged and Right is returned; only enforce mode produces Left. + AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), gateway, username) match { + case Left(_) => return Failure(ErrorMessages.TooManyRequests) + case Right(_) => // continue + } val cbsAndCallContextBox = refreshBankAccounts(jwtPayload, callContext) for { tuple <- cbsAndCallContextBox match { @@ -303,6 +309,11 @@ object GatewayLogin extends RestHelper with MdcLoggable { val jti = getFieldFromPayloadJson(jwtPayload, "jti") val consentId = if (jti.isEmpty) None else Some(jti) logger.debug("login_user_name: " + username) + // Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props. + AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), gateway, username) match { + case Left(_) => return Future.successful(Failure(ErrorMessages.TooManyRequests)) + case Right(_) => // continue + } val cbsAndCallContextF = refreshBankAccountsFuture(jwtPayload, callContext) for { cbs <- cbsAndCallContextF diff --git a/obp-api/src/main/scala/code/api/OBPRestHelper.scala b/obp-api/src/main/scala/code/api/OBPRestHelper.scala index a087fbb3a7..28684a1692 100644 --- a/obp-api/src/main/scala/code/api/OBPRestHelper.scala +++ b/obp-api/src/main/scala/code/api/OBPRestHelper.scala @@ -423,7 +423,10 @@ trait OBPRestHelper extends RestHelper with MdcLoggable { } } } - else if (APIUtil.getPropsAsBoolValue("allow_gateway_login", false) && hasGatewayHeader(authorization)) { + else if (hasGatewayHeader(authorization)) { + if (!APIUtil.getPropsAsBoolValue("allow_gateway_login", false)) { + Full(errorJsonResponse(ErrorMessages.GatewayLoginIsDisabled, 401)) + } else { logger.info("allow_gateway_login-getRemoteIpAddress: " + remoteIpAddress ) APIUtil.getPropsValue("gateway.host") match { case Full(h) if h.split(",").toList.exists(_.equalsIgnoreCase(remoteIpAddress) == true) => // Only addresses from white list can use this feature @@ -462,8 +465,12 @@ trait OBPRestHelper extends RestHelper with MdcLoggable { case _ => Failure(ErrorMessages.GatewayLoginUnknownError) } - } - else if (APIUtil.getPropsAsBoolValue("allow_dauth", false) && hasDAuthHeader(cc.requestHeaders)) { + } + } + else if (hasDAuthHeader(cc.requestHeaders)) { + if (!APIUtil.getPropsAsBoolValue("allow_dauth", false)) { + Full(errorJsonResponse(ErrorMessages.DAuthIsDisabled, 401)) + } else { logger.info("allow_dauth-getRemoteIpAddress: " + remoteIpAddress ) APIUtil.getPropsValue("dauth.host") match { case Full(h) if h.split(",").toList.exists(_.equalsIgnoreCase(remoteIpAddress) == true) => // Only addresses from white list can use this feature @@ -499,7 +506,8 @@ trait OBPRestHelper extends RestHelper with MdcLoggable { case _ => Failure(ErrorMessages.DAuthUnknownError) } - } + } + } else { fn(cc) } diff --git a/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala b/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala index 472f7d8202..aba3545b03 100644 --- a/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala +++ b/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala @@ -41,6 +41,8 @@ object RedisMessaging extends MdcLoggable { jedis.ltrim(key, -channelMaxMessages.toLong, -1) // Refresh TTL on every publish jedis.expire(key, channelTtlSeconds) + // Pub/sub notification for live gRPC subscribers — fire-and-forget, no persistence + jedis.publish(s"obp_signal:$channelName", messageJson) length } catch { case e: Throwable => diff --git a/obp-api/src/main/scala/code/api/dauth.scala b/obp-api/src/main/scala/code/api/dauth.scala index 4b30dde93b..481f5feab2 100755 --- a/obp-api/src/main/scala/code/api/dauth.scala +++ b/obp-api/src/main/scala/code/api/dauth.scala @@ -137,6 +137,11 @@ object DAuth extends RestHelper with MdcLoggable { val userName = getFieldFromPayloadJson(jwtPayload, "smart_contract_address") val provider = "dauth."+getFieldFromPayloadJson(jwtPayload, "network_name") logger.debug("login_user_name: " + userName) + // Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props. + AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), provider, userName) match { + case Left(_) => return Failure(ErrorMessages.TooManyRequests) + case Right(_) => // continue + } for { tuple <- UserX.getOrCreateDauthResourceUser(provider, userName) match { @@ -157,7 +162,12 @@ object DAuth extends RestHelper with MdcLoggable { val username = getFieldFromPayloadJson(jwtPayload, "smart_contract_address") val provider = "dauth."+ getFieldFromPayloadJson(jwtPayload, "network_name") logger.debug("login_user_name: " + username) - + // Pre-credential rate limit. Disabled by default; controlled via auth.rate_limit.* props. + AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), provider, username) match { + case Left(_) => return Future.successful(Failure(ErrorMessages.TooManyRequests)) + case Right(_) => // continue + } + for { tuple <- Future { UserX.getOrCreateDauthResourceUser(provider, username)} map { case (Full(u)) => diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index 7b95de99d3..a165e68a7c 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -168,6 +168,9 @@ object DirectLogin extends RestHelper with MdcLoggable { } else if (userId == AuthUser.userEmailNotValidatedStateCode) { message = ErrorMessages.UserEmailNotValidated httpCode = 401 + } else if (userId == AuthUser.rateLimitExceededStateCode) { + message = ErrorMessages.TooManyRequests + httpCode = 429 } else { val jwtPayloadAsJson = """{ diff --git a/obp-api/src/main/scala/code/api/openidconnect.scala b/obp-api/src/main/scala/code/api/openidconnect.scala index 00cdfb0b18..df9e3bcfac 100644 --- a/obp-api/src/main/scala/code/api/openidconnect.scala +++ b/obp-api/src/main/scala/code/api/openidconnect.scala @@ -100,6 +100,10 @@ object OpenIdConnect extends OBPRestHelper with MdcLoggable { } private def callbackUrlCommonCode(identityProvider: Int): JsonResponse = { + if (!APIUtil.getPropsAsBoolValue("allow_openid_connect", true)) { + return errorJsonResponse(ErrorMessages.OpenIDConnectIsDisabled, 401) + } + val (code, state, sessionState) = extractParams(S) logger.debug("(code, state, sessionState) = " + (code, state, sessionState)) logger.debug("S.receivedCookies = " + S.receivedCookies) 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 dbe641bddf..ae472bbc74 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -762,6 +762,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def check408(message: String): Boolean = { message.contains(extractErrorMessageCode(requestTimeout)) } + def check429(message: String): Boolean = { + message.contains(extractErrorMessageCode(TooManyRequests)) + } val (code, responseHeaders) = message match { case msg if check401(msg) => @@ -773,6 +776,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ (403, getHeaders() ::: headers.list) case msg if check408(msg) => (408, getHeaders() ::: headers.list ::: List((ResponseHeader.Connection, "close"))) + case msg if check429(msg) => + (429, getHeaders() ::: headers.list) case _ => (httpCode, getHeaders() ::: headers.list) } @@ -2655,9 +2660,24 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ */ def getCorrelationId(): String = S.containerSession.map(_.sessionId).openOr("") /** - * @return - the remote address of the client or the last seen proxy. + * @return - the trusted client IP address. + * + * By default returns the immediate TCP peer (proxy IP if behind a proxy, real client if direct). + * When `trust.proxy.enabled = true`, consults `trust.proxy.header` (default "X-Real-IP"). + * See [[RemoteIpUtil]] for configuration details and proxy-trust caveats. */ - def getRemoteIpAddress(): String = S.containerRequest.map(_.remoteAddress).openOr("Unknown") + def getRemoteIpAddress(): String = { + val socketPeer = S.containerRequest.map(_.remoteAddress).openOr("Unknown") + RemoteIpUtil.resolveClientIp(socketPeer, getLiftRequestHeader) + } + + private def getLiftRequestHeader(name: String): Option[String] = { + S.request.toOption.flatMap { req => + req.request.headers + .find(_.name.equalsIgnoreCase(name)) + .flatMap(_.values.headOption) + } + } /** * @return - the fully qualified name of the client host or last seen proxy */ diff --git a/obp-api/src/main/scala/code/api/util/AuthRateLimiter.scala b/obp-api/src/main/scala/code/api/util/AuthRateLimiter.scala new file mode 100644 index 0000000000..ba31327cda --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/AuthRateLimiter.scala @@ -0,0 +1,87 @@ +package code.api.util + +import java.security.MessageDigest + +import code.api.Constant.CALL_COUNTER_PREFIX +import code.util.Helper.MdcLoggable + +/** Pre-credential-check rate limiter for authentication endpoints. + * + * Distinct from [[RateLimitingUtil]] (which fires post-auth, keyed by consumer_id). + * This limiter fires BEFORE the password check, keyed by source IP and (provider, username), + * to defend against brute-force, credential-stuffing, and lockout-DoS attacks. + * + * Three counters checked per call: + * - per-IP per minute — burst defence + * - per-IP per hour — sustained-spray defence + * - per-(provider, username) per minute — single-account targeting and DoS + * + * Modes: + * - `enabled = false` (default): skipped entirely, no Redis calls + * - `enabled = true && mode = "shadow"` (default when enabled): trips are logged, request allowed + * - `enabled = true && mode = "enforce"`: trips return Left(Exceeded), caller renders 429 + * + * Fail-open: if Redis is unavailable, [[RateLimitingUtil.incrementCounter]] returns (-1, -1) + * for that counter and we skip its check. Auth is never blocked by Redis outage. + */ +object AuthRateLimiter extends MdcLoggable { + import RateLimitingPeriod._ + + /** A counter that exceeded its limit. Carries the data the caller needs to render a 429. */ + case class Exceeded(counter: String, current: Long, limit: Long, retryAfterSeconds: Long) + + private def enabled: Boolean = APIUtil.getPropsAsBoolValue("auth.rate_limit.enabled", false) + private def mode: String = APIUtil.getPropsValue("auth.rate_limit.mode", "shadow") + private def perIpPerMinute: Long = APIUtil.getPropsAsLongValue("auth.rate_limit.per_ip.per_minute", 10L) + private def perIpPerHour: Long = APIUtil.getPropsAsLongValue("auth.rate_limit.per_ip.per_hour", 100L) + private def perUserPerMinute: Long = APIUtil.getPropsAsLongValue("auth.rate_limit.per_user.per_minute", 6L) + + def check(ip: String, provider: String, username: String): Either[Exceeded, Unit] = { + if (!enabled) return Right(()) + + val safeIp = if (ip == null || ip.isEmpty) "unknown" else ip + val safeProvider = if (provider == null || provider.isEmpty) "local" else provider + val safeUser = if (username == null) "" else username + val userHash = sha256Hex(safeUser).take(12) + + val counters: List[(String, String, LimitCallPeriod, Long)] = List( + ("ip_per_minute", buildKey(s"ip_$safeIp", PER_MINUTE), PER_MINUTE, perIpPerMinute), + ("user_per_minute", buildKey(s"user_${safeProvider}_$userHash", PER_MINUTE), PER_MINUTE, perUserPerMinute), + ("ip_per_hour", buildKey(s"ip_$safeIp", PER_HOUR), PER_HOUR, perIpPerHour) + ) + + val trips = counters.flatMap { case (name, key, period, limit) => + val (ttl, current) = RateLimitingUtil.incrementCounter(key, period) + // current == -1 signals Redis-unavailable; fail open by skipping this counter. + // limit <= 0 signals "disabled"; skip. + if (current > 0 && limit > 0 && current > limit) { + Some(Exceeded(name, current, limit, ttl)) + } else { + None + } + } + + trips.headOption match { + case None => + Right(()) + case Some(exceeded) if mode == "enforce" => + logger.warn(authRateLimitLog("trip", exceeded, safeIp, safeProvider, userHash)) + Left(exceeded) + case Some(exceeded) => + // shadow mode: log what would have happened, allow the request through + logger.warn(authRateLimitLog("shadow_trip", exceeded, safeIp, safeProvider, userHash)) + Right(()) + } + } + + private def buildKey(scope: String, period: LimitCallPeriod): String = + s"${CALL_COUNTER_PREFIX}auth_${scope}_${RateLimitingPeriod.toString(period)}" + + private def authRateLimitLog(event: String, e: Exceeded, ip: String, provider: String, userHash: String): String = + s"event=auth_rate_limit_$event counter=${e.counter} ip=$ip provider=$provider username_hash=$userHash current=${e.current} limit=${e.limit} retry_after_s=${e.retryAfterSeconds}" + + private def sha256Hex(s: String): String = { + val md = MessageDigest.getInstance("SHA-256") + md.digest(s.getBytes("UTF-8")).map(b => f"$b%02x").mkString + } +} 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 21dfee59bb..170409b91d 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -140,6 +140,9 @@ object ErrorMessages { val InvalidFunctionsParameter = "OBP-10054: Invalid functions parameter. Functions cannot be empty when provided" val InvalidApiCollectionIdParameter = "OBP-10055: Invalid api-collection-id parameter. API collection ID cannot be empty when provided" val IncompleteServerConfiguration = "OBP-10056: A required server configuration property is missing. " + val InvalidSignalChannelName = "OBP-10057: Invalid Signal Channel name. " + + "Signal Channel names must use only alphanumeric characters, dots, hyphens, and underscores, " + + "and be between 1 and 128 characters long." @@ -198,6 +201,7 @@ object ErrorMessages { val GatewayLoginCannotGetCbsToken = "OBP-20044: Cannot get the CBSToken response from South side" val GatewayLoginCannotGetOrCreateUser = "OBP-20045: Cannot get or create user during GatewayLogin process." val GatewayLoginNoJwtForResponse = "OBP-20046: There is no useful value for JWT." + val GatewayLoginIsDisabled = "OBP-20406: Gateway Login is disabled. Set allow_gateway_login=true to enable." val UserLacksPermissionCanGrantAccessToViewForTargetAccount = s"OBP-20047: If target viewId is system view, the current view.can_grant_access_to_views does not contains it. Or" + @@ -234,6 +238,8 @@ object ErrorMessages { val DAuthNoJwtForResponse = "OBP-20070: There is no useful value for JWT." val DAuthJwtTokenIsNotValid = "OBP-20071: The DAuth JWT is corrupted/changed during a transport." val InvalidDAuthHeaderToken = "OBP-20072: DAuth Header value should be one single string." + val DAuthIsDisabled = "OBP-20407: DAuth is disabled. Set allow_dauth=true to enable." + val OpenIDConnectIsDisabled = "OBP-20408: OpenID Connect is disabled. Set allow_openid_connect=true to enable." val InvalidProviderUrl = "OBP-20079: Cannot match the local identity provider." diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala index 7eb3cf75ce..5d9b886664 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -240,30 +240,33 @@ object RateLimitingUtil extends MdcLoggable { * @param limit The rate limit value (-1 means disabled, but counter still incremented) * @return (TTL in seconds, current counter value) or (-1, -1) on Redis error */ + /** Pure Redis INCR with create-if-missing for a fully-formed key. + * No gates, no key formatting — call sites pass the final key and supply their own enable flags. + * Returns (ttl_seconds, current_count); (-1, -1) when Redis is unreachable. */ + private[util] def incrementCounter(key: String, period: LimitCallPeriod): (Long, Long) = { + val ttlOpt = Redis.use(JedisMethod.TTL, key).map(_.toInt) + ttlOpt match { + case Some(-2) => // Key does not exist, create it + val seconds = RateLimitingPeriod.toSeconds(period).toInt + Redis.use(JedisMethod.SET, key, Some(seconds), Some("1")) + (seconds, 1) + case Some(ttl) if ttl > 0 => // Key exists with TTL, increment it + val cnt = Redis.use(JedisMethod.INCR, key).map(_.toInt).getOrElse(1) + (ttl, cnt) + case Some(ttl) if ttl <= 0 => // Key expired or has no expiry (shouldn't happen) + logger.warn(s"Unexpected TTL state ($ttl) for key $key, period $period - recreating counter") + val seconds = RateLimitingPeriod.toSeconds(period).toInt + Redis.use(JedisMethod.SET, key, Some(seconds), Some("1")) + (seconds, 1) + case None => // Redis unavailable + logger.error(s"Redis unavailable when incrementing counter for key $key, period $period") + (-1, -1) + } + } + private def incrementConsumerCounters(consumerKey: String, period: LimitCallPeriod, limit: Long): (Long, Long) = { if (useConsumerLimits) { - val key = createUniqueKey(consumerKey, period) - // Always increment counters regardless of limit value. - // This provides visibility into consumer activity even when rate limiting is disabled (limit = -1). - // Useful for monitoring which apps are active and verifying that call counting infrastructure works. - val ttlOpt = Redis.use(JedisMethod.TTL, key).map(_.toInt) - ttlOpt match { - case Some(-2) => // Key does not exist, create it - val seconds = RateLimitingPeriod.toSeconds(period).toInt - Redis.use(JedisMethod.SET, key, Some(seconds), Some("1")) - (seconds, 1) - case Some(ttl) if ttl > 0 => // Key exists with TTL, increment it - val cnt = Redis.use(JedisMethod.INCR, key).map(_.toInt).getOrElse(1) - (ttl, cnt) - case Some(ttl) if ttl <= 0 => // Key expired or has no expiry (shouldn't happen) - logger.warn(s"Unexpected TTL state ($ttl) for consumer $consumerKey, period $period - recreating counter") - val seconds = RateLimitingPeriod.toSeconds(period).toInt - Redis.use(JedisMethod.SET, key, Some(seconds), Some("1")) - (seconds, 1) - case None => // Redis unavailable - logger.error(s"Redis unavailable when incrementing counter for consumer $consumerKey, period $period") - (-1, -1) - } + incrementCounter(createUniqueKey(consumerKey, period), period) } else { (-1, -1) } diff --git a/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala b/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala new file mode 100644 index 0000000000..4f44a9d5b6 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/RemoteIpUtil.scala @@ -0,0 +1,77 @@ +/** +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.util + +import code.util.Helper.MdcLoggable + +/** Single source of truth for resolving the trusted client IP, used by both Lift and http4s. + * + * Defaults to the immediate socket peer — i.e. no proxy trust, behaving safely when OBP is + * reachable directly. To pick up the real client IP from a reverse proxy: + * + * trust.proxy.enabled = true + * trust.proxy.header = X-Real-IP # default; or "X-Forwarded-For" + * + * The proxy MUST overwrite the configured header so clients cannot spoof it. Example NGINX: + * + * proxy_set_header X-Real-IP $remote_addr; + * + * For `X-Forwarded-For`, the leftmost value is treated as the client. This is only + * trustworthy when the proxy is configured with `set_real_ip_from` + `real_ip_recursive` + * so it sanitises the forwarded chain before forwarding upstream. `X-Real-IP` is the + * simpler choice for single-proxy deployments. + */ +object RemoteIpUtil extends MdcLoggable { + + /** Resolve the trusted client IP. + * @param socketPeer the immediate TCP peer's address (proxy IP, or real client if direct) + * @param getHeader function to read a request header by name (case-insensitive); returns + * the raw header value if present + * @return the trusted client IP — either the parsed header value or `socketPeer` as fallback + */ + def resolveClientIp(socketPeer: String, getHeader: String => Option[String]): String = { + if (!APIUtil.getPropsAsBoolValue("trust.proxy.enabled", false)) { + socketPeer + } else { + val headerName = APIUtil.getPropsValue("trust.proxy.header", "X-Real-IP") + getHeader(headerName) + .flatMap(raw => extractClientIp(headerName, raw)) + .getOrElse(socketPeer) + } + } + + /** Single-value headers (X-Real-IP) yield the value as-is. + * X-Forwarded-For is comma-separated; the leftmost entry is the original client. */ + private def extractClientIp(headerName: String, raw: String): Option[String] = { + val candidate = + if (headerName.equalsIgnoreCase("X-Forwarded-For")) + raw.split(",").headOption.getOrElse("") + else + raw + val trimmed = candidate.trim + if (trimmed.isEmpty) None else Some(trimmed) + } +} 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 892df63784..fc076c4575 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 @@ -11,6 +11,21 @@ object AppsPage { private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + // List of all known public_*_url props with each value as Some(url) only when the + // prop is explicitly configured. None means the prop has only a registered default + // (e.g. localhost) and was not set by the operator — rendered as "Public URL is not set". + private def appDiscoveryEntries: List[(String, Option[String])] = { + APIUtil.getRegisteredDefaults.toList + .filter { case (key, _) => key.startsWith("public_") && key.endsWith("_url") } + .sortBy(_._1) + .map { case (key, _) => + val explicit: Option[String] = APIUtil.getPropsValue(key).toOption + .filter(_.nonEmpty) + .map(v => APIUtil.maskSensitivePropValue(key, v)) + (key, explicit) + } + } + private val acronyms = Set("obp", "api", "mcp") // Render order for probe endpoints (also controls which endpoints are known). @@ -88,11 +103,14 @@ object AppsPage { } private def htmlResponse: IO[Response[IO]] = { - val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) => - val probeLinks = probesFor(name) - .map(ep => s""" [$ep]""") - .mkString - s"""
  • ${humanName(name)} ($name)$probeLinks
  • """ + val appDiscoveryLinks = appDiscoveryEntries.map { + case (name, Some(url)) => + val probeLinks = probesFor(name) + .map(ep => s""" [$ep]""") + .mkString + s"""
  • ${humanName(name)} ($name)$probeLinks
  • """ + case (name, None) => + s"""
  • ${humanName(name)} ($name) Public URL is not set
  • """ }.mkString("\n") val html = diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index d76b576362..d951d2a7d5 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -3,7 +3,7 @@ package code.api.util.http4s import cats.effect._ import code.api.util.APIUtil.{ResourceDoc, getPropsAsBoolValue} import code.api.util.ErrorMessages.InvalidJsonFormat -import code.api.util.{AuthHeaderParser, CallContext, WriteMetricUtil} +import code.api.util.{AuthHeaderParser, CallContext, RemoteIpUtil, WriteMetricUtil} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model.{Bank, BankAccount, CounterpartyTrait, User, View} import net.liftweb.common.{Box, Empty, Full} @@ -490,13 +490,15 @@ object Http4sCallContextBuilder { } /** - * Extract IP address from X-Forwarded-For header or request remote address + * Extract the trusted client IP. Defaults to the immediate socket peer; consults a + * forwarded-for header only when `trust.proxy.enabled = true`. See [[RemoteIpUtil]]. */ private def extractIpAddress(request: Request[IO]): String = { - request.headers.get(CIString("X-Forwarded-For")) - .map(_.head.value.split(",").head.trim) - .orElse(request.remoteAddr.map(_.toUriString)) - .getOrElse("") + val socketPeer = request.remoteAddr.map(_.toUriString).getOrElse("") + RemoteIpUtil.resolveClientIp( + socketPeer, + name => request.headers.get(CIString(name)).map(_.head.value) + ) } /** diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 557bd7b0fe..a1ff93dd08 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -11040,6 +11040,7 @@ trait APIMethods600 { List( $AuthenticatedUserIsRequired, InvalidJsonFormat, + InvalidSignalChannelName, UnknownError ), List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) @@ -11053,7 +11054,7 @@ trait APIMethods600 { postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostSignalMessageJsonV600", 400, callContext) { json.extract[PostSignalMessageJsonV600] } - _ <- Helper.booleanToFuture(failMsg = "Invalid channel name. Use alphanumeric characters, dots, hyphens, underscores. Max 128 chars.", cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = InvalidSignalChannelName, cc = callContext) { RedisMessaging.validateChannelName(channelName) } channelMessageCount <- Future { @@ -11118,6 +11119,7 @@ trait APIMethods600 { signalMessagesJsonV600, List( $AuthenticatedUserIsRequired, + InvalidSignalChannelName, UnknownError ), List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) @@ -11128,7 +11130,7 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = InvalidSignalChannelName, cc = callContext) { RedisMessaging.validateChannelName(channelName) } httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) @@ -11244,6 +11246,7 @@ trait APIMethods600 { signalChannelInfoJsonV600, List( $AuthenticatedUserIsRequired, + InvalidSignalChannelName, UnknownError ), List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) @@ -11254,7 +11257,7 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = InvalidSignalChannelName, cc = callContext) { RedisMessaging.validateChannelName(channelName) } info <- Future { @@ -11294,6 +11297,7 @@ trait APIMethods600 { signalChannelDeletedJsonV600, List( $AuthenticatedUserIsRequired, + InvalidSignalChannelName, UnknownError ), List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) @@ -11364,7 +11368,7 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = InvalidSignalChannelName, cc = callContext) { RedisMessaging.validateChannelName(channelName) } deleted <- Future { diff --git a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index e627f21009..ce77396bca 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -433,6 +433,8 @@ import net.liftweb.util.Helpers._ val usernameLockedStateCode = Long.MaxValue /**Marking the email not validated state to show different error message */ val userEmailNotValidatedStateCode = Long.MaxValue - 1 + /**Marking the auth-rate-limit-exceeded state to render a 429 instead of a 401 */ + val rateLimitExceededStateCode = Long.MaxValue - 2 val connector = code.api.Constant.CONNECTOR.openOrThrowException(s"$MandatoryPropertyIsNotSet. The missing prop is `connector` ") val starConnectorSupportedTypes = APIUtil.getPropsValue("starConnector_supported_types","") @@ -851,7 +853,17 @@ import net.liftweb.util.Helpers._ } logger.info(s"getResourceUserId says: starting for username: $username, provider: $normalizedProvider") - + + // ======================================================================== + // PRE-CREDENTIAL RATE LIMIT + // Disabled by default; controlled via auth.rate_limit.* props. + // Returns sentinel for 429 translation; never blocks auth on Redis outage. + // ======================================================================== + AuthRateLimiter.check(getRemoteIpAddress(), normalizedProvider, username) match { + case Left(_) => return Full(rateLimitExceededStateCode) + case Right(_) => // continue + } + // ======================================================================== // ROUTE DECISION: Local or External Provider? // ======================================================================== diff --git a/obp-api/src/test/scala/code/api/util/AuthRateLimiterTest.scala b/obp-api/src/test/scala/code/api/util/AuthRateLimiterTest.scala new file mode 100644 index 0000000000..2705c43783 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/AuthRateLimiterTest.scala @@ -0,0 +1,133 @@ +/** +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.util + +import code.setup.ServerSetup + +import java.util.concurrent.atomic.AtomicLong + +class AuthRateLimiterTest extends ServerSetup { + + // Unique values per scenario so Redis counters from different scenarios don't collide. + private val ipCounter = new AtomicLong(System.nanoTime()) + private def freshIp(): String = s"203.0.113.${ipCounter.incrementAndGet() % 255 + 1}.${ipCounter.get()}" + private def freshUsername(): String = s"authratelimit_user_${ipCounter.incrementAndGet()}" + private val provider = "local" + + feature("AuthRateLimiter") { + + scenario("disabled by default — always returns Right, no Redis calls") { + // No setPropsValues — relies on the code default `auth.rate_limit.enabled = false` + AuthRateLimiter.check(freshIp(), provider, freshUsername()) shouldBe Right(()) + } + + scenario("enabled, under limits — returns Right") { + setPropsValues( + "auth.rate_limit.enabled" -> "true", + "auth.rate_limit.mode" -> "enforce", + "auth.rate_limit.per_ip.per_minute" -> "10", + "auth.rate_limit.per_ip.per_hour" -> "100", + "auth.rate_limit.per_user.per_minute" -> "6" + ) + AuthRateLimiter.check(freshIp(), provider, freshUsername()) shouldBe Right(()) + } + + scenario("enforce mode: per-IP/min limit trips on the (limit+1)th attempt") { + setPropsValues( + "auth.rate_limit.enabled" -> "true", + "auth.rate_limit.mode" -> "enforce", + "auth.rate_limit.per_ip.per_minute" -> "2", + "auth.rate_limit.per_ip.per_hour" -> "1000", + "auth.rate_limit.per_user.per_minute" -> "1000" + ) + val ip = freshIp() + AuthRateLimiter.check(ip, provider, freshUsername()) shouldBe Right(()) + AuthRateLimiter.check(ip, provider, freshUsername()) shouldBe Right(()) + + val tripped = AuthRateLimiter.check(ip, provider, freshUsername()) + tripped.isLeft shouldBe true + val Left(exceeded) = tripped + exceeded.counter shouldBe "ip_per_minute" + exceeded.limit shouldBe 2L + exceeded.current should be > 2L + exceeded.retryAfterSeconds should (be > 0L and be <= 60L) + } + + scenario("shadow mode: trip is logged but check still returns Right") { + setPropsValues( + "auth.rate_limit.enabled" -> "true", + "auth.rate_limit.mode" -> "shadow", + "auth.rate_limit.per_ip.per_minute" -> "1", + "auth.rate_limit.per_ip.per_hour" -> "1000", + "auth.rate_limit.per_user.per_minute" -> "1000" + ) + val ip = freshIp() + AuthRateLimiter.check(ip, provider, freshUsername()) shouldBe Right(()) + // Would have tripped in enforce mode — shadow lets it through anyway + AuthRateLimiter.check(ip, provider, freshUsername()) shouldBe Right(()) + AuthRateLimiter.check(ip, provider, freshUsername()) shouldBe Right(()) + } + + scenario("per-username/min trips independent of IP") { + setPropsValues( + "auth.rate_limit.enabled" -> "true", + "auth.rate_limit.mode" -> "enforce", + "auth.rate_limit.per_ip.per_minute" -> "1000", + "auth.rate_limit.per_ip.per_hour" -> "1000", + "auth.rate_limit.per_user.per_minute" -> "2" + ) + val username = freshUsername() + AuthRateLimiter.check(freshIp(), provider, username) shouldBe Right(()) + AuthRateLimiter.check(freshIp(), provider, username) shouldBe Right(()) + + val tripped = AuthRateLimiter.check(freshIp(), provider, username) + tripped.isLeft shouldBe true + val Left(exceeded) = tripped + exceeded.counter shouldBe "user_per_minute" + exceeded.limit shouldBe 2L + } + + scenario("same username across providers do not share a counter") { + setPropsValues( + "auth.rate_limit.enabled" -> "true", + "auth.rate_limit.mode" -> "enforce", + "auth.rate_limit.per_ip.per_minute" -> "1000", + "auth.rate_limit.per_ip.per_hour" -> "1000", + "auth.rate_limit.per_user.per_minute" -> "1" + ) + val username = freshUsername() + // First attempt under provider A — counter at 1, allowed. + AuthRateLimiter.check(freshIp(), "providerA", username) shouldBe Right(()) + // Same username under provider B — independent counter, also allowed. + AuthRateLimiter.check(freshIp(), "providerB", username) shouldBe Right(()) + // Second attempt under provider A trips its own counter. + AuthRateLimiter.check(freshIp(), "providerA", username).isLeft shouldBe true + // Provider B's counter is still at 1 — second attempt is its limit-trigger. + AuthRateLimiter.check(freshIp(), "providerB", username).isLeft shouldBe true + } + + } +} From f10ad93d470b0270363c98e87228927de0be6aca Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 1 May 2026 08:39:30 +0200 Subject: [PATCH 3/6] Return 409 instead of 400 in case of a duplicate resource --- .../scala/code/api/v5_0_0/APIMethods500.scala | 72 +++++++++++++++---- .../scala/code/api/v6_0_0/APIMethods600.scala | 20 +++--- .../scala/code/api/v7_0_0/Http4s700.scala | 2 +- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 25 ++++++- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala b/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala index 1188412c50..cd4389cf65 100644 --- a/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala +++ b/obp-api/src/main/scala/code/api/v5_0_0/APIMethods500.scala @@ -635,18 +635,28 @@ trait APIMethods500 { "/consumer/consent-requests", "Create Consent Request", s""" - |Client Authentication (mandatory) + |Create a Consent Request — the first step of the OBP Consent flow. | - |It is used when applications request an access token to access their own resources, not on behalf of a user. + |The calling application (TPP) authenticates with Client Credentials and posts the consent details (entitlements, account_access, VRP fields, etc.). The User then completes the flow by calling Create Consent By CONSENT_REQUEST_ID. | - |The client needs to authenticate themselves for this request. - |In case of public client we use client_id and private key to obtain access token, otherwise we use client_id and client_secret. - |The obtained access token is used in the HTTP Bearer auth header of our request. + |The Consent Request is recorded against the calling consumer (its creator). + | + |Optional body fields of note: + | + |- `consumer_id`: if set, the resulting Consent (created when the User answers this request) will be pinned to this consumer instead of the creator. Use only when the consent is intended for a different application than the one creating the request. Most TPPs should omit this field — when omitted, the resulting Consent is pinned to the creator. Note: this override is being deprecated; v6.0.0 will pin the resulting Consent to the creator unconditionally. + |- `email` / `phone_number`: surface in the SCA challenge if the User chooses EMAIL or SMS at answer time. + |- `valid_from` / `time_to_live`: control the lifetime of the resulting Consent. + | + |Authentication: + | + |The client needs to authenticate themselves for this request. In case of public client we use client_id and private key to obtain access token; otherwise we use client_id and client_secret. The obtained access token is used in the HTTP Bearer auth header of our request. | |Example: |Authorization: Bearer eXtneO-THbQtn3zvK_kQtXXfvOZyZFdBCItlPDbR2Bk.dOWqtXCtFX-tqGTVR0YrIjvAolPIVg7GZ-jz83y6nA0 | - |After successfully creating the VRP consent request, you need to call the `Create Consent By CONSENT_REQUEST_ID` endpoint to finalize the consent. + |After successfully creating the Consent Request, call Create Consent By CONSENT_REQUEST_ID to finalize. + | + |See Glossary entry "Authentication: Consent OBP Flow Example" for an end-to-end walk-through. | |${applicationAccessMessage(true)} | @@ -703,7 +713,16 @@ trait APIMethods500 { "GET", "/consumer/consent-requests/CONSENT_REQUEST_ID", "Get Consent Request", - s"""""", + s""" + |Return the full payload of a previously-created Consent Request — the JSON the TPP submitted to Create Consent Request, plus the consent_request_id and the creating consumer_id. + | + |Use this endpoint to verify the contents of a Consent Request before the User answers it. + | + |Authentication: Application access (any registered consumer/application can read any Consent Request by ID). + | + |Note: this endpoint will be restricted to the creating consumer in v6.0.0. Until then, treat CONSENT_REQUEST_IDs as sensitive — they reveal entitlements, account routings, and contact details (email/phone) submitted at creation. + | + |""".stripMargin, EmptyBody, consentRequestResponseJson, List( @@ -817,12 +836,19 @@ trait APIMethods500 { "/consumer/consent-requests/CONSENT_REQUEST_ID/EMAIL/consents", "Create Consent By CONSENT_REQUEST_ID (EMAIL)", s""" + |Answer a Consent Request and create the resulting Consent, with an EMAIL Strong Customer Authentication challenge. | - |This endpoint continues the process of creating a Consent. + |After the TPP has called Create Consent Request (Client Credentials), the User authenticates and answers the request via this endpoint. This creates the Consent (the credential the consumer will use to access OBP on the User's behalf). | - |It starts the SCA flow which changes the status of the consent from INITIATED to ACCEPTED or REJECTED. + |An SCA challenge code is sent to the email address that was supplied in the Create Consent Request body. The User then completes SCA via Answer Consent Challenge, which moves the Consent from INITIATED to ACCEPTED. | - |Please note that the Consent cannot elevate the privileges of the logged in user. + |Pinning: the resulting Consent is pinned to a single consumer at creation. The pinned consumer is taken from the `consumer_id` field of the original Create Consent Request body if present, otherwise from the consumer that created the Request. After creation, only that consumer can present the resulting Consent JWT — any other consumer presenting it gets ConsentNotFound (consumer mismatch). + | + |Each Consent Request can be answered exactly once. A second call returns ConsentRequestIsInvalid. + | + |The Consent's authority is bounded by the answering User's own entitlements — it cannot grant access beyond what that User already has. + | + |Authentication: Any authenticated User may answer a Consent Request whose CONSENT_REQUEST_ID they know. This will be tightened in v6.0.0; until then, treat CONSENT_REQUEST_IDs as sensitive. | |""", EmptyBody, @@ -849,11 +875,19 @@ trait APIMethods500 { "/consumer/consent-requests/CONSENT_REQUEST_ID/SMS/consents", "Create Consent By CONSENT_REQUEST_ID (SMS)", s""" + |Answer a Consent Request and create the resulting Consent, with an SMS Strong Customer Authentication challenge. + | + |After the TPP has called Create Consent Request (Client Credentials), the User authenticates and answers the request via this endpoint. This creates the Consent (the credential the consumer will use to access OBP on the User's behalf). + | + |An SCA challenge code is sent to the phone number that was supplied in the Create Consent Request body. The User then completes SCA via Answer Consent Challenge, which moves the Consent from INITIATED to ACCEPTED. + | + |Pinning: the resulting Consent is pinned to a single consumer at creation. The pinned consumer is taken from the `consumer_id` field of the original Create Consent Request body if present, otherwise from the consumer that created the Request. After creation, only that consumer can present the resulting Consent JWT — any other consumer presenting it gets ConsentNotFound (consumer mismatch). | - |This endpoint continues the process of creating a Consent. It starts the SCA flow which changes the status of the consent from INITIATED to ACCEPTED or REJECTED. + |Each Consent Request can be answered exactly once. A second call returns ConsentRequestIsInvalid. | - |Please note that the Consent you are creating cannot exceed the entitlements that the User creating this consents already has. + |The Consent's authority is bounded by the answering User's own entitlements — it cannot grant access beyond what that User already has. | + |Authentication: Any authenticated User may answer a Consent Request whose CONSENT_REQUEST_ID they know. This will be tightened in v6.0.0; until then, treat CONSENT_REQUEST_IDs as sensitive. | |""", EmptyBody, @@ -883,9 +917,19 @@ trait APIMethods500 { "/consumer/consent-requests/CONSENT_REQUEST_ID/IMPLICIT/consents", "Create Consent By CONSENT_REQUEST_ID (IMPLICIT)", s""" + |Answer a Consent Request and create the resulting Consent, without a Strong Customer Authentication challenge — the Consent is moved directly from INITIATED to ACCEPTED. + | + |After the TPP has called Create Consent Request (Client Credentials), the User authenticates and answers the request via this endpoint. This creates the Consent (the credential the consumer will use to access OBP on the User's behalf). + | + |IMPLICIT means no SCA challenge is sent. The Consent is immediately ACCEPTED. Use only in flows where the User has already been strongly authenticated by upstream means; for production use behind a public TPP, prefer EMAIL or SMS. + | + |Pinning: the resulting Consent is pinned to a single consumer at creation. The pinned consumer is taken from the `consumer_id` field of the original Create Consent Request body if present, otherwise from the consumer that created the Request. After creation, only that consumer can present the resulting Consent JWT — any other consumer presenting it gets ConsentNotFound (consumer mismatch). + | + |Each Consent Request can be answered exactly once. A second call returns ConsentRequestIsInvalid. + | + |The Consent's authority is bounded by the answering User's own entitlements — it cannot grant access beyond what that User already has. | - |This endpoint continues the process of creating a Consent. It starts the SCA flow which changes the status of the consent from INITIATED to ACCEPTED or REJECTED. - |Please note that the Consent cannot elevate the privileges logged in user already have. + |Authentication: Any authenticated User may answer a Consent Request whose CONSENT_REQUEST_ID they know. This will be tightened in v6.0.0; until then, treat CONSENT_REQUEST_IDs as sensitive. | |""", EmptyBody, diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index a1ff93dd08..2f89d55722 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -2245,7 +2245,7 @@ trait APIMethods600 { !`checkIfContains::::`(postJson.bank_id) } (banks, callContext) <- NewStyle.function.getBanks(cc.callContext) - _ <- Helper.booleanToFuture(failMsg = ErrorMessages.bankIdAlreadyExists, cc = cc.callContext) { + _ <- Helper.booleanToFuture(failMsg = ErrorMessages.bankIdAlreadyExists, failCode = 409, cc = cc.callContext) { !banks.exists { b => postJson.bank_id.contains(b.bankId.value) } } (success, callContext) <- NewStyle.function.createOrUpdateBank( @@ -10576,7 +10576,7 @@ trait APIMethods600 { // Validate target user exists (_, callContext) <- NewStyle.function.findByUserId(postJson.target_user_id, callContext) // Check for existing INITIATED request for same user/account/view - _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestAlreadyExists, failCode = 409, cc = callContext) { code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend .getByUserAccountView(postJson.target_user_id, bankId.value, accountId.value, postJson.view_id) .isEmpty @@ -13266,7 +13266,7 @@ trait APIMethods600 { json.extract[PostChatRoomJsonV600] } existingRoom <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByBankIdAndName(bankId.value, postJson.name)) - _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, failCode = 409, cc = callContext) { existingRoom.isEmpty } room <- Future { @@ -13337,7 +13337,7 @@ trait APIMethods600 { json.extract[PostChatRoomJsonV600] } existingRoom <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByBankIdAndName("", postJson.name)) - _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomAlreadyExists, failCode = 409, cc = callContext) { existingRoom.isEmpty } room <- Future { @@ -14171,7 +14171,7 @@ trait APIMethods600 { !room.isArchived } existingParticipant <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, u.userId)) - _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, failCode = 409, cc = callContext) { existingParticipant.isEmpty } participant <- Future { @@ -14240,7 +14240,7 @@ trait APIMethods600 { !room.isArchived } existingParticipant <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, u.userId)) - _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, failCode = 409, cc = callContext) { existingParticipant.isEmpty } participant <- Future { @@ -14429,7 +14429,7 @@ trait APIMethods600 { if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) } - _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, failCode = 409, cc = callContext) { existingParticipant.isEmpty } participant <- Future { @@ -14516,7 +14516,7 @@ trait APIMethods600 { if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) } - _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ChatRoomParticipantAlreadyExists, failCode = 409, cc = callContext) { existingParticipant.isEmpty } participant <- Future { @@ -16111,7 +16111,7 @@ trait APIMethods600 { x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) } existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) - _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, failCode = 409, cc = callContext) { existingReaction.isEmpty } reaction <- Future { @@ -16186,7 +16186,7 @@ trait APIMethods600 { x => unboxFullOrFail(x, callContext, ChatMessageNotFound, 404) } existingReaction <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) - _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, cc = callContext) { + _ <- Helper.booleanToFuture(failMsg = ReactionAlreadyExists, failCode = 409, cc = callContext) { existingReaction.isEmpty } reaction <- Future { diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index a757cf307f..11e2586630 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -568,7 +568,7 @@ object Http4s700 { UserHasMissingRoles + s" $canCreateEntitlementAtOneBank or $canCreateEntitlementAtAnyBank")( body.bank_id, user.userId, canCreateEntitlementAtOneBank :: canCreateEntitlementAtAnyBank :: Nil, Some(cc)).map(_ => ()) - _ <- Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, cc = Some(cc))( + _ <- Helper.booleanToFuture(failMsg = EntitlementAlreadyExists, failCode = 409, cc = Some(cc))( !hasEntitlement(body.bank_id, userId, role)) entitlement <- Future(Entitlement.entitlement.vend.addEntitlement(body.bank_id, userId, body.role_name)) .map(e => unboxFull(e)) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index ef1506a5ed..d0f1de3610 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -5,7 +5,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, UserHasMissingRoles, UserNotFoundByUserId} import code.customer.CustomerX import code.entitlement.Entitlement import code.metadata.counterparties.Counterparties @@ -1211,6 +1211,29 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { Then("Response is 400") statusCode shouldBe 400 } + + scenario("Return 409 when the entitlement already exists for the user", Http4s700RoutesTag) { + Given("canCreateEntitlementAtAnyBank role granted and the target entitlement already created") + addEntitlement("", resourceUser1.userId, canCreateEntitlementAtAnyBank.toString) + addEntitlement("", resourceUser1.userId, canGetAnyUser.toString) + + When("POST /obp/v7.0.0/users/USER_ID/entitlements with the same (bank_id, role_name)") + val body = s"""{"bank_id":"","role_name":"CanGetAnyUser"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/users/${resourceUser1.userId}/entitlements", body, headers) + + Then("Response is 409 with EntitlementAlreadyExists message") + statusCode shouldBe 409 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(EntitlementAlreadyExists) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } } // ─── getFeatures ────────────────────────────────────────────────────────────── From e1893177a04713ca8844b9cf1f8154138f4044bc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 2 May 2026 08:24:48 +0200 Subject: [PATCH 4/6] Add Organisation to v7.0.0 (tenant, later banks will have an org_id) --- .../main/scala/bootstrap/liftweb/Boot.scala | 2 + .../main/scala/code/api/util/ApiRole.scala | 13 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 9 + .../main/scala/code/api/util/Glossary.scala | 169 +++++++ .../scala/code/api/v6_0_0/OBPAPI6_0_0.scala | 4 +- .../scala/code/api/v7_0_0/Http4s700.scala | 427 ++++++++++++++++- .../code/api/v7_0_0/JSONFactory7.0.0.scala | 87 ++++ .../code/organisation/Organisation.scala | 110 +++++ .../code/organisation/OrganisationTrait.scala | 51 ++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 441 +++++++++++++++++- 11 files changed, 1308 insertions(+), 6 deletions(-) create mode 100644 obp-api/src/main/scala/code/organisation/Organisation.scala create mode 100644 obp-api/src/main/scala/code/organisation/OrganisationTrait.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 4e4390e4ae..29fafd2b86 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -86,6 +86,7 @@ import code.etag.MappedETag import code.featuredapicollection.FeaturedApiCollection import code.fx.{MappedCurrency, MappedFXRate} import code.group.Group +import code.organisation.MappedOrganisation import code.kycchecks.MappedKycCheck import code.kycdocuments.MappedKycDocument import code.kycmedias.MappedKycMedia @@ -1210,6 +1211,7 @@ object ToSchemify { CounterpartyAttributeMapper, BankAccountBalance, Group, + MappedOrganisation, AccountAccessRequest, code.chat.ChatRoom, code.chat.Participant, diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 226809337f..d6ea415f09 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1315,6 +1315,9 @@ object ApiRole extends MdcLoggable{ case class CanSeeAccountAccessForAnyUser(requiresBankId: Boolean = false) extends ApiRole lazy val canSeeAccountAccessForAnyUser = CanSeeAccountAccessForAnyUser() + case class CanGetAccountAccessTrace(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetAccountAccessTrace = CanGetAccountAccessTrace() + case class CanGetSystemIntegrity(requiresBankId: Boolean = false) extends ApiRole lazy val canGetSystemIntegrity = CanGetSystemIntegrity() case class CanGetProviders(requiresBankId: Boolean = false) extends ApiRole @@ -1341,6 +1344,16 @@ object ApiRole extends MdcLoggable{ case class CanGetGroupsAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetGroupsAtOneBank = CanGetGroupsAtOneBank() + // Organisation management roles + case class CanCreateOrganisation(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateOrganisation = CanCreateOrganisation() + case class CanGetAnyOrganisation(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetAnyOrganisation = CanGetAnyOrganisation() + case class CanUpdateOrganisation(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateOrganisation = CanUpdateOrganisation() + case class CanDeleteOrganisation(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteOrganisation = CanDeleteOrganisation() + // Group membership management roles case class CanAddUserToGroupAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canAddUserToGroupAtAllBanks = CanAddUserToGroupAtAllBanks() diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index 56a60ae257..e22f49bfaf 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -91,6 +91,7 @@ object ApiTag { val apiTagBalance = ResourceDocTag("Balance") val apiTagChat = ResourceDocTag("Chat") val apiTagGroup = ResourceDocTag("Group") + val apiTagOrganisation = ResourceDocTag("Organisation") val apiTagWebhook = ResourceDocTag("Webhook") val apiTagMockedData = ResourceDocTag("Mocked-Data") val apiTagConsent = ResourceDocTag("Consent") 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 170409b91d..e319a3b20f 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -460,6 +460,15 @@ object ErrorMessages { val CreateApiProductAttributeError = "OBP-30504: Could not create ApiProductAttribute." val DeleteApiProductAttributeError = "OBP-30505: Could not delete ApiProductAttribute." + val OrganisationNotFound = "OBP-30506: Organisation not found. Please specify a valid value for ORGANISATION_ID." + val OrganisationAlreadyExists = "OBP-30507: Organisation already exists. Please specify a different value for ORGANISATION_ID." + val InvalidOrganisationIdFormat = "OBP-30508: Invalid Organisation Id. The ORGANISATION_ID should only contain 0-9/a-z/A-Z/'-'/'.'/'_', and be between 2 and 64 characters in length." + val InvalidOrganisationStatus = "OBP-30509: Invalid Organisation status. Allowed values are: active, suspended, archived." + val InvalidOrganisationVisibility = "OBP-30510: Invalid Organisation visibility. Allowed values are: public, unlisted, private." + val CreateOrganisationError = "OBP-30511: Could not create Organisation." + val UpdateOrganisationError = "OBP-30512: Could not update Organisation." + val DeleteOrganisationError = "OBP-30513: Could not delete Organisation." + val FeaturedApiCollectionNotFound = "OBP-30400: FeaturedApiCollection not found. Please specify a valid value for API_COLLECTION_ID." val CreateFeaturedApiCollectionError = "OBP-30401: Could not create FeaturedApiCollection." val UpdateFeaturedApiCollectionError = "OBP-30402: Could not update FeaturedApiCollection." diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index a12c87b6f6..9125dcab9a 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -4215,6 +4215,7 @@ object Glossary extends MdcLoggable { |- ABAC_Parameters_Summary - Complete list of all 18 parameters |- ABAC_Object_Properties_Reference - Detailed property reference |- ABAC_Testing_Examples - More testing examples + |- ABAC_Account_Access_Enforcement - Runtime gate model |""".stripMargin) glossaryItems += GlossaryItem( @@ -4276,6 +4277,7 @@ object Glossary extends MdcLoggable { |**Related Documentation:** |- ABAC_Simple_Guide - Getting started guide |- ABAC_Object_Properties_Reference - Detailed property reference + |- ABAC_Account_Access_Enforcement - Runtime gate model |""".stripMargin) glossaryItems += GlossaryItem( @@ -4442,6 +4444,7 @@ object Glossary extends MdcLoggable { |**Related Documentation:** |- ABAC_Simple_Guide - Getting started guide |- ABAC_Parameters_Summary - Complete parameter list + |- ABAC_Account_Access_Enforcement - Runtime gate model |""".stripMargin) glossaryItems += GlossaryItem( @@ -4587,6 +4590,172 @@ object Glossary extends MdcLoggable { |- ABAC_Simple_Guide - Getting started guide |- ABAC_Parameters_Summary - Complete parameter list |- ABAC_Object_Properties_Reference - Property reference + |- ABAC_Account_Access_Enforcement - Runtime gate model + |""".stripMargin) + + glossaryItems += GlossaryItem( + title = "ABAC_Account_Access_Enforcement", + description = + s""" + |# ABAC Account Access Enforcement + | + |How OBP decides whether the ABAC subsystem grants account access at runtime, and + |how that's kept separate from rule management. For writing rules, see + |ABAC_Simple_Guide — this entry is for operators, security reviewers, and anyone + |tracing why a request did or did not succeed. + | + |## Two distinct guard surfaces + | + |**Management plane** — controls who can author and run rules: + | + |- `CanCreateAbacRule` — POST `/management/abac-rules`, validate + |- `CanGetAbacRule` — GET rule(s), schema, list policies + |- `CanUpdateAbacRule` — PUT rule (also flips `is_active`) + |- `CanDeleteAbacRule` — DELETE rule + |- `CanExecuteAbacRule` — POST `/management/abac-rules/{id}/execute` and `…/abac-policies/{policy}/execute` + | + |**Runtime gate** — controls whether ABAC fallback can grant access on a real API + |call. Implemented in `APIUtil.checkAbacAccountAccess`. None of the management + |roles above are involved at request time, with the deliberate exception of + |`CanExecuteAbacRule` (see "dual purpose" below). + | + |## Fallback ordering + | + |ABAC is **only consulted as a fallback** after normal access checks fail. + |`APIUtil.hasAccountAccess` evaluates in this order: + | + |1. Public view → grant + |2. User has firehose access → grant + |3. User has the view via the AccountAccess table → grant + |4. **None of the above and a user is present → try ABAC** + |5. No user → deny + | + |Consequence: ABAC can only ever **widen** access. It cannot deny a user who + |already has access through a normal mechanism, and it cannot revoke a granted + |view. Removing a rule never breaks an existing access path; adding a rule never + |restricts one. + | + |## Six conditions for ABAC to grant access + | + |All six must hold. If any one fails, the runtime gate returns `false` and the + |request is denied at the access layer. + | + |1. **Normal checks failed.** ABAC was reached via the fallback ordering above. + | If any earlier check granted, ABAC is never invoked. + | + |2. **Master switch on.** Props key `allow_abac_account_access=true`. Default is + | **false** — ABAC is off out of the box. When false, + | `checkAbacAccountAccess` returns `Full(false)` immediately; no rules execute. + | + |3. **Target user opted in.** The user being evaluated must hold the + | `CanExecuteAbacRule` system-level entitlement (bankId=`""`). Without it the + | runtime returns `Full(false)`. Granting this entitlement is the act that + | subjects a user to the ABAC subsystem at runtime. + | + |4. **CallContext present.** Internal — `None` returns `Full(false)`. + | + |5. **At least one active rule PASSes.** + | `AbacRuleEngine.executeRulesByPolicyDetailed(ABAC_POLICY_ACCOUNT_ACCESS, ...)` + | evaluates every rule whose `is_active=true` under the `account-access` + | policy. OR semantics — one PASS is enough. Inactive rules are skipped + | entirely. If no rule passes but at least one explicitly denied, the call + | surfaces a `Failure` naming the failing rule IDs instead of a silent deny. + | + |6. **No timeout, no exception.** Rule evaluation is awaited for at most + | 10 seconds, wrapped in try/catch. Any timeout, thrown exception, or engine + | error → `Full(false)` (fail closed). + | + |## Dual purpose of `CanExecuteAbacRule` + | + |The same role gates two unrelated capabilities: + | + |- **Manual testing** — invoking `/management/abac-rules/{id}/execute` or + | `/management/abac-policies/{policy}/execute` to dry-run a rule. + |- **Runtime opt-in** — being eligible for ABAC fallback on real account access + | decisions (condition #3 above). + | + |Deliberate: a user has to be allowed to invoke a rule manually before they can + |be subject to one automatically. But it means revoking "can test rules" also + |revokes "can be granted access via ABAC" — keep this coupling in mind when + |building admin UIs or splitting roles. + | + |## Diagnosing a decision + | + |``` + |GET $getObpApiRoot/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/TARGET_VIEW_ID/users/TARGET_USER_ID/account-access-trace + |``` + | + |Returns a structured trace with each of the six conditions surfaced: + | + |- `account_access_trace.has_account_access_for_view` — whether condition #1 + | even matters (true means normal access already grants, ABAC not reached) + |- `entitlement_trace.has_can_execute_abac_rule` — condition #3 + |- `abac_trace.allow_abac_account_access` — condition #2 + |- `abac_trace.rules_evaluated[].result` — condition #5, per rule (see below) + |- `abac_trace.standalone_abac_result` — the AND of #2, #3, and "at least one + | PASS". This is the verdict ABAC would produce **on its own**, ignoring the + | AccountAccess table. It is **not** the same as "ABAC granted this user's + | access" — see "Standalone vs decisive" below. + |- `has_access` and `access_source` — `"ACCOUNT_ACCESS"` | + | `"ABAC"` | `"NONE"`. `access_source` is what actually decided. + | + |### Standalone vs decisive + | + |`standalone_abac_result` answers the question "if ABAC were the only mechanism, + |would it grant?" It is computed independently of the AccountAccess lookup. + | + |To answer "did ABAC actually grant **this** user's access?", use + |`access_source == "ABAC"` instead. + | + |Worked example: a user holds the `owner` view directly via the AccountAccess + |table, AND every ABAC condition holds (prop on, has `CanExecuteAbacRule`, a + |rule PASSes). The trace will show: + | + |- `account_access_trace.has_account_access_for_view: true` + |- `standalone_abac_result: true` + |- `access_source: "ACCOUNT_ACCESS"` + | + |ABAC didn't grant anything for this user — AccountAccess did. ABAC was simply + |evaluated in parallel and would also have granted if asked. UIs rendering an + |"ABAC access" column should read `access_source`, not + |`standalone_abac_result`. + | + |### Per-rule `result` values + | + |`result` is a four-state string (not a boolean — `FAIL` and `ERROR` are not the + |same thing, and a disabled rule is not the same as a rejecting rule): + | + |- `PASS` — rule executed and returned `true`. Counts toward access being granted. + |- `FAIL` — rule executed and returned `false`. Clean rejection; no error. + |- `ERROR` — rule threw an exception, returned a `Failure`, or returned an empty + | result. `error_message` is populated. Investigate as a bug or upstream + | outage — the rule did not produce a decision. + |- `SKIPPED` — rule has `is_active=false`. Engine never ran it. + | `error_message` is `"Rule is not active"`. + | + |Only `PASS` contributes to granting access. `FAIL`, `ERROR`, and `SKIPPED` all + |mean "this rule did not grant" but are intentionally distinct in the trace so + |operators can tell a rejecting rule from a broken one from an inactive one. + | + |The trace endpoint is **diagnostic only** — it does not affect enforcement. It + |is gated by `CanGetAccountAccessTrace`, a read-only audit role distinct from + |the management and runtime roles above. + | + |## Enabling ABAC in a deployment + | + |1. Set `allow_abac_account_access=true` in props. + |2. Grant `CanCreateAbacRule` to a rule author and create at least one active + | rule under the `account-access` policy. + |3. Grant `CanExecuteAbacRule` to each user who should be eligible for ABAC + | fallback. Without this, rules never run for them. + |4. Grant `CanGetAccountAccessTrace` to anyone who needs to debug decisions + | (audit, support, compliance). + | + |**Related Documentation:** + |- ABAC_Simple_Guide - Writing rules + |- ABAC_Parameters_Summary - Rule parameters + |- ABAC_Object_Properties_Reference - Object properties in rules + |- ABAC_Testing_Examples - Testing patterns |""".stripMargin) glossaryItems += GlossaryItem( diff --git a/obp-api/src/main/scala/code/api/v6_0_0/OBPAPI6_0_0.scala b/obp-api/src/main/scala/code/api/v6_0_0/OBPAPI6_0_0.scala index 43893434d5..bb0312cbdd 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/OBPAPI6_0_0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/OBPAPI6_0_0.scala @@ -64,8 +64,8 @@ object OBPAPI6_0_0 extends OBPRestHelper with APIMethods400 with APIMethods500 with APIMethods510 - with APIMethods600 - with MdcLoggable + with APIMethods600 + with MdcLoggable with VersionedOBPApis{ val version : ApiVersion = ApiVersion.v6_0_0 diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 11e2586630..efcf8be17e 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7,8 +7,8 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} -import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations} +import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, Glossary, NewStyle} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateOrganisation} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, IdempotencyMiddleware, RequestScopeConnection, ResourceDocMiddleware} @@ -24,6 +24,7 @@ import code.bankconnectors.storedprocedure.StoredProcedureUtils import code.migration.MigrationScriptLogProvider import code.bankconnectors.{Connector => BankConnector} import code.entitlement.Entitlement +import code.organisation.OrganisationX import code.metadata.tags.Tags import code.views.Views import code.accountattribute.AccountAttributeX @@ -592,6 +593,169 @@ object Http4s700 { http4sPartialFunction = Some(addEntitlement) ) + // ── Account Access Trace ──────────────────────────────────────────────── + // + // Path uses TARGET_VIEW_ID and TARGET_USER_ID (not VIEW_ID / USER_ID) on + // purpose: the middleware's VIEW_ID validation runs an access check on the + // CALLING user, which is wrong for a diagnostic that asks about ANOTHER user. + // The caller's authority comes from CanGetAccountAccessTrace. + val getAccountAccessTrace: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "views" / targetViewIdStr / "users" / targetUserIdStr / "account-access-trace" => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + val bankIdAccountId = BankIdAccountId(account.bankId, account.accountId) + val targetViewId = ViewId(targetViewIdStr) + for { + // Validate target view exists (custom or system) + _ <- { + Views.views.vend.customViewFuture(targetViewId, bankIdAccountId).flatMap { + case Full(v) => Future.successful(Full(v)) + case _ => Views.views.vend.systemViewFuture(targetViewId) + } + }.map(unboxFullOrFail(_, Some(cc), s"$ViewNotFound Current ViewId is $targetViewIdStr")) + + // Validate target user exists + targetUser <- UserVend.users.vend.getUserByUserIdFuture(targetUserIdStr).map( + x => unboxFullOrFail(x, Some(cc), s"$UserNotFoundByUserId Current USER_ID($targetUserIdStr)", 404) + ) + + // Step A — AccountAccess trace + permissions <- Future(Views.views.vend.permissions(bankIdAccountId)) + targetUserPerm = permissions.find(_.user.userId == targetUser.userId) + accountAccessViewIds = targetUserPerm.toList.flatMap(_.views.map(_.viewId.value)) + hasAccountAccessForView = accountAccessViewIds.contains(targetViewIdStr) + + // Step B — Entitlement trace (mirrors APIUtil.checkAbacAccountAccess gate) + entitlementBox = Entitlement.entitlement.vend.getEntitlement("", targetUser.userId, ApiRole.canExecuteAbacRule.toString) + hasCanExecuteAbacRule = entitlementBox.isDefined + + // Step C — ABAC per-rule trace (lists ALL rules under the policy, active or not) + allRules = code.abacrule.MappedAbacRuleProvider.getAbacRulesByPolicy(ABAC_POLICY_ACCOUNT_ACCESS) + ruleTraces <- Future.sequence(allRules.map { rule => + if (!rule.isActive) { + Future.successful(JSONFactory700.AbacRuleTraceJsonV700( + rule_id = rule.abacRuleId, rule_name = rule.ruleName, + is_active = false, result = "SKIPPED", + error_message = Some("Rule is not active") + )) + } else { + code.abacrule.AbacRuleEngine.executeRule( + ruleId = rule.abacRuleId, + authenticatedUserId = targetUser.userId, + callContext = cc, + bankId = Some(account.bankId.value), + accountId = Some(account.accountId.value), + viewId = Some(targetViewIdStr) + ).map { + case Full(true) => JSONFactory700.AbacRuleTraceJsonV700(rule.abacRuleId, rule.ruleName, true, "PASS", None) + case Full(false) => JSONFactory700.AbacRuleTraceJsonV700(rule.abacRuleId, rule.ruleName, true, "FAIL", None) + case net.liftweb.common.Failure(msg, _, _) => + JSONFactory700.AbacRuleTraceJsonV700(rule.abacRuleId, rule.ruleName, true, "ERROR", Some(msg)) + case _ => JSONFactory700.AbacRuleTraceJsonV700(rule.abacRuleId, rule.ruleName, true, "ERROR", Some("empty result")) + }.recover { case ex => + JSONFactory700.AbacRuleTraceJsonV700(rule.abacRuleId, rule.ruleName, true, "ERROR", Some(ex.getMessage)) + } + } + }) + + allowAbacProp = APIUtil.getPropsAsBoolValue("allow_abac_account_access", false) + anyRulePassed = ruleTraces.exists(_.result == "PASS") + // Mirrors enforcement: prop ON + entitlement + at least one PASS + standaloneAbacResult = allowAbacProp && hasCanExecuteAbacRule && anyRulePassed + + hasAccess = hasAccountAccessForView || standaloneAbacResult + accessSource = + if (hasAccountAccessForView) "ACCOUNT_ACCESS" + else if (standaloneAbacResult) "ABAC" + else "NONE" + } yield { + JSONFactory700.AccountAccessTraceJsonV700( + user_id = targetUser.userId, + bank_id = account.bankId.value, + account_id = account.accountId.value, + view_id = targetViewIdStr, + has_access = hasAccess, + access_source = accessSource, + account_access_trace = JSONFactory700.AccountAccessLookupJsonV700( + has_account_access_for_view = hasAccountAccessForView, + account_access_view_ids = accountAccessViewIds + ), + entitlement_trace = JSONFactory700.EntitlementTraceJsonV700( + has_can_execute_abac_rule = hasCanExecuteAbacRule + ), + abac_trace = JSONFactory700.AbacEvaluationTraceJsonV700( + policy = ABAC_POLICY_ACCOUNT_ACCESS, + allow_abac_account_access = allowAbacProp, + standalone_abac_result = standaloneAbacResult, + rules_evaluated = ruleTraces + ) + ) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getAccountAccessTrace), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/TARGET_VIEW_ID/users/TARGET_USER_ID/account-access-trace", + "Get Account Access Trace", + s"""Return a diagnostic trace of how a target user's access to a view on an account is decided. + |Use this for auditing, debugging "why doesn't user X have access?" support tickets, and + |verifying ABAC rule behaviour against real users. + | + |Top-level verdict: + | + |* `has_access` — does the target user have access to the named view? + |* `access_source` — what actually decided: `"ACCOUNT_ACCESS"` | `"ABAC"` | `"NONE"`. + | Use this (not `standalone_abac_result`) to answer "did ABAC grant this user's access?" + | + |Three diagnostic sections: + | + |* `account_access_trace` — what the AccountAccess table says: which views the target user + | holds on this account, and whether the asked view is among them. + |* `entitlement_trace` — whether the target user has the `CanExecuteAbacRule` entitlement + | (the runtime opt-in for ABAC fallback). + |* `abac_trace` — the ABAC subsystem evaluated standalone: master prop value, the standalone + | verdict, and each active rule under the `account-access` policy with result + | `PASS` / `FAIL` / `ERROR` / `SKIPPED`. + | + |Path uses `TARGET_VIEW_ID` and `TARGET_USER_ID` (not `VIEW_ID` / `USER_ID`) because the + |trace asks about another user, not the caller. The caller's authority to read this comes + |from `CanGetAccountAccessTrace`. + | + |Diagnostic only — does not affect enforcement. For the full runtime gate model, see + |${Glossary.getGlossaryItemLink("ABAC_Account_Access_Enforcement")}. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.AccountAccessTraceJsonV700( + user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + view_id = "owner", + has_access = true, + access_source = "ACCOUNT_ACCESS", + account_access_trace = JSONFactory700.AccountAccessLookupJsonV700( + has_account_access_for_view = true, + account_access_view_ids = List("owner") + ), + entitlement_trace = JSONFactory700.EntitlementTraceJsonV700( + has_can_execute_abac_rule = false + ), + abac_trace = JSONFactory700.AbacEvaluationTraceJsonV700( + policy = ABAC_POLICY_ACCOUNT_ACCESS, + allow_abac_account_access = false, + standalone_abac_result = false, + rules_evaluated = Nil + ) + ), + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, ViewNotFound, UserNotFoundByUserId, UnknownError), + apiTagABAC :: apiTagAccount :: apiTagView :: Nil, + Some(List(canGetAccountAccessTrace)), + http4sPartialFunction = Some(getAccountAccessTrace) + ) + // ── Phase 1 — Simple GETs ─────────────────────────────────────────────── // Route: GET /obp/v7.0.0/features @@ -2241,6 +2405,265 @@ object Http4s700 { // ── End Phase 1 batch 3 ────────────────────────────────────────────────── + // ── Organisations ───────────────────────────────────────────────────────── + // CRUD for the Organisation resource. Migrated from v6.0.0 (Lift) to v7.0.0 + // (http4s). Path uses ORGANISATION_ID; not resolved by middleware (only BANK_ID + // / ACCOUNT_ID / VIEW_ID / COUNTERPARTY_ID are), so endpoints fetch directly + // via OrganisationX.organisation.vend. + + private val ValidOrganisationStatuses = Set("active", "suspended", "archived") + private val ValidOrganisationVisibilities = Set("public", "unlisted", "private") + private val OrganisationIdRegex = "^[a-zA-Z0-9._-]{2,64}$".r + + val createOrganisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "organisations" => + EndpointHelpers.withUserAndBodyCreated[JSONFactory700.PostOrganisationJsonV700, JSONFactory700.OrganisationJsonV700](req) { (user, body, cc) => + for { + _ <- Helper.booleanToFuture(InvalidOrganisationIdFormat, 400, Some(cc)) { + OrganisationIdRegex.findFirstIn(body.organisation_id).isDefined + } + status = body.status.getOrElse("active") + visibility = body.visibility.getOrElse("public") + _ <- Helper.booleanToFuture(InvalidOrganisationStatus, 400, Some(cc)) { + ValidOrganisationStatuses.contains(status) + } + _ <- Helper.booleanToFuture(InvalidOrganisationVisibility, 400, Some(cc)) { + ValidOrganisationVisibilities.contains(visibility) + } + existing <- Future(OrganisationX.organisation.vend.getOrganisation(body.organisation_id)) + _ <- Helper.booleanToFuture(OrganisationAlreadyExists, 409, Some(cc))(existing.isEmpty) + created <- Future { + OrganisationX.organisation.vend.createOrganisation( + body.organisation_id, body.name, body.website, body.logo_url, + status, visibility, user.userId + ) + }.map(unboxFullOrFail(_, Some(cc), CreateOrganisationError, 400)) + } yield JSONFactory700.createOrganisationJsonV700(created) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(createOrganisation), + "POST", + "/organisations", + "Create Organisation", + """Create an Organisation. + | + |The organisation_id must be a URL-safe string (a-z, A-Z, 0-9, '-', '.', '_'), between 2 and 64 characters in length, and is immutable. + | + |Optional fields: + |- status: one of active, suspended, archived (defaults to active) + |- visibility: one of public, unlisted, private (defaults to public) + |- website, logo_url + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PostOrganisationJsonV700( + organisation_id = "tesobe", + name = "TESOBE GmbH", + website = Some("https://www.tesobe.com"), + logo_url = None, + status = Some("active"), + visibility = Some("public") + ), + JSONFactory700.OrganisationJsonV700( + organisation_id = "tesobe", + name = "TESOBE GmbH", + website = Some("https://www.tesobe.com"), + logo_url = None, + status = "active", + visibility = "public", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + InvalidOrganisationIdFormat, InvalidOrganisationStatus, + InvalidOrganisationVisibility, OrganisationAlreadyExists, + CreateOrganisationError, UnknownError), + apiTagOrganisation :: Nil, + Some(List(canCreateOrganisation)), + http4sPartialFunction = Some(createOrganisation) + ) + + val getOrganisations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "organisations" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + allOrgs <- OrganisationX.organisation.vend.getAllOrganisations() + .map(unboxFullOrFail(_, Some(cc), UnknownError, 500)) + hasGetAny = APIUtil.hasEntitlement("", user.userId, canGetAnyOrganisation) + visible = if (hasGetAny) allOrgs else allOrgs.filter(_.visibility == "public") + } yield JSONFactory700.createOrganisationsJsonV700(visible) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getOrganisations), + "GET", + "/organisations", + "Get Organisations", + """Returns Organisations. + | + |By default returns only Organisations whose visibility is `public`. Users granted CanGetAnyOrganisation see all Organisations including unlisted and private. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.OrganisationsJsonV700(organisations = List( + JSONFactory700.OrganisationJsonV700( + organisation_id = "tesobe", + name = "TESOBE GmbH", + website = Some("https://www.tesobe.com"), + logo_url = None, + status = "active", + visibility = "public", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ) + )), + List($AuthenticatedUserIsRequired, UnknownError), + apiTagOrganisation :: Nil, + None, + http4sPartialFunction = Some(getOrganisations) + ) + + val getOrganisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "organisations" / organisationId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + org <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) + _ <- if (org.visibility == "private") + NewStyle.function.hasEntitlement("", user.userId, canGetAnyOrganisation, Some(cc)) + else Future.successful(()) + } yield JSONFactory700.createOrganisationJsonV700(org) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getOrganisation), + "GET", + "/organisations/ORGANISATION_ID", + "Get Organisation", + """Returns the Organisation specified by ORGANISATION_ID. + | + |Organisations with visibility `public` or `unlisted` are visible to any authenticated user. Organisations with visibility `private` require CanGetAnyOrganisation. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + JSONFactory700.OrganisationJsonV700( + organisation_id = "tesobe", + name = "TESOBE GmbH", + website = Some("https://www.tesobe.com"), + logo_url = None, + status = "active", + visibility = "public", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, OrganisationNotFound, UnknownError), + apiTagOrganisation :: Nil, + None, + http4sPartialFunction = Some(getOrganisation) + ) + + val updateOrganisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "organisations" / organisationId => + EndpointHelpers.withUserAndBody[JSONFactory700.PutOrganisationJsonV700, JSONFactory700.OrganisationJsonV700](req) { (_, body, cc) => + for { + _ <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) + _ <- Helper.booleanToFuture(InvalidOrganisationStatus, 400, Some(cc)) { + body.status.forall(ValidOrganisationStatuses.contains) + } + _ <- Helper.booleanToFuture(InvalidOrganisationVisibility, 400, Some(cc)) { + body.visibility.forall(ValidOrganisationVisibilities.contains) + } + updated <- Future { + OrganisationX.organisation.vend.updateOrganisation( + organisationId, body.name, body.website, body.logo_url, body.status, body.visibility + ) + }.map(unboxFullOrFail(_, Some(cc), UpdateOrganisationError, 400)) + } yield JSONFactory700.createOrganisationJsonV700(updated) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(updateOrganisation), + "PUT", + "/organisations/ORGANISATION_ID", + "Update Organisation", + """Update an Organisation. All body fields are optional. The organisation_id is immutable and cannot be changed. + | + |Authentication is Required.""".stripMargin, + JSONFactory700.PutOrganisationJsonV700( + name = Some("TESOBE GmbH"), + website = Some("https://www.tesobe.com"), + logo_url = None, + status = Some("active"), + visibility = Some("public") + ), + JSONFactory700.OrganisationJsonV700( + organisation_id = "tesobe", + name = "TESOBE GmbH", + website = Some("https://www.tesobe.com"), + logo_url = None, + status = "active", + visibility = "public", + created_by_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + created_at = new java.util.Date(), + updated_at = new java.util.Date() + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + OrganisationNotFound, InvalidOrganisationStatus, + InvalidOrganisationVisibility, UpdateOrganisationError, UnknownError), + apiTagOrganisation :: Nil, + Some(List(canUpdateOrganisation)), + http4sPartialFunction = Some(updateOrganisation) + ) + + val deleteOrganisation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "organisations" / organisationId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + _ <- Future(OrganisationX.organisation.vend.getOrganisation(organisationId)) + .map(unboxFullOrFail(_, Some(cc), OrganisationNotFound, 404)) + _ <- Future(OrganisationX.organisation.vend.deleteOrganisation(organisationId)) + .map(unboxFullOrFail(_, Some(cc), DeleteOrganisationError, 400)) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(deleteOrganisation), + "DELETE", + "/organisations/ORGANISATION_ID", + "Delete Organisation", + """Delete the Organisation specified by ORGANISATION_ID. + | + |Authentication is Required.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, OrganisationNotFound, + DeleteOrganisationError, UnknownError), + apiTagOrganisation :: Nil, + Some(List(canDeleteOrganisation)), + http4sPartialFunction = Some(deleteOrganisation) + ) + + // ── End Organisations ───────────────────────────────────────────────────── + // ── Test-only rollback endpoint ─────────────────────────────────────────── // Enabled only in Lift test mode (Props.testMode == true, i.e. -Drun.mode=test). // Props.testMode is set from the JVM system property before any props file loads, diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index 008658affc..3329231fe1 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -444,4 +444,91 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { updated_at = auth.updatedAt.toInstant.toString ) } + + // Account-access decision diagnostic — returned by GET /banks/.../views/.../users/.../account-access-trace + case class AccountAccessLookupJsonV700( + has_account_access_for_view: Boolean, + account_access_view_ids: List[String] + ) + + case class EntitlementTraceJsonV700( + has_can_execute_abac_rule: Boolean + ) + + case class AbacRuleTraceJsonV700( + rule_id: String, + rule_name: String, + is_active: Boolean, + result: String, // "PASS" | "FAIL" | "ERROR" + error_message: Option[String] + ) + + case class AbacEvaluationTraceJsonV700( + policy: String, + allow_abac_account_access: Boolean, + standalone_abac_result: Boolean, + rules_evaluated: List[AbacRuleTraceJsonV700] + ) + + case class AccountAccessTraceJsonV700( + user_id: String, + bank_id: String, + account_id: String, + view_id: String, + has_access: Boolean, + access_source: String, // "ACCOUNT_ACCESS" | "ABAC" | "NONE" + account_access_trace: AccountAccessLookupJsonV700, + entitlement_trace: EntitlementTraceJsonV700, + abac_trace: AbacEvaluationTraceJsonV700 + ) + + // Organisation JSON case classes + case class PostOrganisationJsonV700( + organisation_id: String, + name: String, + website: Option[String], + logo_url: Option[String], + status: Option[String], + visibility: Option[String] + ) + + case class PutOrganisationJsonV700( + name: Option[String], + website: Option[String], + logo_url: Option[String], + status: Option[String], + visibility: Option[String] + ) + + case class OrganisationJsonV700( + organisation_id: String, + name: String, + website: Option[String], + logo_url: Option[String], + status: String, + visibility: String, + created_by_user_id: String, + created_at: java.util.Date, + updated_at: java.util.Date + ) + + case class OrganisationsJsonV700(organisations: List[OrganisationJsonV700]) + + def createOrganisationJsonV700(o: code.organisation.OrganisationTrait): OrganisationJsonV700 = { + OrganisationJsonV700( + organisation_id = o.organisationId, + name = o.name, + website = o.website, + logo_url = o.logoUrl, + status = o.status, + visibility = o.visibility, + created_by_user_id = o.createdByUserId, + created_at = o.createdAt, + updated_at = o.updatedAt + ) + } + + def createOrganisationsJsonV700(orgs: List[code.organisation.OrganisationTrait]): OrganisationsJsonV700 = { + OrganisationsJsonV700(orgs.map(createOrganisationJsonV700)) + } } diff --git a/obp-api/src/main/scala/code/organisation/Organisation.scala b/obp-api/src/main/scala/code/organisation/Organisation.scala new file mode 100644 index 0000000000..718e51d119 --- /dev/null +++ b/obp-api/src/main/scala/code/organisation/Organisation.scala @@ -0,0 +1,110 @@ +package code.organisation + +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo +import com.openbankproject.commons.ExecutionContext.Implicits.global + +import scala.concurrent.Future + +object MappedOrganisationProvider extends OrganisationProvider { + + override def createOrganisation( + organisationId: String, + name: String, + website: Option[String], + logoUrl: Option[String], + status: String, + visibility: String, + createdByUserId: String + ): Box[OrganisationTrait] = { + tryo { + MappedOrganisation.create + .OrganisationId(organisationId) + .Name(name) + .Website(website.getOrElse("")) + .LogoUrl(logoUrl.getOrElse("")) + .Status(status) + .Visibility(visibility) + .CreatedByUserId(createdByUserId) + .saveMe() + } + } + + override def getOrganisation(organisationId: String): Box[OrganisationTrait] = { + MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)) + } + + override def getAllOrganisations(): Future[Box[List[OrganisationTrait]]] = { + Future { + tryo { MappedOrganisation.findAll() } + } + } + + override def updateOrganisation( + organisationId: String, + name: Option[String], + website: Option[String], + logoUrl: Option[String], + status: Option[String], + visibility: Option[String] + ): Box[OrganisationTrait] = { + MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)).flatMap { org => + tryo { + name.foreach(v => org.Name(v)) + website.foreach(v => org.Website(v)) + logoUrl.foreach(v => org.LogoUrl(v)) + status.foreach(v => org.Status(v)) + visibility.foreach(v => org.Visibility(v)) + org.LastUpdate(new java.util.Date()) + org.saveMe() + } + } + } + + override def deleteOrganisation(organisationId: String): Box[Boolean] = { + MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)).flatMap { org => + tryo { org.delete_! } + } + } +} + +class MappedOrganisation extends OrganisationTrait with LongKeyedMapper[MappedOrganisation] with IdPK { + + def getSingleton = MappedOrganisation + + object OrganisationId extends MappedString(this, 64) + object Name extends MappedString(this, 255) + object Website extends MappedString(this, 1024) + object LogoUrl extends MappedString(this, 1024) + object Status extends MappedString(this, 32) + object Visibility extends MappedString(this, 32) + object CreatedByUserId extends MappedString(this, 255) + object CreationDate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + object LastUpdate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + + override def organisationId: String = OrganisationId.get + override def name: String = Name.get + override def website: Option[String] = { + val v = Website.get + if (v == null || v.isEmpty) None else Some(v) + } + override def logoUrl: Option[String] = { + val v = LogoUrl.get + if (v == null || v.isEmpty) None else Some(v) + } + override def status: String = Status.get + override def visibility: String = Visibility.get + override def createdByUserId: String = CreatedByUserId.get + override def createdAt: java.util.Date = CreationDate.get + override def updatedAt: java.util.Date = LastUpdate.get +} + +object MappedOrganisation extends MappedOrganisation with LongKeyedMetaMapper[MappedOrganisation] { + override def dbTableName = "Organisation" + override def dbIndexes = UniqueIndex(OrganisationId) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala b/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala new file mode 100644 index 0000000000..847f0a1eaa --- /dev/null +++ b/obp-api/src/main/scala/code/organisation/OrganisationTrait.scala @@ -0,0 +1,51 @@ +package code.organisation + +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +import scala.concurrent.Future + +object OrganisationX extends SimpleInjector { + val organisation = new Inject(buildOne _) {} + + def buildOne: OrganisationProvider = MappedOrganisationProvider +} + +trait OrganisationProvider { + def createOrganisation( + organisationId: String, + name: String, + website: Option[String], + logoUrl: Option[String], + status: String, + visibility: String, + createdByUserId: String + ): Box[OrganisationTrait] + + def getOrganisation(organisationId: String): Box[OrganisationTrait] + + def getAllOrganisations(): Future[Box[List[OrganisationTrait]]] + + def updateOrganisation( + organisationId: String, + name: Option[String], + website: Option[String], + logoUrl: Option[String], + status: Option[String], + visibility: Option[String] + ): Box[OrganisationTrait] + + def deleteOrganisation(organisationId: String): Box[Boolean] +} + +trait OrganisationTrait { + def organisationId: String + def name: String + def website: Option[String] + def logoUrl: Option[String] + def status: String + def visibility: String + def createdByUserId: String + def createdAt: java.util.Date + def updatedAt: java.util.Date +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index d0f1de3610..bc9e299170 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -4,10 +4,11 @@ import code.Http4sTestServer import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateOrganisation} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, OrganisationAlreadyExists, OrganisationNotFound, UserHasMissingRoles, UserNotFoundByUserId} import code.customer.CustomerX import code.entitlement.Entitlement +import code.organisation.OrganisationX import code.metadata.counterparties.Counterparties import com.openbankproject.commons.model.{BankId => CommBankId, CreditLimit, CreditRating, CustomerFaceImage} @@ -1236,6 +1237,124 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── getAccountAccessTrace ──────────────────────────────────────────────────── + + feature("Http4s700 getAccountAccessTrace endpoint") { + + scenario("Reject unauthenticated GET to account-access-trace", Http4s700RoutesTag) { + Given("GET account-access-trace with no auth") + val bankId = testBankId1.value + val accountId = testAccountId0.value + val viewId = SYSTEM_OWNER_VIEW_ID + val targetUser = resourceUser1.userId + val (statusCode, json, _) = makeHttpRequest( + s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/views/$viewId/users/$targetUser/account-access-trace") + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetAccountAccessTrace role", Http4s700RoutesTag) { + Given("DirectLogin without the required role") + val bankId = testBankId1.value + val accountId = testAccountId0.value + val viewId = SYSTEM_OWNER_VIEW_ID + val targetUser = resourceUser1.userId + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest( + s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/views/$viewId/users/$targetUser/account-access-trace", headers) + + Then("Response is 403 with UserHasMissingRoles message") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserHasMissingRoles) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when target user does not exist", Http4s700RoutesTag) { + Given("canGetAccountAccessTrace granted to caller, missing target user_id in path") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetAccountAccessTrace.toString) + val bankId = testBankId1.value + val accountId = testAccountId0.value + val viewId = SYSTEM_OWNER_VIEW_ID + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest( + s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/views/$viewId/users/no-such-user-xyz/account-access-trace", headers) + + Then("Response is 404 with UserNotFoundByUserId message") + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserNotFoundByUserId) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 with explanation showing ACCOUNT_ACCESS as final source for owner view holder", Http4s700RoutesTag) { + Given("canGetAccountAccessTrace granted; target user (resourceUser1) has the system owner view") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetAccountAccessTrace.toString) + val bankId = testBankId1.value + val accountId = testAccountId0.value + val viewId = SYSTEM_OWNER_VIEW_ID + val targetUser = resourceUser1.userId + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest( + s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/views/$viewId/users/$targetUser/account-access-trace", headers) + + Then("Response is 200 with the explanation shape and has_access=true") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ( + "user_id", "bank_id", "account_id", "view_id", + "has_access", "access_source", + "account_access_trace", "entitlement_trace", "abac_trace" + ) + map.get("user_id") shouldBe Some(JString(targetUser)) + map.get("bank_id") shouldBe Some(JString(bankId)) + map.get("account_id") shouldBe Some(JString(accountId)) + map.get("view_id") shouldBe Some(JString(viewId)) + map.get("has_access") shouldBe Some(JBool(true)) + map.get("access_source") shouldBe Some(JString("ACCOUNT_ACCESS")) + map.get("account_access_trace") match { + case Some(JObject(traceFields)) => + val tm = toFieldMap(traceFields) + tm.get("has_account_access_for_view") shouldBe Some(JBool(true)) + tm.get("account_access_view_ids") match { + case Some(JArray(views)) => views should contain(JString(viewId)) + case _ => fail("Expected account_access_view_ids array") + } + case _ => fail("Expected account_access_trace object") + } + map.get("abac_trace") match { + case Some(JObject(abacFields)) => + val am = toFieldMap(abacFields) + am.keys should contain allOf ("policy", "allow_abac_account_access", "standalone_abac_result", "rules_evaluated") + am.get("policy") shouldBe Some(JString("account-access")) + case _ => fail("Expected abac_trace object") + } + case _ => fail("Expected JSON object") + } + } + } + // ─── getFeatures ────────────────────────────────────────────────────────────── feature("Http4s700 getFeatures endpoint") { @@ -2103,4 +2222,322 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── Organisations ──────────────────────────────────────────────────────── + + /** Create an Organisation directly via the model layer for test setup. */ + private def createTestOrg( + orgId: String, + visibility: String = "public", + status: String = "active" + ): Unit = { + OrganisationX.organisation.vend.createOrganisation( + orgId, s"Test $orgId", None, None, status, visibility, resourceUser1.userId + ) + } + + feature("Http4s700 createOrganisation endpoint") { + + scenario("Reject unauthenticated POST to /organisations", Http4s700RoutesTag) { + Given("POST /obp/v7.0.0/organisations with no auth") + val body = """{"organisation_id":"test-org-401","name":"X"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/organisations", body) + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canCreateOrganisation role", Http4s700RoutesTag) { + Given("DirectLogin without the required role") + val body = """{"organisation_id":"test-org-403","name":"X"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/organisations", body, headers) + + Then("Response is 403 with UserHasMissingRoles message") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserHasMissingRoles) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 201 with organisation JSON when authenticated with role and valid body", Http4s700RoutesTag) { + Given("canCreateOrganisation granted to caller") + addEntitlement("", resourceUser1.userId, canCreateOrganisation.toString) + val orgId = s"test-org-${APIUtil.generateUUID().take(8)}" + val body = s"""{"organisation_id":"$orgId","name":"Test Org"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When(s"POST /obp/v7.0.0/organisations with organisation_id=$orgId") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/organisations", body, headers) + + Then("Response is 201 with the expected fields") + statusCode shouldBe 201 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("organisation_id", "name", "status", "visibility", "created_by_user_id") + map.get("organisation_id") shouldBe Some(JString(orgId)) + map.get("status") shouldBe Some(JString("active")) + map.get("visibility") shouldBe Some(JString("public")) + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when organisation_id format is invalid", Http4s700RoutesTag) { + Given("canCreateOrganisation granted; organisation_id contains an invalid character") + addEntitlement("", resourceUser1.userId, canCreateOrganisation.toString) + val body = """{"organisation_id":"bad id with spaces","name":"X"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("POST /obp/v7.0.0/organisations with invalid id") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/organisations", body, headers) + + Then("Response is 400 with InvalidOrganisationIdFormat message") + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(InvalidOrganisationIdFormat) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 409 when organisation already exists", Http4s700RoutesTag) { + Given("an organisation already exists; canCreateOrganisation granted") + addEntitlement("", resourceUser1.userId, canCreateOrganisation.toString) + val orgId = s"dup-org-${APIUtil.generateUUID().take(8)}" + createTestOrg(orgId) + + When("POST /obp/v7.0.0/organisations with the same organisation_id") + val body = s"""{"organisation_id":"$orgId","name":"X"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/organisations", body, headers) + + Then("Response is 409 with OrganisationAlreadyExists message") + statusCode shouldBe 409 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(OrganisationAlreadyExists) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 getOrganisations endpoint") { + + scenario("Reject unauthenticated GET to /organisations", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/organisations with no auth") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/organisations") + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 with organisations array for an authenticated user", Http4s700RoutesTag) { + Given("an organisation exists") + val orgId = s"list-org-${APIUtil.generateUUID().take(8)}" + createTestOrg(orgId) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("GET /obp/v7.0.0/organisations") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/organisations", headers) + + Then("Response is 200 with an organisations array") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).get("organisations") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected organisations array") + } + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 getOrganisation endpoint") { + + scenario("Reject unauthenticated GET to /organisations/ORGANISATION_ID", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/organisations/anything with no auth") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/organisations/anything") + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when organisation does not exist", Http4s700RoutesTag) { + Given("an authenticated user; organisation_id that does not exist") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("GET /obp/v7.0.0/organisations/no-such-org-xyz") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/organisations/no-such-org-xyz", headers) + + Then("Response is 404 with OrganisationNotFound message") + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(OrganisationNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 with organisation JSON for an existing public organisation", Http4s700RoutesTag) { + Given("a public organisation exists") + val orgId = s"get-org-${APIUtil.generateUUID().take(8)}" + createTestOrg(orgId, visibility = "public") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When(s"GET /obp/v7.0.0/organisations/$orgId") + val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/organisations/$orgId", headers) + + Then("Response is 200 with the expected fields") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("organisation_id") shouldBe Some(JString(orgId)) + map.get("visibility") shouldBe Some(JString("public")) + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 updateOrganisation endpoint") { + + scenario("Reject unauthenticated PUT to /organisations/ORGANISATION_ID", Http4s700RoutesTag) { + Given("PUT /obp/v7.0.0/organisations/anything with no auth") + val body = """{"name":"New Name"}""" + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/organisations/anything", body) + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canUpdateOrganisation role", Http4s700RoutesTag) { + Given("DirectLogin without the required role") + val body = """{"name":"New Name"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", "/obp/v7.0.0/organisations/anything", body, headers) + + Then("Response is 403 with UserHasMissingRoles message") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserHasMissingRoles) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 with updated organisation JSON when authenticated with role", Http4s700RoutesTag) { + Given("an organisation exists; canUpdateOrganisation granted") + addEntitlement("", resourceUser1.userId, canUpdateOrganisation.toString) + val orgId = s"upd-org-${APIUtil.generateUUID().take(8)}" + createTestOrg(orgId) + + When(s"PUT /obp/v7.0.0/organisations/$orgId with a new name") + val body = """{"name":"Updated Name"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/organisations/$orgId", body, headers) + + Then("Response is 200 and name reflects the update") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("organisation_id") shouldBe Some(JString(orgId)) + map.get("name") shouldBe Some(JString("Updated Name")) + case _ => fail("Expected JSON object") + } + } + } + + feature("Http4s700 deleteOrganisation endpoint") { + + scenario("Reject unauthenticated DELETE to /organisations/ORGANISATION_ID", Http4s700RoutesTag) { + Given("DELETE /obp/v7.0.0/organisations/anything with no auth") + val (statusCode, _, _) = makeHttpRequestWithMethod("DELETE", "/obp/v7.0.0/organisations/anything") + + Then("Response is 401") + statusCode shouldBe 401 + } + + scenario("Return 403 when authenticated but missing canDeleteOrganisation role", Http4s700RoutesTag) { + Given("DirectLogin without the required role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithMethod("DELETE", "/obp/v7.0.0/organisations/anything", headers) + + Then("Response is 403 with UserHasMissingRoles message") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UserHasMissingRoles) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 204 when authenticated with role and organisation exists", Http4s700RoutesTag) { + Given("an organisation exists; canDeleteOrganisation granted") + addEntitlement("", resourceUser1.userId, canDeleteOrganisation.toString) + val orgId = s"del-org-${APIUtil.generateUUID().take(8)}" + createTestOrg(orgId) + + When(s"DELETE /obp/v7.0.0/organisations/$orgId") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, _, _) = makeHttpRequestWithMethod("DELETE", s"/obp/v7.0.0/organisations/$orgId", headers) + + Then("Response is 204 with no body") + statusCode shouldBe 204 + } + } + } From ebcba0d129677b6739cad1eef6d9eae2474a8a11 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 2 May 2026 09:10:30 +0200 Subject: [PATCH 5/6] Fixing Organisation model name --- .../src/main/scala/bootstrap/liftweb/Boot.scala | 4 ++-- .../scala/code/organisation/Organisation.scala | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 29fafd2b86..db274dc038 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -86,7 +86,7 @@ import code.etag.MappedETag import code.featuredapicollection.FeaturedApiCollection import code.fx.{MappedCurrency, MappedFXRate} import code.group.Group -import code.organisation.MappedOrganisation +import code.organisation.Organisation import code.kycchecks.MappedKycCheck import code.kycdocuments.MappedKycDocument import code.kycmedias.MappedKycMedia @@ -1211,7 +1211,7 @@ object ToSchemify { CounterpartyAttributeMapper, BankAccountBalance, Group, - MappedOrganisation, + Organisation, AccountAccessRequest, code.chat.ChatRoom, code.chat.Participant, diff --git a/obp-api/src/main/scala/code/organisation/Organisation.scala b/obp-api/src/main/scala/code/organisation/Organisation.scala index 718e51d119..65cd6ad73e 100644 --- a/obp-api/src/main/scala/code/organisation/Organisation.scala +++ b/obp-api/src/main/scala/code/organisation/Organisation.scala @@ -19,7 +19,7 @@ object MappedOrganisationProvider extends OrganisationProvider { createdByUserId: String ): Box[OrganisationTrait] = { tryo { - MappedOrganisation.create + Organisation.create .OrganisationId(organisationId) .Name(name) .Website(website.getOrElse("")) @@ -32,12 +32,12 @@ object MappedOrganisationProvider extends OrganisationProvider { } override def getOrganisation(organisationId: String): Box[OrganisationTrait] = { - MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)) + Organisation.find(By(Organisation.OrganisationId, organisationId)) } override def getAllOrganisations(): Future[Box[List[OrganisationTrait]]] = { Future { - tryo { MappedOrganisation.findAll() } + tryo { Organisation.findAll() } } } @@ -49,7 +49,7 @@ object MappedOrganisationProvider extends OrganisationProvider { status: Option[String], visibility: Option[String] ): Box[OrganisationTrait] = { - MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)).flatMap { org => + Organisation.find(By(Organisation.OrganisationId, organisationId)).flatMap { org => tryo { name.foreach(v => org.Name(v)) website.foreach(v => org.Website(v)) @@ -63,15 +63,15 @@ object MappedOrganisationProvider extends OrganisationProvider { } override def deleteOrganisation(organisationId: String): Box[Boolean] = { - MappedOrganisation.find(By(MappedOrganisation.OrganisationId, organisationId)).flatMap { org => + Organisation.find(By(Organisation.OrganisationId, organisationId)).flatMap { org => tryo { org.delete_! } } } } -class MappedOrganisation extends OrganisationTrait with LongKeyedMapper[MappedOrganisation] with IdPK { +class Organisation extends OrganisationTrait with LongKeyedMapper[Organisation] with IdPK { - def getSingleton = MappedOrganisation + def getSingleton = Organisation object OrganisationId extends MappedString(this, 64) object Name extends MappedString(this, 255) @@ -104,7 +104,7 @@ class MappedOrganisation extends OrganisationTrait with LongKeyedMapper[MappedOr override def updatedAt: java.util.Date = LastUpdate.get } -object MappedOrganisation extends MappedOrganisation with LongKeyedMetaMapper[MappedOrganisation] { +object Organisation extends Organisation with LongKeyedMetaMapper[Organisation] { override def dbTableName = "Organisation" override def dbIndexes = UniqueIndex(OrganisationId) :: super.dbIndexes } From 852fdc60491e2a569da55bd01e330f2b1aa64cc0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 2 May 2026 11:10:45 +0200 Subject: [PATCH 6/6] Test for 409 on duplicate bank. --- .../scala/code/api/v6_0_0/BankTests.scala | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala b/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala index 6e54fab1a0..498eb2882e 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala @@ -120,6 +120,38 @@ class BankTests extends V600ServerSetup with DefaultUsers { And("The error message should indicate BANK_ID validation failed") response.body.extract[ErrorMessage].message should include("BANK_ID") } + + scenario("Return 409 when creating a bank whose bank_id already exists", ApiEndpoint1, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateBank.toString) + + val bankId = "bank." + randomString(11).toLowerCase + val postJson = PostBankJson600( + bank_id = bankId, + bank_code = "test_code", + full_name = Some("Test Bank for Duplicate"), + logo = Some("https://example.com/logo.png"), + website = Some("https://example.com"), + bank_routings = None + ) + val request = (v6_0_0_Request / "banks").POST <@ (user1) + + try { + Given("a bank with this bank_id already exists") + val firstResponse = makePostRequest(request, write(postJson)) + firstResponse.code should equal(201) + + When("We POST the same bank_id again") + val secondResponse = makePostRequest(request, write(postJson)) + + Then("We should get a 409") + secondResponse.code should equal(409) + + And("The error message should be bankIdAlreadyExists") + secondResponse.body.extract[ErrorMessage].message should equal(ErrorMessages.bankIdAlreadyExists) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + } } } \ No newline at end of file