Skip to content
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
2 changes: 1 addition & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
<PackageVersion Include="Newtonsoft.Json" Version="13.0.2" />
<PackageVersion Include="NodaTime" Version="3.2.1" />
<PackageVersion Include="Npgsql" Version="8.0.4" />
<PackageVersion Include="Opa.Native" Version="0.41.0" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.1.0" />
<PackageVersion Include="ProjNET" Version="2.0.0" />
<PackageVersion Include="RabbitMQ.Client" Version="6.4.0" />
Expand All @@ -59,6 +58,7 @@
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.Reactive" Version="6.0.0" />
<PackageVersion Include="Testcontainers" Version="4.3.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.assert" Version="2.9.3" />
<PackageVersion Include="xunit.extensibility.core" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using HotChocolate.AspNetCore.Authorization;
using HotChocolate.Execution.Configuration;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;

namespace Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -42,7 +42,7 @@ public static IRequestExecutorBuilder AddOpaAuthorization(
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
jsonOptions.Converters.Add(
new JsonStringEnumConverter(
Expand All @@ -55,6 +55,15 @@ public static IRequestExecutorBuilder AddOpaAuthorization(
return builder;
}

/// <summary>
/// Adds result handler to the OPA options.
/// </summary>
/// <param name="builder">Instance of <see cref="IRequestExecutorBuilder"/>.</param>
/// <param name="policyPath">The path to the policy.</param>
/// <param name="parseResult">The PDP decision result.</param>
/// <returns>
/// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
/// </returns>
public static IRequestExecutorBuilder AddOpaResultHandler(
this IRequestExecutorBuilder builder,
string policyPath,
Expand All @@ -66,4 +75,27 @@ public static IRequestExecutorBuilder AddOpaResultHandler(
(o, _) => o.PolicyResultHandlers.Add(policyPath, parseResult));
return builder;
}

/// <summary>
/// Adds OPA query request extensions handler to the OPA options.
/// </summary>
/// <param name="builder">Instance of <see cref="IRequestExecutorBuilder"/>.</param>
/// <param name="policyPath">The path to the policy.</param>
/// <param name="opaQueryRequestExtensionsHandler">The handler for the extensions associated with the Policy.
/// </param>
/// <returns>
/// Returns the <see cref="IRequestExecutorBuilder"/> for chaining in more configurations.
/// </returns>
public static IRequestExecutorBuilder AddOpaQueryRequestExtensionsHandler(
this IRequestExecutorBuilder builder,
string policyPath,
OpaQueryRequestExtensionsHandler opaQueryRequestExtensionsHandler)
{
builder.Services
.AddOptions<OpaOptions>()
.Configure<IServiceProvider>(
(o, _) => o.OpaQueryRequestExtensionsHandlers.Add(policyPath, opaQueryRequestExtensionsHandler));
return builder;
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
namespace HotChocolate.AspNetCore.Authorization;

/// <summary>
/// The OPA service interface communicating with OPA server.
/// </summary>
public interface IOpaService
{
/// <summary>
/// The method used to query OPA PDP decision based on the request input.
/// </summary>
/// <param name="policyPath">The string parameter representing path of the evaluating policy.</param>
/// <param name="request">The instance <see cref="OpaQueryRequest"/>.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns></returns>
Task<OpaQueryResponse> QueryAsync(
string policyPath,
OpaQueryRequest request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,36 @@ public OpaAuthorizationHandler(
IOpaQueryRequestFactory requestFactory,
IOptions<OpaOptions> options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
ArgumentNullException.ThrowIfNull(options);

_opaService = opaService ?? throw new ArgumentNullException(nameof(opaService));
_requestFactory = requestFactory ?? throw new ArgumentNullException(nameof(requestFactory));
_options = options.Value;
}

/// <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>
/// <param name="ct">The cancellation token.</param>
/// <returns>
/// Returns a value indicating if the current session is authorized to
/// access the resolver data.
/// </returns>
/// <inheritdoc/>
public async ValueTask<AuthorizeResult> AuthorizeAsync(
IMiddlewareContext context,
AuthorizeDirective directive,
CancellationToken ct)
CancellationToken cancellationToken = default)
{
var authorizationContext = new AuthorizationContext(
context.Schema,
context.Services,
context.ContextData,
context.Operation.Document,
context.Operation.Id);
return await AuthorizeAsync(authorizationContext, directive, ct).ConfigureAwait(false);
return await AuthorizeAsync(
new OpaAuthorizationHandlerContext(context), [directive], cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc/>
public async ValueTask<AuthorizeResult> AuthorizeAsync(
AuthorizationContext context,
IReadOnlyList<AuthorizeDirective> directives,
CancellationToken cancellationToken = default)
{
return await AuthorizeAsync(
new OpaAuthorizationHandlerContext(context), directives, cancellationToken).ConfigureAwait(false);
}

private async ValueTask<AuthorizeResult> AuthorizeAsync(
OpaAuthorizationHandlerContext context,
IReadOnlyList<AuthorizeDirective> directives,
CancellationToken ct)
{
if (directives.Count == 1)
Expand Down Expand Up @@ -89,12 +82,12 @@ public async ValueTask<AuthorizeResult> AuthorizeAsync(
return AuthorizeResult.Allowed;

static async Task<AuthorizeResult> ExecuteAsync(
AuthorizationContext context,
OpaAuthorizationHandlerContext context,
IEnumerator<AuthorizeDirective> partition,
Authorize authorize,
CancellationToken ct)
{
while (partition.MoveNext() && partition.Current is not null)
while (partition.MoveNext())
{
var directive = partition.Current;
var result = await authorize(context, directive, ct).ConfigureAwait(false);
Expand All @@ -110,7 +103,7 @@ static async Task<AuthorizeResult> ExecuteAsync(
}

private async ValueTask<AuthorizeResult> AuthorizeAsync(
AuthorizationContext context,
OpaAuthorizationHandlerContext context,
AuthorizeDirective directive,
CancellationToken ct)
{
Expand All @@ -122,7 +115,7 @@ private async ValueTask<AuthorizeResult> AuthorizeAsync(
}

private delegate ValueTask<AuthorizeResult> Authorize(
AuthorizationContext context,
OpaAuthorizationHandlerContext context,
AuthorizeDirective directive,
CancellationToken ct);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace HotChocolate.AspNetCore.Authorization;

/// <summary>
/// The OPA authorization handler context.
/// </summary>
public class OpaAuthorizationHandlerContext
{
/// <summary>
/// The constructor.
/// </summary>
/// <param name="resource">Either IMiddlewareContext or AuthorizationContext depending on the phase of
/// a rule execution.
/// </param>
public OpaAuthorizationHandlerContext(object resource)
{
ArgumentNullException.ThrowIfNull(resource);

Resource = resource;
}

/// <summary>
/// The object representing instance of either IMiddlewareContext or AuthorizationContext.
/// </summary>
public object Resource { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

namespace HotChocolate.AspNetCore.Authorization;

/// <summary>
/// The class representing OPA configuration options.
/// </summary>
public sealed class OpaOptions
{
private readonly ConcurrentDictionary<string, Regex> _handlerKeysRegexes = new();
Expand All @@ -17,14 +20,34 @@ public sealed class OpaOptions

public Dictionary<string, ParseResult> PolicyResultHandlers { get; } = new();

public Dictionary<string, OpaQueryRequestExtensionsHandler> OpaQueryRequestExtensionsHandlers { get; } = new();

public OpaQueryRequestExtensionsHandler? GetOpaQueryRequestExtensionsHandler(string policyPath)
{
if (OpaQueryRequestExtensionsHandlers.Count == 0)
{
return null;
}
return OpaQueryRequestExtensionsHandlers.TryGetValue(policyPath, out var handler)
? handler :
FindHandler(policyPath, OpaQueryRequestExtensionsHandlers);
}

public ParseResult GetPolicyResultParser(string policyPath)
{
if (PolicyResultHandlers.TryGetValue(policyPath, out var handler))
{
return handler;
}
handler = FindHandler(policyPath, PolicyResultHandlers);
return handler ??
throw new InvalidOperationException(
$"No result handler found for policy: {policyPath}");
}

var maybeHandler = PolicyResultHandlers.SingleOrDefault(
private THandler? FindHandler<THandler>(string policyPath, Dictionary<string, THandler> handlers)
{
var maybeHandler = handlers.SingleOrDefault(
k =>
{
var regex = _handlerKeysRegexes.GetOrAdd(
Expand All @@ -33,14 +56,15 @@ public ParseResult GetPolicyResultParser(string policyPath)
k.Key,
RegexOptions.Compiled |
RegexOptions.Singleline |
RegexOptions.CultureInvariant));
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(500)));
return regex.IsMatch(policyPath);
});

return maybeHandler.Value ??
throw new InvalidOperationException(
$"No result handler found for policy: {policyPath}");
return maybeHandler.Value;
}
}

public delegate AuthorizeResult ParseResult(OpaQueryResponse response);

public delegate object? OpaQueryRequestExtensionsHandler(OpaAuthorizationHandlerContext context);
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,29 @@ internal sealed class OpaService : IOpaService

public OpaService(HttpClient httpClient, IOptions<OpaOptions> options)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
ArgumentNullException.ThrowIfNull(options);

_client = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
ArgumentNullException.ThrowIfNull(httpClient);

_client = httpClient;
_options = options.Value;
}

public async Task<OpaQueryResponse> QueryAsync(
string policyPath,
OpaQueryRequest request,
CancellationToken ct)
CancellationToken cancellationToken = default)
{
if (policyPath is null)
{
throw new ArgumentNullException(nameof(policyPath));
}
ArgumentNullException.ThrowIfNull(policyPath);

if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
ArgumentNullException.ThrowIfNull(request);

using var body = JsonContent.Create(request, options: _options.JsonSerializerOptions);

using var response = await _client.PostAsync(policyPath, body, ct).ConfigureAwait(false);
using var response = await _client.PostAsync(policyPath, body, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var document = await JsonDocument.ParseAsync(stream, default, ct);
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
return new OpaQueryResponse(document);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,20 @@

namespace HotChocolate.AspNetCore.Authorization;

public sealed class OpaQueryResponse : IDisposable
/// <summary>
/// The class representing OPA query response.
/// </summary>
public sealed class OpaQueryResponse(JsonDocument document) : IDisposable
{
private readonly JsonDocument _document;
private readonly JsonElement _root;

public OpaQueryResponse(JsonDocument document)
{
_document = document;
_root = document.RootElement;
}
private readonly JsonElement _root = document.RootElement;

public Guid? DecisionId
=> _root.TryGetProperty("decisionId", out var value)
=> _root.TryGetProperty("decision_id", out var value)
? value.GetGuid()
: null;

public T? GetResult<T>()
=> _root.TryGetProperty("decisionId", out var value)
=> _root.TryGetProperty("result", out var value)
? value.Deserialize<T>()
: default;

Expand All @@ -28,5 +24,5 @@ public bool IsEmpty
_root.EnumerateObject().Any();

public void Dispose()
=> _document.Dispose();
=> document.Dispose();
}
Loading