diff --git a/obp-api/pom.xml b/obp-api/pom.xml index 0e4fead345..3bb1e89ccf 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -225,7 +225,7 @@ org.apache.pekko pekko-http-core_${scala.version} - 1.1.0 + ${pekko-http.version} org.apache.pekko @@ -304,6 +304,12 @@ oauth2-oidc-sdk 11.37.1 + + + net.minidev + json-smart + diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 7cdcd86276..17e62f40dc 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -586,6 +586,78 @@ object Constant extends MdcLoggable { CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE ) + // Auditor system view: read-only on the account itself. The auditor can + // see everything but cannot modify account data, counterparties, images, + // locations, aliases, URLs, or initiate / approve payments. + // The only writes allowed are the auditor's own annotations: comments and + // tags (including where_tags). Those are scoped to the auditor's view as + // separate metadata — they do not change the account or its data. + final val SYSTEM_AUDITOR_VIEW_PERMISSION = List( + // See transactions + CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT, + CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT, + CAN_SEE_TRANSACTION_METADATA, + CAN_SEE_TRANSACTION_AMOUNT, + CAN_SEE_TRANSACTION_TYPE, + CAN_SEE_TRANSACTION_CURRENCY, + CAN_SEE_TRANSACTION_START_DATE, + CAN_SEE_TRANSACTION_FINISH_DATE, + CAN_SEE_TRANSACTION_BALANCE, + CAN_SEE_TRANSACTION_STATUS, + // See this account + CAN_SEE_BANK_ACCOUNT_OWNERS, + CAN_SEE_BANK_ACCOUNT_TYPE, + CAN_SEE_BANK_ACCOUNT_BALANCE, + CAN_SEE_BANK_ACCOUNT_CURRENCY, + CAN_SEE_BANK_ACCOUNT_LABEL, + CAN_SEE_BANK_ACCOUNT_NATIONAL_IDENTIFIER, + CAN_SEE_BANK_ACCOUNT_SWIFT_BIC, + CAN_SEE_BANK_ACCOUNT_IBAN, + CAN_SEE_BANK_ACCOUNT_NUMBER, + CAN_SEE_BANK_ACCOUNT_BANK_NAME, + CAN_SEE_BANK_ACCOUNT_BANK_PERMALINK, + CAN_SEE_BANK_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS, + // See bank + CAN_SEE_BANK_ROUTING_SCHEME, + CAN_SEE_BANK_ROUTING_ADDRESS, + // See other accounts + CAN_SEE_OTHER_ACCOUNT_NATIONAL_IDENTIFIER, + CAN_SEE_OTHER_ACCOUNT_SWIFT_BIC, + CAN_SEE_OTHER_ACCOUNT_IBAN, + CAN_SEE_OTHER_ACCOUNT_BANK_NAME, + CAN_SEE_OTHER_ACCOUNT_NUMBER, + CAN_SEE_OTHER_ACCOUNT_METADATA, + CAN_SEE_OTHER_ACCOUNT_KIND, + CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME, + CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS, + CAN_SEE_OTHER_BANK_ROUTING_SCHEME, + CAN_SEE_OTHER_BANK_ROUTING_ADDRESS, + // See metadata (everyone's) + CAN_SEE_COMMENTS, + CAN_SEE_OWNER_COMMENT, + CAN_SEE_TAGS, + CAN_SEE_WHERE_TAG, + CAN_SEE_IMAGES, + CAN_SEE_MORE_INFO, + CAN_SEE_URL, + CAN_SEE_IMAGE_URL, + CAN_SEE_OPEN_CORPORATES_URL, + CAN_SEE_CORPORATE_LOCATION, + CAN_SEE_PHYSICAL_LOCATION, + CAN_SEE_PUBLIC_ALIAS, + CAN_SEE_PRIVATE_ALIAS, + // Read counterparties (no modification) + CAN_GET_COUNTERPARTY, + // Auditor's own annotations only + CAN_ADD_COMMENT, + CAN_DELETE_COMMENT, + CAN_ADD_TAG, + CAN_DELETE_TAG, + CAN_ADD_WHERE_TAG, + CAN_DELETE_WHERE_TAG + ) + final val ALL_VIEW_PERMISSION_NAMES = List( CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT, CAN_SEE_TRANSACTION_METADATA, 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 9125dcab9a..2c6cd62154 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -939,6 +939,21 @@ object Glossary extends MdcLoggable { | | |Example value: ${bankIdExample.value} + | + |## Version history + | + |The JSON field name for this identifier changed across OBP-API versions: + | + |- **v6.0.0+** (current): `bank_id` — the canonical field name in both request and response bodies (e.g. `PostBankJson600`, `BankJson600`). + |- **v5.0.0**: `id` (Option[String]) — see `PostBankJson500` / `BankJson500`. + |- **v4.0.0**: `id` (String), plus a now-removed `short_name` field — see `PostBankJson400` / `BankJson400`. + | + |The v6 createBank request body shape is exactly: + |`bank_id`, `bank_code`, `full_name`, `logo`, `website`, `bank_routings`. + | + |If you're regenerating client code from older docs, samples, or LLM training data, double-check + |the field name — sending `id` to v6 endpoints will silently produce an empty `bank_id` and + |fail validation with a confusing length error. """) glossaryItems += GlossaryItem( diff --git a/obp-api/src/main/scala/code/api/util/newstyle/ViewNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/ViewNewStyle.scala index 4156e01926..49375e80ed 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/ViewNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/ViewNewStyle.scala @@ -103,6 +103,12 @@ object ViewNewStyle { } } + def factoryResetSystemView(viewId: ViewId, callContext: Option[CallContext]): Future[View] = { + Future(Views.views.vend.factoryResetSystemView(viewId)) map { + unboxFullOrFail(_, callContext, s"$SystemViewNotFound Current ViewId is ${viewId.value}", 404) + } + } + def checkOwnerViewAccessAndReturnOwnerView(user: User, bankAccountId: BankIdAccountId, callContext: Option[CallContext]): Future[View] = { Future { user.checkOwnerViewAccessAndReturnOwnerView(bankAccountId, callContext) diff --git a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala index c0c58318ec..6b2a0e38b7 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala @@ -394,8 +394,15 @@ object JSONFactory1_4_0 extends MdcLoggable{ .find(_.title.toLowerCase.contains(s"${parameter.toLowerCase}")) .map(_.title).getOrElse("").replaceAll(" ","-") case _ => + // First try exact match (e.g. body field "address" → glossary item "address"). + // If that fails, fall back to a dotted-suffix match so body fields like + // "bank_id" / "account_id" / "customer_id" resolve to "Bank.bank_id" / + // "Account.account_id" / "Customer.customer_id". Without this fallback + // body-field glossary links render as [field](/glossary#) with an empty + // anchor — see the v5 → v6 createBank rename for a real-world example. glossaryItems .find(_.title.toLowerCase.equals(s"${parameter.toLowerCase}")) + .orElse(glossaryItems.find(_.title.toLowerCase.endsWith(s".${parameter.toLowerCase}"))) .map(_.title).getOrElse("").replaceAll(" ","-") } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 59ff9e6397..8b8b3d76b6 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -2221,7 +2221,25 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) val failMsg = s"$InvalidJsonFormat The Json body should be the $PostBankJson600 " + // Catch the common v4/v5 → v6 field-rename mistakes before extraction. + // Without this, sending {"id": "..."} silently produces an empty bank_id + // and fails downstream with a confusing BANK_ID length-validation error. + val deprecatedFieldHints: List[String] = json match { + case JObject(fields) => fields.collect { + case JField("id", _) => + "'id' was renamed to 'bank_id' in v6.0.0 (it was the field name in v4.0.0 and v5.0.0)" + case JField("short_name", _) => + "'short_name' was removed in v5.0.0 (it only existed in v4.0.0)" + } + case _ => Nil + } for { + _ <- Helper.booleanToFuture( + failMsg = s"$InvalidJsonFormat Deprecated request-body field(s): ${deprecatedFieldHints.mkString("; ")}. The v6.0.0 createBank body shape is: bank_id, bank_code, full_name, logo, website, bank_routings.", + cc = cc.callContext + ) { + deprecatedFieldHints.isEmpty + } postJson <- NewStyle.function.tryons(failMsg, 400, cc.callContext) { json.extract[PostBankJson600] } 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 7acd976e56..260bc83195 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 @@ -8,7 +8,7 @@ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, IdempotencyMiddleware, RequestScopeConnection, ResourceDocMiddleware} @@ -18,6 +18,7 @@ import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v2_0_0.{BasicViewJson, CreateEntitlementJSON, JSONFactory200} import code.api.v4_0_0.JSONFactory400 import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheNamespaceJsonV600, CacheNamespacesJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, DatabasePoolInfoJsonV600, FeaturesJsonV600, InMemoryCacheStatusJsonV600, JSONFactory600, RedisCacheStatusJsonV600, StoredProcedureConnectorHealthJsonV600, UserV600} +import code.api.v6_0_0.JSONFactory600.ViewJsonV600 import code.api.cache.Redis import code.bankconnectors.storedprocedure.StoredProcedureUtils import code.migration.MigrationScriptLogProvider @@ -150,7 +151,7 @@ object Http4s700 { } } - object Implementations7_0_0 { + object Implementations7_0_0 extends code.util.Helper.MdcLoggable { // Common prefix: /obp/v7.0.0 val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString @@ -3437,6 +3438,87 @@ object Http4s700 { // ── End BULK ────────────────────────────────────────────────────────────── // ── Test-only rollback endpoint ─────────────────────────────────────────── + // Route: POST /obp/v7.0.0/management/system-views/VIEW_ID/factory-reset + // + // Reset an existing system view's permissions and view-level flags to the + // code-defined defaults. The ViewDefinition row is preserved so any + // AccountAccess records that reference this view remain valid — only the + // contents of the view are wiped and rewritten. + // + // Each successful invocation is audit-logged at INFO level with the + // calling user_id and the reset view_id; this is a high-impact admin + // action and we want a trace of who reset what. + val factoryResetSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "system-views" / viewIdStr / "factory-reset" => + EndpointHelpers.withUser(req) { (user, cc) => + val viewId = ViewId(viewIdStr) + for { + view <- ViewNewStyle.factoryResetSystemView(viewId, Some(cc)) + } yield { + logger.info( + s"AUDIT factoryResetSystemView: user_id=${user.userId} provider=${user.provider} " + + s"view_id=${viewId.value} permissions_count=${view.allowed_actions.size}" + ) + JSONFactory600.createViewJsonV600(view) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(factoryResetSystemView), + "POST", + "/management/system-views/VIEW_ID/factory-reset", + "Factory Reset a System View", + s"""Reset the system view identified by VIEW_ID to the code-defined defaults. + | + |This wipes the view's existing permissions and re-applies whatever the + |running OBP-API code currently defines as the default permission set + |for that system view id. View-level flags (name, description, is_firehose, + |alias settings, is_public) are also restored to defaults. + | + |The underlying view row is preserved, so any AccountAccess records that + |grant users this view on specific accounts remain in place — only the + |contents of the view itself are reset. + | + |Each successful invocation is audit-logged with the calling user_id and + |the reset view_id. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + ViewJsonV600( + bank_id = "", + account_id = "", + view_id = "auditor", + view_name = "Auditor", + description = "auditor", + metadata_view = "", + is_public = false, + is_system = true, + is_firehose = Some(false), + alias = "", + hide_metadata_if_alias_used = false, + can_grant_access_to_views = Nil, + can_revoke_access_to_views = Nil, + allowed_actions = List( + "can_see_bank_account_balance", + "can_see_transaction_amount", + "can_add_comment", + "can_add_tag" + ) + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + SystemViewNotFound, + UnknownError + ), + apiTagSystemView :: Nil, + Some(List(canUpdateSystemView)), + http4sPartialFunction = Some(factoryResetSystemView) + ) + // Enabled only in Lift test mode (Props.testMode == true, i.e. -Drun.mode=test). // Props.testMode is set from the JVM system property before any props file loads, // so it is reliably available at object-initialization time unlike file-based props. diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index ab020b3367..e6ad15947a 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -903,75 +903,121 @@ object MapperViews extends Views with MdcLoggable { .usePrivateAliasIfOneExists_(false) //(default is false anyways) .usePublicAliasIfOneExists_(false) //(default is false anyways) .hideOtherAccountMetadataIfAlias_(false) //(default is false anyways) - + applyDefaultsForSystemView(entity, viewId) + } + + /** + * Apply the code-defined default permissions (and any view-level flags such + * as isFirehose) for the given system view id to the supplied entity. + * + * Called both at initial creation (`unsavedSystemView`) and on factory + * reset (`factoryResetSystemView`), so the two paths stay in lock-step. + * + * The caller is responsible for having already cleared any pre-existing + * `ViewPermission` rows associated with this view — `resetViewPermissions` + * here only repopulates them. + */ + def applyDefaultsForSystemView(entity: ViewDefinition, viewId: String): ViewDefinition = { viewId match { - case SYSTEM_OWNER_VIEW_ID | SYSTEM_STANDARD_VIEW_ID =>{ + case SYSTEM_OWNER_VIEW_ID | SYSTEM_STANDARD_VIEW_ID => ViewPermission.resetViewPermissions( entity, - SYSTEM_OWNER_VIEW_PERMISSION_ADMIN ++SYSTEM_VIEW_PERMISSION_COMMON, + SYSTEM_OWNER_VIEW_PERMISSION_ADMIN ++ SYSTEM_VIEW_PERMISSION_COMMON, DEFAULT_CAN_GRANT_AND_REVOKE_ACCESS_TO_VIEWS, DEFAULT_CAN_GRANT_AND_REVOKE_ACCESS_TO_VIEWS ) - entity - } - case SYSTEM_STAGE_ONE_VIEW_ID =>{ + entity + case SYSTEM_STAGE_ONE_VIEW_ID => ViewPermission.resetViewPermissions( entity, - SYSTEM_VIEW_PERMISSION_COMMON++SYSTEM_VIEW_PERMISSION_COMMON + SYSTEM_VIEW_PERMISSION_COMMON ++ SYSTEM_VIEW_PERMISSION_COMMON ) entity - } - case SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID =>{ + case SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID => ViewPermission.resetViewPermissions( entity, - SYSTEM_VIEW_PERMISSION_COMMON++SYSTEM_MANAGER_VIEW_PERMISSION + SYSTEM_VIEW_PERMISSION_COMMON ++ SYSTEM_MANAGER_VIEW_PERMISSION ) entity - } - case SYSTEM_FIREHOSE_VIEW_ID =>{ + case SYSTEM_FIREHOSE_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_VIEW_PERMISSION_COMMON ) - entity // Make additional setup to the existing view - .isFirehose_(true) - } - case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID | + entity.isFirehose_(true) + case SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID | SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID => entity - case SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID =>{ + case SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_PERMISSION ) entity - } - case SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID =>{ + case SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_PERMISSION ) entity - } + case SYSTEM_AUDITOR_VIEW_ID => + ViewPermission.resetViewPermissions( + entity, + SYSTEM_AUDITOR_VIEW_PERMISSION + ) + entity case SYSTEM_ACCOUNTANT_VIEW_ID | - SYSTEM_AUDITOR_VIEW_ID | SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID | SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID | SYSTEM_READ_BALANCES_VIEW_ID | SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID | SYSTEM_READ_TRANSACTIONS_DEBITS_VIEW_ID | - SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => { - + SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => ViewPermission.resetViewPermissions( entity, SYSTEM_VIEW_PERMISSION_COMMON ) entity - } case _ => entity } } + + /** + * Reset an existing system view to the code-defined defaults — i.e. wipe + * all of its `ViewPermission` rows, restore the view-level fields + * (name, description, flags) to what `unsavedSystemView` would set, and + * re-apply the default permission set for this view id. + * + * Preserves the underlying `ViewDefinition` row (and therefore any + * `AccountAccess` records pointing at it) — only the contents are reset. + * + * Returns Empty if no system view with this id exists; the caller can + * then surface the standard `SystemViewNotFound` error. + */ + def factoryResetSystemView(viewId: ViewId): Box[View] = { + ViewDefinition.findSystemView(viewId.value) match { + case Full(existing) => + ViewPermission.findSystemViewPermissions(viewId).foreach(_.delete_!) + existing + .isSystem_(true) + .isFirehose_(false) + .bank_id(null) + .account_id(null) + .name_(StringHelpers.capify(viewId.value)) + .view_id(viewId.value) + .description_(viewId.value) + .isPublic_(false) + .usePrivateAliasIfOneExists_(false) + .usePublicAliasIfOneExists_(false) + .hideOtherAccountMetadataIfAlias_(false) + applyDefaultsForSystemView(existing, viewId.value) + Full(existing.saveMe()) + case Empty => + Empty + case f: Failure => f + } + } def createAndSaveSystemView(viewId: String) : Box[View] = { logger.debug(s"-->createAndSaveSystemView.viewId.start${viewId} ") diff --git a/obp-api/src/main/scala/code/views/Views.scala b/obp-api/src/main/scala/code/views/Views.scala index 1627f90a81..4a462272bb 100644 --- a/obp-api/src/main/scala/code/views/Views.scala +++ b/obp-api/src/main/scala/code/views/Views.scala @@ -100,6 +100,14 @@ trait Views { def getOrCreateSystemView(viewId: String) : Box[View] def getOrCreateCustomPublicView(bankId: BankId, accountId: AccountId, description: String) : Box[View] + /** + * Reset an existing system view's permissions and view-level flags back to + * the code-defined defaults for that view id. Preserves the row itself + * (so any AccountAccess bindings keep working). Returns Empty if no such + * system view exists. + */ + def factoryResetSystemView(viewId: ViewId) : Box[View] + def getOwners(view: View): Set[User] def removeAllAccountAccess(bankId: BankId, accountId: AccountId) : Boolean 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 31c2432798..6d49afe965 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 @@ -6,8 +6,12 @@ import code.api.util.http4s.Http4sLiftWebBridge import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.Constant.SYSTEM_AUDITOR_VIEW_ID +import code.views.MapperViews +import code.views.system.ViewPermission +import com.openbankproject.commons.model.ViewId import code.routingscheme.RoutingSchemes import code.model.dataAccess.BankAccountRouting import code.customer.CustomerX @@ -2964,4 +2968,101 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── factoryResetSystemView ─────────────────────────────────────────────── + + feature("Http4s700 factoryResetSystemView endpoint") { + + scenario("Reject unauthenticated POST to /management/system-views/VIEW_ID/factory-reset", Http4s700RoutesTag) { + Given("POST /obp/v7.0.0/management/system-views/auditor/factory-reset with no auth") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/management/system-views/auditor/factory-reset", "") + + Then("Response is 401") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canUpdateSystemView role", Http4s700RoutesTag) { + Given("POST /obp/v7.0.0/management/system-views/auditor/factory-reset without the required role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/management/system-views/auditor/factory-reset", "", headers) + + Then("Response is 403 with UserHasMissingRoles message naming the required role") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canUpdateSystemView.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 and reset permissions when entitled and view exists", Http4s700RoutesTag) { + Given("the auditor system view exists, with an extra non-default permission") + MapperViews.getOrCreateSystemView(SYSTEM_AUDITOR_VIEW_ID) + ViewPermission.createSystemViewPermission( + ViewId(SYSTEM_AUDITOR_VIEW_ID), + code.api.Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT, + None + ) + addEntitlement("", resourceUser1.userId, canUpdateSystemView.toString) + + When(s"POST /obp/v7.0.0/management/system-views/$SYSTEM_AUDITOR_VIEW_ID/factory-reset is called") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/management/system-views/$SYSTEM_AUDITOR_VIEW_ID/factory-reset", "", headers) + + Then("Response is 200 with the refreshed view JSON, no longer containing the extra permission") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("view_id") match { + case Some(JString(v)) => v shouldBe SYSTEM_AUDITOR_VIEW_ID + case _ => fail("Expected view_id as JSON string") + } + map.get("allowed_actions") match { + case Some(JArray(actions)) => + val names = actions.collect { case JString(s) => s } + names should not contain code.api.Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT + case _ => fail("Expected allowed_actions array") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when system view does not exist", Http4s700RoutesTag) { + Given("canUpdateSystemView role granted and a non-existent view id") + addEntitlement("", resourceUser1.userId, canUpdateSystemView.toString) + + When("POST /obp/v7.0.0/management/system-views/does-not-exist/factory-reset") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody( + "POST", "/obp/v7.0.0/management/system-views/does-not-exist/factory-reset", "", headers) + + Then("Response is 404 with SystemViewNotFound message") + statusCode shouldBe 404 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(SystemViewNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + } + } diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index 55f2736772..35f58da174 100644 --- a/obp-api/src/test/scala/code/views/MappedViewsTest.scala +++ b/obp-api/src/test/scala/code/views/MappedViewsTest.scala @@ -3,9 +3,9 @@ package code.views import code.api.Constant import code.api.util.ErrorMessages.ViewIdNotSupported import code.setup.{DefaultUsers, ServerSetup} -import code.views.system.ViewDefinition -import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId} -import net.liftweb.common.Failure +import code.views.system.{ViewDefinition, ViewPermission} +import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, ViewId} +import net.liftweb.common.{Empty, Failure} class MappedViewsTest extends ServerSetup with DefaultUsers{ @@ -63,11 +63,40 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{ wrongView.toString contains ViewIdNotSupported shouldBe (true) wrongView.toString contains wrongViewId shouldBe(true) - + } - - - + + scenario("factoryResetSystemView restores code-defined defaults") { + Given("an existing auditor system view created by getOrCreateSystemView") + val created = MapperViews.getOrCreateSystemView(viewIdAuditor) + created.isDefined shouldBe true + val defaultActions = created.openOrThrowException("auditor view should exist").allowed_actions + defaultActions.contains(Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT) shouldBe false + + When("we add an extra permission that's not in the default auditor set") + ViewPermission.createSystemViewPermission( + ViewId(viewIdAuditor), + Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT, + None + ).isDefined shouldBe true + val mutated = ViewDefinition.findSystemView(viewIdAuditor) + .openOrThrowException("auditor view should still exist after mutation") + mutated.allowed_actions.contains(Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT) shouldBe true + + Then("factoryResetSystemView removes the extra permission and restores defaults") + val reset = MapperViews.factoryResetSystemView(ViewId(viewIdAuditor)) + reset.isDefined shouldBe true + val resetActions = reset.openOrThrowException("reset should return refreshed view").allowed_actions + resetActions.contains(Constant.CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT) shouldBe false + resetActions.toSet should equal(defaultActions.toSet) + } + + scenario("factoryResetSystemView returns Empty for an unknown system view id") { + MapperViews.factoryResetSystemView(ViewId("does-not-exist")) shouldBe Empty + } + + + } diff --git a/pom.xml b/pom.xml index 6c3c6266e5..bb30fd5925 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,8 @@ 2.12 2.12.20 - 1.1.2 + 1.1.5 + 1.1.0 1.8.2 3.5.0 0.23.30 @@ -73,6 +74,20 @@ pom import + + + net.minidev + json-smart + 2.6.0 + + + net.minidev + accessors-smart + 2.6.0 + com.tesobe obp-commons