Skip to content

Commit

Permalink
Merge pull request #905 from walt-id/wal-541-dynamic-policy
Browse files Browse the repository at this point in the history
Wal 541 dynamic policy
  • Loading branch information
SuperBatata authored Jan 31, 2025
2 parents feff554 + a8f5c96 commit 864f0b7
Show file tree
Hide file tree
Showing 6 changed files with 412 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,8 @@ class NotAllowedIssuerException(
val issuer: String,
val allowedIssuers: List<String>,
) : id.walt.policies.SerializableRuntimeException()


class DynamicPolicyException(
override val message: String
) : Exception(message)
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ object PolicyManager {
HolderBindingPolicy(),
AllowedIssuerPolicy(),
RevocationPolicy(),
PresentationDefinitionPolicy()
PresentationDefinitionPolicy(),
DynamicPolicy()
)
}

Expand Down
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
}
}
}


}
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() }}")
}
}
Loading

0 comments on commit 864f0b7

Please sign in to comment.