From 63ed2ae4d5d25e55307b5c618dfb9c5f37dde875 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 14 Apr 2023 12:55:31 +0800 Subject: [PATCH] v0.9 - Use batch checks in middleware, add Minimal API extensions (#9) --- .nuke/build.schema.json | 9 ++- README.md | 42 ++++++------- .../ComputedRelationshipAttribute.cs | 5 +- samples/Fga.Example.AspNetCore/Program.cs | 4 +- .../appsettings.Development.json | 2 +- .../Attributes/FgaRouteObjectAttribute.cs | 2 +- .../Authorization/FgaCheckDecorator.cs | 5 +- .../FineGrainedAuthorizationHandler.cs | 33 +++++++--- src/Fga.Net.AspNetCore/Authorization/Log.cs | 12 ++-- .../Authorization/MinimalApiExtensions.cs | 62 +++++++++++++++++++ .../Fga.Net.DependencyInjection.csproj | 2 +- tests/Fga.Net.Tests/Client/EndpointTests.cs | 7 +-- .../Fga.Net.Tests/Middleware/WebAppFixture.cs | 20 +++--- tests/Fga.Net.Tests/Unit/ExtensionTests.cs | 39 ++++++++++++ 14 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 src/Fga.Net.AspNetCore/Authorization/MinimalApiExtensions.cs diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index d57c969..cb8c21b 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -40,7 +40,8 @@ }, "NugetApiKey": { "type": "string", - "description": "Nuget Api Key" + "description": "Nuget Api Key", + "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, "Partition": { "type": "string", @@ -71,7 +72,8 @@ "Compile", "NugetPack", "NugetPush", - "Restore" + "Restore", + "Test" ] } }, @@ -89,7 +91,8 @@ "Compile", "NugetPack", "NugetPush", - "Restore" + "Restore", + "Test" ] } }, diff --git a/README.md b/README.md index 5580932..e3cf315 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ builder.Services.AddAuthorization(options => }); ``` -### Built-in Attributes +### Built-in Check Attributes `Fga.Net.AspNetCore` ships with a number of attributes that should cover the most common authorization sources for FGA checks: @@ -82,7 +82,18 @@ builder.Services.AddAuthorization(options => - `FgaQueryObjectAttribute` - Computes the Object via a value in the query string - `FgaRouteObjectAttribute` - Computes the Object via a value in the routes path -These attributes can be used in both minimal APIs & in your controller(s): +If you want to use these attributes, you need to configure how the user's identity is resolved from the `ClaimsPrincipal`. +The example below uses the Name, which is mapped to the User ID in a default Auth0 integration. + +```cs +builder.Services.AddOpenFgaMiddleware(config => +{ + //DSL v1.1 requires the user type to be included + config.UserIdentityResolver = principal => $"user:{principal.Identity!.Name!}"; +}); +``` + +These attributes can then be used in both minimal APIs & in your controller(s): ```cs // Traditional Controllers [ApiController] @@ -101,19 +112,10 @@ These attributes can be used in both minimal APIs & in your controller(s): // Minimal APIs app.MapGet("/viewminimal/{documentId}", (string documentId) => Task.FromResult(documentId)) .RequireAuthorization(FgaAuthorizationDefaults.PolicyKey) - .WithMetadata(new FgaRouteObjectAttribute("read", "document", "documentId")); -``` - - -If you want to use the built-in attributes, you need to configure how the user's identity is resolved from the `ClaimsPrincipal`. -The example below uses the Name, which should be suitable for most people (given the claim is mapped correctly). - -```cs -builder.Services.AddOpenFgaMiddleware(config => -{ - //DSL v1.1 requires the user type to be included - config.UserIdentityResolver = principal => $"user:{principal.Identity!.Name!}"; -}); + // Extensions methods are included for the built-in attributes + .WithFgaRouteCheck("read", "document", "documentId") + // You can apply custom attributes like so + .WithMetadata(new ComputedRelationshipAttribute("document", "documentId")); ``` ### Custom Attributes @@ -123,19 +125,17 @@ To do this, inherit from either `FgaBaseObjectAttribute`, which uses the configu For example, an equivalent to the [How To Integrate Within A Framework](https://docs.fga.dev/integration/framework) tutorial would be: ```cs -public class ComputedRelationshipAttribute : FgaAttribute +public class ComputedRelationshipAttribute : FgaBaseObjectAttribute { private readonly string _prefix; private readonly string _routeValue; - public EntityAuthorizationAttribute(string prefix, string routeValue) + + public ComputedRelationshipAttribute(string prefix, string routeValue) { _prefix = prefix; _routeValue = routeValue; } - - public override ValueTask GetUser(HttpContext context) - => ValueTask.FromResult(context.User.Identity!.Name!); - + public override ValueTask GetRelation(HttpContext context) => ValueTask.FromResult(context.Request.Method switch { diff --git a/samples/Fga.Example.AspNetCore/ComputedRelationshipAttribute.cs b/samples/Fga.Example.AspNetCore/ComputedRelationshipAttribute.cs index d6e53bf..5349d06 100644 --- a/samples/Fga.Example.AspNetCore/ComputedRelationshipAttribute.cs +++ b/samples/Fga.Example.AspNetCore/ComputedRelationshipAttribute.cs @@ -3,7 +3,7 @@ namespace Fga.Example.AspNetCore; //Computes the relationship based on the requests HTTP method. -public class ComputedRelationshipAttribute : FgaAttribute +public class ComputedRelationshipAttribute : FgaBaseObjectAttribute { private readonly string _type; private readonly string _routeValue; @@ -13,9 +13,6 @@ public ComputedRelationshipAttribute(string type, string routeValue) _routeValue = routeValue; } - public override ValueTask GetUser(HttpContext context) - => ValueTask.FromResult(context.User.Identity!.Name!); - public override ValueTask GetRelation(HttpContext context) => ValueTask.FromResult(context.Request.Method switch { diff --git a/samples/Fga.Example.AspNetCore/Program.cs b/samples/Fga.Example.AspNetCore/Program.cs index 5cccc2f..0f65cc2 100644 --- a/samples/Fga.Example.AspNetCore/Program.cs +++ b/samples/Fga.Example.AspNetCore/Program.cs @@ -48,7 +48,7 @@ builder.Services.AddOpenFgaMiddleware(middlewareConfig => { - middlewareConfig.UserIdentityResolver = principal => principal.Identity!.Name!; + middlewareConfig.UserIdentityResolver = principal => $"user:{principal.Identity!.Name!}"; }); builder.Services.AddAuthorization(options => @@ -75,6 +75,6 @@ app.MapGet("/viewminimal/{documentId}", (string documentId) => Task.FromResult(documentId)) .RequireAuthorization(FgaAuthorizationDefaults.PolicyKey) - .WithMetadata(new FgaRouteObjectAttribute("viewer", "doc", "documentId")); + .WithFgaRouteCheck("reader", "document", "documentId"); app.Run(); diff --git a/samples/Fga.Example.AspNetCore/appsettings.Development.json b/samples/Fga.Example.AspNetCore/appsettings.Development.json index bee822d..43b43db 100644 --- a/samples/Fga.Example.AspNetCore/appsettings.Development.json +++ b/samples/Fga.Example.AspNetCore/appsettings.Development.json @@ -1,7 +1,7 @@ { "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft.AspNetCore": "Debug" } } diff --git a/src/Fga.Net.AspNetCore/Authorization/Attributes/FgaRouteObjectAttribute.cs b/src/Fga.Net.AspNetCore/Authorization/Attributes/FgaRouteObjectAttribute.cs index 5b85123..feaea23 100644 --- a/src/Fga.Net.AspNetCore/Authorization/Attributes/FgaRouteObjectAttribute.cs +++ b/src/Fga.Net.AspNetCore/Authorization/Attributes/FgaRouteObjectAttribute.cs @@ -18,7 +18,7 @@ public class FgaRouteObjectAttribute : FgaBaseObjectAttribute /// /// The relationship to check, such as writer or viewer /// The relation between the user and object - /// The query key to get the value from. Will throw an exception if not present. + /// The route key to get the value from. Will throw an exception if not present. public FgaRouteObjectAttribute(string relation, string type, string routeKey) { _relation = relation; diff --git a/src/Fga.Net.AspNetCore/Authorization/FgaCheckDecorator.cs b/src/Fga.Net.AspNetCore/Authorization/FgaCheckDecorator.cs index bc6321d..8e31fa5 100644 --- a/src/Fga.Net.AspNetCore/Authorization/FgaCheckDecorator.cs +++ b/src/Fga.Net.AspNetCore/Authorization/FgaCheckDecorator.cs @@ -27,7 +27,7 @@ public FgaCheckDecorator(OpenFgaClient auth0FgaApi) /// /// /// - public virtual Task Check(ClientCheckRequest request, CancellationToken ct) => _auth0FgaApi.Check(request, cancellationToken: ct); + public virtual Task BatchCheck(List request, CancellationToken ct) => _auth0FgaApi.BatchCheck(request, cancellationToken: ct); } /// @@ -35,11 +35,12 @@ public FgaCheckDecorator(OpenFgaClient auth0FgaApi) /// public interface IFgaCheckDecorator { + /// /// /// /// /// /// - Task Check(ClientCheckRequest request, CancellationToken ct); + Task BatchCheck(List request, CancellationToken ct); } \ No newline at end of file diff --git a/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs b/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs index 89abc2b..2757f7b 100644 --- a/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs +++ b/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs @@ -46,6 +46,9 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext // The user is enforcing the fga policy but there's no attributes here. if (attributes.Count == 0) return; + + var checks = new List(); + foreach (var attribute in attributes) { string? user; @@ -69,22 +72,38 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext _logger.NullValuesReturned(user, relation, @object); return; } - - var result = await _client.Check(new ClientCheckRequest() + checks.Add(new ClientCheckRequest { User = user, Relation = relation, Object = @object - }, httpContext.RequestAborted); + }); + } + + var results = await _client.BatchCheck(checks, httpContext.RequestAborted); - if (result.Allowed is false) + var failedChecks = results.Responses.Where(x=> x.Allowed is false).ToArray(); + + // log all of reasons for the failed checks + if (failedChecks.Length > 0) + { + foreach (var response in failedChecks) { - _logger.CheckFailureDebug(user, relation, @object); - return; + if (response.Error is not null) + { + _logger.CheckException(response.Request.User, response.Request.Relation, response.Request.Object, response.Error); + } + else if (response.Allowed is false) + { + _logger.CheckFailure(response.Request.User, response.Request.Relation, response.Request.Object); + } } } - context.Succeed(requirement); + else + { + context.Succeed(requirement); + } } } } diff --git a/src/Fga.Net.AspNetCore/Authorization/Log.cs b/src/Fga.Net.AspNetCore/Authorization/Log.cs index c3b5baa..7b95ccf 100644 --- a/src/Fga.Net.AspNetCore/Authorization/Log.cs +++ b/src/Fga.Net.AspNetCore/Authorization/Log.cs @@ -23,12 +23,16 @@ namespace Fga.Net.AspNetCore.Authorization; internal static partial class Log { - [LoggerMessage(0, LogLevel.Debug, "FGA Check failed for User: {user}, Relation: {relation}, Object: {object}")] - public static partial void CheckFailureDebug(this ILogger logger, string user, string relation, string @object); + [LoggerMessage(3001, LogLevel.Debug, "FGA Check failed for User: {user}, Relation: {relation}, Object: {object}")] + public static partial void CheckFailure(this ILogger logger, string user, string relation, string @object); - [LoggerMessage(1, LogLevel.Debug, "FGA Attribute returned null value(s), User: {user}, Relation: {relation}, Object: {object}")] + [LoggerMessage(3002, LogLevel.Debug, "FGA Attribute returned null value(s), User: {user}, Relation: {relation}, Object: {object}")] public static partial void NullValuesReturned(this ILogger logger, string? user, string? relation, string? @object); - [LoggerMessage(2, LogLevel.Information, "Unable to compute FGA Object.")] + [LoggerMessage(3003, LogLevel.Information, "Unable to compute FGA Object.")] public static partial void MiddlewareException(this ILogger logger, FgaMiddlewareException ex); + + [LoggerMessage(3004, LogLevel.Warning, "Error occurred whilst attempting to perform middleware check for User: {user}, Relation: {relation}, Object: {object}")] + public static partial void CheckException(this ILogger logger, string user, string relation, string @object, Exception ex); + } \ No newline at end of file diff --git a/src/Fga.Net.AspNetCore/Authorization/MinimalApiExtensions.cs b/src/Fga.Net.AspNetCore/Authorization/MinimalApiExtensions.cs new file mode 100644 index 0000000..d0aa936 --- /dev/null +++ b/src/Fga.Net.AspNetCore/Authorization/MinimalApiExtensions.cs @@ -0,0 +1,62 @@ +using Fga.Net.AspNetCore.Authorization.Attributes; +using Microsoft.AspNetCore.Builder; + +namespace Fga.Net.AspNetCore.Authorization; + +/// +/// Fga extensions for use with Minimal APIs +/// +public static class MinimalApiExtensions +{ + /// + /// + /// + /// The . + /// The relationship to check, such as writer or viewer + /// The relation between the user and object + /// The header to get the value from. Will throw an exception if not present. + /// The . + public static TBuilder WithFgaHeaderCheck(this TBuilder builder, string relation, string type, string headerKey) where TBuilder : IEndpointConventionBuilder + { + return builder.WithMetadata(new FgaHeaderObjectAttribute(relation, type, headerKey)); + } + + /// + /// + /// + /// The . + /// The relationship to check, such as writer or viewer + /// The relation between the user and object + /// The JSON property to get the value from. Must be a string or number. Will throw an exception if not present. + /// The . + public static TBuilder WithFgaPropertyCheck(this TBuilder builder, string relation, string type, string property) where TBuilder : IEndpointConventionBuilder + { + return builder.WithMetadata(new FgaPropertyObjectAttribute(relation, type, property)); + } + + /// + /// + /// + /// The . + /// The relationship to check, such as writer or viewer + /// The relation between the user and object + /// The query key to get the value from. Will throw an exception if not present. + /// The . + public static TBuilder WithFgaQueryCheck(this TBuilder builder, string relation, string type, string queryKey) where TBuilder : IEndpointConventionBuilder + { + return builder.WithMetadata(new FgaQueryObjectAttribute(relation, type, queryKey)); + } + + /// + /// + /// + /// The . + /// The relationship to check, such as writer or viewer + /// The relation between the user and object + /// The route key to get the value from. Will throw an exception if not present. + /// The . + public static TBuilder WithFgaRouteCheck(this TBuilder builder, string relation, string type, string routeKey) where TBuilder : IEndpointConventionBuilder + { + return builder.WithMetadata(new FgaRouteObjectAttribute(relation, type, routeKey)); + } +} \ No newline at end of file diff --git a/src/Fga.Net/Fga.Net.DependencyInjection.csproj b/src/Fga.Net/Fga.Net.DependencyInjection.csproj index 1c69936..6e1390e 100644 --- a/src/Fga.Net/Fga.Net.DependencyInjection.csproj +++ b/src/Fga.Net/Fga.Net.DependencyInjection.csproj @@ -14,7 +14,7 @@ - + diff --git a/tests/Fga.Net.Tests/Client/EndpointTests.cs b/tests/Fga.Net.Tests/Client/EndpointTests.cs index 00a0cb5..3fe8dd9 100644 --- a/tests/Fga.Net.Tests/Client/EndpointTests.cs +++ b/tests/Fga.Net.Tests/Client/EndpointTests.cs @@ -74,7 +74,7 @@ private async Task GetEndpoints_OpenFgaClient_Return_200() Assert.NotNull(modelsResponse); Assert.NotNull(modelsResponse.AuthorizationModels); Assert.True(modelsResponse.AuthorizationModels?.Count > 0); - + var modelId = modelsResponse.AuthorizationModels?.First().Id!; var modelResponse = await client.ReadAuthorizationModel(new ClientReadAuthorizationModelOptions() {AuthorizationModelId = modelId}); @@ -103,12 +103,7 @@ private async Task GetEndpoints_OpenFgaClient_Return_200() var watch = await client.ReadChanges(new ClientReadChangesRequest() {Type = "document"}); Assert.NotNull(watch); - - } - - - } } diff --git a/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs b/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs index 679cfe8..1a6f63c 100644 --- a/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs +++ b/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs @@ -1,4 +1,6 @@ -using System.Threading; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Alba; using Fga.Net.AspNetCore.Authorization; @@ -20,12 +22,16 @@ public async Task InitializeAsync() var authorizationClientMock = new Mock(); authorizationClientMock.Setup(c => - c.Check(It.IsAny(), - It.IsAny())) - .ReturnsAsync((ClientCheckRequest res, CancellationToken _) => - res.User == MockJwtConfiguration.DefaultUser - ? new CheckResponse() { Allowed = true } - : new CheckResponse() { Allowed = false }); + c.BatchCheck(It.IsAny>(), + It.IsAny())) + .ReturnsAsync((List res, CancellationToken _) => + { + var entry = res.First(); + return entry.User == MockJwtConfiguration.DefaultUser + ? new BatchCheckResponse() { Responses = new List() { new(true, entry) } } + : new BatchCheckResponse() { Responses = new List() { new(false, entry) } }; + }); + AlbaHost = await Alba.AlbaHost.For(builder => diff --git a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs index 72e251a..16d69db 100644 --- a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs +++ b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs @@ -1,8 +1,14 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Fga.Net.AspNetCore; using Fga.Net.AspNetCore.Authorization; +using Fga.Net.AspNetCore.Authorization.Attributes; using Fga.Net.DependencyInjection; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using OpenFga.Sdk.Api; using OpenFga.Sdk.Client; @@ -66,5 +72,38 @@ public void AuthorizationPolicyExtension_RegisterCorrectly() Assert.Contains(policy.Requirements, requirement => requirement is FineGrainedAuthorizationRequirement); } + [Fact] + public void MinimalExtensions_RegisterAttributesCorrectly() + { + var builder = new TestEndpointRouteBuilder(); + builder.MapGet("/", () => Task.CompletedTask) + .WithFgaHeaderCheck("x", "y", "z") + .WithFgaRouteCheck("x", "y", "z") + .WithFgaQueryCheck("x", "y", "z") + .WithFgaPropertyCheck("x", "y", "z"); + + + var endpoint = builder.DataSources.Single().Endpoints.Single(); + + var metadata = endpoint.Metadata.GetOrderedMetadata(); + + Assert.Equal(4, metadata.Count); + Assert.Contains(metadata, attribute => attribute is FgaHeaderObjectAttribute); + Assert.Contains(metadata, attribute => attribute is FgaRouteObjectAttribute); + Assert.Contains(metadata, attribute => attribute is FgaQueryObjectAttribute); + Assert.Contains(metadata, attribute => attribute is FgaPropertyObjectAttribute); + + } + + private class TestEndpointRouteBuilder : IEndpointRouteBuilder + { + public IServiceProvider ServiceProvider { get; } = new ServiceCollection().BuildServiceProvider(); + public ICollection DataSources { get; } = new List(); + public IApplicationBuilder CreateApplicationBuilder() + { + throw new NotImplementedException(); + } + } + } }