Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
|
Expand Down
31 changes: 27 additions & 4 deletions obp-api/src/main/scala/code/api/util/NewStyle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
}
}

Expand Down Expand Up @@ -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
}

}
Expand All @@ -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) {
Expand All @@ -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
}
}

Expand Down
19 changes: 19 additions & 0 deletions obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,25 @@
}
}

// TODO: Add a v7.0.0 of Get Current FxRate with a richer, provenance-aware response.

Check warning on line 293 in obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ7L89XjVuc-eaOITU_T&open=AZ7L89XjVuc-eaOITU_T&pullRequest=2841
// 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",
Expand Down
Loading
Loading