-
Notifications
You must be signed in to change notification settings - Fork 57
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #905 from walt-id/wal-541-dynamic-policy
Wal 541 dynamic policy
- Loading branch information
Showing
6 changed files
with
412 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
237 changes: 237 additions & 0 deletions
237
...id-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/DynamicPolicy.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,237 @@ | ||
package id.walt.policies.policies | ||
|
||
import id.walt.credentials.utils.VCFormat | ||
import id.walt.crypto.utils.JsonUtils.toJsonObject | ||
import id.walt.policies.CredentialDataValidatorPolicy | ||
import id.walt.policies.DynamicPolicyException | ||
import io.github.oshai.kotlinlogging.KotlinLogging | ||
import io.ktor.client.* | ||
import io.ktor.client.call.* | ||
import io.ktor.client.plugins.contentnegotiation.* | ||
import io.ktor.client.request.* | ||
import io.ktor.client.statement.* | ||
import io.ktor.http.* | ||
import io.ktor.serialization.kotlinx.json.* | ||
import kotlinx.serialization.Serializable | ||
import kotlinx.serialization.json.* | ||
import love.forte.plugin.suspendtrans.annotation.JsPromise | ||
import love.forte.plugin.suspendtrans.annotation.JvmAsync | ||
import love.forte.plugin.suspendtrans.annotation.JvmBlocking | ||
import kotlin.js.ExperimentalJsExport | ||
import kotlin.js.JsExport | ||
|
||
|
||
private val logger = KotlinLogging.logger {} | ||
@Serializable | ||
data class DynamicPolicyConfig( | ||
val opaServer: String = "http://localhost:8181", | ||
val policyQuery: String = "vc/verification", | ||
val policyName: String, | ||
val rules: Map<String, String>, | ||
val argument: Map<String, String> | ||
) | ||
|
||
|
||
@OptIn(ExperimentalJsExport::class) | ||
@JsExport | ||
@Serializable | ||
class DynamicPolicy : CredentialDataValidatorPolicy() { | ||
|
||
override val name = "dynamic" | ||
override val description = | ||
"A dynamic policy that can be used to implement custom verification logic." | ||
override val supportedVCFormats = setOf(VCFormat.jwt_vc, VCFormat.jwt_vc_json, VCFormat.ldp_vc) | ||
|
||
companion object { | ||
private const val MAX_REGO_CODE_SIZE = 1_000_000 // 1MB limit | ||
private const val MAX_POLICY_NAME_LENGTH = 64 | ||
|
||
private val http = HttpClient { | ||
install(ContentNegotiation) { | ||
json() | ||
} | ||
} | ||
} | ||
|
||
private fun cleanCode(input: String): String { | ||
return input.replace("\r\n", "\n") | ||
.split("\n").joinToString("\n") { it.trim() } | ||
} | ||
|
||
private fun validatePolicyName(policyName: String) { | ||
require(policyName.matches(Regex("^[a-zA-Z]+$"))) { | ||
"Policy name contains invalid characters." | ||
} | ||
require(policyName.length <= MAX_POLICY_NAME_LENGTH) { | ||
"Policy name exceeds maximum length of $MAX_POLICY_NAME_LENGTH characters" | ||
} | ||
} | ||
|
||
private fun validateRegoCode(regoCode: String) { | ||
require(regoCode.isNotEmpty()) { | ||
"Rego code cannot be empty" | ||
} | ||
require(regoCode.length <= MAX_REGO_CODE_SIZE) { | ||
"Rego code exceeds maximum allowed size of $MAX_REGO_CODE_SIZE bytes" | ||
} | ||
} | ||
|
||
|
||
private fun parseConfig(args: Any?): DynamicPolicyConfig { | ||
require(args is JsonObject) { "Args must be a JsonObject" } | ||
|
||
val rules = args["rules"]?.jsonObject | ||
?: throw IllegalArgumentException("The 'rules' field is required.") | ||
val policyName = args["policy_name"]?.jsonPrimitive?.content | ||
?: throw IllegalArgumentException("The 'policy_name' field is required.") | ||
val argument = args["argument"]?.jsonObject | ||
?: throw IllegalArgumentException("The 'argument' field is required.") | ||
|
||
return DynamicPolicyConfig( | ||
opaServer = args["opa_server"]?.jsonPrimitive?.content ?: "http://localhost:8181", | ||
policyQuery = args["policy_query"]?.jsonPrimitive?.content ?: "vc/verification", | ||
policyName = policyName, | ||
rules = rules.mapValues { it.value.jsonPrimitive.content }, | ||
argument = argument.mapValues { it.value.jsonPrimitive.content } | ||
) | ||
} | ||
|
||
|
||
private suspend fun getRegoCode(config: DynamicPolicyConfig): String { | ||
val regoCode = config.rules["rego"] | ||
val policyUrl = config.rules["policy_url"] | ||
|
||
return when { | ||
policyUrl != null -> { | ||
logger.info { "Fetching rego code from URL: $policyUrl" } | ||
try { | ||
val response = http.get(policyUrl) | ||
cleanCode(response.bodyAsText()) | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to fetch rego code from URL: $policyUrl" } | ||
throw DynamicPolicyException("Failed to fetch rego code: ${e.message}") | ||
} | ||
} | ||
|
||
regoCode != null -> cleanCode(regoCode) | ||
else -> throw IllegalArgumentException("Either 'rego' or 'policy_url' must be provided in rules") | ||
} | ||
} | ||
|
||
|
||
private suspend fun uploadPolicy(opaServer: String, policyName: String, regoCode: String): Result<Unit> { | ||
return try { | ||
logger.info { "Uploading policy to OPA server: $policyName" } | ||
val response = http.put("$opaServer/v1/policies/$policyName") { | ||
contentType(ContentType.Text.Plain) | ||
setBody(regoCode) | ||
} | ||
if (!response.status.isSuccess()) { | ||
logger.error { "Failed to upload policy: ${response.status}" } | ||
Result.failure(DynamicPolicyException("Failed to upload policy: ${response.status}")) | ||
} else { | ||
Result.success(Unit) | ||
} | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to upload policy" } | ||
Result.failure(DynamicPolicyException("Failed to upload policy: ${e.message}")) | ||
} | ||
} | ||
|
||
private suspend fun deletePolicy(opaServer: String, policyName: String) { | ||
try { | ||
logger.info { "Deleting policy from OPA server: $policyName" } | ||
http.delete("$opaServer/v1/policies/$policyName") | ||
} catch (e: Exception) { | ||
logger.error(e) { "Failed to delete policy" } | ||
} | ||
} | ||
|
||
|
||
private suspend fun verifyPolicy( | ||
config: DynamicPolicyConfig, | ||
data: JsonObject | ||
): Result<JsonObject> { | ||
return try { | ||
logger.info { "Verifying policy: ${config.policyName}" } | ||
val input = mapOf( | ||
"parameter" to config.argument, | ||
"credentialData" to data.toMap() | ||
).toJsonObject() | ||
|
||
val response = http.post("${config.opaServer}/v1/data/${config.policyQuery}/${config.policyName}") { | ||
contentType(ContentType.Application.Json) | ||
setBody(mapOf("input" to input)) | ||
} | ||
|
||
val result = response.body<JsonObject>()["result"]?.jsonObject | ||
?: throw DynamicPolicyException("Invalid response from OPA server") | ||
|
||
Result.success(result) | ||
} catch (e: Exception) { | ||
logger.error(e) { "Policy verification failed" } | ||
Result.failure(DynamicPolicyException("Policy verification failed: ${e.message}")) | ||
} | ||
} | ||
|
||
@JvmBlocking | ||
@JvmAsync | ||
@JsPromise | ||
@JsExport.Ignore | ||
override suspend fun verify( | ||
data: JsonObject, | ||
args: Any?, | ||
context: Map<String, Any> | ||
): Result<Any> { | ||
|
||
return try { | ||
logger.info { "Starting policy verification process" } | ||
val config = parseConfig(args) | ||
validatePolicyName(config.policyName) | ||
|
||
val regoCode = getRegoCode(config) | ||
validateRegoCode(regoCode) | ||
|
||
uploadPolicy(config.opaServer, config.policyName, regoCode).getOrThrow() | ||
|
||
verifyPolicy(config, data).map { result -> | ||
|
||
val decision = result.values.firstOrNull { | ||
it is JsonPrimitive && it.booleanOrNull == true | ||
} | ||
if (decision != null) { | ||
result | ||
} else { | ||
throw DynamicPolicyException("The policy condition was not met for policy ${config.policyName}") | ||
} | ||
} | ||
} catch (e: Exception) { | ||
logger.error(e) { "Policy verification failed" } | ||
Result.failure( | ||
when (e) { | ||
is DynamicPolicyException -> e | ||
else -> DynamicPolicyException("Policy verification failed: ${e.message}") | ||
} | ||
) | ||
} finally { | ||
runCatching { | ||
val config = parseConfig(args) | ||
deletePolicy(config.opaServer, config.policyName) | ||
} | ||
} | ||
} | ||
|
||
|
||
// Helper function to convert JsonObject to Map | ||
private fun JsonObject.toMap(): Map<String, Any> { | ||
return this.mapValues { (_, value) -> | ||
when (value) { | ||
is JsonPrimitive -> value.content | ||
is JsonObject -> value.toMap() | ||
else -> value | ||
} | ||
} | ||
} | ||
|
||
|
||
} |
125 changes: 125 additions & 0 deletions
125
...fication-policies/src/commonTest/kotlin/id/walt/policies/DynamicPolicyVerificationTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
package id.walt.policies | ||
|
||
import id.walt.credentials.utils.VCFormat | ||
import id.walt.did.dids.DidService | ||
import id.walt.did.dids.resolver.LocalResolver | ||
import id.walt.policies.models.PolicyRequest.Companion.parsePolicyRequests | ||
import io.ktor.client.* | ||
import io.ktor.client.plugins.contentnegotiation.* | ||
import io.ktor.client.request.* | ||
import io.ktor.client.statement.* | ||
import io.ktor.http.* | ||
import io.ktor.serialization.kotlinx.json.* | ||
import kotlinx.coroutines.test.runTest | ||
import kotlinx.serialization.encodeToString | ||
import kotlinx.serialization.json.Json | ||
import kotlinx.serialization.json.JsonObject | ||
import kotlinx.serialization.json.jsonArray | ||
import kotlinx.serialization.json.jsonObject | ||
import kotlin.test.Test | ||
|
||
|
||
class DynamicPolicyTest { | ||
private suspend fun isOpaServerRunning(): Boolean { | ||
val http = HttpClient { | ||
install(ContentNegotiation) { | ||
json() | ||
} | ||
} | ||
|
||
return try { | ||
val response: HttpResponse = http.get("http://localhost:8181") | ||
response.status == HttpStatusCode.OK | ||
} catch (e: Exception) { | ||
println("Error connecting to OPA server: ${e.message}") | ||
false | ||
} finally { | ||
http.close() | ||
} | ||
} | ||
|
||
|
||
@Test | ||
fun testPresentationVerificationWithDynamicPolicy() = runTest { | ||
if (!isOpaServerRunning()) { | ||
println("Skipping test: OPA server is not running.") | ||
return@runTest | ||
} | ||
DidService.apply { | ||
registerResolver(LocalResolver()) | ||
updateResolversForMethods() | ||
} | ||
|
||
val vpToken = | ||
"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDprZXk6ejZNa3BkQ3FUNWZIWlZBYmRRdjJTS1dGTWdyTHN2UmFZdVRmSnpDdkJ4TG4xWHBzI3o2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyJ9.eyJzdWIiOiJkaWQ6a2V5Ono2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyIsIm5iZiI6MTY5Njc2MTcxOSwiaWF0IjoxNjk2NzYxNzc5LCJqdGkiOiJ1cm46dXVpZDpmMjM2ODMxNy03MjhjLTRhMWQtYWMyNC1kMTI4OTI2N2M5N2MiLCJpc3MiOiJkaWQ6a2V5Ono2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyIsIm5vbmNlIjoiIiwidnAiOnsiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIl0sImlkIjoidXJuOnV1aWQ6ZDFhZGUxMTMtMTU2ZC00MDk4LWI4NmItZTQyMmY0ZDQ3MTE3IiwiaG9sZGVyIjoiZGlkOmtleTp6Nk1rcGRDcVQ1ZkhaVkFiZFF2MlNLV0ZNZ3JMc3ZSYVl1VGZKekN2QnhMbjFYcHMiLCJ2ZXJpZmlhYmxlQ3JlZGVudGlhbCI6WyJleUpoYkdjaU9pSkZaRVJUUVNJc0ltdHBaQ0k2SW1ScFpEcHJaWGs2ZWpaTmEzQlNZMjlVUmpJMFMxZHJlRzlTYUdSWmNFTm9VSFpCTkVNM2FuQkdaelZ4ZDI4eldqSXlaM05pTVdoeUluMC5leUp6ZFdJaU9pSmthV1E2YTJWNU9ubzJUV3R3WkVOeFZEVm1TRnBXUVdKa1VYWXlVMHRYUmsxbmNreHpkbEpoV1hWVVprcDZRM1pDZUV4dU1WaHdjeU42TmsxcmNHUkRjVlExWmtoYVZrRmlaRkYyTWxOTFYwWk5aM0pNYzNaU1lWbDFWR1pLZWtOMlFuaE1iakZZY0hNaUxDSnBjM01pT2lKa2FXUTZhMlY1T25vMlRXdHdVbU52VkVZeU5FdFhhM2h2VW1oa1dYQkRhRkIyUVRSRE4ycHdSbWMxY1hkdk0xb3lNbWR6WWpGb2NpSXNJblpqSWpwN0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWl3aWFIUjBjSE02THk5d2RYSnNMbWx0YzJkc2IySmhiQzV2Y21jdmMzQmxZeTl2WWk5Mk0zQXdMMk52Ym5SbGVIUXVhbk52YmlKZExDSnBaQ0k2SW5WeWJqcDFkV2xrT2psaFlXTmtPREZtTFRjM1lUTXRORGRoWWkxaU1tSXlMVGswTlRFd01qazFOVFl5TXlJc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pQY0dWdVFtRmtaMlZEY21Wa1pXNTBhV0ZzSWwwc0ltNWhiV1VpT2lKS1JrWWdlQ0IyWXkxbFpIVWdVR3gxWjBabGMzUWdNeUJKYm5SbGNtOXdaWEpoWW1sc2FYUjVJaXdpYVhOemRXVnlJanA3SW5SNWNHVWlPbHNpVUhKdlptbHNaU0pkTENKcFpDSTZJbVJwWkRwclpYazZlalpOYTNCU1kyOVVSakkwUzFkcmVHOVNhR1JaY0VOb1VIWkJORU0zYW5CR1p6VnhkMjh6V2pJeVozTmlNV2h5SWl3aWJtRnRaU0k2SWtwdlluTWdabTl5SUhSb1pTQkdkWFIxY21VZ0tFcEdSaWtpTENKMWNtd2lPaUpvZEhSd2N6b3ZMM2QzZHk1cVptWXViM0puTHlJc0ltbHRZV2RsSWpvaWFIUjBjSE02THk5M00yTXRZMk5uTG1kcGRHaDFZaTVwYnk5Mll5MWxaQzl3YkhWblptVnpkQzB4TFRJd01qSXZhVzFoWjJWekwwcEdSbDlNYjJkdlRHOWphM1Z3TG5CdVp5SjlMQ0pwYzNOMVlXNWpaVVJoZEdVaU9pSXlNREl6TFRFd0xUQTBWREl6T2pVM09qQTVMalExTURNeU1qVXdNbG9pTENKamNtVmtaVzUwYVdGc1UzVmlhbVZqZENJNmV5SjBlWEJsSWpwYklrRmphR2xsZG1WdFpXNTBVM1ZpYW1WamRDSmRMQ0pwWkNJNkltUnBaRHByWlhrNmVqWk5hM0JrUTNGVU5XWklXbFpCWW1SUmRqSlRTMWRHVFdkeVRITjJVbUZaZFZSbVNucERka0o0VEc0eFdIQnpJM28yVFd0d1pFTnhWRFZtU0ZwV1FXSmtVWFl5VTB0WFJrMW5ja3h6ZGxKaFdYVlVaa3A2UTNaQ2VFeHVNVmh3Y3lJc0ltRmphR2xsZG1WdFpXNTBJanA3SW1sa0lqb2lkWEp1T25WMWFXUTZZV015TlRSaVpEVXRPR1poWkMwMFltSXhMVGxrTWprdFpXWmtPVE00TlRNMk9USTJJaXdpZEhsd1pTSTZXeUpCWTJocFpYWmxiV1Z1ZENKZExDSnVZVzFsSWpvaVNrWkdJSGdnZG1NdFpXUjFJRkJzZFdkR1pYTjBJRE1nU1c1MFpYSnZjR1Z5WVdKcGJHbDBlU0lzSW1SbGMyTnlhWEIwYVc5dUlqb2lWR2hwY3lCM1lXeHNaWFFnYzNWd2NHOXlkSE1nZEdobElIVnpaU0J2WmlCWE0wTWdWbVZ5YVdacFlXSnNaU0JEY21Wa1pXNTBhV0ZzY3lCaGJtUWdhR0Z6SUdSbGJXOXVjM1J5WVhSbFpDQnBiblJsY205d1pYSmhZbWxzYVhSNUlHUjFjbWx1WnlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUhKbGNYVmxjM1FnZDI5eWEyWnNiM2NnWkhWeWFXNW5JRXBHUmlCNElGWkRMVVZFVlNCUWJIVm5SbVZ6ZENBekxpSXNJbU55YVhSbGNtbGhJanA3SW5SNWNHVWlPaUpEY21sMFpYSnBZU0lzSW01aGNuSmhkR2wyWlNJNklsZGhiR3hsZENCemIyeDFkR2x2Ym5NZ2NISnZkbWxrWlhKeklHVmhjbTVsWkNCMGFHbHpJR0poWkdkbElHSjVJR1JsYlc5dWMzUnlZWFJwYm1jZ2FXNTBaWEp2Y0dWeVlXSnBiR2wwZVNCa2RYSnBibWNnZEdobElIQnlaWE5sYm5SaGRHbHZiaUJ5WlhGMVpYTjBJSGR2Y210bWJHOTNMaUJVYUdseklHbHVZMngxWkdWeklITjFZMk5sYzNObWRXeHNlU0J5WldObGFYWnBibWNnWVNCd2NtVnpaVzUwWVhScGIyNGdjbVZ4ZFdWemRDd2dZV3hzYjNkcGJtY2dkR2hsSUdodmJHUmxjaUIwYnlCelpXeGxZM1FnWVhRZ2JHVmhjM1FnZEhkdklIUjVjR1Z6SUc5bUlIWmxjbWxtYVdGaWJHVWdZM0psWkdWdWRHbGhiSE1nZEc4Z1kzSmxZWFJsSUdFZ2RtVnlhV1pwWVdKc1pTQndjbVZ6Wlc1MFlYUnBiMjRzSUhKbGRIVnlibWx1WnlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUhSdklIUm9aU0J5WlhGMVpYTjBiM0lzSUdGdVpDQndZWE56YVc1bklIWmxjbWxtYVdOaGRHbHZiaUJ2WmlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUdGdVpDQjBhR1VnYVc1amJIVmtaV1FnWTNKbFpHVnVkR2xoYkhNdUluMHNJbWx0WVdkbElqcDdJbWxrSWpvaWFIUjBjSE02THk5M00yTXRZMk5uTG1kcGRHaDFZaTVwYnk5Mll5MWxaQzl3YkhWblptVnpkQzB6TFRJd01qTXZhVzFoWjJWekwwcEdSaTFXUXkxRlJGVXRVRXhWUjBaRlUxUXpMV0poWkdkbExXbHRZV2RsTG5CdVp5SXNJblI1Y0dVaU9pSkpiV0ZuWlNKOWZYMTlmUS54Qkg1b0dwZm9xdFpXMTdhMEtlak1tUkUtNDhsMWt6bzExc2lrZUxkR0JoMHFMQ3E5d2pJeUZHeWxVMUxoM0FHaWN1VGRLdDB0bkJqRXhud29ZMmRCZyJdfX0.-29z2twNHmK3tIwS59R-WiHuOhBNJbUS5YXKPCbCaKhPa8QyD1Z8hZ-G6ECFY8K4ZSnoB5b7OCyvvIclYj8gAA" | ||
|
||
//language=json | ||
val vpPolicies = Json.parseToJsonElement( | ||
""" | ||
[ | ||
"signature", | ||
"expired", | ||
"not-before" | ||
] | ||
""" | ||
).jsonArray.parsePolicyRequests() | ||
|
||
//language=json | ||
val vcPolicies = Json.parseToJsonElement( | ||
""" | ||
[ | ||
"signature" | ||
] | ||
""" | ||
).jsonArray.parsePolicyRequests() | ||
|
||
//language=json | ||
val specificPolicies = Json.parseToJsonElement( | ||
""" | ||
{ | ||
"OpenBadgeCredential": [ | ||
{ | ||
"policy": "dynamic", | ||
"args": { | ||
"policy_name": "test", | ||
"opa_server": "http://localhost:8181", | ||
"policy_query": "data", | ||
"rules": { | ||
"rego": "package data.test\r\n\r\ndefault allow := false\r\n\r\nallow if {\r\ninput.parameter.name == input.credentialData.credentialSubject.achievement.name\r\n}" | ||
}, | ||
"argument": { | ||
"name": "JFF x vc-edu PlugFest 3 Interoperability" | ||
} | ||
} | ||
} | ||
] | ||
} | ||
""" | ||
).jsonObject.mapValues { it.value.jsonArray.parsePolicyRequests() } | ||
|
||
|
||
println("SP Policies: $specificPolicies") | ||
|
||
val r = Verifier.verifyPresentation( | ||
VCFormat.jwt_vp_json, | ||
vpToken = vpToken, | ||
vpPolicies = vpPolicies, | ||
globalVcPolicies = vcPolicies, | ||
specificCredentialPolicies = specificPolicies, | ||
mapOf( | ||
"presentationSubmission" to JsonObject(emptyMap()), "challenge" to "abc" | ||
) | ||
) | ||
|
||
println( | ||
Json { prettyPrint = true }.encodeToString( | ||
r | ||
) | ||
) | ||
|
||
val x = r.results.flatMap { it.policyResults } | ||
println("Results: " + x.size) | ||
println("OK: ${x.count { it.isSuccess() }}") | ||
} | ||
} |
Oops, something went wrong.