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
+ }
0 commit comments