Skip to content

Commit

Permalink
Add support for arbitrary C# object types as inputs (#60)
Browse files Browse the repository at this point in the history
The `evaluate<T>` and `evaluateDefault<T>` methods now allow arbitrary C# objects as inputs to a policy. This makes the API much more ergonomic to use, at the expense of a bit of serdes overhead under-the-hood.

We serialize the incoming object to JSON, and then attempt to convert it to a `Dictionary<string, object>`. This is reasonable in context, because other primitive JSON types should be matched by more specific methods, like the `string` or `double` methods, for instance.

Signed-off-by: Philip Conrad <philip@chariot-chaser.net>
  • Loading branch information
philipaconrad authored Jul 25, 2024
1 parent db524da commit a824381
Show file tree
Hide file tree
Showing 2 changed files with 222 additions and 2 deletions.
54 changes: 54 additions & 0 deletions Styra/Opa/OpaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,33 @@ public async Task<bool> check(string path, Dictionary<string, object> input)
return await queryMachinery<T>(path, Input.CreateMapOfAny(input));
}

/// <summary>
/// Evaluate a policy, using the provided object, then coerce the result to
/// type T. This will round-trip an object through Newtonsoft.JsonConvert,
/// in order to generate the input object for the eventual OPA API call.
/// </summary>
/// <param name="input">The input C# object OPA will use for evaluating the rule.</param>
/// <param name="path">The rule to evaluate. (Example: "app/rbac")</param>
/// <returns>Result, as an instance of T, or null in the case of a query failure.</returns>
public async Task<T?> evaluate<T>(string path, object input)
{
if (input == null)
{
return default;
}

// Round-trip through JSON conversion, and deserialize it back to Dictionary<string, object>
var jsonInput = JsonConvert.SerializeObject(input);
var roundTrippedInput = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonInput);

if (roundTrippedInput is null)
{
return default;
}

return await queryMachinery<T>(path, Input.CreateMapOfAny(roundTrippedInput));
}

/// <exclude />
private async Task<T?> queryMachinery<T>(string path, Input input)
{
Expand Down Expand Up @@ -476,6 +503,33 @@ public async Task<bool> check(string path, Dictionary<string, object> input)
return await queryMachineryDefault<T>(Input.CreateMapOfAny(input));
}

/// <summary>
/// Evaluate the server's default policy, using the provided object, then
/// coerce the result to type T. This will round-trip an object through
/// Newtonsoft.JsonConvert, in order to generate the input object for the
/// eventual OPA API call.
/// </summary>
/// <param name="input">The input C# object OPA will use for evaluating the rule.</param>
/// <returns>Result, as an instance of T, or null in the case of a query failure.</returns>
public async Task<T?> evaluateDefault<T>(object input)
{
if (input == null)
{
return default;
}

// Round-trip through JSON conversion, and deserialize it back to Dictionary<string, object>
var jsonInput = JsonConvert.SerializeObject(input);
var roundTrippedInput = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonInput);

if (roundTrippedInput is null)
{
return default;
}

return await queryMachineryDefault<T>(Input.CreateMapOfAny(roundTrippedInput));
}

/// <exclude />
private async Task<T?> queryMachineryDefault<T>(Input input)
{
Expand Down
170 changes: 168 additions & 2 deletions test/SmokeTest.Tests/HighLevelTest.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,51 @@
using Styra.Opa;
using Newtonsoft.Json;
using Styra.Opa;
using Styra.Opa.OpenApi.Models.Components;
using Xunit.Abstractions;

namespace SmokeTest.Tests;

// Note(philip): Run with `--logger "console;verbosity=detailed"` to see logged messages.
public class HighLevelTest : IClassFixture<OPAContainerFixture>, IClassFixture<EOPAContainerFixture>
{
private readonly ITestOutputHelper _testOutput;
public IContainer _containerOpa;
public IContainer _containerEopa;

public HighLevelTest(OPAContainerFixture opaFixture, EOPAContainerFixture eopaFixture)
private class CustomRBACObject
{

[JsonProperty("user")]
public string User = "";

[JsonProperty("action")]
public string Action = "";

[JsonProperty("object")]
public string Object = "";

[JsonProperty("type")]
public string Type = "";

[JsonIgnore]
public string UUID = System.Guid.NewGuid().ToString();

public CustomRBACObject() { }

public CustomRBACObject(string user, string action, string obj, string type)
{
User = user;
Action = action;
Object = obj;
Type = type;
}
}

public HighLevelTest(OPAContainerFixture opaFixture, EOPAContainerFixture eopaFixture, ITestOutputHelper output)
{
_containerOpa = opaFixture.GetContainer();
_containerEopa = eopaFixture.GetContainer();
_testOutput = output;
}

private OpaClient GetOpaClient()
Expand Down Expand Up @@ -99,6 +133,128 @@ public async Task RBACCheckListObjTest()
Assert.False(allow);
}

[Fact]
public async Task DictionaryTypeCoerceTest()
{
var client = GetOpaClient();

var input = new Dictionary<string, string>() {
{ "user", "alice" },
{ "action", "read" },
{ "object", "id123" },
{ "type", "dog" },
};

var result = new Dictionary<string, object>();

try
{
result = await client.evaluate<Dictionary<string, object>>("app/rbac", input);
}
catch (OpaException e)
{
_testOutput.WriteLine("exception while making request against OPA: " + e.Message);
}

var expected = new Dictionary<string, object>() {
{ "allow", true },
{ "user_is_admin", true },
{ "user_is_granted", new List<object>()},
};

Assert.NotNull(result);
Assert.Equivalent(expected, result);
Assert.Equal(expected.Count, result.Count);
}

[Fact]
public async Task BooleanTypeCoerceTest()
{
var client = GetOpaClient();

var input = false;

var result = new Dictionary<string, object>();

try
{
result = await client.evaluate<Dictionary<string, object>>("app/rbac", input);
}
catch (OpaException e)
{
_testOutput.WriteLine("exception while making request against OPA: " + e.Message);
}

var expected = new Dictionary<string, object>() {
{ "allow", false },
{ "user_is_granted", new List<object>()},
};

Assert.NotNull(result);
Assert.Equivalent(expected, result);
Assert.Equal(expected.Count, result.Count);
}

[Fact]
public async Task CustomClassTypeCoerceTest()
{
var client = GetOpaClient();

var input = new CustomRBACObject("alice", "read", "id123", "dog");

var result = new Dictionary<string, object>();

try
{
result = await client.evaluate<Dictionary<string, object>>("app/rbac", input);
}
catch (OpaException e)
{
_testOutput.WriteLine("exception while making request against OPA: " + e.Message);
}

var expected = new Dictionary<string, object>() {
{ "allow", true },
{ "user_is_admin", true },
{ "user_is_granted", new List<object>()},
};

Assert.NotNull(result);
Assert.Equivalent(expected, result);
Assert.Equal(expected.Count, result.Count);
}

[Fact]
public async Task AnonymousObjectTypeCoerceTest()
{
var client = GetOpaClient();

// Relies on Newtonsoft.Json's default serialization rules. `object` is unused by
// the policy, thankfully, so we can get away with mangling that field's name.
var input = new { user = "alice", action = "read", _object = "id123", type = "dog" };

var result = new Dictionary<string, object>();

try
{
result = await client.evaluate<Dictionary<string, object>>("app/rbac", input);
}
catch (OpaException e)
{
_testOutput.WriteLine("exception while making request against OPA: " + e.Message);
}

var expected = new Dictionary<string, object>() {
{ "allow", true },
{ "user_is_admin", true },
{ "user_is_granted", new List<object>()},
};

Assert.NotNull(result);
Assert.Equivalent(expected, result);
Assert.Equal(expected.Count, result.Count);
}

[Fact]
public async Task EvaluateDefaultTest()
{
Expand All @@ -112,6 +268,16 @@ public async Task EvaluateDefaultTest()
Assert.Equal(new Dictionary<string, object>() { { "hello", "world" } }, res?.GetValueOrDefault("echo", ""));
}

[Fact]
public async Task EvaluateDefaultWithAnonymousObjectTest()
{
var client = GetOpaClient();

var res = await client.evaluateDefault<Dictionary<string, object>>(new { hello = "world" });

Assert.Equal(new Dictionary<string, object>() { { "hello", "world" } }, res?.GetValueOrDefault("echo", ""));
}

[Fact]
public async Task RBACBatchAllSuccessTest()
{
Expand Down

0 comments on commit a824381

Please sign in to comment.