From 764fbd1c4f38afc79aa375e809d2efe3178db1c8 Mon Sep 17 00:00:00 2001 From: queil Date: Tue, 29 Mar 2022 10:01:31 +0100 Subject: [PATCH] Introduce policy result handlers --- .../DefaultOpaDecision.cs | 2 +- ...hocolateAuthorizeRequestExecutorBuilder.cs | 52 ++++++++++++++++--- .../OpaAuthorizationHandler.cs | 18 ++++++- .../OpaJsonExtensions.cs | 6 +-- .../OpaOptions.cs | 1 + .../OpaService.cs | 18 +++---- .../Result/DefaultPolicyResultHandler.cs | 17 ++++++ .../Result/DelegatePolicyResultHandler.cs | 11 ++++ .../Result/IPolicyResultHandler.cs | 8 +++ .../Result/PolicyResultContext.cs | 16 ++++++ .../Result/PolicyResultHandlerBase.cs | 21 ++++++++ .../{Types => Result}/QueryResponse.cs | 6 +-- .../Types/IOpaService.cs | 2 +- .../AuthorizationAttributeTestData.cs | 10 +++- .../AuthorizationTestData.cs | 35 ++++++++++++- .../Policies/has_age_defined.rego | 11 +++- 16 files changed, 199 insertions(+), 35 deletions(-) create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DefaultPolicyResultHandler.cs create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DelegatePolicyResultHandler.cs create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/IPolicyResultHandler.cs create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultContext.cs create mode 100644 src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultHandlerBase.cs rename src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/{Types => Result}/QueryResponse.cs (78%) diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/DefaultOpaDecision.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/DefaultOpaDecision.cs index 1e76631552b..7f775bd017d 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/DefaultOpaDecision.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/DefaultOpaDecision.cs @@ -4,7 +4,7 @@ public class DefaultOpaDecision : IOpaDecision { public AuthorizeResult Map(ResponseBase? response) => response switch { - QueryResponse { Result: true } => AuthorizeResult.Allowed, + QueryResponse { Result: true } => AuthorizeResult.Allowed, PolicyNotFound => AuthorizeResult.PolicyNotFound, NoDefaultPolicy => AuthorizeResult.NoDefaultPolicy, _ => AuthorizeResult.NotAllowed diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/HotChocolateAuthorizeRequestExecutorBuilder.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/HotChocolateAuthorizeRequestExecutorBuilder.cs index d01b54edbe4..95dac8e4803 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/HotChocolateAuthorizeRequestExecutorBuilder.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/HotChocolateAuthorizeRequestExecutorBuilder.cs @@ -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; @@ -29,15 +30,16 @@ public static IRequestExecutorBuilder AddOpaAuthorizationHandler( builder.AddAuthorizationHandler(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient((f, c) => { - OpaOptions? options = f.GetRequiredService(); - c.BaseAddress = options.BaseAddress; - c.Timeout = options.ConnectionTimeout; + IOptions? options = f.GetRequiredService>(); + c.BaseAddress = options.Value.BaseAddress; + c.Timeout = options.Value.ConnectionTimeout; }); - builder.Services.AddSingleton(f => + + builder.Services.AddOptions().Configure((o, f) => { - var options = new OpaOptions(); var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -48,10 +50,44 @@ public static IRequestExecutorBuilder AddOpaAuthorizationHandler( #endif }; jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false)); - options.JsonSerializerOptions = jsonOptions; - configure?.Invoke(f.GetRequiredService(), options); - return options; + o.JsonSerializerOptions = jsonOptions; + configure?.Invoke(f.GetRequiredService(), o); }); + + return builder; + } + + public static IRequestExecutorBuilder AddOpaResponseHandler(this IRequestExecutorBuilder builder, string policyPath, Func? factory=null) + where T : class, IPolicyResultHandler + { + if (factory is not null) + { + builder.Services.AddSingleton(factory); + } + else + { + builder.Services.AddSingleton(); + } + + builder.Services.AddOptions() + .Configure((o, f) => + { + o.PolicyResultHandlers.Add(policyPath, f.GetRequiredService()); + }); return builder; } + + public static IRequestExecutorBuilder AddOpaResponseHandlerAsync(this IRequestExecutorBuilder builder, + string policyPath, Func, Task> func) + { + return builder.AddOpaResponseHandler(policyPath, + f => new DelegatePolicyResultHandler(func, f.GetRequiredService>())); + } + + public static IRequestExecutorBuilder AddOpaResponseHandler(this IRequestExecutorBuilder builder, + string policyPath, Func, ResponseBase> func) + { + return builder.AddOpaResponseHandler(policyPath, + f => new DelegatePolicyResultHandler(ctx => Task.FromResult(func(ctx)), f.GetRequiredService>())); + } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs index dd87d48ddf8..1b36fd8cf33 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs @@ -1,5 +1,6 @@ using HotChocolate.Resolvers; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace HotChocolate.AspNetCore.Authorization; @@ -24,9 +25,22 @@ public async ValueTask AuthorizeAsync( IOpaService? opaService = context.Services.GetRequiredService(); IOpaDecision? opaDecision = context.Services.GetRequiredService(); IOpaQueryRequestFactory? factory = context.Services.GetRequiredService(); + IOptions options = context.Services.GetRequiredService>(); - 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; } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaJsonExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaJsonExtensions.cs index 6b229d160c6..f87cc829982 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaJsonExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaJsonExtensions.cs @@ -17,12 +17,12 @@ internal static HttpContent ToJsonContent(this QueryRequest request, JsonSeriali #endif } - internal static async Task QueryResponseFromJsonAsync(this HttpContent content, JsonSerializerOptions options, CancellationToken token) + internal static async Task FromJsonAsync(this HttpContent content, JsonSerializerOptions options, CancellationToken token) { #if NET6_0 - return await content.ReadFromJsonAsync(options, token).ConfigureAwait(false); + return await content.ReadFromJsonAsync(options, token).ConfigureAwait(false); #else - return await JsonSerializer.DeserializeAsync(await content.ReadAsStreamAsync().ConfigureAwait(false), options, token).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(await content.ReadAsStreamAsync().ConfigureAwait(false), options, token).ConfigureAwait(false); #endif } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs index 68d27811d90..ed65d93e710 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs @@ -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 PolicyResultHandlers { get; } = new(); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs index 21d225bb919..11c633e28c0 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs @@ -1,4 +1,4 @@ -using System.Net; +using Microsoft.Extensions.Options; namespace HotChocolate.AspNetCore.Authorization; @@ -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 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 QueryAsync(string policyPath, QueryRequest request, CancellationToken token) + public async Task 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; } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DefaultPolicyResultHandler.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DefaultPolicyResultHandler.cs new file mode 100644 index 00000000000..887fdf75c03 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DefaultPolicyResultHandler.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; + +namespace HotChocolate.AspNetCore.Authorization; + +public class DefaultPolicyResultHandler : PolicyResultHandlerBase, ResponseBase> +{ + public DefaultPolicyResultHandler(IOptions options) : base(options) { } + protected override Task ProcessAsync(PolicyResultContext> context) + { + return Task.FromResult(context.Result switch + { + { Result: null } when context.PolicyPath.Equals(string.Empty) => NoDefaultPolicy.Response, + { Result: null } => PolicyNotFound.Response, + _ => new QueryResponse { Result = false } + }); + } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DelegatePolicyResultHandler.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DelegatePolicyResultHandler.cs new file mode 100644 index 00000000000..98a6097e209 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/DelegatePolicyResultHandler.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Options; + +namespace HotChocolate.AspNetCore.Authorization; + +public class DelegatePolicyResultHandler : PolicyResultHandlerBase + where TOutput : ResponseBase +{ + private readonly Func, Task> _process; + public DelegatePolicyResultHandler(Func, Task> process, IOptions options) : base(options) => _process = process; + protected override Task ProcessAsync(PolicyResultContext context) => _process(context); +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/IPolicyResultHandler.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/IPolicyResultHandler.cs new file mode 100644 index 00000000000..778dad55cd4 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/IPolicyResultHandler.cs @@ -0,0 +1,8 @@ +using HotChocolate.Resolvers; + +namespace HotChocolate.AspNetCore.Authorization; + +public interface IPolicyResultHandler +{ + Task HandleAsync(string policyPath, HttpResponseMessage response, IMiddlewareContext context); +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultContext.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultContext.cs new file mode 100644 index 00000000000..972ac0cc901 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultContext.cs @@ -0,0 +1,16 @@ +using HotChocolate.Resolvers; + +namespace HotChocolate.AspNetCore.Authorization; + +public class PolicyResultContext +{ + 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; } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultHandlerBase.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultHandlerBase.cs new file mode 100644 index 00000000000..52056665174 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/PolicyResultHandlerBase.cs @@ -0,0 +1,21 @@ +using HotChocolate.Resolvers; +using Microsoft.Extensions.Options; + +namespace HotChocolate.AspNetCore.Authorization; + +public abstract class PolicyResultHandlerBase : IPolicyResultHandler + where TOutput : ResponseBase +{ + private readonly IOptions _options; + protected PolicyResultHandlerBase(IOptions options) => _options = options; + protected abstract Task ProcessAsync(PolicyResultContext context); + + public async Task HandleAsync(string policyPath, HttpResponseMessage response, + IMiddlewareContext context) + { + QueryResponse responseObj = await response.Content + .FromJsonAsync>(_options.Value.JsonSerializerOptions, context.RequestAborted) + .ConfigureAwait(false); + return await ProcessAsync(new PolicyResultContext(policyPath, responseObj.Result, context)); + } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/QueryResponse.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/QueryResponse.cs similarity index 78% rename from src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/QueryResponse.cs rename to src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/QueryResponse.cs index 021653e2dec..89bb8c2dd51 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/QueryResponse.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Result/QueryResponse.cs @@ -5,11 +5,11 @@ namespace HotChocolate.AspNetCore.Authorization; public abstract class ResponseBase { } -public sealed class QueryResponse : ResponseBase +public sealed class QueryResponse : 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 diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/IOpaService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/IOpaService.cs index 3866c21411c..1adbf6de5d5 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/IOpaService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Types/IOpaService.cs @@ -2,5 +2,5 @@ namespace HotChocolate.AspNetCore.Authorization; public interface IOpaService { - Task QueryAsync(string policyPath, QueryRequest request, CancellationToken token); + Task QueryAsync(string policyPath, QueryRequest request, CancellationToken token); } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs index 0c0ec3aa88a..72b8eb4ce23 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs @@ -38,7 +38,15 @@ private Action CreateSchema() => .AddOpaAuthorizationHandler((c, o) => { o.ConnectionTimeout = TimeSpan.FromSeconds(60); - }); + }).AddOpaResponseHandler("graphql/authz/has_age_defined", + context => + { + return context.Result switch + { + { Allow: true } => new QueryResponse { Result = true }, + _ => new QueryResponse { Result = false } + }; + }); public IEnumerator GetEnumerator() { diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs index fea19414316..de6b07bb85e 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs @@ -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 { 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 @@ -38,6 +51,15 @@ private Action CreateSchema() => { o.ConnectionTimeout = TimeSpan.FromSeconds(60); }) + .AddOpaResponseHandler("graphql/authz/has_age_defined", + context => + { + return context.Result switch + { + { Allow: true } => new QueryResponse { Result = true }, + _ => new QueryResponse { Result = false } + }; + }) .UseField(_schemaMiddleware); private Action CreateSchemaWithBuilder() => @@ -48,6 +70,15 @@ private Action CreateSchemaWithBuilder() => { o.ConnectionTimeout = TimeSpan.FromSeconds(60); }) + .AddOpaResponseHandler("graphql/authz/has_age_defined", + context => + { + return context.Result switch + { + { Allow: true } => new QueryResponse { Result = true }, + _ => new QueryResponse { Result = false } + }; + }) .UseField(_schemaMiddleware); public IEnumerator GetEnumerator() diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Policies/has_age_defined.rego b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Policies/has_age_defined.rego index 7452dba710c..b66d748932c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Policies/has_age_defined.rego +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Policies/has_age_defined.rego @@ -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 }