From e02a895a336f0affc88fa1e549702d70f4da5d50 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sun, 14 Jun 2026 09:53:48 +0200 Subject: [PATCH 1/2] Remove brand names in utility payment. invalidate cache on dynamic entity DDL operations --- .../resources/props/sample.props.template | 23 ++++++++++++ .../main/scala/code/api/util/ApiRole.scala | 4 +-- .../main/scala/code/api/util/NewStyle.scala | 31 +++++++++++++--- .../scala/code/api/v2_2_0/Http4s220.scala | 19 ++++++++++ .../scala/code/api/v7_0_0/Http4s700.scala | 20 +++++------ .../code/api/v7_0_0/JSONFactory7.0.0.scala | 35 +++++++++---------- .../code/api/v7_0_0/Http4s700RoutesTest.scala | 4 +-- 7 files changed, 100 insertions(+), 36 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 341f1629e7..3f9f3b3dbc 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -725,6 +725,29 @@ apiOptions.getAtmsIsPublic = true apiOptions.getProductsIsPublic = true apiOptions.getApiProductsIsPublic = true apiOptions.getTransactionTypesIsPublic = true + +# apiOptions.getCurrentFxRateIsPublic +# Set to true to make Get Current FxRate publicly accessible (no authentication required). +# Indicative reference FX rates are commonly treated as public data, but before enabling this +# on a production instance, admins should be aware of the following: +# +# 1. Upstream data licensing (most likely blocker): if the bank's rates are sourced from a +# licensed vendor (e.g. Refinitiv/LSEG, Bloomberg, an FX aggregator), publicly +# redistributing them may breach that vendor's licence. This can be a legal "no" and is +# bank/instance specific. +# 2. Indicative vs. executable rates: indicative mid-rates are generally fine to publish. +# Executable/dealable quotes with spreads are commercially sensitive and reveal pricing +# strategy to competitors - do not expose these publicly. +# 3. Provenance/fallback: OBP resolves a rate from the connector first, then bundled fallback +# rate files, then a hardcoded map. A public endpoint silently returning a stale fallback as +# if it were the bank's live rate is a liability risk. If public, treat responses as +# "indicative only", timestamped, and ideally label the source. +# 4. Benchmark regulation: if third parties come to rely on these rates as an index, you may +# drift into benchmark-regulation territory (e.g. EU BMR) with administrator obligations. +# 5. Latency/gaming: a public, slightly-stale rate is easier to arbitrage when it feeds any +# downstream priced/tradeable process. Consider update frequency and deviation thresholds. +# 6. Abuse surface: an unauthenticated endpoint loses metering/consumer visibility and is a +# scraping/DoS surface - mitigate with rate-limiting and caching. apiOptions.getCurrentFxRateIsPublic = true ## Default Bank. Incase the server wants to support a default bank so developers don't have to specify BANK_ID, the default value is OBP. 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 248020e761..10773999b5 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -429,8 +429,8 @@ object ApiRole extends MdcLoggable{ case class CanCreateSettlementAccountAtOneBank (requiresBankId: Boolean = true) extends ApiRole lazy val canCreateSettlementAccountAtOneBank = CanCreateSettlementAccountAtOneBank() - // System role for the south-side rail/adapter to deliver the asynchronous UTILITY (e.g. LUKU) - // vend result (electricity token / receipt) back to OBP. Not bank-scoped — the rail is a + // System role for the south-side rail/adapter to deliver the asynchronous UTILITY + // vend result (e.g. a prepaid-electricity token / receipt) back to OBP. Not bank-scoped — the rail is a // trusted system actor, not a per-bank user. case class CanCreateUtilityVendResult (requiresBankId: Boolean = false) extends ApiRole lazy val canCreateUtilityVendResult = CanCreateUtilityVendResult() diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index 4dc515935f..d1c6b00cfe 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3363,6 +3363,22 @@ object NewStyle extends MdcLoggable{ } } + /** + * Invalidate the Redis-backed resource-doc caches whose contents include + * dynamic-entity documentation (the `dynamic` and `all` views). Bumping the + * namespace version orphans every cached key under that namespace, so the + * next `/resource-docs` request regenerates from the database instead of + * serving a pre-change snapshot (TTL is 1 hour by default). + * + * Call after a dynamic entity is created, updated, or deleted. The static + * resource-doc / swagger caches are not touched because dynamic entities + * never appear in them. + */ + private def invalidateDynamicResourceDocCaches(): Unit = { + Constant.incrementCacheNamespaceVersion(Constant.RD_DYNAMIC_NAMESPACE) + Constant.incrementCacheNamespaceVersion(Constant.RD_ALL_NAMESPACE) + } + private def createDynamicEntity(dynamicEntity: DynamicEntityT, callContext: Option[CallContext]): Future[Box[DynamicEntityT]] = { val existsDynamicEntity = DynamicEntityProvider.connectorMethodProvider.vend.getByEntityName(dynamicEntity.bankId, dynamicEntity.entityName) @@ -3371,12 +3387,14 @@ object NewStyle extends MdcLoggable{ s"$DynamicEntityNameAlreadyExists current entityName is '${dynamicEntity.entityName}'." else s"$DynamicEntityNameAlreadyExists current entityName is '${dynamicEntity.entityName}' bankId is ${dynamicEntity.bankId.getOrElse("")}." - + return Helper.booleanToFuture(errorMsg, cc=callContext)(existsDynamicEntity.isEmpty).map(_.asInstanceOf[Box[DynamicEntityT]]) } Future { - DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(dynamicEntity) + val result = DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(dynamicEntity) + if (result.isDefined) invalidateDynamicResourceDocCaches() + result } } @@ -3405,7 +3423,9 @@ object NewStyle extends MdcLoggable{ } Future { - DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(dynamicEntity) + val result = DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(dynamicEntity) + if (result.isDefined) invalidateDynamicResourceDocCaches() + result } } @@ -3424,7 +3444,7 @@ object NewStyle extends MdcLoggable{ def deleteDynamicEntity(bankId: Option[String], dynamicEntityId: String): Future[Box[Boolean]] = { validateBankId(bankId, None) Future { - for { + val result = for { entity <- DynamicEntityProvider.connectorMethodProvider.vend.getById(bankId, dynamicEntityId) deleteEntityResult <- DynamicEntityProvider.connectorMethodProvider.vend.delete(entity) deleteEntitleMentResult <- if (deleteEntityResult) { @@ -3438,6 +3458,9 @@ object NewStyle extends MdcLoggable{ } deleteEntitleMentResult } + // The entity (and its generated resource docs) are gone — drop the stale doc caches. + if (result.exists(deleted => deleted)) invalidateDynamicResourceDocCaches() + result } } diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala index f777431b59..b2b18f3cd6 100644 --- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala +++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala @@ -290,6 +290,25 @@ object Http4s220 { } } + // TODO: Add a v7.0.0 of Get Current FxRate with a richer, provenance-aware response. + // The current (v2.2.0) response returns only conversion_value / inverse_conversion_value / + // effective_date, which cannot express how the rate was sourced or how much to trust it. + // Proposed improvements for the v7.0.0 response body: + // - status / quality: "indicative" | "executable" | "fallback" — so consumers know whether + // the rate is tradeable or for reference only (especially important when the endpoint is + // public via apiOptions.getCurrentFxRateIsPublic). + // - source / provenance: which tier produced the rate — "connector" | "fallback_file" | + // "hardcoded_map" — so a stale fallback is never mistaken for the bank's live rate. + // - bid / ask / mid + spread (instead of a single conversion_value), so indicative mid can + // be distinguished from executable quotes, and spread can be withheld when public. + // - retrieved_at (when OBP fetched it) and explicit age/staleness, alongside effective_date. + // - precision/scale of the currency pair (ISO 4217 minor units) for correct rounding. + // - optional `amount` query param to return a converted amount with documented rounding. + // - first-class crypto asset support (e.g. lovelace, ETH) — already hinted at in the + // InvalidISOCurrencyCode error message. + // - a disclaimer field ("indicative only") + cache TTL hint when served publicly. + // - consider a batch variant accepting multiple currency pairs in one request. + // Keep v2.2.0 in place for backward compatibility; v7.0.0 should be additive. resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(getCurrentFxRate), "GET", "/banks/BANK_ID/fx/FROM_CURRENCY_CODE/TO_CURRENCY_CODE", 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 9348c22349..0e8e5b617f 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 @@ -3062,7 +3062,7 @@ object Http4s700 { // ── UTILITY vend-result delivery (inbound, asynchronous) ─────────────────── // The downstream rail/adapter calls this once the utility vend settles, delivering - // the electricity token / receipt (e.g. a LUKU 20-digit STS token). OBP persists the + // the token / receipt (e.g. a 20-digit STS prepaid-electricity token). OBP persists the // vend fields as transaction-request attributes and — if the payer registered a // callback_url on the original request — POSTs the vend result to it. The rail is a // trusted system actor, gated by canCreateUtilityVendResult. Returns 200. @@ -3076,10 +3076,10 @@ object Http4s700 { // Only present fields are persisted; the vend status is always written. val attrs: List[(String, String)] = (JSONFactory700.UtilityVendAttribute.VendStatus -> body.status) :: List( - body.luku_token.map(JSONFactory700.UtilityVendAttribute.Token -> _), + body.token.map(JSONFactory700.UtilityVendAttribute.Token -> _), body.rcpt_num.map(JSONFactory700.UtilityVendAttribute.RcptNum -> _), body.units.map(JSONFactory700.UtilityVendAttribute.Units -> _), - body.gwx_reference.map(JSONFactory700.UtilityVendAttribute.GwxReference -> _), + body.provider_reference.map(JSONFactory700.UtilityVendAttribute.ProviderReference -> _), body.provider_message.map(JSONFactory700.UtilityVendAttribute.ProviderMessage -> _) ).flatten for { @@ -3127,11 +3127,11 @@ object Http4s700 { "/banks/BANK_ID/utility-payments/UTILITY_TRANSACTION_REQUEST_ID/vend-result", "Deliver UTILITY Vend Result", """**System endpoint** — called by the downstream rail/adapter (not the payer) to deliver the - |asynchronous result of a UTILITY payment, e.g. a LUKU (TANESCO prepaid electricity) purchase. + |asynchronous result of a UTILITY payment, e.g. a prepaid-electricity purchase. | |The vend is asynchronous: the original `POST .../transaction-request-types/UTILITY/transaction-requests` - |returns immediately with `vend_result: null`, and the actual deliverable — the **20-digit STS - |electricity token** plus receipt (`rcpt_num`, `units`, provider reference) — arrives here once the + |returns immediately with `vend_result: null`, and the actual deliverable — the **STS prepaid token** + |(typically 20 digits) plus receipt (`rcpt_num`, `units`, `provider_reference`) — arrives here once the |rail settles the vend. OBP records the vend fields as attributes on the transaction request and, |if the payer registered a `callback_url`, POSTs this vend result to that URL (a failed or |unreachable callback never fails this request). @@ -3141,10 +3141,10 @@ object Http4s700 { |Requires the `CanCreateUtilityVendResult` system entitlement.""".stripMargin, JSONFactory700.PostUtilityVendResultJsonV700( status = "COMPLETED", - luku_token = Some("1234 5678 9012 3456 7890"), + token = Some("1234 5678 9012 3456 7890"), rcpt_num = Some("202306141018422348674"), units = Some("46.5"), - gwx_reference = Some("GWX800930701197"), + provider_reference = Some("REF800930701197"), provider_message = Some("Vend successful") ), JSONFactory700.UtilityVendResultResponseJsonV700( @@ -3153,10 +3153,10 @@ object Http4s700 { status = "COMPLETED", vend_result = Some(JSONFactory700.UtilityVendResultJsonV700( status = "COMPLETED", - luku_token = Some("1234 5678 9012 3456 7890"), + token = Some("1234 5678 9012 3456 7890"), rcpt_num = Some("202306141018422348674"), units = Some("46.5"), - gwx_reference = Some("GWX800930701197"), + provider_reference = Some("REF800930701197"), provider_message = Some("Vend successful") )), callback = Some(JSONFactory700.UtilityCallbackJsonV700( 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 66b2cb4bef..26c39301c1 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 @@ -813,33 +813,32 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) // The asynchronous vend result delivered by the downstream rail/adapter after the - // utility purchase settles — e.g. the 20-digit STS electricity token for a LUKU - // (TANESCO prepaid electricity) meter. Field names mirror the Gateway X - // `gepgVendCustInfoRes` payload. Persisted on the transaction request as attributes - // and surfaced here (and on the client callback) once the vend completes. + // utility purchase settles — e.g. the STS token (typically 20 digits) for a prepaid + // electricity meter. Persisted on the transaction request as attributes and surfaced + // here (and on the client callback) once the vend completes. case class UtilityVendResultJsonV700( status: String, // ACCEPTED | COMPLETED | FAILED (provider vend status) - luku_token: Option[String], // the 20-digit STS token the customer keys into the meter + token: Option[String], // the STS token the customer keys into the meter (e.g. 20 digits) rcpt_num: Option[String], // provider receipt number - units: Option[String], // electricity units purchased (kWh) - gwx_reference: Option[String], // downstream rail reference (gwxReference) + units: Option[String], // units purchased (e.g. electricity kWh) + provider_reference: Option[String], // downstream rail / provider reference provider_message: Option[String] // free-text provider remark ) /** Inbound body for the vend-result delivery endpoint (rail/adapter → OBP). */ case class PostUtilityVendResultJsonV700( status: String, - luku_token: Option[String], + token: Option[String], rcpt_num: Option[String], units: Option[String], - gwx_reference: Option[String], + provider_reference: Option[String], provider_message: Option[String] ) // Response of the vend-result delivery endpoint, and the payload OBP POSTs to the // payer's registered callback_url. Deliberately lean — it carries the vend result // (the token), not an echo of the original request (the payer already has that from - // the create response). Mirrors the Gateway X callback, which delivers the vend result. + // the create response). case class UtilityVendResultResponseJsonV700( transaction_request_id: String, `type`: String, // always "UTILITY" @@ -850,12 +849,12 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { // Attribute names under which the vend result is persisted on the transaction request. object UtilityVendAttribute { - val Token = "LUKU_TOKEN" - val RcptNum = "LUKU_RCPT_NUM" - val Units = "LUKU_UNITS" - val GwxReference = "LUKU_GWX_REFERENCE" - val VendStatus = "LUKU_VEND_STATUS" - val ProviderMessage = "LUKU_PROVIDER_MESSAGE" + val Token = "UTILITY_VEND_TOKEN" + val RcptNum = "UTILITY_VEND_RCPT_NUM" + val Units = "UTILITY_VEND_UNITS" + val ProviderReference = "UTILITY_VEND_PROVIDER_REFERENCE" + val VendStatus = "UTILITY_VEND_STATUS" + val ProviderMessage = "UTILITY_VEND_PROVIDER_MESSAGE" } // v7 response shape for UTILITY. Mirrors MOBILE_WALLET's wrapper and adds the @@ -913,10 +912,10 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { byName.get(UtilityVendAttribute.VendStatus).map { status => UtilityVendResultJsonV700( status = status, - luku_token = byName.get(UtilityVendAttribute.Token), + token = byName.get(UtilityVendAttribute.Token), rcpt_num = byName.get(UtilityVendAttribute.RcptNum), units = byName.get(UtilityVendAttribute.Units), - gwx_reference = byName.get(UtilityVendAttribute.GwxReference), + provider_reference = byName.get(UtilityVendAttribute.ProviderReference), provider_message = byName.get(UtilityVendAttribute.ProviderMessage) ) } 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 c9acd2665f..220505ee50 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 @@ -1981,7 +1981,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { feature("Http4s700 createUtilityVendResult endpoint") { val vendBody = - """{"status":"COMPLETED","luku_token":"1234 5678 9012 3456 7890","rcpt_num":"202306141018422348674","units":"46.5","gwx_reference":"GWX800930701197"}""" + """{"status":"COMPLETED","token":"1234 5678 9012 3456 7890","rcpt_num":"202306141018422348674","units":"46.5","provider_reference":"REF800930701197"}""" scenario("Reject unauthenticated POST", Http4s700RoutesTag) { val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/${testBankId1.value}/utility-payments/any-tr-id/vend-result", vendBody) @@ -2030,7 +2030,7 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { map.get("vend_result") match { case Some(JObject(vrFields)) => val vr = toFieldMap(vrFields) - vr.get("luku_token") shouldBe Some(JString("1234 5678 9012 3456 7890")) + vr.get("token") shouldBe Some(JString("1234 5678 9012 3456 7890")) vr.get("rcpt_num") shouldBe Some(JString("202306141018422348674")) vr.get("status") shouldBe Some(JString("COMPLETED")) case other => fail(s"Expected vend_result object, got: $other") From a4a20526d56ac271f6caf316f794f6b8c5862762 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Jun 2026 17:12:08 +0200 Subject: [PATCH 2/2] write_role and read_role_required snake case. Now a user can PATCH one field. --- .../dynamic/entity/Http4sDynamicEntity.scala | 40 +++-- .../entity/helper/DynamicEntityHelper.scala | 18 ++- .../main/scala/code/api/util/Glossary.scala | 6 +- .../scala/code/api/v6_0_0/Http4s600.scala | 22 +-- .../dynamicEntity/DynamicEntityProvider.scala | 34 ++-- .../v6_0_0/DynamicEntityFieldRolesTest.scala | 152 +++++++++++++++++- 6 files changed, 219 insertions(+), 53 deletions(-) diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala index ed610dfa82..0e773699c6 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/Http4sDynamicEntity.scala @@ -213,14 +213,30 @@ object Http4sDynamicEntity extends MdcLoggable { } } - // ----- Field-level write path (PATCH): role-gated writes to restricted fields ----- - // Names of the per-field write roles the caller is missing for the restricted fields present in the body. - private def missingFieldWriteRoleNames(bodyFieldNames: List[String], bankId: Option[String], entityName: String, userId: String): List[String] = { + // ----- Field-level write path (PATCH): per-field authorisation ----- + // Authorisation is per field present in the body: a write-restricted field is governed by its field write + // role; an unrestricted field is governed by the entity update role. The caller may change a field iff they + // hold the role that governs it — there is no blanket entity-update precondition, so holding only a field's + // write role is sufficient to PATCH that field alone. Returns the distinct role names the caller is missing. + // For a personal entity that doesn't require a role, the entity update role on unrestricted fields is skipped, + // but write-restricted fields are still gated by their field write role. + private def missingPatchRoleNames( + bodyFieldNames: List[String], bankId: Option[String], entityName: String, userId: String, requireEntityRole: Boolean + ): List[String] = { val info = DynamicEntityHelper.definitionsMap.get((bankId, entityName)) - val restrictedInBody = info.map(_.writeRestrictedFields).getOrElse(Nil).intersect(bodyFieldNames) - restrictedInBody.flatMap { f => - val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) - if (code.api.util.APIUtil.hasEntitlement(bankId.getOrElse(""), userId, role)) None else Some(role.toString()) + // Only declared schema fields are meaningful (id/audit/unknown fields are ignored by the merge). + val schemaFields = info.map(_.propertyNames).getOrElse(bodyFieldNames) + val touched = bodyFieldNames.intersect(schemaFields) + val writeRestricted = info.map(_.writeRestrictedFields).getOrElse(Nil).toSet + def has(role: code.api.util.ApiRole): Boolean = code.api.util.APIUtil.hasEntitlement(bankId.getOrElse(""), userId, role) + touched.flatMap { f => + if (writeRestricted.contains(f)) { + val role = DynamicEntityInfo.fieldWriteRole(entityName, f, bankId, info.flatMap(_.explicitWriteRole(f))) + if (has(role)) None else Some(role.toString()) + } else if (requireEntityRole) { + val role = DynamicEntityInfo.canUpdateRole(entityName, bankId) + if (has(role)) None else Some(role.toString()) + } else None }.distinct } @@ -372,14 +388,14 @@ object Http4sDynamicEntity extends MdcLoggable { (Full(u), callContext) <- authenticatedAccess(callContext0) (_, callContext) <- bankCheck(bankId, callContext) personalRequiresRole = DynamicEntityHelper.definitionsMap.get((bankId, entityName)).exists(_.personalRequiresRole) - // Baseline: PATCH needs the entity update role (like PUT) for unrestricted fields. - _ <- if (isPersonalEntity && !personalRequiresRole) Future.successful(true) - else NewStyle.function.hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canUpdateRole(entityName, bankId), callContext) _ <- failIf(afterIntercept(callContext, operationId), callContext) json <- NewStyle.function.tryons(InvalidJsonFormat, 400, callContext) { net.liftweb.json.parse(cc.httpBody.getOrElse("")) } bodyObj = json.asInstanceOf[JObject] - // Per-field: every write-restricted field present in the body needs its field-level write role. - missingRoles = missingFieldWriteRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId) + // Per-field authorisation: each field present in the body needs the role that governs it — its field + // write role if write-restricted, otherwise the entity update role. No blanket entity-update precondition. + // For a personal entity without a required role, the entity role on unrestricted fields is skipped. + requireEntityRole = !(isPersonalEntity && !personalRequiresRole) + missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId, requireEntityRole) _ <- Helper.booleanToFuture(s"$UserHasMissingRoles ${missingRoles.mkString(", ")}", 403, cc = callContext) { missingRoles.isEmpty } (existing, _) <- NewStyle.function.invokeDynamicConnector(GET_ONE, entityName, None, Some(id), bankId, None, Some(u.userId), isPersonalEntity, Some(cc)) _ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined } diff --git a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala index d30c31ab19..a68b537e9e 100644 --- a/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala +++ b/obp-api/src/main/scala/code/api/dynamic/entity/helper/DynamicEntityHelper.scala @@ -346,9 +346,11 @@ object DynamicEntityHelper { s"""Partially update $splitName: only the fields supplied in the body are changed; others are preserved. | |This is also the write path for **field-level write-restricted** fields (those declared with - |`writeRoleRequired` or an explicit `writeRole`). To write such a field the caller must hold that field's - |write role; otherwise the request is rejected with 403 (missing role). Unrestricted fields require - |the entity update role, as for PUT. + |`write_role_required` or an explicit `write_role`). Authorisation is **per field present in the body**: + |a write-restricted field requires that field's write role, and an unrestricted field requires the entity + |update role. You may change a field if (and only if) you hold the role that governs it; the request is + |rejected with 403 (missing role) listing the roles you lack. There is no blanket entity-update + |precondition — holding only a field's write role is sufficient to PATCH that field alone. |${dynamicEntityInfo.description} | |${dynamicEntityInfo.fieldsDescription} @@ -847,13 +849,13 @@ case class DynamicEntityInfo(definition: String, entityName: String, bankId: Opt case _ => Nil } /** Fields written only via the role-gated PATCH path (not via POST/PUT). */ - lazy val writeRestrictedFields: List[String] = restrictedFields("writeRoleRequired", "writeRole") + lazy val writeRestrictedFields: List[String] = restrictedFields("write_role_required", "write_role") /** Fields omitted from GET unless the caller holds the read role. */ - lazy val readRestrictedFields: List[String] = restrictedFields("readRoleRequired", "readRole") + lazy val readRestrictedFields: List[String] = restrictedFields("read_role_required", "read_role") def explicitWriteRole(fieldName: String): Option[String] = - (entity \ "properties" \ fieldName \ "writeRole") match { case JString(s) if s.nonEmpty => Some(s); case _ => None } + (entity \ "properties" \ fieldName \ "write_role") match { case JString(s) if s.nonEmpty => Some(s); case _ => None } def explicitReadRole(fieldName: String): Option[String] = - (entity \ "properties" \ fieldName \ "readRole") match { case JString(s) if s.nonEmpty => Some(s); case _ => None } + (entity \ "properties" \ fieldName \ "read_role") match { case JString(s) if s.nonEmpty => Some(s); case _ => None } /** Declared schema property names (used to bound a PATCH merge to real fields). */ lazy val propertyNames: List[String] = (entity \ "properties") match { case props: JObject => props.obj.map(_.name) @@ -907,7 +909,7 @@ object DynamicEntityInfo { canDeleteRole(entityName, bankId) ).map(_.toString()) - // Field-level roles. If the definition declares an explicit writeRole/readRole, use it verbatim + // Field-level roles. If the definition declares an explicit write_role/read_role, use it verbatim // (so many fields/entities can share one role); otherwise auto-generate a per-field role. def fieldWriteRole(entityName: String, fieldName: String, bankId: Option[String], explicit: Option[String]): ApiRole = explicit match { 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 481bc482a7..e967a2f790 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3263,15 +3263,15 @@ object Glossary extends MdcLoggable { | |Each property in the schema can optionally restrict who may write or read that field, independently of the entity-level roles above: | -|* `writeRoleRequired` (boolean) or `writeRole` (explicit role name) — the field becomes **write-restricted**: it cannot be set via POST or PUT (its existing value is preserved), only via **PATCH** by a caller holding the field's write role. -|* `readRoleRequired` (boolean) or `readRole` (explicit role name) — the field becomes **read-restricted**: it is omitted from GET responses unless the caller holds the field's read role (public/anonymous access omits it entirely). +|* `write_role_required` (boolean) or `write_role` (explicit role name) — the field becomes **write-restricted**: it cannot be set via POST or PUT (its existing value is preserved), only via **PATCH** by a caller holding the field's write role. +|* `read_role_required` (boolean) or `read_role` (explicit role name) — the field becomes **read-restricted**: it is omitted from GET responses unless the caller holds the field's read role (public/anonymous access omits it entirely). | |Restriction is on if either the boolean is `true` or an explicit role name is given. When a boolean is used, OBP auto-generates the role; e.g. for entity 'FooBar' field 'owner': | |* CanWriteDynamicEntityField_FooBar__owner (bank level) / CanWriteDynamicEntityField_SystemFooBar__owner (system level) |* CanGetDynamicEntityField_FooBar__owner / CanGetDynamicEntityField_SystemFooBar__owner | -|Naming an explicit `writeRole`/`readRole` lets several fields (even across entities) share a single role — useful for a privileged service (e.g. an indexer) that maintains many fields. Typical use: a field written only by a verifier/service or projected from an external system, but read by ordinary consumers. +|Naming an explicit `write_role`/`read_role` lets several fields (even across entities) share a single role — useful for a privileged service (e.g. an indexer) that maintains many fields. Typical use: a field written only by a verifier/service or projected from an external system, but read by ordinary consumers. | |**Management endpoints:** | diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 0aec4ca566..e6b1788eca 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -6782,7 +6782,7 @@ object Http4s600 { | "properties": { | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, - | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted", "writeRoleRequired": true} + | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted", "write_role_required": true} | } | } |} @@ -6792,7 +6792,7 @@ object Http4s600 { |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property MUST include an `example` field with a valid example value. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. - |* Each property can optionally declare **field-level access control**: `writeRoleRequired`/`readRoleRequired` (booleans — auto-generate a per-field role) or `writeRole`/`readRole` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. + |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). @@ -6804,7 +6804,7 @@ object Http4s600 { has_public_access = Some(false), has_community_access = Some(false), personal_requires_role = Some(false), - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "writeRoleRequired": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (write_role_required)", "write_role_required": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -6815,7 +6815,7 @@ object Http4s600 { has_public_access = false, has_community_access = false, personal_requires_role = false, - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "writeRoleRequired": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (write_role_required)", "write_role_required": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, @@ -6847,7 +6847,7 @@ object Http4s600 { | "properties": { | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, - | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted", "writeRoleRequired": true} + | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted", "write_role_required": true} | } | } |} @@ -6857,7 +6857,7 @@ object Http4s600 { |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property MUST include an `example` field with a valid example value. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. - |* Each property can optionally declare **field-level access control**: `writeRoleRequired`/`readRoleRequired` (booleans — auto-generate a per-field role) or `writeRole`/`readRole` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. + |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). @@ -6869,7 +6869,7 @@ object Http4s600 { has_public_access = Some(false), has_community_access = Some(false), personal_requires_role = Some(false), - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "writeRoleRequired": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (write_role_required)", "write_role_required": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -6880,7 +6880,7 @@ object Http4s600 { has_public_access = false, has_community_access = false, personal_requires_role = false, - schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "writeRoleRequired": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] + schema = net.liftweb.json.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (write_role_required)", "write_role_required": true}}}""").asInstanceOf[net.liftweb.json.JsonAST.JObject] ), List( $BankNotFound, @@ -6927,7 +6927,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. - |* Each property can optionally declare **field-level access control**: `writeRoleRequired`/`readRoleRequired` (booleans — auto-generate a per-field role) or `writeRole`/`readRole` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. + |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). @@ -6986,7 +6986,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. - |* Each property can optionally declare **field-level access control**: `writeRoleRequired`/`readRoleRequired` (booleans — auto-generate a per-field role) or `writeRole`/`readRole` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. + |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). @@ -7051,7 +7051,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. - |* Each property can optionally declare **field-level access control**: `writeRoleRequired`/`readRoleRequired` (booleans — auto-generate a per-field role) or `writeRole`/`readRole` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. + |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). |* Set `personal_requires_role` to `true` to require the corresponding role (e.g. CanCreateDynamicEntity_, CanGetDynamicEntity_) for `/my/` personal entity endpoints. Default is `false` (any authenticated user can use `/my/` endpoints). diff --git a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala index 2fc0601608..3b7f59aac8 100644 --- a/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala +++ b/obp-api/src/main/scala/code/dynamicEntity/DynamicEntityProvider.scala @@ -73,9 +73,9 @@ trait DynamicEntityT { case None => definition } - // ----- Field-level access control (writeRole / readRole) ----- - // A field is write-restricted if it sets writeRoleRequired:true OR a non-empty writeRole. - // A field is read-restricted if it sets readRoleRequired:true OR a non-empty readRole. + // ----- Field-level access control (write_role / read_role) ----- + // A field is write-restricted if it sets write_role_required:true OR a non-empty write_role. + // A field is read-restricted if it sets read_role_required:true OR a non-empty read_role. private def restrictedFieldNames(requiredFlag: String, roleKey: String): List[String] = { def isRestricted(propDef: JValue): Boolean = (propDef \ requiredFlag) == JBool(true) || @@ -87,19 +87,19 @@ trait DynamicEntityT { } /** Fields whose writing requires a field-level role (not writable via POST/PUT; only via the role-gated PATCH path). */ - lazy val writeRestrictedFields: List[String] = restrictedFieldNames("writeRoleRequired", "writeRole") + lazy val writeRestrictedFields: List[String] = restrictedFieldNames("write_role_required", "write_role") /** Fields whose reading requires a field-level role (omitted from GET responses otherwise). */ - lazy val readRestrictedFields: List[String] = restrictedFieldNames("readRoleRequired", "readRole") + lazy val readRestrictedFields: List[String] = restrictedFieldNames("read_role_required", "read_role") - /** Explicit writeRole declared on a field, if any (otherwise an auto-generated role applies). */ + /** Explicit write_role declared on a field, if any (otherwise an auto-generated role applies). */ def explicitWriteRole(fieldName: String): Option[String] = - (definition \ entityName \ "properties" \ fieldName \ "writeRole") match { + (definition \ entityName \ "properties" \ fieldName \ "write_role") match { case JString(s) if s.nonEmpty => Some(s) case _ => None } - /** Explicit readRole declared on a field, if any. */ + /** Explicit read_role declared on a field, if any. */ def explicitReadRole(fieldName: String): Option[String] = - (definition \ entityName \ "properties" \ fieldName \ "readRole") match { + (definition \ entityName \ "properties" \ fieldName \ "read_role") match { case JString(s) if s.nonEmpty => Some(s) case _ => None } @@ -594,21 +594,21 @@ object DynamicEntityCommons extends Converter[DynamicEntityT, DynamicEntityCommo } // validate optional field-level access-control keywords (all optional; absence => unrestricted) - val writeRoleRequired = value \ "writeRoleRequired" + val writeRoleRequired = value \ "write_role_required" if(writeRoleRequired != JNothing) { - checkFormat(writeRoleRequired.isInstanceOf[JBool], s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'writeRoleRequired' field must be boolean.") + checkFormat(writeRoleRequired.isInstanceOf[JBool], s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'write_role_required' field must be boolean.") } - val readRoleRequired = value \ "readRoleRequired" + val readRoleRequired = value \ "read_role_required" if(readRoleRequired != JNothing) { - checkFormat(readRoleRequired.isInstanceOf[JBool], s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'readRoleRequired' field must be boolean.") + checkFormat(readRoleRequired.isInstanceOf[JBool], s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'read_role_required' field must be boolean.") } - val writeRole = value \ "writeRole" + val writeRole = value \ "write_role" if(writeRole != JNothing) { - checkFormat(writeRole.isInstanceOf[JString] && writeRole.asInstanceOf[JString].s.nonEmpty, s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'writeRole' field must be a non-empty string.") + checkFormat(writeRole.isInstanceOf[JString] && writeRole.asInstanceOf[JString].s.nonEmpty, s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'write_role' field must be a non-empty string.") } - val readRole = value \ "readRole" + val readRole = value \ "read_role" if(readRole != JNothing) { - checkFormat(readRole.isInstanceOf[JString] && readRole.asInstanceOf[JString].s.nonEmpty, s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'readRole' field must be a non-empty string.") + checkFormat(readRole.isInstanceOf[JString] && readRole.asInstanceOf[JString].s.nonEmpty, s"$DynamicEntityInstanceValidateFail The property of $fieldName's 'read_role' field must be a non-empty string.") } // validate optional indexing keywords (DE_indexing). All optional; absence => field is not queryable. diff --git a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala index c91123c794..8a2f754cba 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/DynamicEntityFieldRolesTest.scala @@ -26,6 +26,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v6_0_0 import code.api.util.APIUtil.OAuth._ +import com.openbankproject.commons.model.ErrorMessage import code.api.util.ApiRole._ import code.entitlement.Entitlement import com.openbankproject.commons.util.ApiVersion @@ -50,6 +51,12 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { private def grant(role: String): Unit = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, role) + // Grant to a specific user. Creating a dynamic entity auto-grants its CRUD roles to the *creator* + // (resourceUser1, via createSystemEntity), so the "no entity update role" scenarios use resourceUser2 — + // a user who did not create the entity and therefore only holds what we explicitly grant here. + private def grantTo(userId: String, role: String): Unit = + Entitlement.entitlement.vend.addEntitlement("", userId, role) + private def createSystemEntity(entityJson: JValue): (Int, JValue) = { grant(CanCreateSystemLevelDynamicEntity.toString) val request = (v6_0_0_Request / "management" / "system-dynamic-entities").POST <@(user1) @@ -84,8 +91,8 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { | "required": ["name"], | "properties": { | "name": {"type": "string", "minLength": 1, "maxLength": 40, "example": "Acme"}, - | "internal_note": {"type": "string", "example": "set via patch", "writeRoleRequired": true}, - | "secret_note": {"type": "string", "example": "hush", "readRoleRequired": true} + | "internal_note": {"type": "string", "example": "set via patch", "write_role_required": true}, + | "secret_note": {"type": "string", "example": "hush", "read_role_required": true} | } |} """.stripMargin) @@ -97,6 +104,26 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { private def recordId(createBody: JValue): String = (createBody \ single \ idName).extract[String] + // ---- Per-entity fixtures for the per-field authorisation scenarios ---- + // Each scenario uses a UNIQUE entity name so the (entity-scoped) role names don't collide with grants + // accumulated by earlier scenarios on resourceUser1 — that's what lets us test "role X alone". + private def createRoleFor(n: String) = s"CanCreateDynamicEntity_System$n" + private def getRoleFor(n: String) = s"CanGetDynamicEntity_System$n" + private def updateRoleFor(n: String) = s"CanUpdateDynamicEntity_System$n" + private def writeNoteRoleFor(n: String) = s"CanWriteDynamicEntityField_System${n}__internal_note" + + private def fieldRolesEntity(n: String): JValue = + ("entity_name" -> n) ~ + ("has_personal_entity" -> true) ~ + ("schema" -> parse( + s"""{"description":"Per-field auth test entity.","required":["name"], + |"properties":{ + |"name":{"type":"string","minLength":1,"maxLength":40,"example":"Acme"}, + |"internal_note":{"type":"string","example":"set via patch","write_role_required":true}, + |"secret_note":{"type":"string","example":"hush","read_role_required":true}}}""".stripMargin)) + + private def recordIdFor(n: String, body: JValue): String = (body \ n \ s"${n}_id").extract[String] + // ==================== Scenarios ==================== feature("Field-level write/read role permissions on Dynamic Entities") { @@ -199,4 +226,125 @@ class DynamicEntityFieldRolesTest extends V600ServerSetup { } finally deleteSystemEntity(dynamicEntityId) } } + + feature("Per-field PATCH authorisation (no blanket entity-update precondition)") { + + scenario("Field write role alone (no entity update role) can PATCH the restricted field", VersionOfApi) { + val n = "fr_field_alone" + val (code, body) = createSystemEntity(fieldRolesEntity(n)) // user1 is the creator (auto-granted entity roles) + code should equal(201) + val deId = (body \ "dynamic_entity_id").extract[String] + try { + val createResp = makePostRequest((dynamicEntity_Request / n).POST <@(user1), write(parse("""{"name":"Acme"}"""))) + createResp.code should equal(201) + val id = recordIdFor(n, createResp.body) + // user2 (NOT the creator) holds ONLY the field write role — no entity update/get role. + grantTo(resourceUser2.userId, writeNoteRoleFor(n)) + + When("user2 PATCHes the restricted field holding only its field write role (no entity update role)") + val patch = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"internal_note":"viaPatch"}"""))) + Then("It succeeds — a field role alone is sufficient to write that field") + patch.code should equal(200) + val getResp = makeGetRequest((dynamicEntity_Request / n / id).GET <@(user1)) + (getResp.body \ n \ "internal_note").extract[String] should equal("viaPatch") + (getResp.body \ n \ "name").extract[String] should equal("Acme") + } finally deleteSystemEntity(deId) + } + + scenario("Field write role alone cannot PATCH an unrestricted field", VersionOfApi) { + val n = "fr_unrestricted_denied" + val (code, body) = createSystemEntity(fieldRolesEntity(n)) // user1 is the creator + code should equal(201) + val deId = (body \ "dynamic_entity_id").extract[String] + try { + val createResp = makePostRequest((dynamicEntity_Request / n).POST <@(user1), write(parse("""{"name":"Acme"}"""))) + val id = recordIdFor(n, createResp.body) + // user2 (NOT the creator) holds ONLY the field write role — no entity update role. + grantTo(resourceUser2.userId, writeNoteRoleFor(n)) + + When("user2 PATCHes an unrestricted field without the entity update role") + val patch1 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"name":"Acme2"}"""))) + Then("We get 403 naming the entity update role") + patch1.code should equal(403) + patch1.body.extract[ErrorMessage].message should include(updateRoleFor(n)) + + And("A mixed body (restricted + unrestricted) is also rejected") + val patch2 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"internal_note":"x","name":"Acme2"}"""))) + patch2.code should equal(403) + + And("The unrestricted field is unchanged") + val getResp = makeGetRequest((dynamicEntity_Request / n / id).GET <@(user1)) + (getResp.body \ n \ "name").extract[String] should equal("Acme") + } finally deleteSystemEntity(deId) + } + + scenario("Entity update role alone can PATCH unrestricted fields but not restricted ones", VersionOfApi) { + val n = "fr_baseline_only" + val (code, body) = createSystemEntity(fieldRolesEntity(n)) + code should equal(201) + val deId = (body \ "dynamic_entity_id").extract[String] + try { + grant(createRoleFor(n)); grant(getRoleFor(n)); grant(updateRoleFor(n)) // NOT the field write role + val createResp = makePostRequest((dynamicEntity_Request / n).POST <@(user1), write(parse("""{"name":"Acme"}"""))) + val id = recordIdFor(n, createResp.body) + + When("We PATCH an unrestricted field with the entity update role") + val patch1 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user1), write(parse("""{"name":"Acme2"}"""))) + Then("It succeeds") + patch1.code should equal(200) + + When("We PATCH the restricted field without its field write role") + val patch2 = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user1), write(parse("""{"internal_note":"x"}"""))) + Then("We get 403 naming the field write role") + patch2.code should equal(403) + patch2.body.extract[ErrorMessage].message should include(writeNoteRoleFor(n)) + } finally deleteSystemEntity(deId) + } + + scenario("PATCH a restricted field with its current (unchanged) value still requires the role", VersionOfApi) { + val n = "fr_unchanged_value" + val (code, body) = createSystemEntity(fieldRolesEntity(n)) + code should equal(201) + val deId = (body \ "dynamic_entity_id").extract[String] + try { + grant(createRoleFor(n)); grant(getRoleFor(n)); grant(updateRoleFor(n)); grant(writeNoteRoleFor(n)) + val createResp = makePostRequest((dynamicEntity_Request / n).POST <@(user1), write(parse("""{"name":"Acme"}"""))) + val id = recordIdFor(n, createResp.body) + // user1 (who holds the field role) sets internal_note to a known value + makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user1), write(parse("""{"internal_note":"verified"}"""))).code should equal(200) + + When("user2 (no roles for this entity) PATCHes the restricted field to the SAME value") + val patch = makePatchRequest((dynamicEntity_Request / n / id).PATCH <@(user2), write(parse("""{"internal_note":"verified"}"""))) + Then("It is still rejected — presence in the body is checked, not whether the value changed") + patch.code should equal(403) + patch.body.extract[ErrorMessage].message should include(writeNoteRoleFor(n)) + } finally deleteSystemEntity(deId) + } + + scenario("Personal entity without personal_requires_role: unrestricted PATCH needs no role; restricted still needs the field role", VersionOfApi) { + val n = "fr_personal" + val (code, body) = createSystemEntity(fieldRolesEntity(n)) // has_personal_entity=true, personal_requires_role defaults false + code should equal(201) + val deId = (body \ "dynamic_entity_id").extract[String] + try { + // user2 holds no roles for this entity; personal_requires_role defaults false. + When("user2 creates a personal record without any entity role") + val createResp = makePostRequest((dynamicEntity_Request / "my" / n).POST <@(user2), write(parse("""{"name":"Acme"}"""))) + createResp.code should equal(201) + val id = recordIdFor(n, createResp.body) + + Then("PATCH of an unrestricted field succeeds without any role") + makePatchRequest((dynamicEntity_Request / "my" / n / id).PATCH <@(user2), write(parse("""{"name":"Acme2"}"""))).code should equal(200) + + And("PATCH of the restricted field is still rejected without the field write role") + val patch1 = makePatchRequest((dynamicEntity_Request / "my" / n / id).PATCH <@(user2), write(parse("""{"internal_note":"x"}"""))) + patch1.code should equal(403) + patch1.body.extract[ErrorMessage].message should include(writeNoteRoleFor(n)) + + And("Granting the field write role lets the restricted field be PATCHed") + grantTo(resourceUser2.userId, writeNoteRoleFor(n)) + makePatchRequest((dynamicEntity_Request / "my" / n / id).PATCH <@(user2), write(parse("""{"internal_note":"x"}"""))).code should equal(200) + } finally deleteSystemEntity(deId) + } + } }