From d1b262d8e70ff0cf68366534f535c7ed9fa83d6f Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 12:00:59 +0200 Subject: [PATCH 1/5] feat(dva-api): add synchronous attestation and verify endpoints --- .../hu/bme/mit/ftsrg/dva/api/AoVDTOs.kt | 30 ++ .../hu/bme/mit/ftsrg/dva/api/Application.kt | 11 - .../bme/mit/ftsrg/dva/api/route/aovRoutes.kt | 267 ++++++++++++------ .../api/src/main/resources/application.yaml | 16 +- .../dva/dto/aov/AttestationRequestDTO.kt | 1 + 5 files changed, 227 insertions(+), 98 deletions(-) create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/AoVDTOs.kt diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/AoVDTOs.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/AoVDTOs.kt new file mode 100644 index 00000000..5eb93f40 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/AoVDTOs.kt @@ -0,0 +1,30 @@ +package hu.bme.mit.ftsrg.dva.api + +import kotlinx.serialization.Serializable + +@Serializable +data class EvaluationResultDTO( + val engine: String? = null, + val timestamp: String, + val success: Boolean, + val details: String? = null, + val error: String? = null, +) + +@Serializable +data class AoVResponseDTO( + val jws: String? = null, + val evaluationPassing: Boolean, + val evaluationResults: List, +) + +@Serializable +data class AttestationVerifySyncRequestDTO( + val jws: String, +) + +@Serializable +data class AttestationVerifySyncResponseDTO( + val verified: Boolean, + val reason: String? = null, +) \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt index 0426d212..83b683af 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt @@ -1,10 +1,7 @@ package hu.bme.mit.ftsrg.dva.api -import com.rabbitmq.client.Connection -import com.rabbitmq.client.ConnectionFactory import hu.bme.mit.ftsrg.dva.api.db.* import hu.bme.mit.ftsrg.dva.api.err.addHandlers -import hu.bme.mit.ftsrg.dva.api.rabbit.connectWithRetry import hu.bme.mit.ftsrg.dva.api.route.* import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo import hu.bme.mit.ftsrg.dva.log.VerifRequestLogRepo @@ -61,15 +58,7 @@ fun Application.installPlugins() { } fun Application.configureKoin() { - val rabbitHost = environment.config.property("rabbitmq.host").getString() - val appModule = module { - single { - ConnectionFactory().run { - host = rabbitHost - connectWithRetry(logger = log) - } - } single { HttpClient(CIO) { install(ClientContentNegotiation) { diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt index 1c87ac9f..92657aae 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt @@ -1,33 +1,26 @@ package hu.bme.mit.ftsrg.dva.api.route -import com.rabbitmq.client.Connection -import com.rabbitmq.client.MessageProperties +import hu.bme.mit.ftsrg.dva.api.AoVResponseDTO +import hu.bme.mit.ftsrg.dva.api.AttestationVerifySyncRequestDTO +import hu.bme.mit.ftsrg.dva.api.AttestationVerifySyncResponseDTO +import hu.bme.mit.ftsrg.dva.api.EvaluationResultDTO import hu.bme.mit.ftsrg.dva.api.resource.Attestations -import hu.bme.mit.ftsrg.dva.dto.IDDTO -import hu.bme.mit.ftsrg.dva.dto.aov.ACAPyPresentationRequestDTO -import hu.bme.mit.ftsrg.dva.dto.aov.ACAPyPresentationResponseDTO +import hu.bme.mit.ftsrg.dva.dto.ErrDTO import hu.bme.mit.ftsrg.dva.dto.aov.AttestationRequestDTO -import hu.bme.mit.ftsrg.dva.dto.aov.AttestationVerificationRequestDTO import hu.bme.mit.ftsrg.dva.log.* -import io.github.viartemev.rabbitmq.channel.confirmChannel -import io.github.viartemev.rabbitmq.channel.publish -import io.github.viartemev.rabbitmq.publisher.OutboundMessage -import io.github.viartemev.rabbitmq.queue.QueueSpecification -import io.github.viartemev.rabbitmq.queue.declareQueue import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.http.HttpStatusCode.Companion.Accepted +import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.resources.post import io.ktor.server.response.* import io.ktor.server.routing.* -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* import org.koin.ktor.ext.inject import java.util.* import kotlin.time.Clock @@ -35,94 +28,210 @@ import kotlin.time.ExperimentalTime import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Serializable +private data class EvaluateBatchRequest( + val vla: JsonObject, + val data: JsonElement, +) + +@Serializable +private data class AovIssueRequest( + val vcId: String, + val validSince: String, + val subject: String, + val issuerId: String, + val recordId: String, + val contractId: String, + val dataExchangeId: String, + val payload: String, + val evaluationResults: List, +) + +@Serializable +private data class AovIssueResponse( + val jws: String, +) + @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) fun Application.aovRoutes() { - val rmqConnection by inject() val reqsRepo by inject() - val verifsRepo by inject() val httpClient by inject() + val processingURL = + environment.config.propertyOrNull("processing.url")?.getString() ?: "http://localhost:5000" + val vlaManagerURL = + environment.config.propertyOrNull("vlaManager.url")?.getString() ?: "http://localhost:8000" + val vcManagerURL = + environment.config.propertyOrNull("vcManager.url")?.getString() ?: "http://localhost:8000" + routing { post { val request: AttestationRequestDTO = call.receive() + val now = Clock.System.now() + val data = (request.data as? JsonObject) ?: JsonObject(emptyMap()) - val id = UUID.randomUUID().toString() - val requestWithID: AttestationRequestDTO = request.copy(id = id) + val rawVlaId = request.vlaId + if (rawVlaId.isNullOrBlank()) { + call.respond( + HttpStatusCode.BadRequest, + ErrDTO(type = "BAD_REQUEST", title = "vlaId is required"), + ) + return@post + } - reqsRepo.add( - RequestLogNew( - type = RequestType.ATTESTATION_REQUEST, - requestID = Uuid.parse(requestWithID.id!!), - exchangeID = requestWithID.exchangeID, - contractID = requestWithID.contract["id"].toString(), - vlaID = Uuid.parse(requestWithID.contract["vla"]?.jsonObject["id"]?.jsonPrimitive?.content!!), - data = requestWithID.data, - attesterID = requestWithID.attesterID, - receivedDate = Clock.System.now(), + val parsedUuid = try { + Uuid.parse(rawVlaId) + } catch (e: IllegalArgumentException) { + call.respond( + HttpStatusCode.BadRequest, + ErrDTO( + type = "BAD_REQUEST", + title = "invalid vlaId — expected a UUID v4, got: $rawVlaId", + ) ) - ) + return@post + } - rmqConnection.confirmChannel { - declareQueue(QueueSpecification("ATTESTATION_REQUESTS")) - publish { - publishWithConfirm(createMessage(Json.encodeToString(requestWithID))) + val vla: JsonObject = try { + val resp: HttpResponse = httpClient.get("$vlaManagerURL/vla/$parsedUuid") { + accept(ContentType.Application.Json) + } + if (resp.status == HttpStatusCode.NotFound) { + call.respond( + HttpStatusCode.NotFound, + ErrDTO( + type = "NOT_FOUND", + title = "VLA $parsedUuid not found at the Data Intermediary", + ) + ) + return@post } + resp.body() + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadGateway, + ErrDTO(type = "BAD_GATEWAY", title = "VLA MANAGER API unreachable: ${e.message}"), + ) + return@post } - call.respond(status = Accepted, message = IDDTO(id)) - } + val results: List = try { + val resp: HttpResponse = httpClient.post("$processingURL/evaluate-batch") { + contentType(ContentType.Application.Json) + setBody(EvaluateBatchRequest(vla = vla, data = data)) + } + resp.body>() + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadGateway, + ErrDTO(type = "BAD_GATEWAY", title = "DVA PROCESSING unreachable: ${e.message}"), + ) + return@post + } - post { - val request: AttestationVerificationRequestDTO = call.receive() - - val id = UUID.randomUUID().toString() - val requestWithID: AttestationVerificationRequestDTO = request.copy(id = id) - - val verifLogEntity = verifsRepo.add( - VerifRequestLogNew( - exchangeID = requestWithID.exchangeID, - contractID = requestWithID.contractID, - attesterAgentURL = requestWithID.attesterAgentURL, - attesterAgentLabel = requestWithID.attesterAgentLabel, - receivedDate = Clock.System.now(), + val allSuccess = results.isNotEmpty() && results.all { it.success } + + val recordId = UUID.randomUUID().toString() + val vcId = Uuid.random().toString() + var jws: String? = null + + val contractId = request.contract["id"]?.jsonPrimitive?.contentOrNull + ?: request.contract["_id"]?.jsonPrimitive?.contentOrNull + ?: "" + val dataProvider = request.contract["dataProvider"]?.jsonPrimitive?.contentOrNull + ?: request.attesterID + + if (allSuccess) { + try { + val upstreamResp: HttpResponse = httpClient.post("$vcManagerURL/aov/issue") { + contentType(ContentType.Application.Json) + setBody( + AovIssueRequest( + vcId = vcId, + validSince = now.toString(), + subject = dataProvider, + issuerId = request.attesterID, + recordId = recordId, + contractId = contractId, + dataExchangeId = request.exchangeID, + payload = request.data.toString(), + evaluationResults = results, + ) + ) + } + if (upstreamResp.status == OK) { + jws = upstreamResp.body().jws + } else { + call.respond( + upstreamResp.status, + ErrDTO( + type = "VC_MANAGER_${upstreamResp.status.value}", + title = upstreamResp.bodyAsText(), + ) + ) + return@post + } + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadGateway, + ErrDTO(type = "BAD_GATEWAY", title = "DVA VC MANAGER unreachable: ${e.message}"), + ) + return@post + } + } + + reqsRepo.add( + RequestLogNew( + type = RequestType.ATTESTATION_REQUEST, + requestID = Uuid.parse(recordId), + exchangeID = request.exchangeID, + contractID = contractId, + vlaID = parsedUuid, + data = request.data, + attesterID = request.attesterID, + evaluationPassing = allSuccess, + evaluationResults = Json.encodeToString(results), + receivedDate = now, + evaluationDate = now, + vcIssuedDate = if (allSuccess) now else null, + vcID = if (allSuccess) vcId else null, ) ) - val resp: HttpResponse = - httpClient.post( - "${ - environment.config.property("acaPy.controller.url").getString() - }/request_presentation_from_peer" - ) { + call.respond( + OK, + AoVResponseDTO( + jws = jws, + evaluationPassing = allSuccess, + evaluationResults = results, + ) + ) + } + + post { + val request: AttestationVerifySyncRequestDTO = call.receive() + try { + val upstreamResp: HttpResponse = httpClient.post("$vcManagerURL/aov/verify") { contentType(ContentType.Application.Json) - setBody( - ACAPyPresentationRequestDTO( - dataExchangeId = requestWithID.exchangeID, - attesterAgentURL = requestWithID.attesterAgentURL, - attesterLabel = requestWithID.attesterAgentLabel + setBody(request) + } + if (upstreamResp.status == OK) { + call.respond(OK, upstreamResp.body()) + } else { + call.respond( + upstreamResp.status, + ErrDTO( + type = "VC_MANAGER_${upstreamResp.status.value}", + title = upstreamResp.bodyAsText(), ) ) } - val acaPyResp: ACAPyPresentationResponseDTO = resp.body() - - if (verifLogEntity != null) { - verifsRepo.update( - VerifRequestLogPatch( - id = verifLogEntity.id, - presentationRequestData = acaPyResp.aov, - ) + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadGateway, + ErrDTO(type = "BAD_GATEWAY", title = "DVA VC MANAGER unreachable: ${e.message}"), ) } - - call.respond(status = resp.status, message = acaPyResp) } } -} - -private fun createMessage(body: String): OutboundMessage = - OutboundMessage( - exchange = "", - routingKey = "ATTESTATION_REQUESTS", - properties = MessageProperties.PERSISTENT_BASIC, - msg = body - ) \ No newline at end of file +} \ No newline at end of file diff --git a/dva-api/api/src/main/resources/application.yaml b/dva-api/api/src/main/resources/application.yaml index f2af4bc3..ddb9118f 100644 --- a/dva-api/api/src/main/resources/application.yaml +++ b/dva-api/api/src/main/resources/application.yaml @@ -13,14 +13,14 @@ postgres: user: "$DVA_POSTGRES_USER:postgres" password: "$DVA_POSTGRES_PASSWORD:postgres" -rabbitmq: - host: "$DVA_RABBITMQ_HOST:localhost" - processing: url: "$DVA_PROCESSING_URL:http://localhost:5000" -acaPy: - agent: - url: "$DVA_ACA_PY_AGENT_URL:http://localhost:8030" - controller: - url: "$DVA_ACA_PY_CONTROLLER_URL:http://localhost:8050" \ No newline at end of file +vlaManager: + url: "$DVA_VLA_MANAGER_URL:http://localhost:8000" + +vcManager: + url: "$DVA_VC_MANAGER_URL:http://localhost:8000" + +dva: + apiKey: "$DVA_API_KEY:" \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt index 1dcc5757..d757f2a8 100644 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt @@ -11,4 +11,5 @@ data class AttestationRequestDTO( val contract: JsonObject, val data: JsonElement, val attesterID: String, + val vlaId: String? = null, ) \ No newline at end of file From ecb49a31a20693b819bd868959abd92e95fd7857 Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 12:04:03 +0200 Subject: [PATCH 2/5] fix(dva-api): fix dataProvider test fixture to use a DID --- .../ftsrg/dva/api/route/AoVSyncRoutesTest.kt | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVSyncRoutesTest.kt diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVSyncRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVSyncRoutesTest.kt new file mode 100644 index 00000000..68191dd2 --- /dev/null +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVSyncRoutesTest.kt @@ -0,0 +1,223 @@ +package hu.bme.mit.ftsrg.dva.api.route + +import hu.bme.mit.ftsrg.dva.api.AoVResponseDTO +import hu.bme.mit.ftsrg.dva.api.AttestationVerifySyncRequestDTO +import hu.bme.mit.ftsrg.dva.api.AttestationVerifySyncResponseDTO +import hu.bme.mit.ftsrg.dva.api.EvaluationResultDTO +import hu.bme.mit.ftsrg.dva.api.testutil.createTestClient +import hu.bme.mit.ftsrg.dva.api.testutil.setupTestApplication +import hu.bme.mit.ftsrg.dva.dto.aov.AttestationRequestDTO +import hu.bme.mit.ftsrg.dva.log.FakeReqestLogRepo +import hu.bme.mit.ftsrg.dva.log.FakeVerifRequestLogRepo +import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo +import hu.bme.mit.ftsrg.dva.log.VerifRequestLogRepo +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.mock.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin +import java.util.* + +class AoVSyncRoutesTest { + + @Test + fun `attestation returns 200 with JWS when data passes`() = testApplication { + setupApplication(evaluationSuccess = true, issueJws = true) + val client = createTestClient() + + val response = client.post("/attestation") { + contentType(ContentType.Application.Json) + setBody(buildAttestationRequest()) + } + + assertEquals(OK, response.status) + val body = response.body() + assertTrue(body.evaluationPassing, "evaluationPassing must be true") + assertNotNull(body.jws, "JWS must be present when data passes") + assertEquals(3, body.jws!!.split(".").size, "JWS must have 3 parts") + } + + @Test + fun `attestation returns 200 with null JWS when data fails`() = testApplication { + setupApplication(evaluationSuccess = false, issueJws = false) + val client = createTestClient() + + val response = client.post("/attestation") { + contentType(ContentType.Application.Json) + setBody(buildAttestationRequest()) + } + + assertEquals(OK, response.status) + val body = response.body() + assertFalse(body.evaluationPassing, "evaluationPassing must be false") + assertNull(body.jws, "JWS must be null when data fails") + } + + @Test + fun `attestation verify delegates to VC MANAGER and returns verified true`() = testApplication { + setupApplication(evaluationSuccess = true, issueJws = true, verifyResult = true) + val client = createTestClient() + + val issueResp = client.post("/attestation") { + contentType(ContentType.Application.Json) + setBody(buildAttestationRequest()) + } + val aovBody = issueResp.body() + assertNotNull(aovBody.jws) + + val verifyResp = client.post("/attestation/verify") { + contentType(ContentType.Application.Json) + setBody(AttestationVerifySyncRequestDTO(jws = aovBody.jws!!)) + } + + assertEquals(OK, verifyResp.status) + val verifyBody = verifyResp.body() + assertTrue(verifyBody.verified, "verified must be true") + } + + @Test + fun `attestation verify delegates to VC MANAGER and returns verified false for tampered JWS`() = testApplication { + setupApplication(evaluationSuccess = true, issueJws = true, verifyResult = false, verifyReason = "signature mismatch") + val client = createTestClient() + + val issueResp = client.post("/attestation") { + contentType(ContentType.Application.Json) + setBody(buildAttestationRequest()) + } + val aovBody = issueResp.body() + + val verifyResp = client.post("/attestation/verify") { + contentType(ContentType.Application.Json) + setBody(AttestationVerifySyncRequestDTO(jws = aovBody.jws!!)) + } + + assertEquals(OK, verifyResp.status) + val verifyBody = verifyResp.body() + assertFalse(verifyBody.verified, "verified must be false for a tampered JWS") + assertEquals("signature mismatch", verifyBody.reason) + } + + private fun mockHttpClient( + evaluationSuccess: Boolean, + issueJws: Boolean, + verifyResult: Boolean = true, + verifyReason: String? = null, + ): HttpClient = HttpClient(MockEngine) { + install(ContentNegotiation) { json() } + engine { + addHandler { request -> + val url = request.url.toString() + when { + url.contains("/vla/") && request.method == HttpMethod.Get -> { + val vla = buildJsonObject { + put("id", UUID.randomUUID().toString()) + put("apiVersion", "v3.0.2") + put("kind", "DataContract") + putJsonArray("schema") { + addJsonObject { + putJsonArray("quality") { + addJsonObject { + put("engine", "JQ") + put("implementation", "{ success: true }") + } + } + } + } + } + respond( + content = Json.encodeToString(JsonObject.serializer(), vla), + status = OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + url.contains("/evaluate-batch") -> { + val results = listOf( + EvaluationResultDTO( + engine = "JQ", + timestamp = "2024-01-01T00:00:00Z", + success = evaluationSuccess, + ) + ) + respond( + content = Json.encodeToString(results), + status = OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + url.contains("/aov/issue") -> { + if (issueJws) { + val issueResp = buildJsonObject { + put("jws", "eyJhbGciOiJFZERTQSIsInR5cCI6IlZDK0xELUpTT04rSldTIn0.eyJ0ZXN0IjoidGVzdCJ9.aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890abcdefghijklmnopqrstuvwxyz1234567890") + } + respond( + content = Json.encodeToString(JsonObject.serializer(), issueResp), + status = OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } else { + respond( + content = "{}", + status = OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + } + url.contains("/aov/verify") -> { + val verifyResp = buildJsonObject { + put("verified", verifyResult) + if (verifyReason != null) put("reason", verifyReason) + } + respond( + content = Json.encodeToString(JsonObject.serializer(), verifyResp), + status = OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + else -> respond( + content = "{}", + status = HttpStatusCode.NotFound, + ) + } + } + } + } + + private fun buildAttestationRequest(): AttestationRequestDTO = AttestationRequestDTO( + id = null, + exchangeID = "xchg-sync-0001", + attesterID = "attester-sync-0001", + contract = buildJsonObject { + put("id", "contract-sync-0001") + put("dataProvider", "did:web:provider.example.com:provider-sync") + }, + data = buildJsonObject { + putJsonObject("result") { put("success", true) } + }, + vlaId = UUID.randomUUID().toString(), + ) + + private fun ApplicationTestBuilder.setupApplication( + evaluationSuccess: Boolean, + issueJws: Boolean, + verifyResult: Boolean = true, + verifyReason: String? = null, + ) = setupTestApplication { + val testModule = module { + single { FakeReqestLogRepo() } + single { FakeVerifRequestLogRepo() } + single { mockHttpClient(evaluationSuccess, issueJws, verifyResult, verifyReason) } + } + this.install(Koin) { modules(testModule) } + aovRoutes() + } +} \ No newline at end of file From b3476637e889a3b070d7a843b5ff66b6b3a0b75e Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 12:06:15 +0200 Subject: [PATCH 3/5] docs(dva-api): update openapi spec for attestation endpoints --- docs/spec/dva-api.yaml | 375 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/spec/dva-api.yaml diff --git a/docs/spec/dva-api.yaml b/docs/spec/dva-api.yaml new file mode 100644 index 00000000..17ac9c7b --- /dev/null +++ b/docs/spec/dva-api.yaml @@ -0,0 +1,375 @@ +openapi: 3.1.0 +info: + title: DVA API + version: 0.6.0 + description: |- + HTTP gateway / orchestrator for the attestation flow of the Data Veracity + Assurance (DVA) building block. + + Responsibilities of this service (and ONLY this service): + + 1. `POST /attestation` — attestation orchestration: + - Resolves the Veracity Level Agreement (VLA) via + `GET {vlaManagerURL}/vla/{id}` for the supplied `vlaId`. + - Calls `POST {processingURL}/evaluate-batch` with the contract data. + - If every quality evaluation passes, requests credential issuance via + `POST {vcManagerURL}/aov/issue` and returns the resulting Ed25519 JWS + in the response. + 2. `POST /attestation/verify` — verifies a JWS by proxying the request + body unchanged to `POST {vcManagerURL}/aov/verify`. + 3. `GET /info/requests` — audit rows (`RequestLog`). + 4. `GET /info/presentations` — audit rows (`VerifRequestLog`). + 5. `GET /info/credentials` — opaque passthrough of + `GET {acaPyAgentURL}/credentials`. + + The AoV JWS is returned in the `POST /attestation` response. If the veracity + checks fail, `evaluationPassing` is `false` and `jws` is `null`; a `200 OK` + is still returned. Invalid requests (e.g. malformed `vlaId`) yield + `400 BAD REQUEST`. +servers: + - url: http://localhost:9091 + description: Provider + - url: http://localhost:9092 + description: Consumer +tags: + - name: AoV + description: Endpoints related to attestations of veracity (AoVs) + - name: Info + description: Audit info endpoints backed by local `RequestLog` / `VerifRequestLog` tables or proxied agent calls +paths: + /attestation: + post: + tags: [AoV] + summary: Request an Attestation of Veracity (AoV) + description: |- + Attestation orchestration. The `contract` carries a reference to a VLA + via the top-level `vlaId` string UUID; this gateway resolves the VLA via + the VLA MANAGER API. `vlaId` is required. + operationId: requestAov + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationRequest' + examples: + VlaIdReference: + summary: VLA reference by vlaId + value: + exchangeID: xchg-0001 + contract: + id: contract-0001 + dataProvider: did:web:provider.example.com:123 + dataConsumer: did:web:consumer.example.com:456 + serviceOffering: /catalog/serviceofferings/serviceoffering-test-did + purpose: [] + negotiators: + - did: did:web:provider.example.com:123 + - did: did:web:consumer.example.com:456 + status: PENDING + policy: + - uid: /policy/policy-0-uid + permission: + - type: permission + uid: /target/3f8d1b0e-8e2e-4b69-9b1f-089fe2f3e9d7 + action: use + vlaId: ddf4a56a-228b-461c-9448-d0e16135e315 + attesterID: attester-0000 + data: + actor: + name: Jean Dupont + mbox: mailto:jeandupont@example.com + verb: + id: http://adlnet.gov/expapi/verbs/interacted + object: + id: https://navy.mil/netc/xapi/activities/simulations/b9e16535-4fc9-4c66-ac87-3ad7ce515f5c/events/0221144 + definition: + name: + en-US: Event in Simulator + description: + en-US: You're wearing all your PPE + type: http://adlnet.gov/expapi/activities/interaction + result: + success: true + timestamp: '2025-03-16T03:25:00Z' + responses: + '200': + description: >- + Response — the AoV JWS (if all checks pass) or null JWS + (if checks fail). + content: + application/json: + schema: + $ref: '#/components/schemas/AoVResponse' + '400': + description: Malformed request (e.g. invalid `vlaId`). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: VLA not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Request body was syntactically valid JSON but semantically malformed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '502': + description: >- + Downstream service (VLA MANAGER API, the processing service, or DVA VC + MANAGER) unreachable or returned an error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /attestation/verify: + post: + tags: [AoV] + summary: Verify an attestation JWS + description: |- + Verifies a JWS by proxying the request body unchanged to + `POST {vcManagerURL}/aov/verify`. The downstream verifier performs the + Ed25519 signature check. + operationId: requestAovVerification + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationVerifyRequest' + examples: + VerifyJws: + summary: Verify a compact JWS + value: + jws: >- + eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa29iQ2c4Y2tUZU1Hekg4RWZYYnVKU2RRNng3UXZnZGVMNkZqQjdDeng1V1VZV0FteSJ9..pQYZ8ViPzZbnY3RJZUE3Gp_b2GXG3oFnu1Px5r2to-sZGNDv5Cj8Qp5sJvbE_3gwec6GjNmNJZpK7ve1r7UtCw + responses: + '200': + description: Verification result. + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationVerifyResponse' + '400': + description: Malformed verification request. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Request body was syntactically valid JSON but semantically malformed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '502': + description: DVA VC MANAGER unreachable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /info/requests: + get: + tags: [Info] + summary: List attestation request audit rows + description: >- + Returns `RequestLog` audit rows persisted by this gateway. The row + shape is dynamic (driven by the local audit schema) and intentionally + opaque at the API boundary. + operationId: getRequests + responses: + '200': + description: List of `RequestLog` audit rows. + content: + application/json: + schema: + type: array + description: RequestLog audit rows; opaque object shape defined by the local audit table. + items: + type: object + description: One RequestLog row. Object shape is dynamic and intentionally opaque. + additionalProperties: true + /info/presentations: + get: + tags: [Info] + summary: List verification request audit rows + description: >- + Returns `VerifRequestLog` audit rows persisted by this gateway. The row + shape is dynamic and intentionally opaque at the API boundary. + operationId: getPresentations + responses: + '200': + description: List of `VerifRequestLog` audit rows. + content: + application/json: + schema: + type: array + description: VerifRequestLog audit rows; opaque object shape defined by the local audit table. + items: + type: object + description: One VerifRequestLog row. Object shape is dynamic and intentionally opaque. + additionalProperties: true + /info/credentials: + get: + tags: [Info] + summary: List credentials from the ACA-Py agent + description: >- + Opaque passthrough of `GET {acaPyAgentURL}/credentials`. No body + transformation is performed; the agent's response is returned as-is. + operationId: getCredentials + responses: + '200': + description: Opaque credentials payload from the ACA-Py agent. + content: + application/json: + schema: + type: object + description: ACA-Py `/credentials` response, passed through unchanged. + additionalProperties: true + '502': + description: ACA-Py agent unreachable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + VeracityRequest: + description: Base shape shared by attestation / verification requests. + type: object + required: [exchangeID, contract, data] + properties: + exchangeID: + type: string + example: xchg-0001 + contract: + type: object + description: >- + Data contract. `id` plus a top-level `vlaId` (reference resolved by + the gateway) must be supplied. + required: [id] + properties: + id: + type: string + example: contract-0001 + dataProvider: + type: string + description: >- + Optional. DID identifying the data provider; relevant only for + bilateral data exchanges. + example: did:web:provider.example.com:123 + data: + description: The veracity data payload unit (any JSON value). + AttestationRequest: + allOf: + - $ref: '#/components/schemas/VeracityRequest' + - type: object + required: [attesterID, vlaId] + properties: + attesterID: + type: string + example: attester-0000 + vlaId: + type: string + format: uuid + description: >- + Reference to a VLA owned by the VLA MANAGER. The gateway resolves + it via `GET {vlaManagerURL}/vla/{id}`. + example: ddf4a56a-228b-461c-9448-d0e16135e315 + QualityEngine: + description: Identifier of the evaluation engine invoked by the processing service. + type: string + enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + example: SCHEMA + EvaluationResult: + description: Result of one quality-engine evaluation run against `data`. + type: object + properties: + engine: + description: Engine that evaluated `data`. + $ref: '#/components/schemas/QualityEngine' + timestamp: + type: string + format: date-time + description: ISO-8601 timestamp at which the evaluation ran. + example: '2025-03-16T03:25:00Z' + success: + type: boolean + description: Whether the evaluated check passed. + example: true + details: + type: string + nullable: true + description: Human-readable success detail (absent or null on failure). + error: + type: string + nullable: true + description: Human-readable failure detail (absent or null on success). + required: [timestamp, success] + AoVResponse: + description: Attestation response returned by this gateway. + type: object + properties: + jws: + type: string + nullable: true + description: Compact Ed25519 JWS encoding the attestation, or null when checks failed. + evaluationPassing: + type: boolean + description: Whether every engine evaluation passed. + example: true + evaluationResults: + type: array + description: Per-engine evaluation results. + items: + $ref: '#/components/schemas/EvaluationResult' + required: [evaluationPassing, evaluationResults] + AttestationVerifyRequest: + description: JWS verification request proxied to DVA VC MANAGER's `/aov/verify`. + type: object + required: [jws] + properties: + jws: + type: string + description: Compact Ed25519 JWS to verify. + AttestationVerifyResponse: + description: Verification result returned from DVA VC MANAGER's `/aov/verify`. + type: object + required: [verified] + properties: + verified: + type: boolean + description: Whether the JWS signature was valid. + example: true + reason: + type: string + nullable: true + description: Failure reason when `verified` is false; null otherwise. + example: signature did not validate + Error: + description: RFC 7807 (application/problem+json) error object. + type: object + properties: + type: + type: string + description: A URI reference identifying the problem type. + example: about:blank + title: + type: string + description: Short human-readable summary of the problem type. + example: Bad Request + detail: + type: string + nullable: true + description: Human-readable explanation specific to this occurrence. + example: vlaId must be a valid UUID + instance: + type: string + nullable: true + description: URI reference identifying the specific occurrence of the problem. + required: [type, title] \ No newline at end of file From 544eb38322cb6fd6376283fc08c7c817a36b3a8c Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Mon, 27 Jul 2026 12:59:29 +0200 Subject: [PATCH 4/5] fix(dva-api): add ktor-client-mock dep for AoVSyncRoutesTest MockEngine --- dva-api/api/build.gradle.kts | 1 + dva-api/gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/dva-api/api/build.gradle.kts b/dva-api/api/build.gradle.kts index 627cea29..4f2d70a5 100644 --- a/dva-api/api/build.gradle.kts +++ b/dva-api/api/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { testImplementation(libs.bundles.testcontainers.rabbitmq) testImplementation(libs.ktor.client.content.negotiation) + testImplementation(libs.ktor.client.mock) testImplementation(libs.ktor.server.test.host) } diff --git a/dva-api/gradle/libs.versions.toml b/dva-api/gradle/libs.versions.toml index 0b689e22..f1bda612 100644 --- a/dva-api/gradle/libs.versions.toml +++ b/dva-api/gradle/libs.versions.toml @@ -39,6 +39,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } From 90c15ed9f7901f2a30ca0c72ff38d746c29c5e81 Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Mon, 27 Jul 2026 13:01:33 +0200 Subject: [PATCH 5/5] test(dva-api): drop obsolete async AoV routes test superseded by AoVSyncRoutesTest --- .../mit/ftsrg/dva/api/route/AoVRoutesTest.kt | 221 ------------------ 1 file changed, 221 deletions(-) delete mode 100644 dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt deleted file mode 100644 index 668e98b3..00000000 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -package hu.bme.mit.ftsrg.dva.api.route - -import com.rabbitmq.client.Connection -import com.rabbitmq.client.ConnectionFactory -import hu.bme.mit.ftsrg.dva.api.testutil.createTestClient -import hu.bme.mit.ftsrg.dva.api.testutil.setupTestApplication -import hu.bme.mit.ftsrg.dva.dto.aov.AttestationRequestDTO -import hu.bme.mit.ftsrg.dva.log.FakeReqestLogRepo -import hu.bme.mit.ftsrg.dva.log.FakeVerifRequestLogRepo -import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo -import hu.bme.mit.ftsrg.dva.log.VerifRequestLogRepo -import io.ktor.client.* -import io.ktor.client.plugins.contentnegotiation.* -import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* -import io.ktor.server.application.* -import io.ktor.server.testing.* -import kotlinx.serialization.json.* -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test -import org.koin.dsl.module -import org.koin.ktor.plugin.Koin -import org.testcontainers.containers.RabbitMQContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import java.util.* - -@Testcontainers -class AoVRoutesTest { - - @Container - val rmqContainer: RabbitMQContainer = RabbitMQContainer("rabbitmq").withExposedPorts(5672) - - @Test - fun `should create attestation request`() = testApplication { - setupApplication() - val client = createTestClient() - - val request = AttestationRequestDTO( - id = "request-test-0000", - exchangeID = "xchg-0000", - attesterID = "attester-0000", - contract = buildJsonObject { - put("id", "contract-0001") - put("dataProvider", "/catalog/participants/provider-test-id") - put("dataConsumer", "/catalog/participants/consumer-test-did") - put("serviceOffering", "/catalog/serviceofferings/serviceoffering-test-did") - - putJsonArray("purpose") { - addJsonObject { - put("purpose", "/catalog/serviceofferings") - put("piiCategory", buildJsonArray {}) - } - } - - putJsonArray("negotiators") { - addJsonObject { - put("did", "/catalog/participants/provider-test-id") - } - addJsonObject { - put("did", "/catalog/participants/consumer-test-id") - } - } - - put("status", "pending") - - putJsonArray("policy") { - addJsonObject { - put("uid", "/policy/policy-0-uid") - putJsonArray("permission") { - addJsonObject { - put("target", "/target/3f8d1b0e-8e2e-4b69-9b1f-089fe2f3e9d7") - put("action", "use") - } - } - } - } - - putJsonObject("vla") { - put("version", "1.0.0") - put("kind", "DataContract") - put("id", UUID.randomUUID().toString()) - put("status", "active") - put("name", "test") - put("dataProduct", "test") - put("apiVersion", "v3.0.1") - - putJsonArray("schema") { - addJsonObject { - put("schemaElement", "xapi_statement") - put("logicalType", "object") - putJsonArray("properties") { - addJsonObject { - put("schemaElement", "id") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "actor") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "verb") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "object") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "result") - put("logicalType", "object") - } - addJsonObject { - put("schemaElement", "context") - put("logicalType", "object") - } - addJsonObject { - put("schemaElement", "timestamp") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "stored") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "version") - put("logicalType", "string") - } - } - putJsonArray("quality") { - addJsonObject { - put("dataQuality", "custom") - put("engine", "greatExpectations") - put( - "implementation", - """ - type: ExpectColumnValuesToBeBetween - kwargs: - column: timestamp - min_value: '2025-01-01T00:00:00Z' - max_value: '2026-01-01T00:00:00Z' - """.trimIndent() - ) - } - } - } - } - } - }, - data = Json.parseToJsonElement( - """ - { - "actor": { - "name": "Jean Dupont", - "mbox": "mailto:jeandupont@example.com" - }, - "verb": { - "id": "http://adlnet.gov/expapi/verbs/interacted", - "display": { - "en-US": "interacted" - } - }, - "object": { - "id": "https://navy.mil/netc/xapi/activities/simulations/b9e16535-4fc9-4c66-ac87-3ad7ce515f5c/events/0221144", - "definition": { - "name": { - "en-US": "Event in Simulator" - }, - "description": { - "en-US": "You're wearing all your PPE" - }, - "type": "http://adlnet.gov/expapi/activities/interaction" - } - }, - "context": { - "registration": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "extensions": { - "https://w3id.org/xapi/cmi5/context/extensions/sessionid": "moodle-session-12345" - } - }, - "result": { - "success":true, - "extensions": { - "http://id.tincanapi.com/extension/severity": "info" - } - }, - "timestamp": "2024-03-16T30:25:00Z" - } - """ - ) - ) - client.post("/attestation") { - contentType(ContentType.Application.Json) - setBody(request) - }.apply { - Assertions.assertEquals(HttpStatusCode.Accepted, status) - } - } - - private fun ApplicationTestBuilder.setupApplication() = setupTestApplication { - val testModule = module { - single { FakeReqestLogRepo() } - single { FakeVerifRequestLogRepo() } - single { HttpClient { install(ContentNegotiation) { json() } } } - single { - ConnectionFactory().run { - host = rmqContainer.host - port = rmqContainer.firstMappedPort - newConnection() - } - } - } - this.install(Koin) { modules(testModule) } - - aovRoutes() - } -}