commit 15ccaad99c4629b6226f9c2d9ef4107b4f4c91ce
parent c4aac943d0f81327d837584063d8f619d6326257
Author: Antoine A <>
Date: Fri, 20 Sep 2024 14:00:55 +0200
nexus: ebics-setup better HKD use
warn when required orders are unsupported or unauthorized
show subscriber status
improve debug output
Diffstat:
4 files changed, 178 insertions(+), 80 deletions(-)
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/XmlCombinators.kt
@@ -183,7 +183,22 @@ class XmlDestructor internal constructor(private val el: Element) {
fun dateTime(): LocalDateTime = LocalDateTime.parse(text(), DateTimeFormatter.ISO_DATE_TIME)
inline fun <reified T : Enum<T>> enum(): T = java.lang.Enum.valueOf(T::class.java, text())
- fun attr(index: String): String = el.getAttribute(index)
+ fun optAttr(index: String): String? {
+ val attr = el.getAttribute(index)
+ if (attr == "") {
+ return null
+ } else {
+ return attr
+ }
+ }
+ fun attr(index: String): String {
+ val attr = optAttr(index)
+ if (attr == null) {
+ throw DestructionError("missing attribute '$index' at '${el.tagName}'")
+ }
+ return attr
+ }
+
companion object {
fun <T> fromStream(xml: InputStream, root: String, f: XmlDestructor.() -> T): T {
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsSetup.kt
@@ -222,28 +222,77 @@ class EbicsSetup: CliktCommand() {
clientKeys,
bankKeys
).download(EbicsOrder.V3("HKD"), null, null) { stream ->
- val hkd = EbicsAdministrative.parseHKD(stream)
- val account = hkd.account
- // TODO parse and check more information
- if (account.currency != null && account.currency != cfg.currency)
- logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank")
- if (account.iban != null && account.iban != cfg.account.iban)
- logger.error("Expected IBAN '${cfg.account.iban}' from config got '${account.iban}' from bank")
- if (account.name != null && account.name != cfg.account.name)
- logger.warn("Expected NAME '${cfg.account.name}' from config got '${account.name}' from bank")
-
- logger.debug("User status: ${hkd.status.name} ${hkd.status.description}")
- logger.debug("Supported orders:")
- for (order in hkd.orders) {
- logger.debug("${order.type}${order.params}: ${order.description}")
+ val (partner, users) = EbicsAdministrative.parseHKD(stream)
+ val user = users.find { it -> it.id == cfg.ebicsUserId }
+ // Debug logging
+ logger.debug {
+ buildString {
+ if (partner.name != null || partner.accounts.isNotEmpty()) {
+ append("Partner Info: ")
+ if (partner.name != null) {
+ append("'")
+ append(partner.name)
+ append("' - ")
+ }
+ for ((iban, currency) in partner.accounts) {
+ append(iban)
+ append('_')
+ append(currency)
+ }
+ append('\n')
+ }
+ append("Supported orders:\n")
+ for ((order, description) in partner.orders) {
+ append("- ")
+ append(order.fullDescription())
+ append(": ")
+ append(description)
+ append('\n')
+ }
+ if (user != null) {
+ append("Authorized orders:\n")
+ for ((order) in partner.orders) {
+ append("- ")
+ append(order.fullDescription())
+ append('\n')
+ }
+ }
+ }
}
- logger.debug("Authorized orders:")
- for (order in hkd.permissions) {
- logger.debug("${order.type}${order.params}")
+
+ // Check partner info match config
+ if (partner.name != null && partner.name != cfg.account.name)
+ logger.warn("Expected NAME '${cfg.account.name}' from config got '${partner.name}' from bank")
+ val account = partner.accounts.find { it.iban == cfg.account.iban }
+ if (account != null) {
+ if (account.currency != null && account.currency != cfg.currency)
+ logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank")
+ } else if (partner.accounts.isNotEmpty()) {
+ val ibans = partner.accounts.map { it.iban }.joinToString(", ")
+ logger.error("Expected IBAN ${cfg.account.iban} from config got $ibans from bank")
+ }
+
+ val requireOrders = cfg.dialect.orders()
+
+ // Check partner support required orders
+ val unsupportedOrder = requireOrders subtract partner.orders.map { it.order }
+ if (unsupportedOrder.isNotEmpty()) {
+ logger.warn("Unsupported orders: {}", unsupportedOrder.map(EbicsOrder::fullDescription).joinToString(", "))
+ }
+
+ // Check user is authorized for required orders
+ if (user != null) {
+ val unauthorizedOrders = requireOrders subtract user.permissions subtract unsupportedOrder
+ if (unauthorizedOrders.isNotEmpty()) {
+ logger.warn("Unauthorized orders: {}", unauthorizedOrders.map(EbicsOrder::fullDescription).joinToString(", "))
+ }
+
+ logger.info("Subscriber status: {}", user.status.description)
}
}
}
} catch (e: Exception) {
+ // This can happen if HKD is not supported
logger.warn("HKD failed: ${e.fmt()}")
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsAdministrative.kt
@@ -30,22 +30,28 @@ data class VersionNumber(val number: Float, val schema: String) {
}
data class HKD (
- val account: AccountInfo,
- val orders: List<OrderInfo>,
- val permissions: List<OrderInfo>,
- val status: UserStatus
+ val partner: PartnerInfo,
+ val users: List<UserInfo>
)
-data class AccountInfo (
- val currency: String?,
- val iban: String?,
- val name: String?
+data class PartnerInfo(
+ val name: String?,
+ val accounts: List<AccountInfo>,
+ val orders: List<OrderInfo>
)
data class OrderInfo (
- val type: String,
- val params: String,
+ val order: EbicsOrder,
val description: String,
)
-// TODO use this in ebics setup to get current state and required actions
+data class AccountInfo (
+ val currency: String,
+ val iban: String,
+)
+data class UserInfo(
+ val id: String,
+ val status: UserStatus,
+ val permissions: List<EbicsOrder>,
+)
+
enum class UserStatus(val description: String) {
Ready("Subscriber is permitted access"),
New("Subscriber is established, pending access permission"),
@@ -71,7 +77,7 @@ object EbicsAdministrative {
EbicsReturnCode.lookup(one("ReturnCode").text())
}
val versions = map("VersionNumber") {
- VersionNumber(text().toFloat(), attr("ProtocolVersion"))
+ VersionNumber(text().toFloat(), attr("ProtocolVersion")!!)
}
EbicsResponse(
technicalCode = technicalCode,
@@ -82,66 +88,62 @@ object EbicsAdministrative {
}
fun parseHKD(stream: InputStream): HKD {
- fun XmlDestructor.orderInfo() = OrderInfo(
- one("AdminOrderType").text(),
+ fun XmlDestructor.order(): EbicsOrder {
+ var order = EbicsOrder.V3(one("AdminOrderType").text())
opt("Service") {
- // TODO user a structured type to enable comparison
- val params = StringBuilder()
- opt("ServiceName")?.run {
- params.append(" ${text()}")
- }
- opt("Scope")?.run {
- params.append(" ${text()}")
- }
- opt("ServiceOption")?.run {
- params.append(" ${text()}")
- }
- opt("MsgName")?.run {
- params.append(" ${text()}")
- }
- opt("Container")?.run {
- params.append(" ${attr("containerType")}")
- }
- params.toString()
- } ?: "",
- opt("Description")?.text() ?: ""
- )
- // TODO handle multiple partner, accounts and user using their respective ids
+ order = order.copy(
+ service = opt("ServiceName")?.text(),
+ scope = opt("Scope")?.text(),
+ option = opt("ServiceOption")?.text(),
+ container = opt("Container")?.attr("containerType"),
+ message = opt("MsgName")?.text(),
+ version = opt("MsgName")?.optAttr("version"),
+ )
+ }
+ return order
+ }
return XmlDestructor.fromStream(stream, "HKDResponseOrderData") {
- val (account, orders) = one("PartnerInfo") {
- var currency: String? = null
- var iban: String? = null
- val name = opt("AddressInfo")?.one("Name")?.text()
- opt("AccountInfo") {
- currency = attr("Currency")
+ val partnerInfo = one("PartnerInfo") {
+ val name = one("AddressInfo").opt("Name")?.text()
+ val accounts = map("AccountInfo") {
+ var currency = attr("Currency")
+ lateinit var iban: String
each("AccountNumber") {
if (attr("international") == "true") {
iban = text()
}
}
+ AccountInfo(currency, iban)
+ }
+ val orders = map("OrderInfo") {
+ OrderInfo(
+ order = order(),
+ description = one("Description").text()
+ )
}
- val orders = map("OrderInfo") { orderInfo() }
- Pair(AccountInfo(currency, iban, name), orders)
+ PartnerInfo(name, accounts, orders)
}
- val (permissions, status) = one("UserInfo") {
- val userId = one("UserID").text()
- val status = when (val status = one("UserID").attr("Status")) {
- "1" -> UserStatus.Ready
- "2" -> UserStatus.New
- "3" -> UserStatus.INI
- "4" -> UserStatus.HIA
- "5" -> UserStatus.Initialised
- "6" -> UserStatus.SuspendedFailedAttempts
- // 7 is not applicable per spec
- "8" -> UserStatus.SuspendedSPR
- "9" -> UserStatus.SuspendedBank
- else -> throw Exception("Unknown user statte $status")
+ val usersInfo = map("UserInfo") {
+ val (id, status) = one("UserID") {
+ val id = text()
+ val status = when (val status = attr("Status")) {
+ "1" -> UserStatus.Ready
+ "2" -> UserStatus.New
+ "3" -> UserStatus.INI
+ "4" -> UserStatus.HIA
+ "5" -> UserStatus.Initialised
+ "6" -> UserStatus.SuspendedFailedAttempts
+ // 7 is not applicable per spec
+ "8" -> UserStatus.SuspendedSPR
+ "9" -> UserStatus.SuspendedBank
+ else -> throw Exception("Unknown user statte $status")
+ }
+ Pair(id, status)
}
- val userName = opt("Name")?.text()
- val permissions = map("Permission") { orderInfo() }
- Pair(permissions, status)
+ val permissions = map("Permission") { order() }
+ UserInfo(id, status, permissions)
}
- HKD(account, orders, permissions, status)
+ HKD(partnerInfo, usersInfo)
}
}
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsOrder.kt
@@ -44,6 +44,7 @@ sealed class EbicsOrder(val schema: String) {
)
}
+ /** Minimal text description for file logging */
fun description(): String = buildString {
when (this@EbicsOrder) {
is V2_5 -> {
@@ -53,7 +54,7 @@ sealed class EbicsOrder(val schema: String) {
}
is V3 -> {
append(type)
- for (part in sequenceOf(service, message, option)) {
+ for (part in sequenceOf(service, option, message)) {
if (part != null) {
append('-')
append(part)
@@ -63,6 +64,34 @@ sealed class EbicsOrder(val schema: String) {
}
}
+ /** Full text description */
+ fun fullDescription(): String = buildString {
+ when (this@EbicsOrder) {
+ is V2_5 -> {
+ append(type)
+ append(' ')
+ append(attribute)
+ }
+ is V3 -> {
+ append(type)
+ for (part in sequenceOf(service, scope, option, container)) {
+ if (part != null) {
+ append(' ')
+ append(part)
+ }
+ }
+ if (message != null) {
+ append(' ')
+ append(message)
+ if (version != null) {
+ append('v')
+ append(version)
+ }
+ }
+ }
+ }
+ }
+
fun doc(): OrderDoc? {
return when (this) {
is V2_5 -> {
@@ -163,4 +192,7 @@ enum class Dialect {
gls -> EbicsOrder.V3("BTU", "SCT", null, "pain.001")
}
}
+
+ fun orders(): Set<EbicsOrder> =
+ (sequenceOf(EbicsOrder.V3("HAA")) + OrderDoc.entries.map { downloadDoc(it, false) }).toSet()
}
\ No newline at end of file