From 92129d7ab2d3475f97c8635736691294cb4aef8d Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 16 Feb 2026 14:10:37 +0100 Subject: [PATCH 1/2] Account Access Request --- .../main/scala/bootstrap/liftweb/Boot.scala | 4 +- .../AccountAccessRequest.scala | 138 +++++ .../AccountAccessRequestTrait.scala | 61 +++ .../SwaggerDefinitionsJSON.scala | 38 +- .../main/scala/code/api/util/ApiRole.scala | 16 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/util/ErrorMessages.scala | 9 + .../scala/code/api/v6_0_0/APIMethods600.scala | 506 ++++++++++++++++++ .../code/api/v6_0_0/JSONFactory6.0.0.scala | 58 ++ .../commons/model/enums/Enumerations.scala | 5 + 10 files changed, 834 insertions(+), 2 deletions(-) create mode 100644 obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequest.scala create mode 100644 obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequestTrait.scala diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 8183374420..276cf685fd 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -81,6 +81,7 @@ import code.endpointMapping.EndpointMapping import code.endpointTag.EndpointTag import code.entitlement.{Entitlement, MappedEntitlement} import code.entitlementrequest.MappedEntitlementRequest +import code.accountaccessrequest.AccountAccessRequest import code.etag.MappedETag import code.fx.{MappedCurrency, MappedFXRate} import code.group.Group @@ -1187,7 +1188,8 @@ object ToSchemify { TransactionIdMapping, RegulatedEntityAttribute, BankAccountBalance, - Group + Group, + AccountAccessRequest ) // start grpc server diff --git a/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequest.scala b/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequest.scala new file mode 100644 index 0000000000..397587363b --- /dev/null +++ b/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequest.scala @@ -0,0 +1,138 @@ +package code.accountaccessrequest + +import java.util.Date +import code.util.{MappedUUID, UUIDString} +import com.openbankproject.commons.model.enums.AccountAccessRequestStatus +import net.liftweb.common.{Box, Full} +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +object MappedAccountAccessRequestProvider extends AccountAccessRequestProvider { + + override def createAccountAccessRequest( + bankId: String, + accountId: String, + viewId: String, + isSystemView: Boolean, + requestorUserId: String, + targetUserId: String, + businessJustification: String + ): Box[AccountAccessRequestTrait] = { + tryo { + AccountAccessRequest.create + .BankId(bankId) + .AccountId(accountId) + .ViewId(viewId) + .IsSystemView(isSystemView) + .RequestorUserId(requestorUserId) + .TargetUserId(targetUserId) + .BusinessJustification(businessJustification) + .Status(AccountAccessRequestStatus.INITIATED.toString) + .CheckerUserId("") + .CheckerComment("") + .saveMe() + } + } + + override def getById(accountAccessRequestId: String): Box[AccountAccessRequestTrait] = { + AccountAccessRequest.find(By(AccountAccessRequest.AccountAccessRequestId, accountAccessRequestId)) + } + + override def getByAccount(bankId: String, accountId: String): Box[List[AccountAccessRequestTrait]] = { + tryo { + AccountAccessRequest.findAll( + By(AccountAccessRequest.BankId, bankId), + By(AccountAccessRequest.AccountId, accountId), + OrderBy(AccountAccessRequest.id, Descending) + ) + } + } + + override def getByAccountAndStatus(bankId: String, accountId: String, status: String): Box[List[AccountAccessRequestTrait]] = { + tryo { + AccountAccessRequest.findAll( + By(AccountAccessRequest.BankId, bankId), + By(AccountAccessRequest.AccountId, accountId), + By(AccountAccessRequest.Status, status), + OrderBy(AccountAccessRequest.id, Descending) + ) + } + } + + override def getByRequestorUserId(requestorUserId: String): Box[List[AccountAccessRequestTrait]] = { + tryo { + AccountAccessRequest.findAll( + By(AccountAccessRequest.RequestorUserId, requestorUserId), + OrderBy(AccountAccessRequest.id, Descending) + ) + } + } + + override def getByUserAccountView( + targetUserId: String, + bankId: String, + accountId: String, + viewId: String + ): Box[AccountAccessRequestTrait] = { + AccountAccessRequest.find( + By(AccountAccessRequest.TargetUserId, targetUserId), + By(AccountAccessRequest.BankId, bankId), + By(AccountAccessRequest.AccountId, accountId), + By(AccountAccessRequest.ViewId, viewId), + By(AccountAccessRequest.Status, AccountAccessRequestStatus.INITIATED.toString) + ) + } + + override def updateStatus( + accountAccessRequestId: String, + status: String, + checkerUserId: String, + checkerComment: String + ): Box[AccountAccessRequestTrait] = { + AccountAccessRequest.find(By(AccountAccessRequest.AccountAccessRequestId, accountAccessRequestId)).flatMap { request => + tryo { + request + .Status(status) + .CheckerUserId(checkerUserId) + .CheckerComment(checkerComment) + .saveMe() + } + } + } +} + +class AccountAccessRequest extends AccountAccessRequestTrait with LongKeyedMapper[AccountAccessRequest] with IdPK with CreatedUpdated { + + def getSingleton = AccountAccessRequest + + object AccountAccessRequestId extends MappedUUID(this) + object BankId extends UUIDString(this) + object AccountId extends UUIDString(this) + object ViewId extends MappedString(this, 255) + object IsSystemView extends MappedBoolean(this) + object RequestorUserId extends UUIDString(this) + object TargetUserId extends UUIDString(this) + object BusinessJustification extends MappedText(this) + object Status extends MappedString(this, 64) + object CheckerUserId extends MappedString(this, 255) + object CheckerComment extends MappedText(this) + + override def accountAccessRequestId: String = AccountAccessRequestId.get.toString + override def bankId: String = BankId.get + override def accountId: String = AccountId.get + override def viewId: String = ViewId.get + override def isSystemView: Boolean = IsSystemView.get + override def requestorUserId: String = RequestorUserId.get + override def targetUserId: String = TargetUserId.get + override def businessJustification: String = BusinessJustification.get + override def status: String = Status.get + override def checkerUserId: String = CheckerUserId.get + override def checkerComment: String = CheckerComment.get + override def created: Date = createdAt.get + override def updated: Date = updatedAt.get +} + +object AccountAccessRequest extends AccountAccessRequest with LongKeyedMetaMapper[AccountAccessRequest] { + override def dbTableName = "AccountAccessRequest" + override def dbIndexes = Index(AccountAccessRequestId) :: Index(BankId, AccountId) :: Index(RequestorUserId) :: Index(Status) :: super.dbIndexes +} diff --git a/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequestTrait.scala b/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequestTrait.scala new file mode 100644 index 0000000000..6a11dbb6e4 --- /dev/null +++ b/obp-api/src/main/scala/code/accountaccessrequest/AccountAccessRequestTrait.scala @@ -0,0 +1,61 @@ +package code.accountaccessrequest + +import java.util.Date +import net.liftweb.common.Box +import net.liftweb.util.SimpleInjector + +object AccountAccessRequestTrait extends SimpleInjector { + val accountAccessRequest = new Inject(buildOne _) {} + + def buildOne: AccountAccessRequestProvider = MappedAccountAccessRequestProvider +} + +trait AccountAccessRequestProvider { + def createAccountAccessRequest( + bankId: String, + accountId: String, + viewId: String, + isSystemView: Boolean, + requestorUserId: String, + targetUserId: String, + businessJustification: String + ): Box[AccountAccessRequestTrait] + + def getById(accountAccessRequestId: String): Box[AccountAccessRequestTrait] + + def getByAccount(bankId: String, accountId: String): Box[List[AccountAccessRequestTrait]] + + def getByAccountAndStatus(bankId: String, accountId: String, status: String): Box[List[AccountAccessRequestTrait]] + + def getByRequestorUserId(requestorUserId: String): Box[List[AccountAccessRequestTrait]] + + def getByUserAccountView( + targetUserId: String, + bankId: String, + accountId: String, + viewId: String + ): Box[AccountAccessRequestTrait] + + def updateStatus( + accountAccessRequestId: String, + status: String, + checkerUserId: String, + checkerComment: String + ): Box[AccountAccessRequestTrait] +} + +trait AccountAccessRequestTrait { + def accountAccessRequestId: String + def bankId: String + def accountId: String + def viewId: String + def isSystemView: Boolean + def requestorUserId: String + def targetUserId: String + def businessJustification: String + def status: String + def checkerUserId: String + def checkerComment: String + def created: Date + def updated: Date +} diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index d710b9b472..c27548ee8a 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -6153,8 +6153,44 @@ object SwaggerDefinitionsJSON { description = descriptionExample.value ) + // Account Access Request samples (V600) + lazy val postAccountAccessRequestJsonV600 = JSONFactory600.PostAccountAccessRequestJsonV600( + target_user_id = userIdExample.value, + view_id = viewIdExample.value, + is_system_view = true, + business_justification = "Need access to review monthly account statements for audit purposes." + ) + + lazy val postApproveAccountAccessRequestJsonV600 = JSONFactory600.PostApproveAccountAccessRequestJsonV600( + comment = Some("Approved for Q1 audit.") + ) + + lazy val postRejectAccountAccessRequestJsonV600 = JSONFactory600.PostRejectAccountAccessRequestJsonV600( + comment = "Insufficient business justification provided." + ) + + lazy val accountAccessRequestJsonV600 = JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = bankIdExample.value, + account_id = accountIdExample.value, + view_id = viewIdExample.value, + is_system_view = true, + requestor_user_id = userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "INITIATED", + checker_user_id = "", + checker_comment = "", + created = DateWithMsExampleObject, + updated = DateWithMsExampleObject + ) + + lazy val accountAccessRequestsJsonV600 = JSONFactory600.AccountAccessRequestsJsonV600( + account_access_requests = List(accountAccessRequestJsonV600) + ) + //The common error or success format. - //Just some helper format to use in Json + //Just some helper format to use in Json case class NotSupportedYet() lazy val notSupportedYet = NotSupportedYet() 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 d8772b7c0f..d3a2b8ae73 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1279,6 +1279,22 @@ object ApiRole extends MdcLoggable{ case class CanGetUserGroupMembershipsAtOneBank(requiresBankId: Boolean = true) extends ApiRole lazy val canGetUserGroupMembershipsAtOneBank = CanGetUserGroupMembershipsAtOneBank() + // Account Access Request roles + case class CanCreateAccountAccessRequestAtAnyBank(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateAccountAccessRequestAtAnyBank = CanCreateAccountAccessRequestAtAnyBank() + case class CanCreateAccountAccessRequestAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canCreateAccountAccessRequestAtOneBank = CanCreateAccountAccessRequestAtOneBank() + + case class CanGetAccountAccessRequestsAtAnyBank(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetAccountAccessRequestsAtAnyBank = CanGetAccountAccessRequestsAtAnyBank() + case class CanGetAccountAccessRequestsAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canGetAccountAccessRequestsAtOneBank = CanGetAccountAccessRequestsAtOneBank() + + case class CanUpdateAccountAccessRequestAtAnyBank(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateAccountAccessRequestAtAnyBank = CanUpdateAccountAccessRequestAtAnyBank() + case class CanUpdateAccountAccessRequestAtOneBank(requiresBankId: Boolean = true) extends ApiRole + lazy val canUpdateAccountAccessRequestAtOneBank = CanUpdateAccountAccessRequestAtOneBank() + private val dynamicApiRoles = new ConcurrentHashMap[String, ApiRole] private case class DynamicApiRole(role: String, requiresBankId: Boolean = false) extends ApiRole{ diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index b3ee2ac81c..b9ffbc87d7 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -25,6 +25,7 @@ object ApiTag { val apiTagAccount = ResourceDocTag("Account") val apiTagAccountAttribute = ResourceDocTag("Account-Attribute") val apiTagAccountAccess = ResourceDocTag("Account-Access") + val apiTagAccountAccessRequest = ResourceDocTag("Account-Access-Request") val apiTagDirectDebit = ResourceDocTag("Direct-Debit") val apiTagStandingOrder = ResourceDocTag("Standing-Order") val apiTagAccountMetadata = ResourceDocTag("Account-Metadata") diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index b5c6b4c7b2..d0b977de8b 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -565,6 +565,15 @@ object ErrorMessages { val ParentCustomerNotFound = "OBP-30273: Parent customer not found. The parent_customer_id must reference an existing customer at the same bank." val CustomerTypeMismatch = "OBP-30274: Customer type does not match the endpoint. Use the generic /customers endpoint or the correct type-specific endpoint." + val AccountAccessRequestNotFound = "OBP-30275: Account Access Request not found." + val AccountAccessRequestAlreadyExists = "OBP-30276: An Account Access Request already exists for this user, account, and view." + val AccountAccessRequestCannotBeCreated = "OBP-30277: Account Access Request could not be created." + val AccountAccessRequestStatusNotInitiated = "OBP-30278: Account Access Request status is not INITIATED. Only INITIATED requests can be approved or rejected." + val MakerCheckerSameUser = "OBP-30279: The checker (approver/rejecter) cannot be the same user as the maker (requestor). Maker/Checker separation is required." + val BusinessJustificationRequired = "OBP-30280: Business justification is required." + val CheckerCommentRequiredForRejection = "OBP-30281: A comment is required when rejecting an Account Access Request." + val AccountAccessRequestCannotBeUpdated = "OBP-30282: Account Access Request could not be updated." + val TaxResidenceNotFound = "OBP-30300: Tax Residence not found by TAX_RESIDENCE_ID. " val CustomerAddressNotFound = "OBP-30310: Customer's Address not found by CUSTOMER_ADDRESS_ID. " val AccountApplicationNotFound = "OBP-30311: AccountApplication not found by ACCOUNT_APPLICATION_ID. " 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 a1da9687f3..d3fed192e6 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 @@ -66,6 +66,7 @@ import net.liftweb.json.{Extraction, JsonParser} import net.liftweb.json.JsonAST.{JArray, JField, JObject, JString, JValue} import net.liftweb.json.JsonDSL._ import net.liftweb.mapper.{By, Descending, MaxRows, NullRef, OrderBy} +import code.api.util.ExampleValue import code.api.util.ExampleValue.dynamicEntityResponseBodyExample import net.liftweb.common.Box @@ -9904,6 +9905,511 @@ trait APIMethods600 { } } + // --- Account Access Request Endpoints --- + + staticResourceDocs += ResourceDoc( + createAccountAccessRequest, + implementedInApiVersion, + nameOf(createAccountAccessRequest), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests", + "Create Account Access Request", + s"""Create a new Account Access Request (maker step in maker/checker workflow). + | + |The requestor (maker) creates a request to grant a target user access to a specific view on an account. + |A business justification is required. + | + |The request is created with status INITIATED and must be approved or rejected by a different user (checker). + | + |Authentication is Required + | + |""".stripMargin, + JSONFactory600.PostAccountAccessRequestJsonV600( + target_user_id = ExampleValue.userIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + business_justification = "Need access to review monthly account statements for audit purposes." + ), + JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "INITIATED", + checker_user_id = "", + checker_comment = "", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + $BankNotFound, + $BankAccountNotFound, + BusinessJustificationRequired, + AccountAccessRequestAlreadyExists, + AccountAccessRequestCannotBeCreated, + UnknownError + ), + List(apiTagAccountAccessRequest), + Some(List(canCreateAccountAccessRequestAtOneBank, canCreateAccountAccessRequestAtAnyBank)) + ) + + lazy val createAccountAccessRequest: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "account-access-requests" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + _ <- NewStyle.function.hasAtLeastOneEntitlement(bankId.value, u.userId, canCreateAccountAccessRequestAtOneBank :: canCreateAccountAccessRequestAtAnyBank :: Nil, callContext) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ${JSONFactory600.PostAccountAccessRequestJsonV600.getClass.getSimpleName}", 400, callContext) { + json.extract[JSONFactory600.PostAccountAccessRequestJsonV600] + } + _ <- Helper.booleanToFuture(failMsg = BusinessJustificationRequired, cc = callContext) { + postJson.business_justification.trim.nonEmpty + } + // Validate target user exists + (_, callContext) <- NewStyle.function.findByUserId(postJson.target_user_id, callContext) + // Check for existing INITIATED request for same user/account/view + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestAlreadyExists, cc = callContext) { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByUserAccountView(postJson.target_user_id, bankId.value, accountId.value, postJson.view_id) + .isEmpty + } + // Validate the view exists + _ <- if (postJson.is_system_view) { + ViewNewStyle.systemView(ViewId(postJson.view_id), callContext).map(_ => ()) + } else { + ViewNewStyle.customView(ViewId(postJson.view_id), BankIdAccountId(bankId, accountId), callContext).map(_ => ()) + } + request <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.createAccountAccessRequest( + bankId.value, + accountId.value, + postJson.view_id, + postJson.is_system_view, + u.userId, + postJson.target_user_id, + postJson.business_justification + ) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestCannotBeCreated, 400) + } + } yield { + (JSONFactory600.createAccountAccessRequestJsonV600(request), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAccountAccessRequestsForAccount, + implementedInApiVersion, + nameOf(getAccountAccessRequestsForAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests", + "Get Account Access Requests for Account", + s"""Get Account Access Requests for a specific account (checker view). + | + |Optionally filter by status using the query parameter: ?status=INITIATED + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + JSONFactory600.AccountAccessRequestsJsonV600( + account_access_requests = List(JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "INITIATED", + checker_user_id = "", + checker_comment = "", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + )) + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + $BankAccountNotFound, + UnknownError + ), + List(apiTagAccountAccessRequest), + Some(List(canGetAccountAccessRequestsAtOneBank, canGetAccountAccessRequestsAtAnyBank)) + ) + + lazy val getAccountAccessRequestsForAccount: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "account-access-requests" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + _ <- NewStyle.function.hasAtLeastOneEntitlement(bankId.value, u.userId, canGetAccountAccessRequestsAtOneBank :: canGetAccountAccessRequestsAtAnyBank :: Nil, callContext) + statusParam = ObpS.param("status") + requests <- Future { + statusParam match { + case Full(status) => + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByAccountAndStatus(bankId.value, accountId.value, status) + case _ => + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByAccount(bankId.value, accountId.value) + } + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get account access requests", 400) + } + } yield { + (JSONFactory600.createAccountAccessRequestsJsonV600(requests), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getMyAccountAccessRequests, + implementedInApiVersion, + nameOf(getMyAccountAccessRequests), + "GET", + "/my/account-access-requests", + "Get My Account Access Requests", + s"""Get Account Access Requests created by the current user (maker view). + | + |No special roles are required — a user can always see their own requests. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + JSONFactory600.AccountAccessRequestsJsonV600( + account_access_requests = List(JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "INITIATED", + checker_user_id = "", + checker_comment = "", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + )) + ), + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagAccountAccessRequest), + None + ) + + lazy val getMyAccountAccessRequests: OBPEndpoint = { + case "my" :: "account-access-requests" :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + requests <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByRequestorUserId(u.userId) + } map { + x => unboxFullOrFail(x, callContext, s"$UnknownError Cannot get account access requests", 400) + } + } yield { + (JSONFactory600.createAccountAccessRequestsJsonV600(requests), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAccountAccessRequestById, + implementedInApiVersion, + nameOf(getAccountAccessRequestById), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID", + "Get Account Access Request by Id", + s"""Get a single Account Access Request by its ID. + | + |Authentication is Required + | + |""".stripMargin, + EmptyBody, + JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "INITIATED", + checker_user_id = "", + checker_comment = "", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + $BankNotFound, + $BankAccountNotFound, + AccountAccessRequestNotFound, + UnknownError + ), + List(apiTagAccountAccessRequest), + Some(List(canGetAccountAccessRequestsAtOneBank, canGetAccountAccessRequestsAtAnyBank)) + ) + + lazy val getAccountAccessRequestById: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "account-access-requests" :: accountAccessRequestId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + _ <- NewStyle.function.hasAtLeastOneEntitlement(bankId.value, u.userId, canGetAccountAccessRequestsAtOneBank :: canGetAccountAccessRequestsAtAnyBank :: Nil, callContext) + request <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getById(accountAccessRequestId) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestNotFound, 404) + } + // Verify the request belongs to this bank/account + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestNotFound, cc = callContext) { + request.bankId == bankId.value && request.accountId == accountId.value + } + } yield { + (JSONFactory600.createAccountAccessRequestJsonV600(request), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + approveAccountAccessRequest, + implementedInApiVersion, + nameOf(approveAccountAccessRequest), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/approval", + "Approve Account Access Request", + s"""Approve an Account Access Request (checker step in maker/checker workflow). + | + |The checker must be a different user than the maker (requestor). This enforces dual control / maker-checker separation. + | + |Only requests with status INITIATED can be approved. + | + |On approval, the system automatically grants the target user access to the specified view. + | + |Authentication is Required + | + |""".stripMargin, + JSONFactory600.PostApproveAccountAccessRequestJsonV600( + comment = Some("Approved for Q1 audit.") + ), + JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "APPROVED", + checker_user_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + checker_comment = "Approved for Q1 audit.", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + $BankNotFound, + $BankAccountNotFound, + AccountAccessRequestNotFound, + AccountAccessRequestStatusNotInitiated, + MakerCheckerSameUser, + AccountAccessRequestCannotBeUpdated, + UnknownError + ), + List(apiTagAccountAccessRequest), + Some(List(canUpdateAccountAccessRequestAtOneBank, canUpdateAccountAccessRequestAtAnyBank)) + ) + + lazy val approveAccountAccessRequest: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "account-access-requests" :: accountAccessRequestId :: "approval" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + _ <- NewStyle.function.hasAtLeastOneEntitlement(bankId.value, u.userId, canUpdateAccountAccessRequestAtOneBank :: canUpdateAccountAccessRequestAtAnyBank :: Nil, callContext) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostApproveAccountAccessRequestJsonV600", 400, callContext) { + json.extract[JSONFactory600.PostApproveAccountAccessRequestJsonV600] + } + request <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getById(accountAccessRequestId) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestNotFound, 404) + } + // Verify the request belongs to this bank/account + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestNotFound, cc = callContext) { + request.bankId == bankId.value && request.accountId == accountId.value + } + // Only INITIATED requests can be approved + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestStatusNotInitiated, cc = callContext) { + request.status == com.openbankproject.commons.model.enums.AccountAccessRequestStatus.INITIATED.toString + } + // Maker/checker separation: checker must not be the requestor + _ <- Helper.booleanToFuture(failMsg = MakerCheckerSameUser, cc = callContext) { + u.userId != request.requestorUserId + } + // Get the target user + (targetUser, callContext) <- NewStyle.function.findByUserId(request.targetUserId, callContext) + // Grant view access + _ <- if (request.isSystemView) { + ViewNewStyle.systemView(ViewId(request.viewId), callContext).flatMap { view => + ViewNewStyle.grantAccessToSystemView(bankId, accountId, view, targetUser, callContext) + } + } else { + ViewNewStyle.customView(ViewId(request.viewId), BankIdAccountId(bankId, accountId), callContext).flatMap { view => + ViewNewStyle.grantAccessToCustomView(view, targetUser, callContext) + } + } + // Update the request status to APPROVED + updatedRequest <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.updateStatus( + accountAccessRequestId, + com.openbankproject.commons.model.enums.AccountAccessRequestStatus.APPROVED.toString, + u.userId, + postJson.comment.getOrElse("") + ) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestCannotBeUpdated, 400) + } + } yield { + (JSONFactory600.createAccountAccessRequestJsonV600(updatedRequest), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + rejectAccountAccessRequest, + implementedInApiVersion, + nameOf(rejectAccountAccessRequest), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/rejection", + "Reject Account Access Request", + s"""Reject an Account Access Request (checker step in maker/checker workflow). + | + |The checker must be a different user than the maker (requestor). This enforces dual control / maker-checker separation. + | + |Only requests with status INITIATED can be rejected. + | + |A comment is required when rejecting a request. + | + |Authentication is Required + | + |""".stripMargin, + JSONFactory600.PostRejectAccountAccessRequestJsonV600( + comment = "Insufficient business justification provided." + ), + JSONFactory600.AccountAccessRequestJsonV600( + account_access_request_id = "b4e0352a-9a0f-4bfa-b30b-9003aa467f51", + bank_id = ExampleValue.bankIdExample.value, + account_id = ExampleValue.accountIdExample.value, + view_id = ExampleValue.viewIdExample.value, + is_system_view = true, + requestor_user_id = ExampleValue.userIdExample.value, + target_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b2", + business_justification = "Need access to review monthly account statements for audit purposes.", + status = "REJECTED", + checker_user_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + checker_comment = "Insufficient business justification provided.", + created = APIUtil.DateWithMsExampleObject, + updated = APIUtil.DateWithMsExampleObject + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + $BankNotFound, + $BankAccountNotFound, + AccountAccessRequestNotFound, + AccountAccessRequestStatusNotInitiated, + MakerCheckerSameUser, + CheckerCommentRequiredForRejection, + AccountAccessRequestCannotBeUpdated, + UnknownError + ), + List(apiTagAccountAccessRequest), + Some(List(canUpdateAccountAccessRequestAtOneBank, canUpdateAccountAccessRequestAtAnyBank)) + ) + + lazy val rejectAccountAccessRequest: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: "account-access-requests" :: accountAccessRequestId :: "rejection" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.getBank(bankId, callContext) + (_, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + _ <- NewStyle.function.hasAtLeastOneEntitlement(bankId.value, u.userId, canUpdateAccountAccessRequestAtOneBank :: canUpdateAccountAccessRequestAtAnyBank :: Nil, callContext) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostRejectAccountAccessRequestJsonV600", 400, callContext) { + json.extract[JSONFactory600.PostRejectAccountAccessRequestJsonV600] + } + _ <- Helper.booleanToFuture(failMsg = CheckerCommentRequiredForRejection, cc = callContext) { + postJson.comment.trim.nonEmpty + } + request <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getById(accountAccessRequestId) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestNotFound, 404) + } + // Verify the request belongs to this bank/account + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestNotFound, cc = callContext) { + request.bankId == bankId.value && request.accountId == accountId.value + } + // Only INITIATED requests can be rejected + _ <- Helper.booleanToFuture(failMsg = AccountAccessRequestStatusNotInitiated, cc = callContext) { + request.status == com.openbankproject.commons.model.enums.AccountAccessRequestStatus.INITIATED.toString + } + // Maker/checker separation: checker must not be the requestor + _ <- Helper.booleanToFuture(failMsg = MakerCheckerSameUser, cc = callContext) { + u.userId != request.requestorUserId + } + // Update the request status to REJECTED + updatedRequest <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.updateStatus( + accountAccessRequestId, + com.openbankproject.commons.model.enums.AccountAccessRequestStatus.REJECTED.toString, + u.userId, + postJson.comment + ) + } map { + x => unboxFullOrFail(x, callContext, AccountAccessRequestCannotBeUpdated, 400) + } + } yield { + (JSONFactory600.createAccountAccessRequestJsonV600(updatedRequest), HttpCode.`201`(callContext)) + } + } + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index c971e7cd4c..febdb37f48 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -2261,4 +2261,62 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ConnectorTracesJsonV600(traces.map(createConnectorTraceJsonV600)) } + // Account Access Request JSON case classes + case class PostAccountAccessRequestJsonV600( + target_user_id: String, + view_id: String, + is_system_view: Boolean, + business_justification: String + ) + + case class PostApproveAccountAccessRequestJsonV600( + comment: Option[String] + ) + + case class PostRejectAccountAccessRequestJsonV600( + comment: String + ) + + case class AccountAccessRequestJsonV600( + account_access_request_id: String, + bank_id: String, + account_id: String, + view_id: String, + is_system_view: Boolean, + requestor_user_id: String, + target_user_id: String, + business_justification: String, + status: String, + checker_user_id: String, + checker_comment: String, + created: java.util.Date, + updated: java.util.Date + ) + + case class AccountAccessRequestsJsonV600( + account_access_requests: List[AccountAccessRequestJsonV600] + ) + + def createAccountAccessRequestJsonV600(r: code.accountaccessrequest.AccountAccessRequestTrait): AccountAccessRequestJsonV600 = { + AccountAccessRequestJsonV600( + account_access_request_id = r.accountAccessRequestId, + bank_id = r.bankId, + account_id = r.accountId, + view_id = r.viewId, + is_system_view = r.isSystemView, + requestor_user_id = r.requestorUserId, + target_user_id = r.targetUserId, + business_justification = r.businessJustification, + status = r.status, + checker_user_id = r.checkerUserId, + checker_comment = r.checkerComment, + created = r.created, + updated = r.updated + ) + } + + def createAccountAccessRequestsJsonV600(requests: List[code.accountaccessrequest.AccountAccessRequestTrait]): AccountAccessRequestsJsonV600 = { + AccountAccessRequestsJsonV600(requests.map(createAccountAccessRequestJsonV600)) + } + } diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index 35853e5b33..cc587c6c96 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -319,6 +319,11 @@ object TransactionRequestStatus extends Enumeration { val INITIATED, PENDING, NEXT_CHALLENGE_PENDING, FAILED, COMPLETED, FORWARDED, REJECTED, CANCELLED, CANCELLATION_PENDING = Value } +object AccountAccessRequestStatus extends Enumeration { + type AccountAccessRequestStatus = Value + val INITIATED, PENDING, APPROVED, REJECTED = Value +} + object AccountRoutingScheme extends Enumeration { type AccountRoutingScheme = Value val IBAN = Value From 639bae1a39b095077ff512e9b0d155cf8da6c82c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 16 Feb 2026 15:16:42 +0100 Subject: [PATCH 2/2] deduplicate personal Dynamic Entity list --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 1 - 1 file changed, 1 deletion(-) 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 d5fc1c6355..98036c6ee1 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 @@ -8651,7 +8651,6 @@ trait APIMethods600 { for { // Get all dynamic entities (system and bank level) allDynamicEntities <- Future( - NewStyle.function.getDynamicEntities(None, false) ++ NewStyle.function.getDynamicEntities(None, true) ) } yield {