Skip to content

Commit 864f0b7

Browse files
authored
Merge pull request #905 from walt-id/wal-541-dynamic-policy
Wal 541 dynamic policy
2 parents feff554 + a8f5c96 commit 864f0b7

File tree

6 files changed

+412
-2
lines changed

6 files changed

+412
-2
lines changed

waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/Exceptions.kt

+5
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,8 @@ class NotAllowedIssuerException(
104104
val issuer: String,
105105
val allowedIssuers: List<String>,
106106
) : id.walt.policies.SerializableRuntimeException()
107+
108+
109+
class DynamicPolicyException(
110+
override val message: String
111+
) : Exception(message)

waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/PolicyManager.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ object PolicyManager {
4949
HolderBindingPolicy(),
5050
AllowedIssuerPolicy(),
5151
RevocationPolicy(),
52-
PresentationDefinitionPolicy()
52+
PresentationDefinitionPolicy(),
53+
DynamicPolicy()
5354
)
5455
}
5556

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package id.walt.policies.policies
2+
3+
import id.walt.credentials.utils.VCFormat
4+
import id.walt.crypto.utils.JsonUtils.toJsonObject
5+
import id.walt.policies.CredentialDataValidatorPolicy
6+
import id.walt.policies.DynamicPolicyException
7+
import io.github.oshai.kotlinlogging.KotlinLogging
8+
import io.ktor.client.*
9+
import io.ktor.client.call.*
10+
import io.ktor.client.plugins.contentnegotiation.*
11+
import io.ktor.client.request.*
12+
import io.ktor.client.statement.*
13+
import io.ktor.http.*
14+
import io.ktor.serialization.kotlinx.json.*
15+
import kotlinx.serialization.Serializable
16+
import kotlinx.serialization.json.*
17+
import love.forte.plugin.suspendtrans.annotation.JsPromise
18+
import love.forte.plugin.suspendtrans.annotation.JvmAsync
19+
import love.forte.plugin.suspendtrans.annotation.JvmBlocking
20+
import kotlin.js.ExperimentalJsExport
21+
import kotlin.js.JsExport
22+
23+
24+
private val logger = KotlinLogging.logger {}
25+
@Serializable
26+
data class DynamicPolicyConfig(
27+
val opaServer: String = "http://localhost:8181",
28+
val policyQuery: String = "vc/verification",
29+
val policyName: String,
30+
val rules: Map<String, String>,
31+
val argument: Map<String, String>
32+
)
33+
34+
35+
@OptIn(ExperimentalJsExport::class)
36+
@JsExport
37+
@Serializable
38+
class DynamicPolicy : CredentialDataValidatorPolicy() {
39+
40+
override val name = "dynamic"
41+
override val description =
42+
"A dynamic policy that can be used to implement custom verification logic."
43+
override val supportedVCFormats = setOf(VCFormat.jwt_vc, VCFormat.jwt_vc_json, VCFormat.ldp_vc)
44+
45+
companion object {
46+
private const val MAX_REGO_CODE_SIZE = 1_000_000 // 1MB limit
47+
private const val MAX_POLICY_NAME_LENGTH = 64
48+
49+
private val http = HttpClient {
50+
install(ContentNegotiation) {
51+
json()
52+
}
53+
}
54+
}
55+
56+
private fun cleanCode(input: String): String {
57+
return input.replace("\r\n", "\n")
58+
.split("\n").joinToString("\n") { it.trim() }
59+
}
60+
61+
private fun validatePolicyName(policyName: String) {
62+
require(policyName.matches(Regex("^[a-zA-Z]+$"))) {
63+
"Policy name contains invalid characters."
64+
}
65+
require(policyName.length <= MAX_POLICY_NAME_LENGTH) {
66+
"Policy name exceeds maximum length of $MAX_POLICY_NAME_LENGTH characters"
67+
}
68+
}
69+
70+
private fun validateRegoCode(regoCode: String) {
71+
require(regoCode.isNotEmpty()) {
72+
"Rego code cannot be empty"
73+
}
74+
require(regoCode.length <= MAX_REGO_CODE_SIZE) {
75+
"Rego code exceeds maximum allowed size of $MAX_REGO_CODE_SIZE bytes"
76+
}
77+
}
78+
79+
80+
private fun parseConfig(args: Any?): DynamicPolicyConfig {
81+
require(args is JsonObject) { "Args must be a JsonObject" }
82+
83+
val rules = args["rules"]?.jsonObject
84+
?: throw IllegalArgumentException("The 'rules' field is required.")
85+
val policyName = args["policy_name"]?.jsonPrimitive?.content
86+
?: throw IllegalArgumentException("The 'policy_name' field is required.")
87+
val argument = args["argument"]?.jsonObject
88+
?: throw IllegalArgumentException("The 'argument' field is required.")
89+
90+
return DynamicPolicyConfig(
91+
opaServer = args["opa_server"]?.jsonPrimitive?.content ?: "http://localhost:8181",
92+
policyQuery = args["policy_query"]?.jsonPrimitive?.content ?: "vc/verification",
93+
policyName = policyName,
94+
rules = rules.mapValues { it.value.jsonPrimitive.content },
95+
argument = argument.mapValues { it.value.jsonPrimitive.content }
96+
)
97+
}
98+
99+
100+
private suspend fun getRegoCode(config: DynamicPolicyConfig): String {
101+
val regoCode = config.rules["rego"]
102+
val policyUrl = config.rules["policy_url"]
103+
104+
return when {
105+
policyUrl != null -> {
106+
logger.info { "Fetching rego code from URL: $policyUrl" }
107+
try {
108+
val response = http.get(policyUrl)
109+
cleanCode(response.bodyAsText())
110+
} catch (e: Exception) {
111+
logger.error(e) { "Failed to fetch rego code from URL: $policyUrl" }
112+
throw DynamicPolicyException("Failed to fetch rego code: ${e.message}")
113+
}
114+
}
115+
116+
regoCode != null -> cleanCode(regoCode)
117+
else -> throw IllegalArgumentException("Either 'rego' or 'policy_url' must be provided in rules")
118+
}
119+
}
120+
121+
122+
private suspend fun uploadPolicy(opaServer: String, policyName: String, regoCode: String): Result<Unit> {
123+
return try {
124+
logger.info { "Uploading policy to OPA server: $policyName" }
125+
val response = http.put("$opaServer/v1/policies/$policyName") {
126+
contentType(ContentType.Text.Plain)
127+
setBody(regoCode)
128+
}
129+
if (!response.status.isSuccess()) {
130+
logger.error { "Failed to upload policy: ${response.status}" }
131+
Result.failure(DynamicPolicyException("Failed to upload policy: ${response.status}"))
132+
} else {
133+
Result.success(Unit)
134+
}
135+
} catch (e: Exception) {
136+
logger.error(e) { "Failed to upload policy" }
137+
Result.failure(DynamicPolicyException("Failed to upload policy: ${e.message}"))
138+
}
139+
}
140+
141+
private suspend fun deletePolicy(opaServer: String, policyName: String) {
142+
try {
143+
logger.info { "Deleting policy from OPA server: $policyName" }
144+
http.delete("$opaServer/v1/policies/$policyName")
145+
} catch (e: Exception) {
146+
logger.error(e) { "Failed to delete policy" }
147+
}
148+
}
149+
150+
151+
private suspend fun verifyPolicy(
152+
config: DynamicPolicyConfig,
153+
data: JsonObject
154+
): Result<JsonObject> {
155+
return try {
156+
logger.info { "Verifying policy: ${config.policyName}" }
157+
val input = mapOf(
158+
"parameter" to config.argument,
159+
"credentialData" to data.toMap()
160+
).toJsonObject()
161+
162+
val response = http.post("${config.opaServer}/v1/data/${config.policyQuery}/${config.policyName}") {
163+
contentType(ContentType.Application.Json)
164+
setBody(mapOf("input" to input))
165+
}
166+
167+
val result = response.body<JsonObject>()["result"]?.jsonObject
168+
?: throw DynamicPolicyException("Invalid response from OPA server")
169+
170+
Result.success(result)
171+
} catch (e: Exception) {
172+
logger.error(e) { "Policy verification failed" }
173+
Result.failure(DynamicPolicyException("Policy verification failed: ${e.message}"))
174+
}
175+
}
176+
177+
@JvmBlocking
178+
@JvmAsync
179+
@JsPromise
180+
@JsExport.Ignore
181+
override suspend fun verify(
182+
data: JsonObject,
183+
args: Any?,
184+
context: Map<String, Any>
185+
): Result<Any> {
186+
187+
return try {
188+
logger.info { "Starting policy verification process" }
189+
val config = parseConfig(args)
190+
validatePolicyName(config.policyName)
191+
192+
val regoCode = getRegoCode(config)
193+
validateRegoCode(regoCode)
194+
195+
uploadPolicy(config.opaServer, config.policyName, regoCode).getOrThrow()
196+
197+
verifyPolicy(config, data).map { result ->
198+
199+
val decision = result.values.firstOrNull {
200+
it is JsonPrimitive && it.booleanOrNull == true
201+
}
202+
if (decision != null) {
203+
result
204+
} else {
205+
throw DynamicPolicyException("The policy condition was not met for policy ${config.policyName}")
206+
}
207+
}
208+
} catch (e: Exception) {
209+
logger.error(e) { "Policy verification failed" }
210+
Result.failure(
211+
when (e) {
212+
is DynamicPolicyException -> e
213+
else -> DynamicPolicyException("Policy verification failed: ${e.message}")
214+
}
215+
)
216+
} finally {
217+
runCatching {
218+
val config = parseConfig(args)
219+
deletePolicy(config.opaServer, config.policyName)
220+
}
221+
}
222+
}
223+
224+
225+
// Helper function to convert JsonObject to Map
226+
private fun JsonObject.toMap(): Map<String, Any> {
227+
return this.mapValues { (_, value) ->
228+
when (value) {
229+
is JsonPrimitive -> value.content
230+
is JsonObject -> value.toMap()
231+
else -> value
232+
}
233+
}
234+
}
235+
236+
237+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package id.walt.policies
2+
3+
import id.walt.credentials.utils.VCFormat
4+
import id.walt.did.dids.DidService
5+
import id.walt.did.dids.resolver.LocalResolver
6+
import id.walt.policies.models.PolicyRequest.Companion.parsePolicyRequests
7+
import io.ktor.client.*
8+
import io.ktor.client.plugins.contentnegotiation.*
9+
import io.ktor.client.request.*
10+
import io.ktor.client.statement.*
11+
import io.ktor.http.*
12+
import io.ktor.serialization.kotlinx.json.*
13+
import kotlinx.coroutines.test.runTest
14+
import kotlinx.serialization.encodeToString
15+
import kotlinx.serialization.json.Json
16+
import kotlinx.serialization.json.JsonObject
17+
import kotlinx.serialization.json.jsonArray
18+
import kotlinx.serialization.json.jsonObject
19+
import kotlin.test.Test
20+
21+
22+
class DynamicPolicyTest {
23+
private suspend fun isOpaServerRunning(): Boolean {
24+
val http = HttpClient {
25+
install(ContentNegotiation) {
26+
json()
27+
}
28+
}
29+
30+
return try {
31+
val response: HttpResponse = http.get("http://localhost:8181")
32+
response.status == HttpStatusCode.OK
33+
} catch (e: Exception) {
34+
println("Error connecting to OPA server: ${e.message}")
35+
false
36+
} finally {
37+
http.close()
38+
}
39+
}
40+
41+
42+
@Test
43+
fun testPresentationVerificationWithDynamicPolicy() = runTest {
44+
if (!isOpaServerRunning()) {
45+
println("Skipping test: OPA server is not running.")
46+
return@runTest
47+
}
48+
DidService.apply {
49+
registerResolver(LocalResolver())
50+
updateResolversForMethods()
51+
}
52+
53+
val vpToken =
54+
"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImRpZDprZXk6ejZNa3BkQ3FUNWZIWlZBYmRRdjJTS1dGTWdyTHN2UmFZdVRmSnpDdkJ4TG4xWHBzI3o2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyJ9.eyJzdWIiOiJkaWQ6a2V5Ono2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyIsIm5iZiI6MTY5Njc2MTcxOSwiaWF0IjoxNjk2NzYxNzc5LCJqdGkiOiJ1cm46dXVpZDpmMjM2ODMxNy03MjhjLTRhMWQtYWMyNC1kMTI4OTI2N2M5N2MiLCJpc3MiOiJkaWQ6a2V5Ono2TWtwZENxVDVmSFpWQWJkUXYyU0tXRk1nckxzdlJhWXVUZkp6Q3ZCeExuMVhwcyIsIm5vbmNlIjoiIiwidnAiOnsiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIl0sImlkIjoidXJuOnV1aWQ6ZDFhZGUxMTMtMTU2ZC00MDk4LWI4NmItZTQyMmY0ZDQ3MTE3IiwiaG9sZGVyIjoiZGlkOmtleTp6Nk1rcGRDcVQ1ZkhaVkFiZFF2MlNLV0ZNZ3JMc3ZSYVl1VGZKekN2QnhMbjFYcHMiLCJ2ZXJpZmlhYmxlQ3JlZGVudGlhbCI6WyJleUpoYkdjaU9pSkZaRVJUUVNJc0ltdHBaQ0k2SW1ScFpEcHJaWGs2ZWpaTmEzQlNZMjlVUmpJMFMxZHJlRzlTYUdSWmNFTm9VSFpCTkVNM2FuQkdaelZ4ZDI4eldqSXlaM05pTVdoeUluMC5leUp6ZFdJaU9pSmthV1E2YTJWNU9ubzJUV3R3WkVOeFZEVm1TRnBXUVdKa1VYWXlVMHRYUmsxbmNreHpkbEpoV1hWVVprcDZRM1pDZUV4dU1WaHdjeU42TmsxcmNHUkRjVlExWmtoYVZrRmlaRkYyTWxOTFYwWk5aM0pNYzNaU1lWbDFWR1pLZWtOMlFuaE1iakZZY0hNaUxDSnBjM01pT2lKa2FXUTZhMlY1T25vMlRXdHdVbU52VkVZeU5FdFhhM2h2VW1oa1dYQkRhRkIyUVRSRE4ycHdSbWMxY1hkdk0xb3lNbWR6WWpGb2NpSXNJblpqSWpwN0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWl3aWFIUjBjSE02THk5d2RYSnNMbWx0YzJkc2IySmhiQzV2Y21jdmMzQmxZeTl2WWk5Mk0zQXdMMk52Ym5SbGVIUXVhbk52YmlKZExDSnBaQ0k2SW5WeWJqcDFkV2xrT2psaFlXTmtPREZtTFRjM1lUTXRORGRoWWkxaU1tSXlMVGswTlRFd01qazFOVFl5TXlJc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pQY0dWdVFtRmtaMlZEY21Wa1pXNTBhV0ZzSWwwc0ltNWhiV1VpT2lKS1JrWWdlQ0IyWXkxbFpIVWdVR3gxWjBabGMzUWdNeUJKYm5SbGNtOXdaWEpoWW1sc2FYUjVJaXdpYVhOemRXVnlJanA3SW5SNWNHVWlPbHNpVUhKdlptbHNaU0pkTENKcFpDSTZJbVJwWkRwclpYazZlalpOYTNCU1kyOVVSakkwUzFkcmVHOVNhR1JaY0VOb1VIWkJORU0zYW5CR1p6VnhkMjh6V2pJeVozTmlNV2h5SWl3aWJtRnRaU0k2SWtwdlluTWdabTl5SUhSb1pTQkdkWFIxY21VZ0tFcEdSaWtpTENKMWNtd2lPaUpvZEhSd2N6b3ZMM2QzZHk1cVptWXViM0puTHlJc0ltbHRZV2RsSWpvaWFIUjBjSE02THk5M00yTXRZMk5uTG1kcGRHaDFZaTVwYnk5Mll5MWxaQzl3YkhWblptVnpkQzB4TFRJd01qSXZhVzFoWjJWekwwcEdSbDlNYjJkdlRHOWphM1Z3TG5CdVp5SjlMQ0pwYzNOMVlXNWpaVVJoZEdVaU9pSXlNREl6TFRFd0xUQTBWREl6T2pVM09qQTVMalExTURNeU1qVXdNbG9pTENKamNtVmtaVzUwYVdGc1UzVmlhbVZqZENJNmV5SjBlWEJsSWpwYklrRmphR2xsZG1WdFpXNTBVM1ZpYW1WamRDSmRMQ0pwWkNJNkltUnBaRHByWlhrNmVqWk5hM0JrUTNGVU5XWklXbFpCWW1SUmRqSlRTMWRHVFdkeVRITjJVbUZaZFZSbVNucERka0o0VEc0eFdIQnpJM28yVFd0d1pFTnhWRFZtU0ZwV1FXSmtVWFl5VTB0WFJrMW5ja3h6ZGxKaFdYVlVaa3A2UTNaQ2VFeHVNVmh3Y3lJc0ltRmphR2xsZG1WdFpXNTBJanA3SW1sa0lqb2lkWEp1T25WMWFXUTZZV015TlRSaVpEVXRPR1poWkMwMFltSXhMVGxrTWprdFpXWmtPVE00TlRNMk9USTJJaXdpZEhsd1pTSTZXeUpCWTJocFpYWmxiV1Z1ZENKZExDSnVZVzFsSWpvaVNrWkdJSGdnZG1NdFpXUjFJRkJzZFdkR1pYTjBJRE1nU1c1MFpYSnZjR1Z5WVdKcGJHbDBlU0lzSW1SbGMyTnlhWEIwYVc5dUlqb2lWR2hwY3lCM1lXeHNaWFFnYzNWd2NHOXlkSE1nZEdobElIVnpaU0J2WmlCWE0wTWdWbVZ5YVdacFlXSnNaU0JEY21Wa1pXNTBhV0ZzY3lCaGJtUWdhR0Z6SUdSbGJXOXVjM1J5WVhSbFpDQnBiblJsY205d1pYSmhZbWxzYVhSNUlHUjFjbWx1WnlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUhKbGNYVmxjM1FnZDI5eWEyWnNiM2NnWkhWeWFXNW5JRXBHUmlCNElGWkRMVVZFVlNCUWJIVm5SbVZ6ZENBekxpSXNJbU55YVhSbGNtbGhJanA3SW5SNWNHVWlPaUpEY21sMFpYSnBZU0lzSW01aGNuSmhkR2wyWlNJNklsZGhiR3hsZENCemIyeDFkR2x2Ym5NZ2NISnZkbWxrWlhKeklHVmhjbTVsWkNCMGFHbHpJR0poWkdkbElHSjVJR1JsYlc5dWMzUnlZWFJwYm1jZ2FXNTBaWEp2Y0dWeVlXSnBiR2wwZVNCa2RYSnBibWNnZEdobElIQnlaWE5sYm5SaGRHbHZiaUJ5WlhGMVpYTjBJSGR2Y210bWJHOTNMaUJVYUdseklHbHVZMngxWkdWeklITjFZMk5sYzNObWRXeHNlU0J5WldObGFYWnBibWNnWVNCd2NtVnpaVzUwWVhScGIyNGdjbVZ4ZFdWemRDd2dZV3hzYjNkcGJtY2dkR2hsSUdodmJHUmxjaUIwYnlCelpXeGxZM1FnWVhRZ2JHVmhjM1FnZEhkdklIUjVjR1Z6SUc5bUlIWmxjbWxtYVdGaWJHVWdZM0psWkdWdWRHbGhiSE1nZEc4Z1kzSmxZWFJsSUdFZ2RtVnlhV1pwWVdKc1pTQndjbVZ6Wlc1MFlYUnBiMjRzSUhKbGRIVnlibWx1WnlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUhSdklIUm9aU0J5WlhGMVpYTjBiM0lzSUdGdVpDQndZWE56YVc1bklIWmxjbWxtYVdOaGRHbHZiaUJ2WmlCMGFHVWdjSEpsYzJWdWRHRjBhVzl1SUdGdVpDQjBhR1VnYVc1amJIVmtaV1FnWTNKbFpHVnVkR2xoYkhNdUluMHNJbWx0WVdkbElqcDdJbWxrSWpvaWFIUjBjSE02THk5M00yTXRZMk5uTG1kcGRHaDFZaTVwYnk5Mll5MWxaQzl3YkhWblptVnpkQzB6TFRJd01qTXZhVzFoWjJWekwwcEdSaTFXUXkxRlJGVXRVRXhWUjBaRlUxUXpMV0poWkdkbExXbHRZV2RsTG5CdVp5SXNJblI1Y0dVaU9pSkpiV0ZuWlNKOWZYMTlmUS54Qkg1b0dwZm9xdFpXMTdhMEtlak1tUkUtNDhsMWt6bzExc2lrZUxkR0JoMHFMQ3E5d2pJeUZHeWxVMUxoM0FHaWN1VGRLdDB0bkJqRXhud29ZMmRCZyJdfX0.-29z2twNHmK3tIwS59R-WiHuOhBNJbUS5YXKPCbCaKhPa8QyD1Z8hZ-G6ECFY8K4ZSnoB5b7OCyvvIclYj8gAA"
55+
56+
//language=json
57+
val vpPolicies = Json.parseToJsonElement(
58+
"""
59+
[
60+
"signature",
61+
"expired",
62+
"not-before"
63+
]
64+
"""
65+
).jsonArray.parsePolicyRequests()
66+
67+
//language=json
68+
val vcPolicies = Json.parseToJsonElement(
69+
"""
70+
[
71+
"signature"
72+
73+
]
74+
"""
75+
).jsonArray.parsePolicyRequests()
76+
77+
//language=json
78+
val specificPolicies = Json.parseToJsonElement(
79+
"""
80+
{
81+
"OpenBadgeCredential": [
82+
{
83+
"policy": "dynamic",
84+
"args": {
85+
"policy_name": "test",
86+
"opa_server": "http://localhost:8181",
87+
"policy_query": "data",
88+
"rules": {
89+
"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}"
90+
},
91+
"argument": {
92+
"name": "JFF x vc-edu PlugFest 3 Interoperability"
93+
}
94+
}
95+
}
96+
]
97+
}
98+
"""
99+
).jsonObject.mapValues { it.value.jsonArray.parsePolicyRequests() }
100+
101+
102+
println("SP Policies: $specificPolicies")
103+
104+
val r = Verifier.verifyPresentation(
105+
VCFormat.jwt_vp_json,
106+
vpToken = vpToken,
107+
vpPolicies = vpPolicies,
108+
globalVcPolicies = vcPolicies,
109+
specificCredentialPolicies = specificPolicies,
110+
mapOf(
111+
"presentationSubmission" to JsonObject(emptyMap()), "challenge" to "abc"
112+
)
113+
)
114+
115+
println(
116+
Json { prettyPrint = true }.encodeToString(
117+
r
118+
)
119+
)
120+
121+
val x = r.results.flatMap { it.policyResults }
122+
println("Results: " + x.size)
123+
println("OK: ${x.count { it.isSuccess() }}")
124+
}
125+
}

0 commit comments

Comments
 (0)