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
33 changes: 32 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,38 @@ public_keycloak_url=http://localhost:7787
# Docker (Dockerfile EXPOSE 8087) -> 8087 (or override via SERVER_PORT env var)
# The default below matches Docker; for local non-Docker dev use http://localhost:48123 instead.
public_obp_hola_url=http://localhost:8087
public_obp_mcp_url=http://localhost:9100
# ---------------------------------------------------------------------------
# OBP-MCP — the Model Context Protocol tool surface for OBP-API.
#
# Two instances may run side by side. They expose the same tools; they differ
# in who they serve, and therefore how they authenticate:
#
# external run_server_oauth.sh, :9101 (mirrors k8s-mcp2)
# AUTH_PROVIDER=obp-oidc, OBP_AUTHORIZATION_VIA=oauth
# Full OAuth 2.1 + Dynamic Client Registration. For MCP clients
# that sign a user in and cannot drive a consent flow themselves
# — Claude Code, VS Code, Claude Desktop.
#
# internal run_server.sh, :9100
# AUTH_PROVIDER=bearer-only, OBP_AUTHORIZATION_VIA=consent
# For agents running inside the deployment — Opey reaches it via
# its own mcp_servers.json, not via this prop.
#
# public_obp_mcp_url is the one the App Directory advertises, so it must name
# an instance an outside client can actually authenticate against. Pointed at
# the internal instance it yields a server that connects and lists tools, then
# fails every call with consent_required.
#
# The public_ prefix marks a prop as an App Directory entry (see
# APIUtil.publicAppUrlDefaults) — it is not a claim that the target is
# internet-facing. The internal instance is listed so operators can see and
# health-probe it.
#
# Each instance reports its own live mode at <url>/status, which is the source
# of truth. These props only say where the instances are.
# ---------------------------------------------------------------------------
public_obp_mcp_url=http://localhost:9101
public_obp_mcp_internal_url=http://localhost:9100
public_obp_opey_url=http://localhost:5000
# OBP-Stripe subscription / payment service (Go server, default port 4242)
public_obp_stripe_url=http://localhost:4242
Expand Down
4 changes: 4 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ class Boot extends MdcLoggable {
// The method self-guards (skips when the table is absent or has no duplicates), so running it
// on every boot is a cheap no-op on fresh/clean/test databases.
Migration.database.deduplicateBeforeUniqueIndexSchemify()
// Same reasoning, for the Dynamic Entity tables: their unique indexes are becoming
// space-scoped, which needs the system level rows off SQL NULL and the superseded
// single-column indexes dropped, both before Schemifier issues the new index DDL.
Migration.database.prepareDynamicEntitySpaceScopedIndexes()
schemifyAll()

logger.info("Mapper database info: " + Migration.DbFunction.mapperDatabaseInfo)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6390,7 +6390,8 @@ object SwaggerDefinitionsJSON {
ConfigPropJsonV600("public_obp_oidc_url", "http://localhost:9000"),
ConfigPropJsonV600("public_keycloak_url", "http://localhost:7787"),
ConfigPropJsonV600("public_obp_hola_url", "http://localhost:48123"),
ConfigPropJsonV600("public_obp_mcp_url", "http://localhost:9100"),
ConfigPropJsonV600("public_obp_mcp_url", "http://localhost:9101"),
ConfigPropJsonV600("public_obp_mcp_internal_url", "http://localhost:9100"),
ConfigPropJsonV600("public_obp_opey_url", "http://localhost:5000"),
ConfigPropJsonV600("public_obp_stripe_url", "http://localhost:4242")
)
Expand Down
24 changes: 24 additions & 0 deletions obp-api/src/main/scala/code/api/constant/constant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ object Constant extends MdcLoggable {
// identified by their group_id.
final val group_membership = "GROUP_MEMBERSHIP"

/**
* This is the value the bank id column holds for a Dynamic Entity record that belongs to no bank.
*
* A Dynamic Entity record is either scoped to one bank, which the Dynamic Entity feature calls a
* space, or it is system level and belongs to the instance as a whole. The system level case used
* to be written as a SQL NULL. That could not stay, because the uniqueness of a record's id has to
* be enforced per space rather than across the whole instance: two spaces may each legitimately
* hold a record whose natural key is the country code DE, and before this change the second one
* was refused. Postgres treats NULLs as distinct inside a unique index, so a composite unique
* index over the bank id would have stopped enforcing anything at all for the system level rows --
* the very rows every existing instance is full of. Writing a real value instead removes that
* exception, and the index in DynamicData.dbIndexes can then say plainly what it means.
*
* The value is three characters long, and every endpoint that accepts a caller supplied bank id
* requires at least four (APIUtil.checkShortString plus a per endpoint minimum length check), so
* no caller can create a bank that collides with it. DynamicEntitySystemLevelBankIdTest holds that
* property down, because the minimum length check is written out separately in each endpoint
* rather than shared, and so is the part of the rule most likely to drift.
*
* This value is an internal storage detail and is never published. DynamicData.bankId filters it
* back out, so every reader still sees None for a system level record exactly as before.
*/
final val DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID = "SYS"

object Pagination {
final val offset = 0
final val limit = 50
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ object Http4sDynamicEntity extends MdcLoggable {
// In-memory floor: fetch all rows (unscoped) and keep those the ACL marks readable.
// (The projection EXISTS backend for row-level get-all is a documented follow-up; the
// in-memory path is always correct, just not index-accelerated.)
val readable = aclVend.getReadableDynamicDataIds(u.userId, entityName, bankId).toSet
val readable = aclVend.getReadableDynamicDataIds(bankId, entityName, u.userId).toSet
val readableRows = dataVend.getAllCommunity(bankId, entityName).filter(_.dynamicDataId.exists(readable.contains))
val readableJson: JArray = JArray(readableRows.map(r => parse(r.dataJson)))
val filtered = filterDynamicObjects(readableJson, queryParams(req))
Expand All @@ -416,7 +416,7 @@ object Http4sDynamicEntity extends MdcLoggable {
for {
// Hide existence: a row you cannot read is indistinguishable from a missing row (404).
_ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) {
box.isDefined && aclVend.allows(id, u.userId, DynamicDataAccessPermission.Read)
box.isDefined && aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Read)
}
} yield {
val singleObject: JValue = unboxResult(box, entityName)
Expand All @@ -438,7 +438,7 @@ object Http4sDynamicEntity extends MdcLoggable {
existing: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson))
_ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined }
_ <- Helper.booleanToFuture(s"$UserHasMissingRoles update access on this row", 403, cc = callContext) {
aclVend.allows(id, u.userId, DynamicDataAccessPermission.Update) }
aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Update) }
// Field-level write roles still apply on top of the row ACL.
updateJson = preserveRestrictedOnPut(json.asInstanceOf[JObject], existing, writeRestrictedFieldsOf(bankId, entityName))
box: Box[JValue] = dataVend.updateCommunity(bankId, entityName, updateJson, id).map(it => parse(it.dataJson))
Expand All @@ -458,7 +458,7 @@ object Http4sDynamicEntity extends MdcLoggable {
bodyObj = json.asInstanceOf[JObject]
// Row ACL replaces the entity-update role; per-field write roles still apply (requireEntityRole = false).
_ <- Helper.booleanToFuture(s"$UserHasMissingRoles update access on this row", 403, cc = callContext) {
aclVend.allows(id, u.userId, DynamicDataAccessPermission.Update) }
aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Update) }
missingRoles = missingPatchRoleNames(bodyObj.obj.map(_.name), bankId, entityName, u.userId, code.api.util.APIUtil.getConsumerPrimaryKey(callContext), requireEntityRole = false)
_ <- Helper.booleanToFuture(s"$UserHasMissingRoles ${missingRoles.mkString(", ")}", 403, cc = callContext) { missingRoles.isEmpty }
existing: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson))
Expand All @@ -480,9 +480,9 @@ object Http4sDynamicEntity extends MdcLoggable {
existing: Box[JValue] = dataVend.getCommunity(bankId, entityName, id).map(it => parse(it.dataJson))
_ <- Helper.booleanToFuture(notFoundMsg(entityName, id, bankId), 404, cc = callContext) { existing.isDefined }
_ <- Helper.booleanToFuture(s"$UserHasMissingRoles delete access on this row", 403, cc = callContext) {
aclVend.allows(id, u.userId, DynamicDataAccessPermission.Delete) }
aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Delete) }
_ = dataVend.deleteCommunity(bankId, entityName, id)
_ = aclVend.deleteAllForRow(id) // cascade the ACL rows for the deleted data row
_ = aclVend.deleteAllForRow(bankId, entityName, id) // cascade the ACL rows for the deleted data row
} yield JObject(Nil)
}

Expand All @@ -503,8 +503,8 @@ object Http4sDynamicEntity extends MdcLoggable {
("can_grant" -> a.canGrant) ~
("granted_by" -> a.grantedBy)

private def rowAccessListJson(dataId: String): JObject =
("access" -> JArray(aclVend.getAccessForRow(dataId).map(rowAccessRowJson)))
private def rowAccessListJson(bankId: Option[String], entityName: String, dataId: String): JObject =
("access" -> JArray(aclVend.getAccessForRow(bankId, entityName, dataId).map(rowAccessRowJson)))

// Shared preamble: before-intercept, auth, bank, flag-off (400), grant authorisation (403).
private def rowAccessAuthorise(cc: CallContext, bankId: Option[String], entityName: String, id: String,
Expand All @@ -517,7 +517,7 @@ object Http4sDynamicEntity extends MdcLoggable {
(_, callContext2) <- bankCheck(bankId, callContext)
_ <- Helper.booleanToFuture(RowLevelAccessNotEnabled, 400, cc = callContext2) { isRowLevel(bankId, entityName) }
_ <- Helper.booleanToFuture(s"$UserHasMissingRoles grant access on this row", 403, cc = callContext2) {
aclVend.allows(id, u.userId, DynamicDataAccessPermission.Grant) ||
aclVend.allows(bankId, entityName, id, u.userId, DynamicDataAccessPermission.Grant) ||
hasEntitlement(bankId.getOrElse(""), u.userId, DynamicEntityInfo.canGrantRowAccessRole(entityName, bankId))
}
} yield (u, callContext2)
Expand All @@ -527,7 +527,7 @@ object Http4sDynamicEntity extends MdcLoggable {
EndpointHelpers.executeAndRespond(req) { cc =>
for {
_ <- rowAccessAuthorise(cc, bankId, entityName, id, GET_ALL)
} yield rowAccessListJson(id)
} yield rowAccessListJson(bankId, entityName, id)
}

private def upsertRowAccess(req: Request[IO], bankId: Option[String], entityName: String, id: String): IO[Response[IO]] =
Expand All @@ -544,21 +544,21 @@ object Http4sDynamicEntity extends MdcLoggable {
entries.nonEmpty && entries.forall(o => strField(o, "user_id").isDefined)
}
_ = entries.foreach { o =>
aclVend.grant(id, strField(o, "user_id").get,
aclVend.grant(bankId, entityName, id, strField(o, "user_id").get,
canRead = boolField(o, "can_read", default = false),
canUpdate = boolField(o, "can_update", default = false),
canDelete = boolField(o, "can_delete", default = false),
canGrant = boolField(o, "can_grant", default = true), // §8.1: re-share by default
entityName, bankId, grantedBy = granter.userId)
grantedBy = granter.userId)
}
} yield rowAccessListJson(id)
} yield rowAccessListJson(bankId, entityName, id)
}

private def revokeRowAccess(req: Request[IO], bankId: Option[String], entityName: String, id: String, grantUserId: String): IO[Response[IO]] =
EndpointHelpers.executeAndRespond(req) { cc =>
for {
_ <- rowAccessAuthorise(cc, bankId, entityName, id, DELETE)
removed = aclVend.revoke(id, grantUserId).getOrElse(0) // cascades to downstream grants (§7)
removed = aclVend.revoke(bankId, entityName, id, grantUserId).getOrElse(0) // cascades to downstream grants (§7)
} yield (("revoked_count" -> removed): JObject)
}

Expand Down Expand Up @@ -637,7 +637,7 @@ object Http4sDynamicEntity extends MdcLoggable {
// edit, and share their own record with no role and no meta-admin hop (§4 / §8.1).
_ = if (isRowLevel(bankId, entityName)) (singleObject \ DynamicEntityHelper.createEntityId(entityName)) match {
case JString(rid) =>
userIdOpt.foreach(uid => aclVend.grant(rid, uid, canRead = true, canUpdate = true, canDelete = true, canGrant = true, entityName, bankId, grantedBy = uid))
userIdOpt.foreach(uid => aclVend.grant(bankId, entityName, rid, uid, canRead = true, canUpdate = true, canDelete = true, canGrant = true, grantedBy = uid))
case _ =>
}
} yield wrapBankId(bankId, (singleName(entityName) -> singleObject))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ TESOBE (http://www.tesobe.com/)
package code.api.dynamic.entity.projection

import code.DynamicData.DynamicData
import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID
import doobie._
import doobie.implicits._

Expand Down Expand Up @@ -84,17 +85,19 @@ object ProjectionStore {
.query[(String, String)].to[List]

/**
* Scope predicate mirroring `MappedDynamicDataProvider`'s get-all: entity name always; bankId via
* IS NOT DISTINCT FROM (handles system-level NULL); personal flag; userId only when personal.
* Scope predicate mirroring `MappedDynamicDataProvider`'s get-all: entity name always; bankId
* always (a system-level record stores a sentinel, never NULL); personal flag; userId only when
* personal, and that column IS still nullable so it keeps its null-safe comparison.
* Returned without the `WHERE` keyword so callers can AND it with index predicates.
*/
def scope(bankId: Option[String], entityName: String, isPersonalEntity: Boolean, userId: Option[String], alias: String = ""): Fragment = {
val p = if (alias.isEmpty) "" else alias + "."
// Bind as Option[String]: a system-level entity has bankId=None, which must bind SQL NULL — `orNull`
// as a plain String trips doobie's non-nullable Put[String] ("oops, null"). Put[Option[String]]
// emits NULL for None, and `IS NOT DISTINCT FROM NULL` is the intended null-safe match.
// The bank id column holds Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID for a record that belongs
// to no bank, never a SQL NULL, so this is a plain equality. It used to bind an Option and
// compare with IS NOT DISTINCT FROM; that stopped matching the moment the sentinel replaced the
// NULL, and this is the one place outside the Mapper queries that reads the column directly.
val byEntity = Fragment.const(p + entityNameColumn) ++ fr"=" ++ fr0"$entityName"
val byBank = Fragment.const(p + bankIdColumn) ++ fr"IS NOT DISTINCT FROM" ++ fr0"${bankId: Option[String]}"
val byBank = Fragment.const(p + bankIdColumn) ++ fr"=" ++ fr0"${bankId.getOrElse(DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID)}"
val byPersonal = Fragment.const(p + personalColumn) ++ fr"=" ++ fr0"$isPersonalEntity"
val base = byEntity ++ fr"AND" ++ byBank ++ fr"AND" ++ byPersonal
if (isPersonalEntity) base ++ fr"AND" ++ Fragment.const(p + userIdColumn) ++ fr"IS NOT DISTINCT FROM" ++ fr0"${userId: Option[String]}"
Expand Down
9 changes: 6 additions & 3 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3772,8 +3772,10 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
}

// Convention-based public app URL props.
// Any prop starting with "public_" and ending with "_url" is included in the App Directory.
// Register known defaults so they appear in getConfigPropsPairs when set.
// Every key registered here appears in getConfigPropsPairs and so in the App Directory.
// The set is fixed in code: an operator-added public_*_url prop is NOT picked up
// automatically, because getConfigPropsPairs reads getRegisteredDefaults rather than
// scanning the props file.
// Note: public_obp_api_url falls back to hostname prop if not explicitly set.
// Note: public_obp_portal_url falls back to portal_external_url if not explicitly set.
val publicAppUrlDefaults: Map[String, String] = Map(
Expand All @@ -3785,7 +3787,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
"public_obp_oidc_url" -> getPropsValue("public_obp_oidc_url").openOr("http://localhost:9000"),
"public_keycloak_url" -> getPropsValue("public_keycloak_url").openOr("http://localhost:7787"),
"public_obp_hola_url" -> getPropsValue("public_obp_hola_url").openOr("http://localhost:48123"),
"public_obp_mcp_url" -> getPropsValue("public_obp_mcp_url").openOr("http://localhost:9100"),
"public_obp_mcp_url" -> getPropsValue("public_obp_mcp_url").openOr("http://localhost:9101"),
"public_obp_mcp_internal_url" -> getPropsValue("public_obp_mcp_internal_url").openOr("http://localhost:9100"),
"public_obp_opey_url" -> getPropsValue("public_obp_opey_url").openOr("http://localhost:5000"),
"public_obp_stripe_url" -> getPropsValue("public_obp_stripe_url").openOr("http://localhost:4242"),
"public_rabbit_cats_adapter_url" -> getPropsValue("public_rabbit_cats_adapter_url").openOr("http://localhost:8089")
Expand Down
Loading
Loading