From 41e0934b2fc7ca7a0dc05c926adb6f065d8f1f9d Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 5 Jan 2024 18:55:17 +0800 Subject: [PATCH] Support OpenFGA 0.3.0 (#14) * Support OpenFGA 0.3.0 * Fix build --- Package.Build.props | 2 +- README.md | 12 +- samples/Fga.Example.AspNetCore/Program.cs | 2 +- samples/Fga.Example.GenericHost/Program.cs | 18 +-- .../FineGrainedAuthorizationHandler.cs | 125 +++++++++--------- .../Fga.Net.AspNetCore.csproj | 2 +- .../Auth0FgaConnectionBuilder.cs | 6 +- .../Configuration/OpenFgaConnectionBuilder.cs | 24 +++- .../Fga.Net.DependencyInjection.csproj | 11 +- src/Fga.Net/FgaConnectionConfiguration.cs | 2 +- src/Fga.Net/ServiceCollectionExtensions.cs | 3 +- tests/Fga.Net.Tests/Client/EndpointTests.cs | 15 ++- tests/Fga.Net.Tests/Fga.Net.Tests.csproj | 5 +- tests/Fga.Net.Tests/Unit/ExtensionTests.cs | 22 +-- 14 files changed, 134 insertions(+), 115 deletions(-) diff --git a/Package.Build.props b/Package.Build.props index f56fc55..ed84296 100644 --- a/Package.Build.props +++ b/Package.Build.props @@ -1,6 +1,6 @@ - 1.0.0 + 1.1.0 Hawxy true Apache-2.0 diff --git a/README.md b/README.md index f522c01..e15a21e 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,14 @@ The `ConfigureAuth0Fga` extension will configure the client to work with the Aut ### OpenFGA -1. Add the FGA `ApiScheme`, `ApiHost` & `StoreId` to your application configuration. +1. Add the FGA `ApiUrl` & `StoreId` to your application configuration. 2. Add the following code to your ASP.NET Core configuration: ```cs services.AddOpenFgaClient(config => { config.ConfigureOpenFga(x => { - x.SetConnection(context.Configuration["Fga:ApiScheme"] context.Configuration["Fga:ApiHost"]); + x.SetConnection(context.Configuration["Fga:ApiUrl"]); }); config.SetStoreId(context.Configuration["Fga:StoreId"]); }); @@ -64,7 +64,7 @@ Authentication can be added to OpenFGA connections via the relevant extensions: ```csharp config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + x.SetConnection(context.Configuration["Fga:ApiUrl"]); // Add API key auth x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); @@ -187,7 +187,7 @@ services.PostConfigureFgaClient(config => config.SetStoreId(storeId); config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttp, openFgaUrl); + x.SetConnection(context.Configuration["Fga:ApiUrl"]); }); }); @@ -200,7 +200,7 @@ services.PostConfigureFgaClient(config => To get started: 1. Install `Fga.Net.DependencyInjection` -2. Add your `StoreId`, `ClientId` and `ClientSecret` Auth0 FGA configuration **OR** `ApiScheme`, `ApiHost` & `StoreId` OpenFGA configuration to your application configuration, ideally via the [dotnet secrets manager](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#enable-secret-storage). +2. Add your `StoreId`, `ClientId` and `ClientSecret` Auth0 FGA configuration **OR** `ApiUrl` & `StoreId` OpenFGA configuration to your application configuration, ideally via the [dotnet secrets manager](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows#enable-secret-storage). 3. Register the authorization client: ```cs @@ -222,7 +222,7 @@ var host = Host.CreateDefaultBuilder(args) { config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + x.SetConnection(context.Configuration["Fga:ApiUrl"]); // Optionally add authentication settings x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); diff --git a/samples/Fga.Example.AspNetCore/Program.cs b/samples/Fga.Example.AspNetCore/Program.cs index b0947ae..0dabb61 100644 --- a/samples/Fga.Example.AspNetCore/Program.cs +++ b/samples/Fga.Example.AspNetCore/Program.cs @@ -44,7 +44,7 @@ { x.ConfigureOpenFga(x => { - x.SetConnection(builder.Configuration["Fga:ApiScheme"]!, builder.Configuration["Fga:ApiHost"]!); + x.SetConnection(context.Configuration["Fga:ApiUrl"]!); }); x.SetStoreId(builder.Configuration["Fga:StoreId"]); diff --git a/samples/Fga.Example.GenericHost/Program.cs b/samples/Fga.Example.GenericHost/Program.cs index cbc72e0..fdfafc0 100644 --- a/samples/Fga.Example.GenericHost/Program.cs +++ b/samples/Fga.Example.GenericHost/Program.cs @@ -9,9 +9,9 @@ { config.ConfigureAuth0Fga(x => { - x.WithAuthentication(context.Configuration["Auth0Fga:ClientId"], context.Configuration["Auth0Fga:ClientSecret"]); + x.WithAuthentication(context.Configuration["Auth0Fga:ClientId"]!, context.Configuration["Auth0Fga:ClientSecret"]!); }); - config.SetStoreId(context.Configuration["Auth0Fga:StoreId"]); + config.SetStoreId(context.Configuration["Auth0Fga:StoreId"]!); }); // OpenFGA @@ -19,17 +19,17 @@ { config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + x.SetConnection(context.Configuration["Fga:ApiUrl"]!); // Optionally add authentication settings - x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); + x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]!); x.WithOidcAuthentication( - context.Configuration["Fga:ClientId"], - context.Configuration["Fga:ClientSecret"], - context.Configuration["Fga:Issuer"], - context.Configuration["Fga:Audience"]); + context.Configuration["Fga:ClientId"]!, + context.Configuration["Fga:ClientSecret"]!, + context.Configuration["Fga:Issuer"]!, + context.Configuration["Fga:Audience"]!); }); - config.SetStoreId(context.Configuration["Fga:StoreId"]); + config.SetStoreId(context.Configuration["Fga:StoreId"]!); }); services.AddHostedService(); diff --git a/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs b/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs index f1432ea..4c69eb9 100644 --- a/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs +++ b/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs @@ -37,81 +37,80 @@ public FineGrainedAuthorizationHandler(IFgaCheckDecorator client, ILogger(); - // The user is enforcing the fga policy but there's no attributes here. - if (attributes.Count == 0) - return; + if (context.Resource is not HttpContext httpContext) + throw new InvalidOperationException($"{nameof(FineGrainedAuthorizationHandler)} called with invalid resource type. This handler is only compatible with endpoint routing."); + + var endpoint = httpContext.GetEndpoint(); + if (endpoint is null) + return; + var attributes = endpoint.Metadata.GetOrderedMetadata(); + // The user is enforcing the fga policy but there's no attributes here. + if (attributes.Count == 0) + return; - var checks = new List(); + var checks = new List(); - foreach (var attribute in attributes) + foreach (var attribute in attributes) + { + string? user; + string? relation; + string? @object; + try { - string? user; - string? relation; - string? @object; - try - { - user = await attribute.GetUser(httpContext); - relation = await attribute.GetRelation(httpContext); - @object = await attribute.GetObject(httpContext); - } - catch (FgaMiddlewareException ex) - { - _logger.MiddlewareException(ex); - return; - } + user = await attribute.GetUser(httpContext); + relation = await attribute.GetRelation(httpContext); + @object = await attribute.GetObject(httpContext); + } + catch (FgaMiddlewareException ex) + { + _logger.MiddlewareException(ex); + return; + } - // If we get back nulls from anything we cannot perform a check. - if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(relation) || string.IsNullOrEmpty(@object)) - { - _logger.NullValuesReturned(user, relation, @object); - return; - } + // If we get back nulls from anything we cannot perform a check. + if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(relation) || string.IsNullOrEmpty(@object)) + { + _logger.NullValuesReturned(user, relation, @object); + return; + } - if (!Validation.IsValidUser(user)) - { - _logger.InvalidUser(user); - return; - } - - checks.Add(new ClientCheckRequest - { - User = user, - Relation = relation, - Object = @object - }); + if (!Validation.IsValidUser(user)) + { + _logger.InvalidUser(user); + return; } + + checks.Add(new ClientCheckRequest + { + User = user, + Relation = relation, + Object = @object + }); + } - var results = await _client.BatchCheck(checks, httpContext.RequestAborted); + var results = await _client.BatchCheck(checks, httpContext.RequestAborted); - var failedChecks = results.Responses.Where(x=> x.Allowed is false).ToArray(); + var failedChecks = results.Responses.Where(x=> x.Allowed is false).ToArray(); - // log all of reasons for the failed checks - if (failedChecks.Length > 0) + // log all of reasons for the failed checks + if (failedChecks.Length > 0) + { + foreach (var response in failedChecks) { - foreach (var response in failedChecks) + if (response.Error is not null) { - 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); - } + _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); } } - else - { - context.Succeed(requirement); - } } + else + { + context.Succeed(requirement); + } + } - - -} +} \ No newline at end of file diff --git a/src/Fga.Net.AspNetCore/Fga.Net.AspNetCore.csproj b/src/Fga.Net.AspNetCore/Fga.Net.AspNetCore.csproj index a5c6ab9..0e35314 100644 --- a/src/Fga.Net.AspNetCore/Fga.Net.AspNetCore.csproj +++ b/src/Fga.Net.AspNetCore/Fga.Net.AspNetCore.csproj @@ -1,7 +1,7 @@  - net6.0;net7.0 + net6.0;net7.0;net8.0 enable enable diff --git a/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs b/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs index 03f46de..475297a 100644 --- a/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs +++ b/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs @@ -32,7 +32,7 @@ public enum Auth0Environment Us } -internal sealed record Auth0FgaEnvironment(string Scheme, string ApiHost, string ApiTokenIssuer, string ApiAudience); +internal sealed record Auth0FgaEnvironment(string ApiHost, string ApiTokenIssuer, string ApiAudience); /// @@ -45,7 +45,7 @@ public sealed class Auth0FgaConnectionBuilder { { Auth0Environment.Us, - new Auth0FgaEnvironment(Uri.UriSchemeHttps, "api.us1.fga.dev", "fga.us.auth0.com", "https://api.us1.fga.dev/") + new Auth0FgaEnvironment("https://api.us1.fga.dev", "fga.us.auth0.com", "https://api.us1.fga.dev/") } }; @@ -88,6 +88,6 @@ internal FgaConnectionConfiguration Build() } }; - return new FgaConnectionConfiguration(environment.Scheme, environment.ApiHost, credentials); + return new FgaConnectionConfiguration(environment.ApiHost, credentials); } } \ No newline at end of file diff --git a/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs b/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs index 8cb2989..58b9170 100644 --- a/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs +++ b/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs @@ -25,8 +25,7 @@ namespace Fga.Net.DependencyInjection.Configuration; /// public sealed class OpenFgaConnectionBuilder { - private string _apiScheme = Uri.UriSchemeHttps; - private string? _apiHost; + private string? _apiUrl; /// /// Sets the connection configuration for the host. @@ -34,10 +33,21 @@ public sealed class OpenFgaConnectionBuilder /// API scheme, either http or https. /// API host, should be in be plain URI format /// + [Obsolete("Passing in a split scheme & host is obsolete and will be removed in a future release. Use SetConnection(string apiUrl)")] public OpenFgaConnectionBuilder SetConnection(string apiScheme, string apiHost) { - _apiScheme = apiScheme; - _apiHost = apiHost; + _apiUrl = $"{apiScheme}://{apiHost}"; + return this; + } + + /// + /// Sets the connection configuration for the host. + /// + /// The URL for the API, should include scheme + domain. Can optionally include port. + /// + public OpenFgaConnectionBuilder SetConnection(string apiUrl) + { + _apiUrl = apiUrl; return this; } @@ -85,9 +95,9 @@ public void WithOidcAuthentication(string clientId, string clientSecret, string internal FgaConnectionConfiguration Build() { - if (string.IsNullOrEmpty(_apiHost)) + if (string.IsNullOrEmpty(_apiUrl)) throw new InvalidOperationException("API Host cannot be null or empty"); - if (_apiScheme != Uri.UriSchemeHttps && _apiScheme != Uri.UriSchemeHttp) + if (!_apiUrl.Contains(Uri.UriSchemeHttps) && !_apiUrl.Contains(Uri.UriSchemeHttp)) throw new InvalidOperationException("API Scheme must be http or https"); if (_credentials?.Method == CredentialsMethod.ApiToken && string.IsNullOrEmpty(_credentials.Config?.ApiToken)) throw new InvalidOperationException("API Key cannot be empty"); @@ -98,6 +108,6 @@ internal FgaConnectionConfiguration Build() || string.IsNullOrEmpty(_credentials.Config?.ApiAudience))) throw new InvalidOperationException("Clients credential configuration cannot be contain missing values."); - return new FgaConnectionConfiguration(_apiScheme, _apiHost, _credentials); + return new FgaConnectionConfiguration(_apiUrl, _credentials); } } \ 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 428f057..6024c47 100644 --- a/src/Fga.Net/Fga.Net.DependencyInjection.csproj +++ b/src/Fga.Net/Fga.Net.DependencyInjection.csproj @@ -12,9 +12,16 @@ - - + + + + + + + + + diff --git a/src/Fga.Net/FgaConnectionConfiguration.cs b/src/Fga.Net/FgaConnectionConfiguration.cs index 59e1dad..72e4d6a 100644 --- a/src/Fga.Net/FgaConnectionConfiguration.cs +++ b/src/Fga.Net/FgaConnectionConfiguration.cs @@ -28,4 +28,4 @@ internal sealed record FgaBuiltConfiguration( int? MinWaitInMs, FgaConnectionConfiguration Connection); -internal sealed record FgaConnectionConfiguration(string ApiScheme, string ApiHost, Credentials? Credentials); \ No newline at end of file +internal sealed record FgaConnectionConfiguration(string ApiUrl, Credentials? Credentials); \ No newline at end of file diff --git a/src/Fga.Net/ServiceCollectionExtensions.cs b/src/Fga.Net/ServiceCollectionExtensions.cs index c324466..81c9912 100644 --- a/src/Fga.Net/ServiceCollectionExtensions.cs +++ b/src/Fga.Net/ServiceCollectionExtensions.cs @@ -80,8 +80,7 @@ public static void PostConfigureFgaClient(this IServiceCollection collection, Ac private static void ConfigureFgaOptions(this FgaClientConfiguration x, FgaBuiltConfiguration config) { - x.ApiScheme = config.Connection.ApiScheme; - x.ApiHost = config.Connection.ApiHost; + x.ApiUrl = config.Connection.ApiUrl; x.StoreId = config.StoreId; x.AuthorizationModelId = config.AuthorizationModelId; diff --git a/tests/Fga.Net.Tests/Client/EndpointTests.cs b/tests/Fga.Net.Tests/Client/EndpointTests.cs index eff4c38..ebe6384 100644 --- a/tests/Fga.Net.Tests/Client/EndpointTests.cs +++ b/tests/Fga.Net.Tests/Client/EndpointTests.cs @@ -2,7 +2,9 @@ using System.Linq; using System.Threading.Tasks; using Alba; +using Fga.Net.DependencyInjection.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenFga.Sdk.Api; using OpenFga.Sdk.Client; using OpenFga.Sdk.Client.Model; @@ -26,7 +28,8 @@ private async Task GetEndpoints_OpenFgaApi_Return_200() { using var scope = _host.Services.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); - var modelsResponse = await client.ReadAuthorizationModels(); + var config = scope.ServiceProvider.GetRequiredService>().Value; + var modelsResponse = await client.ReadAuthorizationModels(config.StoreId!); Assert.NotNull(modelsResponse); Assert.NotNull(modelsResponse.AuthorizationModels); @@ -34,12 +37,12 @@ private async Task GetEndpoints_OpenFgaApi_Return_200() var modelId = modelsResponse.AuthorizationModels?.First().Id!; - var modelResponse = await client.ReadAuthorizationModel(modelId); + var modelResponse = await client.ReadAuthorizationModel(config.StoreId!, modelId); Assert.NotNull(modelResponse); Assert.NotNull(modelResponse.AuthorizationModel?.Id); - var assertions = await client.ReadAssertions(modelId); + var assertions = await client.ReadAssertions(config.StoreId!, modelId); Assert.NotNull(assertions); Assert.True(assertions.Assertions?.Count > 0); @@ -49,16 +52,16 @@ private async Task GetEndpoints_OpenFgaApi_Return_200() Assert.NotEmpty(assertion.Relation!); Assert.NotEmpty(assertion.User!); - var graph = await client.Expand(new ExpandRequest() + var graph = await client.Expand(config.StoreId!, new ExpandRequest() { AuthorizationModelId = modelId, - TupleKey = assertion + TupleKey = new ExpandRequestTupleKey(assertion.Relation, assertion.Object) }); Assert.NotNull(graph.Tree); Assert.NotNull(graph.Tree!.Root!.Name); - var watch = await client.ReadChanges(); + var watch = await client.ReadChanges(config.StoreId!); Assert.NotNull(watch); diff --git a/tests/Fga.Net.Tests/Fga.Net.Tests.csproj b/tests/Fga.Net.Tests/Fga.Net.Tests.csproj index abb3156..51d591d 100644 --- a/tests/Fga.Net.Tests/Fga.Net.Tests.csproj +++ b/tests/Fga.Net.Tests/Fga.Net.Tests.csproj @@ -11,8 +11,9 @@ - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs index 9e524cd..4dab971 100644 --- a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs +++ b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs @@ -27,15 +27,15 @@ public class ExtensionTests new ExtensionScenario("Empty Extension Config - Auth0 FGA", config => config.ConfigureAuth0Fga(x => { })), new ExtensionScenario("Empty Schema", - config => config.ConfigureOpenFga(x => { x.SetConnection("", "localhost"); })), + config => config.ConfigureOpenFga(x => { x.SetConnection("localhost"); })), new ExtensionScenario("Empty API Key", config => config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttps, "localhost") + x.SetConnection("http://localhost") .WithApiKeyAuthentication(""); })), new ExtensionScenario("Empty OIDC configuration", config => config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttps, "localhost") + x.SetConnection("http://localhost") .WithOidcAuthentication("clientId", "", "issuer", "audience"); })), }; @@ -65,7 +65,7 @@ public void ClientExtensions_RegisterCorrectly(ExtensionScenario scenario) collection.AddOpenFgaClient(config => { - config.SetStoreId(Guid.NewGuid().ToString()); + config.SetStoreId(Ulid.NewUlid().ToString()); scenario.Configuration(config); @@ -91,7 +91,7 @@ public void AspNetCoreServiceExtensions_RegisterCorrectly(ExtensionScenario scen collection.AddOpenFgaClient(config => { - config.SetStoreId(Guid.NewGuid().ToString()); + config.SetStoreId(Ulid.NewUlid().ToString()); scenario.Configuration(config); }); @@ -117,19 +117,19 @@ public void AspNetCoreServiceExtensions_RegisterCorrectly(ExtensionScenario scen })), new ExtensionScenario("OpenFGA - No Credentials", config => config.ConfigureOpenFga(x => - { x.SetConnection(Uri.UriSchemeHttp, "localhost"); + { x.SetConnection("http://localhost"); })), new ExtensionScenario("OpenFGA - API Key Auth", config => config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttps, "localhost") + x.SetConnection("http://localhost") .WithApiKeyAuthentication("my-special-key"); })), new ExtensionScenario("OpenFGA - OIDC Auth", config => config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttps, "localhost") + x.SetConnection("http://localhost") .WithOidcAuthentication("clientId", "clientSecret", "issuer", "audience"); })), }; @@ -189,13 +189,13 @@ public void PostConfigureOptions_OverridesSettings() }); - var openFgaUrl = "localhost:8080"; + var openFgaUrl = "http://localhost:8080"; collection.PostConfigureFgaClient(config => { config.SetStoreId(Guid.NewGuid().ToString()); config.ConfigureOpenFga(x => { - x.SetConnection(Uri.UriSchemeHttp, openFgaUrl); + x.SetConnection(openFgaUrl); }); }); @@ -204,7 +204,7 @@ public void PostConfigureOptions_OverridesSettings() var config = provider.GetRequiredService>().Value; Assert.Null(config.Credentials); - Assert.Equal(openFgaUrl, config.ApiHost); + Assert.Equal(openFgaUrl, config.ApiUrl); }