diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 57c322aab03..395dcd66ae4 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -39,7 +39,6 @@ - @@ -59,6 +58,7 @@ + diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Extensions/HotChocolateAuthorizeRequestExecutorBuilder.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Extensions/HotChocolateAuthorizeRequestExecutorBuilder.cs index a802bb5e6a2..7bdd2ecfdc4 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Extensions/HotChocolateAuthorizeRequestExecutorBuilder.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Extensions/HotChocolateAuthorizeRequestExecutorBuilder.cs @@ -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; @@ -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( @@ -55,6 +55,15 @@ public static IRequestExecutorBuilder AddOpaAuthorization( return builder; } + /// + /// Adds result handler to the OPA options. + /// + /// Instance of . + /// The path to the policy. + /// The PDP decision result. + /// + /// Returns the for chaining in more configurations. + /// public static IRequestExecutorBuilder AddOpaResultHandler( this IRequestExecutorBuilder builder, string policyPath, @@ -66,4 +75,27 @@ public static IRequestExecutorBuilder AddOpaResultHandler( (o, _) => o.PolicyResultHandlers.Add(policyPath, parseResult)); return builder; } + + /// + /// Adds OPA query request extensions handler to the OPA options. + /// + /// Instance of . + /// The path to the policy. + /// The handler for the extensions associated with the Policy. + /// + /// + /// Returns the for chaining in more configurations. + /// + public static IRequestExecutorBuilder AddOpaQueryRequestExtensionsHandler( + this IRequestExecutorBuilder builder, + string policyPath, + OpaQueryRequestExtensionsHandler opaQueryRequestExtensionsHandler) + { + builder.Services + .AddOptions() + .Configure( + (o, _) => o.OpaQueryRequestExtensionsHandlers.Add(policyPath, opaQueryRequestExtensionsHandler)); + return builder; + } + } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/IOpaService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/IOpaService.cs index 4218692373b..9840c7af544 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/IOpaService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/IOpaService.cs @@ -1,7 +1,17 @@ namespace HotChocolate.AspNetCore.Authorization; +/// +/// The OPA service interface communicating with OPA server. +/// public interface IOpaService { + /// + /// The method used to query OPA PDP decision based on the request input. + /// + /// The string parameter representing path of the evaluating policy. + /// The instance . + /// Cancellation token. + /// Task QueryAsync( string policyPath, OpaQueryRequest request, diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs index d2b7c17c779..6ca37a71ed4 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandler.cs @@ -19,43 +19,36 @@ public OpaAuthorizationHandler( IOpaQueryRequestFactory requestFactory, IOptions 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; } - /// - /// Authorize current directive using OPA (Open Policy Agent). - /// - /// The current middleware context. - /// The authorization directive. - /// The cancellation token. - /// - /// Returns a value indicating if the current session is authorized to - /// access the resolver data. - /// + /// public async ValueTask 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); } + /// public async ValueTask AuthorizeAsync( AuthorizationContext context, IReadOnlyList directives, + CancellationToken cancellationToken = default) + { + return await AuthorizeAsync( + new OpaAuthorizationHandlerContext(context), directives, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask AuthorizeAsync( + OpaAuthorizationHandlerContext context, + IReadOnlyList directives, CancellationToken ct) { if (directives.Count == 1) @@ -89,12 +82,12 @@ public async ValueTask AuthorizeAsync( return AuthorizeResult.Allowed; static async Task ExecuteAsync( - AuthorizationContext context, + OpaAuthorizationHandlerContext context, IEnumerator 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); @@ -110,7 +103,7 @@ static async Task ExecuteAsync( } private async ValueTask AuthorizeAsync( - AuthorizationContext context, + OpaAuthorizationHandlerContext context, AuthorizeDirective directive, CancellationToken ct) { @@ -122,7 +115,7 @@ private async ValueTask AuthorizeAsync( } private delegate ValueTask Authorize( - AuthorizationContext context, + OpaAuthorizationHandlerContext context, AuthorizeDirective directive, CancellationToken ct); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandlerContext.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandlerContext.cs new file mode 100644 index 00000000000..59811e92efb --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaAuthorizationHandlerContext.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.AspNetCore.Authorization; + +/// +/// The OPA authorization handler context. +/// +public class OpaAuthorizationHandlerContext +{ + /// + /// The constructor. + /// + /// Either IMiddlewareContext or AuthorizationContext depending on the phase of + /// a rule execution. + /// + public OpaAuthorizationHandlerContext(object resource) + { + ArgumentNullException.ThrowIfNull(resource); + + Resource = resource; + } + + /// + /// The object representing instance of either IMiddlewareContext or AuthorizationContext. + /// + public object Resource { get; } +} diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs index 955487008f6..a0b8af88034 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaOptions.cs @@ -5,6 +5,9 @@ namespace HotChocolate.AspNetCore.Authorization; +/// +/// The class representing OPA configuration options. +/// public sealed class OpaOptions { private readonly ConcurrentDictionary _handlerKeysRegexes = new(); @@ -17,14 +20,34 @@ public sealed class OpaOptions public Dictionary PolicyResultHandlers { get; } = new(); + public Dictionary 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(string policyPath, Dictionary handlers) + { + var maybeHandler = handlers.SingleOrDefault( k => { var regex = _handlerKeysRegexes.GetOrAdd( @@ -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); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs index 578763b186d..7576f3e1c67 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/OpaService.cs @@ -11,36 +11,29 @@ internal sealed class OpaService : IOpaService public OpaService(HttpClient httpClient, IOptions 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 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); } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/QueryResponse.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/QueryResponse.cs index 18f2a00e917..2c770dbf897 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/QueryResponse.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/QueryResponse.cs @@ -2,24 +2,20 @@ namespace HotChocolate.AspNetCore.Authorization; -public sealed class OpaQueryResponse : IDisposable +/// +/// The class representing OPA query response. +/// +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() - => _root.TryGetProperty("decisionId", out var value) + => _root.TryGetProperty("result", out var value) ? value.Deserialize() : default; @@ -28,5 +24,5 @@ public bool IsEmpty _root.EnumerateObject().Any(); public void Dispose() - => _document.Dispose(); + => document.Dispose(); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/DefaultQueryRequestFactory.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/DefaultQueryRequestFactory.cs index 77e6b349659..1decfec9c1f 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/DefaultQueryRequestFactory.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/DefaultQueryRequestFactory.cs @@ -1,23 +1,39 @@ using HotChocolate.Authorization; +using HotChocolate.Resolvers; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; namespace HotChocolate.AspNetCore.Authorization; -public sealed class DefaultQueryRequestFactory : IOpaQueryRequestFactory +/// +/// Default implementation of . +/// +internal sealed class DefaultQueryRequestFactory : IOpaQueryRequestFactory { - public OpaQueryRequest CreateRequest(AuthorizationContext context, AuthorizeDirective directive) + private readonly OpaOptions _options; + + public DefaultQueryRequestFactory(IOptions options) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } + ArgumentNullException.ThrowIfNull(options); - if (directive is null) - { - throw new ArgumentNullException(nameof(directive)); - } + _options = options.Value; + } - var httpContext = (HttpContext)context.ContextData[nameof(HttpContext)]!; + /// + public OpaQueryRequest CreateRequest(OpaAuthorizationHandlerContext context, AuthorizeDirective directive) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(directive); + + var httpContext = + context.Resource switch + { + IMiddlewareContext middlewareContext => + (HttpContext)middlewareContext.ContextData[nameof(HttpContext)]!, + AuthorizationContext authorizationContext => + (HttpContext)authorizationContext.ContextData[nameof(HttpContext)]!, + _ => throw new ArgumentException("Invalid context data.") + }; var connection = httpContext.Connection; var policy = new Policy( @@ -40,6 +56,13 @@ public OpaQueryRequest CreateRequest(AuthorizationContext context, AuthorizeDire connection.LocalIpAddress!.ToString(), connection.LocalPort); - return new OpaQueryRequest(policy, originalRequest, source, destination); + object? extensions = null; + if (directive.Policy is not null && + _options.GetOpaQueryRequestExtensionsHandler(directive.Policy) is { } extensionsHandler) + { + extensions = extensionsHandler(context); + } + + return new OpaQueryRequest(policy, originalRequest, source, destination, extensions); } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IOpaQueryRequestFactory.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IOpaQueryRequestFactory.cs index 92a35f2c95c..0ed248eb250 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IOpaQueryRequestFactory.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IOpaQueryRequestFactory.cs @@ -2,9 +2,20 @@ namespace HotChocolate.AspNetCore.Authorization; +/// +/// The OPA query request factory interface. +/// public interface IOpaQueryRequestFactory { + /// + /// Creates . + /// + /// The OPA authorization handler context. + /// Depending on the query execution phase the context's Resource is different + /// see for details. + /// + /// The OPA authorization directive. See . OpaQueryRequest CreateRequest( - AuthorizationContext context, + OpaAuthorizationHandlerContext context, AuthorizeDirective directive); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IPAndPort.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IPAndPort.cs index 55cfd1f9611..d19ea2c1f04 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IPAndPort.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/IPAndPort.cs @@ -1,21 +1,34 @@ namespace HotChocolate.AspNetCore.Authorization; // ReSharper disable once InconsistentNaming +/// +/// A structure to store information about IP address and port in OPA query request input. +/// public sealed class IPAndPort { + /// + /// Public constructor. + /// + /// IP address. + /// Port number. + /// Thrown if port values is out of range: [0:65535]. public IPAndPort(string ipAddress, int port = 0) { - if (port <= 0) - { - throw new ArgumentOutOfRangeException(nameof(port)); - } + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, (1 << 16) - 1); IPAddress = ipAddress ?? throw new ArgumentNullException(nameof(ipAddress)); Port = port; } // ReSharper disable once InconsistentNaming + /// + /// IP address string. + /// public string IPAddress { get; } + /// + /// Port value. + /// public int Port { get; } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OpaQueryRequest.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OpaQueryRequest.cs index 579af63a72a..2108e2920d9 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OpaQueryRequest.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OpaQueryRequest.cs @@ -1,7 +1,22 @@ namespace HotChocolate.AspNetCore.Authorization; +/// +/// A structure representing OPA query request input. +/// public sealed class OpaQueryRequest { + /// + /// The constructor. + /// + /// The instance of . + /// The instance of . + /// Stores information about the original GraphQl request. + /// The instance . + /// Stores information about the source address of the original request + /// The instance . + /// Stores information about the destination address of the original request. + /// The instance of object that provides extended information for the OPA query request. + /// Usually is represented as a dictionary. public OpaQueryRequest( Policy policy, OriginalRequest request, @@ -9,55 +24,63 @@ public OpaQueryRequest( IPAndPort destination, object? extensions = null) { - if (policy is null) - { - throw new ArgumentNullException(nameof(policy)); - } - - if (request is null) - { - throw new ArgumentNullException(nameof(request)); - } - - if (source is null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (destination is null) - { - throw new ArgumentNullException(nameof(destination)); - } + ArgumentNullException.ThrowIfNull(policy); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destination); Input = new OpaQueryRequestInput(policy, request, source, destination, extensions); } + /// + /// The property to get the instance of . + /// public OpaQueryRequestInput Input { get; } - public sealed class OpaQueryRequestInput + /// + /// The class representing an input that will be sent to the OPA server. + /// + /// The instance of . + /// The instance of . + /// Stores information about the original GraphQl request. + /// The instance . + /// Stores information about the source address of the original request + /// The instance . + /// Stores information about the destination address of the original request. + /// The instance of object the provides extended information for the OPA query request. + /// Usually is represented as a dictionary. + public sealed class OpaQueryRequestInput( + Policy policy, + OriginalRequest request, + IPAndPort source, + IPAndPort destination, + object? extensions) { - public OpaQueryRequestInput( - Policy policy, - OriginalRequest request, - IPAndPort source, - IPAndPort destination, - object? extensions) - { - Policy = policy; - Request = request; - Source = source; - Destination = destination; - Extensions = extensions; - } - - public Policy Policy { get; } + /// + /// The property to get instance of . + /// + public Policy Policy { get; } = policy; - public OriginalRequest Request { get; } + /// + /// The property to get instance of . + /// + public OriginalRequest Request { get; } = request; - public IPAndPort Source { get; } + /// + /// The property to get instance of representing + /// the original request source IP address and port. + /// + public IPAndPort Source { get; } = source; - public IPAndPort Destination { get; } + /// + /// The property to get instance of representing + /// the original request destination IP address and port. + /// + public IPAndPort Destination { get; } = destination; - public object? Extensions { get; } + /// + /// The property to get instance of object representing OPA query input extension data. + /// + public object? Extensions { get; } = extensions; } } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OriginalRequest.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OriginalRequest.cs index 5d31d0c67e4..f86f96ebf0f 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OriginalRequest.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/OriginalRequest.cs @@ -3,33 +3,44 @@ namespace HotChocolate.AspNetCore.Authorization; -public sealed class OriginalRequest +/// +/// The class representing the information about the original GraphQl request. +/// +public sealed class OriginalRequest( + IHeaderDictionary headers, + string host, + string method, + string path, + IEnumerable>? query, + string scheme) { - public OriginalRequest( - IHeaderDictionary headers, - string host, - string method, - string path, - IEnumerable>? query, - string scheme) - { - Headers = headers ?? throw new ArgumentNullException(nameof(headers)); - Host = host ?? throw new ArgumentNullException(nameof(host)); - Method = method ?? throw new ArgumentNullException(nameof(method)); - Path = path ?? throw new ArgumentNullException(nameof(path)); - Query = query; - Scheme = scheme ?? throw new ArgumentNullException(nameof(scheme)); - } - - public IHeaderDictionary Headers { get; } - - public string Host { get; } - - public string Method { get; } - - public string Path { get; } - - public IEnumerable>? Query { get; } - - public string Scheme { get; } + /// + /// Original request headers. + /// + public IHeaderDictionary Headers { get; } = headers ?? throw new ArgumentNullException(nameof(headers)); + + /// + /// Information about the host sent request. + /// + public string Host { get; } = host ?? throw new ArgumentNullException(nameof(host)); + + /// + /// The HTTP request method. + /// + public string Method { get; } = method ?? throw new ArgumentNullException(nameof(method)); + + /// + /// Path of the request. + /// + public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path)); + + /// + /// The query of the request. + /// + public IEnumerable>? Query { get; } = query; + + /// + /// GraphQl schema of the request. + /// + public string Scheme { get; } = scheme ?? throw new ArgumentNullException(nameof(scheme)); } diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/Policy.cs b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/Policy.cs index 3f88bd1fc85..0c7e689252d 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/Policy.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore.Authorization.Opa/Request/Policy.cs @@ -1,14 +1,17 @@ namespace HotChocolate.AspNetCore.Authorization; -public sealed class Policy +/// +/// The structure representing information about an OPA policy to be evaluated by the OPA server. +/// +public sealed class Policy(string path, IReadOnlyList roles) { - public Policy(string path, IReadOnlyList roles) - { - Path = path ?? throw new ArgumentNullException(nameof(path)); - Roles = roles ?? throw new ArgumentNullException(nameof(roles)); - } + /// + /// Path of the policy. Contains the path string appended to the OPA base address. + /// + public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path)); - public string Path { get; } - - public IReadOnlyList Roles { get; } + /// + /// Roles associated with the user to evaluate by the policy rule. + /// + public IReadOnlyList Roles { get; } = roles ?? throw new ArgumentNullException(nameof(roles)); } 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 9f9317f971e..d621181de44 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationAttributeTestData.cs @@ -13,7 +13,7 @@ public class Query public string GetDefault() => "foo"; [Authorize(Policy = Policies.HasDefinedAge)] - public string GetAge() => "foo"; + public string? GetAge() => "foo"; [Authorize(Roles = ["a",])] public string GetRoles() => "foo"; @@ -40,11 +40,13 @@ private Action CreateSchema() => }) .AddOpaResultHandler( Policies.HasDefinedAge, - response => response.GetResult() switch - { - { Allow: true, } => AuthorizeResult.Allowed, - _ => AuthorizeResult.NotAllowed, - }); + response => response.DecisionId is null + ? AuthorizeResult.NotAllowed + : response.GetResult() switch + { + { Allow: true, } => AuthorizeResult.Allowed, + _ => AuthorizeResult.NotAllowed, + }); 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 d762e9ada9c..04e37b255ce 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTestData.cs @@ -37,11 +37,13 @@ private Action CreateSchema() => }) .AddOpaResultHandler( Policies.HasDefinedAge, - response => response.GetResult() switch - { - { Allow: true, } => AuthorizeResult.Allowed, - _ => AuthorizeResult.NotAllowed, - }) + response => response.DecisionId is null + ? AuthorizeResult.NotAllowed + : response.GetResult() switch + { + { Allow: true, } => AuthorizeResult.Allowed, + _ => AuthorizeResult.NotAllowed, + }) .UseField(_schemaMiddleware); private Action CreateSchemaWithBuilder() => @@ -54,11 +56,13 @@ private Action CreateSchemaWithBuilder() => }) .AddOpaResultHandler( Policies.HasDefinedAge, - response => response.GetResult() switch - { - { Allow: true, } => AuthorizeResult.Allowed, - _ => AuthorizeResult.NotAllowed, - }) + response => response.DecisionId is null + ? AuthorizeResult.NotAllowed + : response.GetResult() switch + { + { Allow: true, } => AuthorizeResult.Allowed, + _ => AuthorizeResult.NotAllowed, + }) .UseField(_schemaMiddleware); public IEnumerator GetEnumerator() diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTests.cs index 86a58554872..d9a3568ba20 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTests.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/AuthorizationTests.cs @@ -1,17 +1,18 @@ using System.Net; using HotChocolate.AspNetCore.Tests.Utilities; +using HotChocolate.Authorization; using HotChocolate.Execution.Configuration; +using HotChocolate.Resolvers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Opa.Native; namespace HotChocolate.AspNetCore.Authorization; public class AuthorizationTests : ServerTestBase, IAsyncLifetime { - private OpaHandle? _opaHandle; + private OpaProcess? _opaHandle; public AuthorizationTests(TestServerFactory serverFactory) : base(serverFactory) @@ -29,7 +30,7 @@ private static void SetUpHttpContext(HttpContext context) public async Task InitializeAsync() => _opaHandle = await OpaProcess.StartServerAsync(); - [Theory(Skip = "The local server needs to be packaged with squadron")] + [Theory] [ClassData(typeof(AuthorizationTestData))] [ClassData(typeof(AuthorizationAttributeTestData))] public async Task Policy_NotFound(Action configure) @@ -51,7 +52,7 @@ public async Task Policy_NotFound(Action configure) result.MatchSnapshot(); } - [Theory(Skip = "The local server needs to be packaged with squadron")] + [Theory] [ClassData(typeof(AuthorizationTestData))] [ClassData(typeof(AuthorizationAttributeTestData))] public async Task Policy_NotAuthorized(Action configure) @@ -70,7 +71,7 @@ public async Task Policy_NotAuthorized(Action configure "iwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; })); - var hasAgeDefinedPolicy = await File.ReadAllTextAsync("policies/has_age_defined.rego"); + var hasAgeDefinedPolicy = await File.ReadAllTextAsync("Policies/has_age_defined.rego"); using var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:8181"), }; var putPolicyResponse = await client.PutAsync( @@ -86,7 +87,7 @@ public async Task Policy_NotAuthorized(Action configure result.MatchSnapshot(); } - [Theory(Skip = "The local server needs to be packaged with squadron")] + [Theory] [ClassData(typeof(AuthorizationTestData))] [ClassData(typeof(AuthorizationAttributeTestData))] public async Task Policy_Authorized(Action configure) @@ -106,7 +107,50 @@ public async Task Policy_Authorized(Action configure) "jXBglnxac"; })); - var hasAgeDefinedPolicy = await File.ReadAllTextAsync("policies/has_age_defined.rego"); + var hasAgeDefinedPolicy = await File.ReadAllTextAsync("Policies/has_age_defined.rego"); + using var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:8181"), }; + + var putPolicyResponse = await client.PutAsync( + "/v1/policies/has_age_defined", + new StringContent(hasAgeDefinedPolicy)); + putPolicyResponse.EnsureSuccessStatusCode(); + + // act + var result = await server.PostAsync(new ClientQueryRequest { Query = "{ age }", }); + + // assert + Assert.Equal(HttpStatusCode.OK, result.StatusCode); + result.MatchSnapshot(); + } + + [Theory] + [ClassData(typeof(AuthorizationTestData))] + [ClassData(typeof(AuthorizationAttributeTestData))] + public async Task Policy_Authorized_WithExtensions(Action configure) + { + // arrange + var server = CreateTestServer( + builder => + { + configure(builder); + builder.Services.AddAuthorization(); + builder.AddOpaQueryRequestExtensionsHandler(Policies.HasDefinedAge, + context => context.Resource is IMiddlewareContext or AuthorizationContext + ? new Dictionary { { "secret", "secret" } } + : null); + }, + SetUpHttpContext + (Action)(c => + { + // The token is the same but swapped alg and typ, + // as a result Base64 representation is not the one as expected by Rego rule + // See policies/has_age_defined.rego file for details + c.Request.Headers["Authorization"] = + "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." + + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJiaXJ0aGRhdGUiOiIxNy0x" + + "MS0yMDAwIn0.01Hb6X-HXl9ASf3X82Mt63RMpZ4SVJZT9hTI2dYet-k"; + })); + + var hasAgeDefinedPolicy = await File.ReadAllTextAsync("Policies/has_age_defined.rego"); using var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:8181"), }; var putPolicyResponse = await client.PutAsync( diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Claims.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Claims.cs index 585c4d11d33..d667b63e708 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Claims.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/Claims.cs @@ -1,12 +1,18 @@ +using System.Text.Json.Serialization; + namespace HotChocolate.AspNetCore.Authorization; public class Claims { + [JsonPropertyName("birthdate")] public string Birthdate { get; set; } = default!; + [JsonPropertyName("iat")] public long Iat { get; set; } + [JsonPropertyName("name")] public string Name { get; set; } = default!; + [JsonPropertyName("sub")] public string Sub { get; set; } = default!; } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HasAgeDefinedResponse.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HasAgeDefinedResponse.cs index 1f26c7b92a0..45bf4ee62b8 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HasAgeDefinedResponse.cs +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HasAgeDefinedResponse.cs @@ -1,8 +1,12 @@ +using System.Text.Json.Serialization; + namespace HotChocolate.AspNetCore.Authorization; public class HasAgeDefinedResponse { + [JsonPropertyName("allow")] public bool Allow { get; set; } = default!; + [JsonPropertyName("claims")] public Claims Claims { get; set; } = default!; } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HotChocolate.AspNetCore.Authorization.Opa.Tests.csproj b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HotChocolate.AspNetCore.Authorization.Opa.Tests.csproj index 6d2e1e41a02..02a418b5774 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HotChocolate.AspNetCore.Authorization.Opa.Tests.csproj +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/HotChocolate.AspNetCore.Authorization.Opa.Tests.csproj @@ -13,11 +13,7 @@ - - - - - + Always @@ -26,4 +22,8 @@ + + + + diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/OpaProcess/OpaProcess.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/OpaProcess/OpaProcess.cs new file mode 100644 index 00000000000..ab301dcba34 --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/OpaProcess/OpaProcess.cs @@ -0,0 +1,36 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace HotChocolate.AspNetCore.Authorization; + +public class OpaProcess +{ + private readonly IContainer _container; + + public OpaProcess(IContainer container) + { + _container = container; + } + public static async Task StartServerAsync() + { + var opaProcess = new OpaProcess(new ContainerBuilder() + .WithImage("openpolicyagent/opa") + .WithPortBinding(8181, 8181) + .WithCommand( + "run", "--server", + "--addr", ":8181", + "--log-level", "debug", + "--set", "decision_logs.console=true") + // Wait until the HTTP endpoint of the container is available. + .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(8181))) + // Build the container configuration. + .Build()); + await opaProcess._container.StartAsync(); + return opaProcess; + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } +} 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 70e0f0e5319..82119d20d7c 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,14 +7,24 @@ import input.request default allow = { "allow" : false } -valid_jwt = [is_valid, claims] { - token := replace(request.headers["Authorization"], "Bearer ", "") +valid_jwt := [is_valid, claims] if { + token := replace(request.headers["Authorization"][0], "Bearer ", "") claims := io.jwt.decode(token)[1] - is_valid := startswith(token, "eyJhbG") # a toy validation + + exts := object.get(input, "extensions", {}) + secret := object.get(exts, "secret", "") + + is_valid := is_valid_token_or_secret(token, secret) is_valid } -allow = {"allow": is_valid, "claims": claims } { +is_valid_token_or_secret(token, secret) if { + # a toy validation + checks := { startswith(token, "eyJhbG"), secret == "secret" } # imitate OR + checks[true] +} + +allow := {"allow": is_valid, "claims": claims } if { [is_valid, claims] := valid_jwt claims.birthdate } diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized.snap index 3d4b82771e9..f1bcfb78bf9 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized.snap +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized.snap @@ -1,5 +1,5 @@ { - "ContentType": "application/json; charset=utf-8", + "ContentType": "application/graphql-response+json; charset=utf-8", "StatusCode": "OK", "Data": { "age": "foo" diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized_WithExtensions.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized_WithExtensions.snap new file mode 100644 index 00000000000..f1bcfb78bf9 --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_Authorized_WithExtensions.snap @@ -0,0 +1,9 @@ +{ + "ContentType": "application/graphql-response+json; charset=utf-8", + "StatusCode": "OK", + "Data": { + "age": "foo" + }, + "Errors": null, + "Extensions": null +} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotAuthorized.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotAuthorized.snap index 39a2261460f..7290ee0650c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotAuthorized.snap +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotAuthorized.snap @@ -1,5 +1,5 @@ { - "ContentType": "application/json; charset=utf-8", + "ContentType": "application/graphql-response+json; charset=utf-8", "StatusCode": "OK", "Data": { "age": null diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotFound.snap b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotFound.snap index 7fb288f4848..7290ee0650c 100644 --- a/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotFound.snap +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Authorization.Opa.Tests/__snapshots__/AuthorizationTests.Policy_NotFound.snap @@ -1,12 +1,12 @@ { - "ContentType": "application/json; charset=utf-8", + "ContentType": "application/graphql-response+json; charset=utf-8", "StatusCode": "OK", "Data": { "age": null }, "Errors": [ { - "message": "The `graphql/authz/has_age_defined/allow` authorization policy does not exist.", + "message": "The current user is not authorized to access this resource.", "locations": [ { "line": 1, @@ -17,7 +17,7 @@ "age" ], "extensions": { - "code": "AUTH_POLICY_NOT_FOUND" + "code": "AUTH_NOT_AUTHORIZED" } } ],