Skip to content

Commit

Permalink
feat: added Json extension to support string handling as Json map
Browse files Browse the repository at this point in the history
fix: PolicyActionRelationship not handling PolicyActions correctly
chore: Pet example added
release jar
  • Loading branch information
ivsokol committed Sep 26, 2024
1 parent e82a930 commit 2cea7a4
Show file tree
Hide file tree
Showing 9 changed files with 754 additions and 5 deletions.
99 changes: 98 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,99 @@ it is up to the client to provide all necessary data to the engine as part of th

Engine also ships with expression language support (PEEL - [PolicyEngine Expression Language](https://ivsokol.github.io/policy-engine/docs/expression-language.md)). It allows users to define custom conditions and policies through strings that can be evaluated by the engine.

## Simple example

**Only pet owner can update pet information**

We want to allow only pet owners to update pet information, and deny for others.

```kotlin
test("only pet owner can update pet information") {
// define owner in subject store
val owner = json.decodeFromString<Map<String, String?>>("""{"username" : "jDoe"}""")
// prepare request payload
val request =
mapOf(
"pet" to """{"kind": "dog", "name" : "Fido","owner" : "jDoe"}""".toJsonAny(),
"action" to "update")
// define policy
val policy =
"""
*permit(
*eq(
*dyn(*key(username,#opts(source=subject))),
*dyn(*jq(.pet.owner))
),
#opts(strictTargetEffect)
)
"""
.trimIndent()
// prepare context
val context = Context(subject = owner, request = request)
// evaluate policy
val resultOwner = PolicyEngine().evaluatePolicy(PEELParser(policy).parsePolicy(), context)
// will print 'permit'
println(resultOwner.first)
resultOwner.first shouldBe PolicyResultEnum.PERMIT

// now let's try to update pet info without being owner
val notOwner = json.decodeFromString<Map<String, String?>>("""{"username" : "mSmith"}""")

val newContext = Context(subject = notOwner, request = request)
val resultNotOwner =
PolicyEngine().evaluatePolicy(PEELParser(policy).parsePolicy(), newContext)
// will print 'deny'
println(resultNotOwner.first)
resultNotOwner.first shouldBe PolicyResultEnum.DENY
}
```

This example shows how easy it is to define a policy and evaluate it by using PolicyEngine expression language. It is simple example that can evolve into more complex policies, depending on the requirements.

Engine can support multiple variations on input stores (strings, maps, objects). It can also work with old and new object versions at the same time, if data model was upgraded.

Business requirements can grow more complex over time, like covering read action to be permitted for everyone or providing welcome or warning message for users.

All these examples are covered in the [Pet Example](https://ivsokol.github.io/policy-engine/docs/examples/pet-example/)
page, and in repository [PetTest](https://github.com/ivsokol/policy-engine/blob/main/src/test/kotlin/io/github/ivsokol/poe/examples/catalog/PetTest.kt).

## Competitive advantage over other libraries

Looking at the simple example above, one can arrive at the conclusion that it is easier to implement the same
functionality by using simple conditional statements in code. And that is correct. If you need to define static policies,
then it is easier and faster to write the code.

Problem with static code arises when you need to define policies that can be changed over time. In that case, if you write static code, every time
you need to change the policy, you need to recompile the code and deploy your application.

There are ways to solve this problem by using dynamic languages, like Groovy or JavaScript, but that leads to the restriction that
only individuals experienced in those languages can create or change the policy.

Main goal of this library is to provide an engine that evaluates policies based on provided policy catalog (it serves as Policy Decision Point - **PDP**; check [here](https://docs.oasis-open.org/xacml/3.0/xacml-3.0-core-spec-os-en.html#_Toc325047068) and [here](https://docs.oasis-open.org/xacml/3.0/xacml-3.0-core-spec-os-en.html#_Toc325047088) for more details).

Policy definitions (hundreds or thousands of them) can be defined in a separate entity called Policy Administration
Point - **PAP**. This entity can be a separate application, with its own GUI and API optimized for Policy creation. It can be adjusted for business users, to allow them to create policies or conditions by populating predefined forms for such entities, or by using AI to generate policies.

For all other simple cases, Policy Engine Expression Language can be used to define policies in a declarative way.

By having this kind of architecture, you can define policies in a declarative way, and then use the same policies in
different applications and change them in runtime (for example by providing a catalog through a REST API or pulling it from a database). In that way you can deploy new catalog version
without redeploying your application.

Another advantage is that policies are not only providing a result (permit/deny), but also additional information, if such information is needed. In the [variation](https://ivsokol.github.io/policy-engine/docs/examples/pet-example#pet-owner-can-update-pet-information-two-variations-of-pet-object) of simple pet example, it is possible to define a message (static or dynamic) for the end user, and such message can be pulled from the context data store after evaluation.

To summarize, these are advantages of using PoE:

* **Catalog driven** - Policy Engine runs on provided Policy Catalog definitions
* **Dynamic behaviour** - Policy Catalog can be defined in a separate application, and then loaded into the engine, thus supporting dynamic change without application redeployment.
* **Declarative** - Policy Catalog can be defined in a declarative way, using Policy Engine Expression Language.
* **Additional information** - Policy Engine can provide additional information, like message for the end user, or a
list of resources that are permitted or denied together with Policy evaluation result.
* **Policy and condition evaluation** - Policy Engine can evaluate both policy and condition expressions.
* **Adaptable to any input data model** - Policy Engine can work with any data model, as long as it is possible to convert it to a string or a map.
* **Multi-tenancy** - Policy Engine can work with multiple tenants, by using different Policy Catalogs for each tenant.
* **Customizable** - Policy Engine can be customized to fit your needs. Every Policy Engine entity has a set of options that
can be defined, thus adjusting execution behaviour of an entity.

## Documentation

Expand Down Expand Up @@ -55,11 +148,15 @@ PolicyEngine is based on following entities:
In order to use PoE in your project, you need to add the following dependency to your project:

```kotlin
implementation("io.github.ivsokol:poe:1.1.0")
implementation("io.github.ivsokol:poe:1.2.0")
```

After that you need to define a PolicyCatalog and instantiate a PolicyEngine.

Examples below are **code snippets** and not full examples. If you want to check full examples with explanations, please
visit [examples](https://ivsokol.github.io/policy-engine/docs/examples/) page,
or check catalog tests in [repository](https://github.com/ivsokol/policy-engine/tree/main/src/test/kotlin/io/github/ivsokol/poe/examples/catalog).

```kotlin
val catalog = "..."
val engine = PolicyEngine(catalog)
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ plugins {

group = "io.github.ivsokol"

version = "1.1.0"
version = "1.2.0"

repositories {
mavenLocal()
Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/io/github/ivsokol/poe/el/ParserRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ object ParserRegistry {
"*act",
PolicyEntityEnum.DSL,
listOf(
PolicyEntityEnum.POLICY_ACTION,
PolicyEntityEnum.POLICY_ACTION_SAVE,
PolicyEntityEnum.POLICY_ACTION_CLEAR,
PolicyEntityEnum.POLICY_ACTION_JSON_MERGE,
Expand Down
136 changes: 136 additions & 0 deletions src/main/kotlin/io/github/ivsokol/poe/variable/JsonExtensions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package io.github.ivsokol.poe.variable

import kotlinx.serialization.json.*

enum class JsonElementKind {
NULL,
BOOLEAN,
STRING,
NUMBER,
INT,
OBJECT,
ARRAY
}

/**
* Parses a nullable string to a JSON-compatible [Any] value.
*
* If the input string is null, returns null. If the input string is blank or empty, returns the
* string itself. Otherwise, parses the string to a [JsonElement] using the provided [Json]
* instance, and then recursively parses the [JsonElement] to a [Any] value using
* [parseStringToJsonAny].
*
* @param json The [Json] instance to use for parsing the input string. Defaults to a [Json]
* instance with `ignoreUnknownKeys = false`.
* @return The parsed [Any] value, or null if the input string is null.
*/
fun String?.toJsonAny(json: Json = Json { ignoreUnknownKeys = false }): Any? {
if (this == null) return null
if (this.isBlank() || this.isEmpty()) return this
return parseStringToJsonAny(json.parseToJsonElement(this.trim()))
}

/**
* Recursively parses a [JsonElement] to a [Any] value.
*
* This function takes a [JsonElement] and recursively parses it to a [Any] value. It handles the
* different types of [JsonElement] (null, boolean, int, number, string, object, array) and returns
* the corresponding [Any] value.
*
* @param elem The [JsonElement] to parse.
* @return The parsed [Any] value, or null if the [JsonElement] is null.
*/
internal fun parseStringToJsonAny(elem: JsonElement): Any? {
val decodeElem = elem.kind()
return when (decodeElem.first) {
JsonElementKind.NULL -> null
JsonElementKind.BOOLEAN -> decodeElem.second
JsonElementKind.INT -> decodeElem.second
JsonElementKind.NUMBER -> decodeElem.second
JsonElementKind.STRING -> decodeElem.second
JsonElementKind.OBJECT -> {
val obj = mutableMapOf<String, Any?>()
(decodeElem.second as? JsonObject)?.forEach { obj[it.key] = parseStringToJsonAny(it.value) }
obj
}
JsonElementKind.ARRAY -> {
val arr = mutableListOf<Any?>()
(decodeElem.second as? JsonArray)?.forEach {
parseStringToJsonAny(it).let { i -> arr.add(i) }
}
arr
}
}
}

private fun JsonElement.isNull() = runCatching { this.jsonNull }

private fun JsonElement.isPrimitive() = runCatching { this.jsonPrimitive }

private fun JsonElement.isObject() = runCatching { this.jsonObject }

private fun JsonElement.isArray() = runCatching { this.jsonArray }

/**
* Determines the type of the [JsonElement] and returns a pair of the [JsonElementKind] and the
* corresponding value.
*
* This function examines the [JsonElement] and returns a pair containing the type of the element
* (as a [JsonElementKind]) and the value of the element. It handles the different types of
* [JsonElement] (null, primitive, object, array) and returns the appropriate pair.
*
* @return A pair containing the [JsonElementKind] and the corresponding value of the [JsonElement].
* @throws IllegalArgumentException if the [JsonElement] is of an unknown type.
*/
internal fun JsonElement.kind(): Pair<JsonElementKind, Any> {
if (this.isNull().isSuccess) return Pair(JsonElementKind.NULL, JsonNull)
val primitive = this.isPrimitive()
if (primitive.isSuccess) {
return primitive.getOrThrow().kind()
}
val obj = this.isObject()
if (obj.isSuccess) {
return Pair(JsonElementKind.OBJECT, obj.getOrThrow())
}
val arr = this.isArray()
if (arr.isSuccess) {
return Pair(JsonElementKind.ARRAY, arr.getOrThrow())
}
throw IllegalArgumentException("Unknown JsonElement kind")
}

private fun JsonPrimitive.isBoolean() = kotlin.runCatching { this.boolean }

private fun JsonPrimitive.isInt() = kotlin.runCatching { this.int }

private fun JsonPrimitive.isLong() = kotlin.runCatching { this.long }

private fun JsonPrimitive.isNumber() = kotlin.runCatching { this.double }

private fun JsonPrimitive.isString() = kotlin.runCatching { this.content }

/**
* Determines the type of the [JsonPrimitive] and returns a pair of the [JsonElementKind] and the
* corresponding value.
*
* This function examines the [JsonPrimitive] and returns a pair containing the type of the element
* (as a [JsonElementKind]) and the value of the element. It handles the different types of
* [JsonPrimitive] (boolean, int, long, number, string) and returns the appropriate pair.
*
* @return A pair containing the [JsonElementKind] and the corresponding value of the
* [JsonPrimitive].
* @throws IllegalArgumentException if the [JsonPrimitive] is of an unknown type.
*/
internal fun JsonPrimitive.kind(): Pair<JsonElementKind, Any> {
val bool = this.isBoolean()
if (bool.isSuccess) return Pair(JsonElementKind.BOOLEAN, bool.getOrThrow())
val int = this.isInt()
if (int.isSuccess) return Pair(JsonElementKind.INT, int.getOrThrow())
val long = this.isLong()
if (long.isSuccess) return Pair(JsonElementKind.INT, long.getOrThrow())
val number = this.isNumber()
if (number.isSuccess) return Pair(JsonElementKind.NUMBER, number.getOrThrow())
val string = this.isString()
if (string.isSuccess) return Pair(JsonElementKind.STRING, string.getOrThrow())
throw IllegalArgumentException("Unknown JsonPrimitive type")
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ class PolicyActionRelationshipELDeserializerTest :
val actual = PEELParser(given).parsePolicy()
actual shouldBe expected
}
it("has action in action rel") {
val given = """#permit(*act(*save(foo,#str(bar))))"""
val expected =
PolicyDefault(
PolicyResultEnum.PERMIT,
actions =
listOf(
PolicyActionRelationship(
PolicyActionSave(
key = "foo",
value =
PolicyVariableStatic(
value = "bar", type = VariableValueTypeEnum.STRING)))))

val actual = PEELParser(given).parsePolicy()
actual shouldBe expected
}
it("has multiple actions") {
val given = """#permit(*save(foo,#str(bar)),*clear(foo2))"""
val expected =
Expand Down Expand Up @@ -177,7 +194,7 @@ class PolicyActionRelationshipELDeserializerTest :
it("should throw on bad relationship class ") {
val given = """#permit(*act(#int(1))))"""
shouldThrow<IllegalStateException> { PEELParser(given).parsePolicy() }.message shouldBe
"Child command type mismatch on position 13 for command '*act'. Expected: 'POLICY_ACTION_SAVE, POLICY_ACTION_CLEAR, POLICY_ACTION_JSON_MERGE, POLICY_ACTION_JSON_PATCH, REFERENCE', actual: 'VARIABLE_STATIC'"
"Child command type mismatch on position 13 for command '*act'. Expected: 'POLICY_ACTION, POLICY_ACTION_SAVE, POLICY_ACTION_CLEAR, POLICY_ACTION_JSON_MERGE, POLICY_ACTION_JSON_PATCH, REFERENCE', actual: 'VARIABLE_STATIC'"
}
it("should throw on relationship content") {
val given = """#permit(*act(content))"""
Expand Down
Loading

0 comments on commit 2cea7a4

Please sign in to comment.