Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Opa Authorization Handler for AspNetCore #5146

Merged
merged 19 commits into from
Oct 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/HotChocolate/AspNetCore/HotChocolate.AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Transport.Sock
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.AspNetCore.Tests.Utilities", "test\AspNetCore.Tests.Utilities\HotChocolate.AspNetCore.Tests.Utilities.csproj", "{8DC0428A-C7C6-4496-95AB-2612FA95BE9E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.AspNetCore.Authorization.Opa", "src\AspNetCore.Authorization.Opa\HotChocolate.AspNetCore.Authorization.Opa.csproj", "{C9FA286C-81FB-4563-86FC-0880ED34A3FA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.AspNetCore.Authorization.Opa.Tests", "test\AspNetCore.Authorization.Opa.Tests\HotChocolate.AspNetCore.Authorization.Opa.Tests.csproj", "{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -305,6 +309,30 @@ Global
{8DC0428A-C7C6-4496-95AB-2612FA95BE9E}.Release|x64.Build.0 = Release|Any CPU
{8DC0428A-C7C6-4496-95AB-2612FA95BE9E}.Release|x86.ActiveCfg = Release|Any CPU
{8DC0428A-C7C6-4496-95AB-2612FA95BE9E}.Release|x86.Build.0 = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|x64.ActiveCfg = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|x64.Build.0 = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|x86.ActiveCfg = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Debug|x86.Build.0 = Debug|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|Any CPU.Build.0 = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|x64.ActiveCfg = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|x64.Build.0 = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|x86.ActiveCfg = Release|Any CPU
{C9FA286C-81FB-4563-86FC-0880ED34A3FA}.Release|x86.Build.0 = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|x64.ActiveCfg = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|x64.Build.0 = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|x86.ActiveCfg = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Debug|x86.Build.0 = Debug|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|Any CPU.Build.0 = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|x64.ActiveCfg = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|x64.Build.0 = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|x86.ActiveCfg = Release|Any CPU
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -330,6 +358,8 @@ Global
{1284182A-3F75-4AF3-A1EE-7D7085C3545A} = {2E2070DF-95C2-48F2-A8DF-7FE3734817ED}
{D9401ED9-D6BB-49C7-A92E-E0714D04590F} = {936FF2E5-6576-4257-A7A3-F2093D44E6CD}
{8DC0428A-C7C6-4496-95AB-2612FA95BE9E} = {936FF2E5-6576-4257-A7A3-F2093D44E6CD}
{C9FA286C-81FB-4563-86FC-0880ED34A3FA} = {2E2070DF-95C2-48F2-A8DF-7FE3734817ED}
{4C914422-2CAF-4B14-B6D2-EC9D5929FB79} = {936FF2E5-6576-4257-A7A3-F2093D44E6CD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EAA92712-961A-4595-82AD-C031830477CC}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System;
using System.Linq;
using HotChocolate.Resolvers;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace HotChocolate.AspNetCore.Authorization;

public class DefaultQueryRequestFactory : IOpaQueryRequestFactory
{
public QueryRequest CreateRequest(IMiddlewareContext context, AuthorizeDirective directive)
{
IHttpContextAccessor? accessor = context.Services.GetService<IHttpContextAccessor>();
HttpContext? http = accessor.HttpContext;
ConnectionInfo? connection = http.Connection;

var request = new QueryRequest
{
Input = new Input
{
Policy =
new Policy
{
Path = directive.Policy ?? string.Empty,
Roles = directive.Roles is null ? Array.Empty<string>() : directive.Roles.ToArray()
},
Request = new OriginalRequest
{
Headers = http.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
Host = http.Request.Host.Value,
Method = http.Request.Method,
Path = http.Request.Path.Value,
Query = http.Request.Query,
Scheme = http.Request.Scheme
},
Source = new IPAndPort
{
IpAddress = connection.RemoteIpAddress.ToString(), Port = connection.RemotePort
},
Destination = new IPAndPort
{
IpAddress = connection.LocalIpAddress.ToString(), Port = connection.LocalPort
}
}
};
return request;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(LibraryTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Core\src\Types\HotChocolate.Types.csproj" />
<ProjectReference Include="..\AspNetCore.Authorization\HotChocolate.AspNetCore.Authorization.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System;
using HotChocolate.AspNetCore.Authorization;
using HotChocolate.Execution.Configuration;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using HotChocolate.Resolvers;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Provides extension methods for the GraphQL builder.
/// </summary>
public static class HotChocolateAuthorizeRequestExecutorBuilder
{
/// <summary>
/// Adds OPA authorization handler.
/// </summary>
/// <param name="builder">
/// The <see cref="IRequestExecutorBuilder"/>.
/// </param>
/// <param name="configure">
/// Configure <see cref="OpaOptions"/>.
/// </param>
/// <returns>
/// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
/// </returns>
public static IRequestExecutorBuilder AddOpaAuthorizationHandler(
this IRequestExecutorBuilder builder, Action<IConfiguration, OpaOptions>? configure = null)
{
builder.AddAuthorizationHandler<OpaAuthorizationHandler>();
builder.Services.AddSingleton<IOpaQueryRequestFactory, DefaultQueryRequestFactory>();
builder.Services.AddHttpClient<IOpaService, OpaService>((f, c) =>
{
IOptions<OpaOptions>? options = f.GetRequiredService<IOptions<OpaOptions>>();
c.BaseAddress = options.Value.BaseAddress;
c.Timeout = TimeSpan.FromMilliseconds(options.Value.TimeoutMs);
});

builder.Services.AddOptions<OpaOptions>().Configure<IServiceProvider>((o, f) =>
{
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
#if NET5_0_OR_GREATER
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
#else
IgnoreNullValues = true
#endif
};
jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, false));
o.JsonSerializerOptions = jsonOptions;
configure?.Invoke(f.GetRequiredService<IConfiguration>(), o);
});

return builder;
}

public static IRequestExecutorBuilder AddOpaResultHandler<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 AddOpaResultHandler<T>(this IRequestExecutorBuilder builder,
string policyPath, Func<PolicyResultContext<T>, Task<IOpaAuthzResult<T>>> makeDecisionFunc,
OnAfterResult<T>? onAllowed = null,
OnAfterResult<T>? onNotAllowed = null,
OnAfterResult<T>? onNotAuthenticated = null,
OnAfterResult<T>? onPolicyNotFound = null,
OnAfterResult<T>? onNoDefaultPolicy = null)
{
return builder.AddOpaResultHandler(policyPath,
f => new DelegatePolicyResultHandler<T>(makeDecisionFunc, f.GetRequiredService<IOptions<OpaOptions>>())
{
OnAllowedFunc = onAllowed,
OnNotAllowedFunc = onNotAllowed,
OnNotAuthenticatedFunc = onNotAuthenticated,
OnPolicyNotFoundFunc = onPolicyNotFound,
OnNoDefaultPolicyFunc = onNoDefaultPolicy
});
}

public static IRequestExecutorBuilder AddOpaResultHandler<T>(this IRequestExecutorBuilder builder,
string policyPath, Func<PolicyResultContext<T>, IOpaAuthzResult<T>> makeDecisionFunc,
OnAfterResult<T>? onAllowed = null,
OnAfterResult<T>? onNotAllowed = null,
OnAfterResult<T>? onNotAuthenticated = null,
OnAfterResult<T>? onPolicyNotFound = null,
OnAfterResult<T>? onNoDefaultPolicy = null
)
{
return builder.AddOpaResultHandler(policyPath, ctx => Task.FromResult(makeDecisionFunc(ctx)),
onAllowed, onNotAllowed, onNotAuthenticated, onPolicyNotFound, onNoDefaultPolicy);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using HotChocolate.Resolvers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

/// <summary>
/// An implementation that delegates authz to OPA (Open Policy Agent) REST API endpoint
/// </summary>
public class OpaAuthorizationHandler : IAuthorizationHandler
{
/// <summary>
/// Authorize current directive using OPA (Open Policy Agent).
/// </summary>
/// <param name="context">The current middleware context.</param>
/// <param name="directive">The authorization directive.</param>
/// <returns>
/// Returns a value indicating if the current session is authorized to
/// access the resolver data.
/// </returns>
public async ValueTask<AuthorizeResult> AuthorizeAsync(
IMiddlewareContext context,
AuthorizeDirective directive)
{
IOpaService? opaService = context.Services.GetRequiredService<IOpaService>();
IOpaQueryRequestFactory? factory = context.Services.GetRequiredService<IOpaQueryRequestFactory>();
IOptions<OpaOptions> options = context.Services.GetRequiredService<IOptions<OpaOptions>>();

var policyPath = directive.Policy ?? string.Empty;

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

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

return await options.Value.GetResultHandlerFor(policyPath).HandleAsync(policyPath, httpResponse, context)
.ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#if NET6_0
using System.Net.Http.Json;
#endif
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace HotChocolate.AspNetCore.Authorization;

internal static class OpaJsonExtensions
{
internal static HttpContent ToJsonContent(this QueryRequest request, JsonSerializerOptions options)
{
#if NET6_0
return JsonContent.Create(request, options: options);
#else
var body = JsonSerializer.Serialize(request, options);
return new StringContent(body, System.Text.Encoding.UTF8, "application/json");
#endif
}

internal static async Task<T?> FromJsonAsync<T>(this HttpContent content, JsonSerializerOptions options,
CancellationToken token)
{
#if NET6_0
return await content.ReadFromJsonAsync<T>(options, token).ConfigureAwait(false);
#else
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
@@ -0,0 +1,33 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace HotChocolate.AspNetCore.Authorization;

public sealed class OpaOptions
{
public Uri BaseAddress { get; set; } = new("http://127.0.0.1:8181/v1/data/");
public int TimeoutMs { get; set; } = 250;
public JsonSerializerOptions JsonSerializerOptions { get; set; } = new();
public Dictionary<string, IPolicyResultHandler> PolicyResultHandlers { get; } = new();
private readonly ConcurrentDictionary<string, Regex> _handlerKeysRegexes = new();

public IPolicyResultHandler GetResultHandlerFor(string policyPath)
{
if (PolicyResultHandlers.TryGetValue(policyPath, out IPolicyResultHandler? handler))
{
return handler;
}

KeyValuePair<string, IPolicyResultHandler> maybeHandler = PolicyResultHandlers.SingleOrDefault(k =>
{
Regex regex = _handlerKeysRegexes.GetOrAdd(k.Key,
new Regex(k.Key, RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.CultureInvariant));
return regex.IsMatch(policyPath);
});
return maybeHandler.Value ?? throw new InvalidOperationException($"No result handler found for policy: {policyPath}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;

namespace HotChocolate.AspNetCore.Authorization;

public sealed class OpaService : IOpaService
{
private readonly HttpClient _httpClient;
private readonly OpaOptions _options;

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

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);
response.EnsureSuccessStatusCode();
return response;
}
}
Loading