Skip to content

Commit

Permalink
Introduce policy result handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
queil committed Jun 13, 2022
1 parent 08ecbb9 commit 764fbd1
Show file tree
Hide file tree
Showing 16 changed files with 199 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public class DefaultOpaDecision : IOpaDecision
{
public AuthorizeResult Map(ResponseBase? response) => response switch
{
QueryResponse { Result: true } => AuthorizeResult.Allowed,
QueryResponse<bool?> { Result: true } => AuthorizeResult.Allowed,
PolicyNotFound => AuthorizeResult.PolicyNotFound,
NoDefaultPolicy => AuthorizeResult.NoDefaultPolicy,
_ => AuthorizeResult.NotAllowed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.DependencyInjection;

Expand All @@ -29,15 +30,16 @@ public static IRequestExecutorBuilder AddOpaAuthorizationHandler(
builder.AddAuthorizationHandler<OpaAuthorizationHandler>();
builder.Services.AddSingleton<IOpaQueryRequestFactory, DefaultQueryRequestFactory>();
builder.Services.AddSingleton<IOpaDecision, DefaultOpaDecision>();
builder.Services.AddSingleton<DefaultPolicyResultHandler>();
builder.Services.AddHttpClient<IOpaService, OpaService>((f, c) =>
{
OpaOptions? options = f.GetRequiredService<OpaOptions>();
c.BaseAddress = options.BaseAddress;
c.Timeout = options.ConnectionTimeout;
IOptions<OpaOptions>? options = f.GetRequiredService<IOptions<OpaOptions>>();
c.BaseAddress = options.Value.BaseAddress;
c.Timeout = options.Value.ConnectionTimeout;
});
builder.Services.AddSingleton(f =>

builder.Services.AddOptions<OpaOptions>().Configure<IServiceProvider>((o, f) =>
{
var options = new OpaOptions();
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Expand All @@ -48,10 +50,44 @@ public static IRequestExecutorBuilder AddOpaAuthorizationHandler(
#endif
};
jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false));
options.JsonSerializerOptions = jsonOptions;
configure?.Invoke(f.GetRequiredService<IConfiguration>(), options);
return options;
o.JsonSerializerOptions = jsonOptions;
configure?.Invoke(f.GetRequiredService<IConfiguration>(), o);
});

return builder;
}

public static IRequestExecutorBuilder AddOpaResponseHandler<T>(this IRequestExecutorBuilder builder, string policyPath, Func<IServiceProvider, T?>? factory=null)
where T : class, IPolicyResultHandler
{
if (factory is not null)
{
builder.Services.AddSingleton(factory);
}
else
{
builder.Services.AddSingleton<T>();
}

builder.Services.AddOptions<OpaOptions>()
.Configure<IServiceProvider>((o, f) =>
{
o.PolicyResultHandlers.Add(policyPath, f.GetRequiredService<T>());
});
return builder;
}

public static IRequestExecutorBuilder AddOpaResponseHandlerAsync<T>(this IRequestExecutorBuilder builder,
string policyPath, Func<PolicyResultContext<T>, Task<ResponseBase>> func)
{
return builder.AddOpaResponseHandler(policyPath,
f => new DelegatePolicyResultHandler<T, ResponseBase>(func, f.GetRequiredService<IOptions<OpaOptions>>()));
}

public static IRequestExecutorBuilder AddOpaResponseHandler<T>(this IRequestExecutorBuilder builder,
string policyPath, Func<PolicyResultContext<T>, ResponseBase> func)
{
return builder.AddOpaResponseHandler(policyPath,
f => new DelegatePolicyResultHandler<T, ResponseBase>(ctx => Task.FromResult(func(ctx)), f.GetRequiredService<IOptions<OpaOptions>>()));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using HotChocolate.Resolvers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

Expand All @@ -24,9 +25,22 @@ public async ValueTask<AuthorizeResult> AuthorizeAsync(
IOpaService? opaService = context.Services.GetRequiredService<IOpaService>();
IOpaDecision? opaDecision = context.Services.GetRequiredService<IOpaDecision>();
IOpaQueryRequestFactory? factory = context.Services.GetRequiredService<IOpaQueryRequestFactory>();
IOptions<OpaOptions> options = context.Services.GetRequiredService<IOptions<OpaOptions>>();

ResponseBase? response = await opaService.QueryAsync(directive.Policy ?? string.Empty,
var policyPath = directive.Policy ?? string.Empty;

HttpResponseMessage? httpResponse = await opaService.QueryAsync(policyPath,
factory.CreateRequest(context, directive), context.RequestAborted).ConfigureAwait(false);
return opaDecision.Map(response);

if (httpResponse is null) throw new InvalidOperationException("Opa response must not be null");

if (!options.Value.PolicyResultHandlers.TryGetValue(policyPath, out IPolicyResultHandler? handler))
{
throw new InvalidOperationException($"No policy result handler registered for policy: '{policyPath}'");
}

ResponseBase? response = await handler.HandleAsync(policyPath, httpResponse, context);
AuthorizeResult decision = opaDecision.Map(response);
return decision;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ internal static HttpContent ToJsonContent(this QueryRequest request, JsonSeriali
#endif
}

internal static async Task<QueryResponse?> QueryResponseFromJsonAsync(this HttpContent content, JsonSerializerOptions options, CancellationToken token)
internal static async Task<T> FromJsonAsync<T>(this HttpContent content, JsonSerializerOptions options, CancellationToken token)
{
#if NET6_0
return await content.ReadFromJsonAsync<QueryResponse>(options, token).ConfigureAwait(false);
return await content.ReadFromJsonAsync<T>(options, token).ConfigureAwait(false);
#else
return await JsonSerializer.DeserializeAsync<QueryResponse>(await content.ReadAsStreamAsync().ConfigureAwait(false), options, token).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<T>(await content.ReadAsStreamAsync().ConfigureAwait(false), options, token).ConfigureAwait(false);
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public sealed class OpaOptions
public Uri BaseAddress { get; set; } = new("http://127.0.0.1:8181/v1/data/");
public TimeSpan ConnectionTimeout { get; set; } = TimeSpan.FromMilliseconds(250);
public JsonSerializerOptions JsonSerializerOptions { get; set; } = new();
public Dictionary<string, IPolicyResultHandler> PolicyResultHandlers { get; } = new();
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Net;
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

Expand All @@ -7,25 +7,19 @@ public sealed class OpaService : IOpaService
private readonly HttpClient _httpClient;
private readonly OpaOptions _options;

public OpaService(HttpClient httpClient, OpaOptions options)
public OpaService(HttpClient httpClient, IOptions<OpaOptions> options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
}

public async Task<ResponseBase?> QueryAsync(string policyPath, QueryRequest request, CancellationToken token)
public async Task<HttpResponseMessage?> QueryAsync(string policyPath, QueryRequest request, CancellationToken token)
{
if (policyPath is null) throw new ArgumentNullException(nameof(policyPath));
if (request is null) throw new ArgumentNullException(nameof(request));

HttpResponseMessage response = await _httpClient.PostAsync(policyPath, request.ToJsonContent(_options.JsonSerializerOptions), token).ConfigureAwait(false);
HttpResponseMessage response = await _httpClient.PostAsync(policyPath, request.ToJsonContent(_options.JsonSerializerOptions), token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
QueryResponse? result = await response.Content.QueryResponseFromJsonAsync(_options.JsonSerializerOptions, token).ConfigureAwait(false);
return result switch
{
{ Result: null } when policyPath.Equals(string.Empty) => NoDefaultPolicy.Response,
{ Result: null } => PolicyNotFound.Response,
var r => r
};
return response;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

public class DefaultPolicyResultHandler : PolicyResultHandlerBase<QueryResponse<bool?>, ResponseBase>
{
public DefaultPolicyResultHandler(IOptions<OpaOptions> options) : base(options) { }
protected override Task<ResponseBase> ProcessAsync(PolicyResultContext<QueryResponse<bool?>> context)
{
return Task.FromResult<ResponseBase>(context.Result switch
{
{ Result: null } when context.PolicyPath.Equals(string.Empty) => NoDefaultPolicy.Response,
{ Result: null } => PolicyNotFound.Response,
_ => new QueryResponse<bool?> { Result = false }
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

public class DelegatePolicyResultHandler<T, TOutput> : PolicyResultHandlerBase<T, TOutput>
where TOutput : ResponseBase
{
private readonly Func<PolicyResultContext<T>, Task<TOutput>> _process;
public DelegatePolicyResultHandler(Func<PolicyResultContext<T>, Task<TOutput>> process, IOptions<OpaOptions> options) : base(options) => _process = process;
protected override Task<TOutput> ProcessAsync(PolicyResultContext<T> context) => _process(context);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using HotChocolate.Resolvers;

namespace HotChocolate.AspNetCore.Authorization;

public interface IPolicyResultHandler
{
Task<ResponseBase?> HandleAsync(string policyPath, HttpResponseMessage response, IMiddlewareContext context);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using HotChocolate.Resolvers;

namespace HotChocolate.AspNetCore.Authorization;

public class PolicyResultContext<T>
{
public PolicyResultContext(string policyPath, T? result, IMiddlewareContext context)
{
PolicyPath = policyPath;
Result = result;
MiddlewareContext = context;
}
public string PolicyPath { get; }
public T? Result { get; }
public IMiddlewareContext MiddlewareContext { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using HotChocolate.Resolvers;
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

public abstract class PolicyResultHandlerBase<T, TOutput> : IPolicyResultHandler
where TOutput : ResponseBase
{
private readonly IOptions<OpaOptions> _options;
protected PolicyResultHandlerBase(IOptions<OpaOptions> options) => _options = options;
protected abstract Task<TOutput> ProcessAsync(PolicyResultContext<T> context);

public async Task<ResponseBase?> HandleAsync(string policyPath, HttpResponseMessage response,
IMiddlewareContext context)
{
QueryResponse<T?> responseObj = await response.Content
.FromJsonAsync<QueryResponse<T?>>(_options.Value.JsonSerializerOptions, context.RequestAborted)
.ConfigureAwait(false);
return await ProcessAsync(new PolicyResultContext<T>(policyPath, responseObj.Result, context));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ namespace HotChocolate.AspNetCore.Authorization;

public abstract class ResponseBase { }

public sealed class QueryResponse : ResponseBase
public sealed class QueryResponse<T> : ResponseBase
{
public Guid? DecisionId { get; set; }
[JsonConverter(typeof(OpaResultFieldConverter))]
public bool? Result { get; set; }
//[JsonConverter(typeof(OpaResultFieldConverter))]
public T? Result { get; set; }
}

public sealed class PolicyNotFound : ResponseBase
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace HotChocolate.AspNetCore.Authorization;

public interface IOpaService
{
Task<ResponseBase?> QueryAsync(string policyPath, QueryRequest request, CancellationToken token);
Task<HttpResponseMessage?> QueryAsync(string policyPath, QueryRequest request, CancellationToken token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,15 @@ private Action<IRequestExecutorBuilder> CreateSchema() =>
.AddOpaAuthorizationHandler((c, o) =>
{
o.ConnectionTimeout = TimeSpan.FromSeconds(60);
});
}).AddOpaResponseHandler<HasAgeDefinedResponse>("graphql/authz/has_age_defined",
context =>
{
return context.Result switch
{
{ Allow: true } => new QueryResponse<bool?> { Result = true },
_ => new QueryResponse<bool?> { Result = false }
};
});

public IEnumerator<object[]> GetEnumerator()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.Json;
using HotChocolate.Execution.Configuration;
using HotChocolate.Resolvers;
using Microsoft.Extensions.DependencyInjection;

namespace HotChocolate.AspNetCore.Authorization;

public class HasAgeDefinedResponse
{
public bool Allow { get; set; }
public Claims Claims { get; set; }
}

public class Claims
{
public string Birthdate { get; set; }
public long Iat { get; set; }
public string Name { get; set; }
public string Sub { get; set; }
}

public class AuthorizationTestData : IEnumerable<object[]>
{
private readonly string SchemaCode = @"
type Query {
default: String @authorize
age: String @authorize(policy: ""graphql/authz/has_age_defined/allow"")
age: String @authorize(policy: ""graphql/authz/has_age_defined"")
roles: String @authorize(roles: [""a""])
roles_ab: String @authorize(roles: [""a"" ""b""])
piped: String
Expand All @@ -38,6 +51,15 @@ private Action<IRequestExecutorBuilder> CreateSchema() =>
{
o.ConnectionTimeout = TimeSpan.FromSeconds(60);
})
.AddOpaResponseHandler<HasAgeDefinedResponse>("graphql/authz/has_age_defined",
context =>
{
return context.Result switch
{
{ Allow: true } => new QueryResponse<bool?> { Result = true },
_ => new QueryResponse<bool?> { Result = false }
};
})
.UseField(_schemaMiddleware);

private Action<IRequestExecutorBuilder> CreateSchemaWithBuilder() =>
Expand All @@ -48,6 +70,15 @@ private Action<IRequestExecutorBuilder> CreateSchemaWithBuilder() =>
{
o.ConnectionTimeout = TimeSpan.FromSeconds(60);
})
.AddOpaResponseHandler<HasAgeDefinedResponse>("graphql/authz/has_age_defined",
context =>
{
return context.Result switch
{
{ Allow: true } => new QueryResponse<bool?> { Result = true },
_ => new QueryResponse<bool?> { Result = false }
};
})
.UseField(_schemaMiddleware);

public IEnumerator<object[]> GetEnumerator()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ import input.request

default allow = false

input["token"] = replace(request.headers["Authorization"], "Bearer ", "")
valid_jwt = token {
token := replace(request.headers["Authorization"], "Bearer ", "")
startswith(token, "eyJhbG") # a toy validation
}

claims := io.jwt.decode(input.token)[1]
claims = cl {
cl := io.jwt.decode(valid_jwt)[1]
valid_jwt
}

allow {
valid_jwt
claims.birthdate
}

0 comments on commit 764fbd1

Please sign in to comment.