From c59b3a9ab8e2422630dfcd0b9624da036e8eeb82 Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 29 May 2023 09:51:11 +0800 Subject: [PATCH] v1.0 Beta 1 (#11) * WIP configuration builder * Fully working config builder * Update nuke build * WIP expanded test suite * File scoped namespaces in tests * WIP further config setup * Finalize design * Cleanup * Add post-configure tests * Update identifier configuration * Fix obsolete nuke usage * Remove accidental null annotation --- .github/workflows/Build_&_Test.yml | 6 +- .github/workflows/Manual_Nuget_Push.yml | 6 +- .nuke/build.schema.json | 4 +- Package.Build.props | 4 +- README.md | 108 +++++++-- build/Build.cs | 3 +- build/_build.csproj | 2 +- samples/Fga.Example.AspNetCore/Program.cs | 27 ++- .../TestControllers/TestAttribute.cs | 8 +- .../TestControllers/TestController.cs | 1 + samples/Fga.Example.GenericHost/Program.cs | 32 ++- .../FineGrainedAuthorizationHandler.cs | 8 + src/Fga.Net.AspNetCore/Authorization/Log.cs | 5 + .../Authorization/Validation.cs | 37 +++ .../FgaAspNetCoreConfiguration.cs | 41 +++- .../Auth0FgaConnectionBuilder.cs | 93 +++++++ .../FgaClientConfiguration.cs | 6 +- .../Configuration/OpenFgaConnectionBuilder.cs | 103 ++++++++ src/Fga.Net/Constants.cs | 6 + .../Fga.Net.DependencyInjection.csproj | 2 +- .../FgaClientConfigurationExtensions.cs | 52 ---- src/Fga.Net/FgaConfigurationBuilder.cs | 102 ++++++++ src/Fga.Net/FgaConnectionConfiguration.cs | 31 +++ src/Fga.Net/InjectableFgaApi.cs | 34 ++- src/Fga.Net/ServiceCollectionExtensions.cs | 70 +++++- tests/Fga.Net.Tests/Client/EndpointTests.cs | 141 ++++++----- .../Middleware/MiddlewareTests.cs | 55 +++-- .../Fga.Net.Tests/Middleware/WebAppFixture.cs | 2 +- tests/Fga.Net.Tests/TheoryData.cs | 25 ++ tests/Fga.Net.Tests/Unit/AttributeTests.cs | 2 +- tests/Fga.Net.Tests/Unit/ExtensionScenario.cs | 34 +++ tests/Fga.Net.Tests/Unit/ExtensionTests.cs | 228 +++++++++++++----- tests/Fga.Net.Tests/Unit/ValidationTests.cs | 19 ++ 33 files changed, 995 insertions(+), 302 deletions(-) create mode 100644 src/Fga.Net.AspNetCore/Authorization/Validation.cs create mode 100644 src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs rename src/Fga.Net/{ => Configuration}/FgaClientConfiguration.cs (93%) create mode 100644 src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs create mode 100644 src/Fga.Net/Constants.cs delete mode 100644 src/Fga.Net/FgaClientConfigurationExtensions.cs create mode 100644 src/Fga.Net/FgaConfigurationBuilder.cs create mode 100644 src/Fga.Net/FgaConnectionConfiguration.cs create mode 100644 tests/Fga.Net.Tests/TheoryData.cs create mode 100644 tests/Fga.Net.Tests/Unit/ExtensionScenario.cs create mode 100644 tests/Fga.Net.Tests/Unit/ValidationTests.cs diff --git a/.github/workflows/Build_&_Test.yml b/.github/workflows/Build_&_Test.yml index 6e49e01..f60b2db 100644 --- a/.github/workflows/Build_&_Test.yml +++ b/.github/workflows/Build_&_Test.yml @@ -30,14 +30,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Cache .nuke/temp, ~/.nuget/packages + - name: 'Cache: .nuke/temp, ~/.nuget/packages' uses: actions/cache@v3 with: path: | .nuke/temp ~/.nuget/packages - key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj') }} - - name: Run './build.cmd Test' + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Run: Test' run: ./build.cmd Test env: FgaStoreId: ${{ secrets.FGA_STORE_ID }} diff --git a/.github/workflows/Manual_Nuget_Push.yml b/.github/workflows/Manual_Nuget_Push.yml index 5d17b54..799fe86 100644 --- a/.github/workflows/Manual_Nuget_Push.yml +++ b/.github/workflows/Manual_Nuget_Push.yml @@ -24,14 +24,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Cache .nuke/temp, ~/.nuget/packages + - name: 'Cache: .nuke/temp, ~/.nuget/packages' uses: actions/cache@v3 with: path: | .nuke/temp ~/.nuget/packages - key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj') }} - - name: Run './build.cmd NugetPush' + key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }} + - name: 'Run: NugetPush' run: ./build.cmd NugetPush env: NugetApiKey: ${{ secrets.NUGET_API_KEY }} diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index d65e543..68c5085 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "Build Schema", "$ref": "#/definitions/build", + "title": "Build Schema", "definitions": { "build": { "type": "object", @@ -125,4 +125,4 @@ } } } -} \ No newline at end of file +} diff --git a/Package.Build.props b/Package.Build.props index 819acfd..cd5a58f 100644 --- a/Package.Build.props +++ b/Package.Build.props @@ -1,13 +1,13 @@ - 0.9.0-alpha + 1.0.0-beta.1 Hawxy true Apache-2.0 https://github.com/Hawxy/Fga.Net https://github.com/Hawxy/Fga.Net git - Hawxy 2022 + Hawxy 2022-2023 true README.md diff --git a/README.md b/README.md index e3cf315..8b597b0 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,16 @@ [![Nuget (with prereleases)](https://img.shields.io/nuget/vpre/Fga.Net.DependencyInjection?label=Fga.Net.DependencyInjection&style=flat-square)](https://www.nuget.org/packages/Fga.Net.DependencyInjection) [![Nuget (with prereleases)](https://img.shields.io/nuget/vpre/Fga.Net.AspNetCore?label=Fga.Net.AspNetCore&style=flat-square)](https://www.nuget.org/packages/Fga.Net.AspNetCore) -#### Note: This project is in its early stages and will have breaking changes as FGA matures. +#### Note: This project is currently in beta. Breaking changes may occur before release. ### Packages **`Fga.Net.DependencyInjection`**: Provides dependency injection/configuration extensions for [OpenFga.Sdk](https://github.com/openfga/dotnet-sdk) -**`Fga.Net.AspNetCore`**: Includes Authorization middleware to support FGA checks as part of a request's lifecycle. +**`Fga.Net.AspNetCore`**: Authorization middleware to perform FGA checks for inbound requests. ## Getting Started -This package is compatible with the OSS OpenFGA as well as the managed Auth0 FGA service. +This package is compatible with the OSS OpenFGA as well as the managed Auth0 FGA service. Usage of DSL v1.1 is required. Please ensure you have a basic understanding of how FGA works before continuing: [OpenFGA Docs](https://openfga.dev/) or [Auth0 FGA Docs](https://docs.fga.dev/) @@ -26,42 +26,63 @@ Install `Fga.Net.AspNetCore` from Nuget before continuing. Ensure you have a Store ID, Client ID, and Client Secret ready from [How to get your API keys](https://docs.fga.dev/integration/getting-your-api-keys). - 1. Add your `StoreId`, `ClientId` and `ClientSecret` 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 the following code to your ASP.NET Core services configuration: ```cs -builder.Services.AddOpenFgaClient(x => +builder.Services.AddOpenFgaClient(config => { - x.WithAuth0FgaDefaults(builder.Configuration["Auth0Fga:ClientId"], builder.Configuration["Auth0Fga:ClientSecret"]); + config.ConfigureAuth0Fga(x => + { + x.WithAuthentication(builder.Configuration["Auth0Fga:ClientId"]!, builder.Configuration["Auth0Fga:ClientSecret"]!); + }); - x.StoreId = builder.Configuration["Auth0Fga:StoreId"]; + config.SetStoreId(builder.Configuration["Auth0Fga:StoreId"]!); }); builder.Services.AddOpenFgaMiddleware(); ``` -The `WithAuth0FgaDefaults` extension will configure the relevant OpenFGA client settings to work with Auth0 FGA's US environment. +The `ConfigureAuth0Fga` extension will configure the client to work with the Auth0 US environment. An environment selector will be added as additional regions come online. ### OpenFGA -OpenFGA configuration is very similar to the [SDK Setup Guide](https://openfga.dev/docs/getting-started/setup-sdk-client) - 1. Add the FGA `ApiScheme`, `ApiHost` & `StoreId` to your application configuration. 2. Add the following code to your ASP.NET Core configuration: ```cs -builder.Services.AddOpenFgaClient(x => +services.AddOpenFgaClient(config => { - x.ApiScheme = builder.Configuration["Fga:ApiScheme"]; - x.ApiHost = builder.Configuration["Fga:ApiHost"]; - x.StoreId = builder.Configuration["Fga:StoreId"]; + config.ConfigureOpenFga(x => + { + x.SetConnection(context.Configuration["Fga:ApiScheme"] context.Configuration["Fga:ApiHost"]); + }); + config.SetStoreId(context.Configuration["Fga:StoreId"]); }); builder.Services.AddOpenFgaMiddleware(); ``` +Authentication can be added to OpenFGA connections via the relevant extensions: + +```csharp +config.ConfigureOpenFga(x => +{ + x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + + // Add API key auth + x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); + // or OIDC auth + x.WithOidcAuthentication( + context.Configuration["Fga:ClientId"], + context.Configuration["Fga:ClientSecret"], + context.Configuration["Fga:Issuer"], + context.Configuration["Fga:Audience"]); +}); + +``` + ### Authorization Policy Setup -We'll need to setup our authorization policy like so: +Your authorization policy should be configured with `RequireAuthenticatedUser` and `AddFgaRequirement` at minimum: ```cs builder.Services.AddAuthorization(options => @@ -73,6 +94,8 @@ builder.Services.AddAuthorization(options => }); ``` +A constant authorization key is included for convenience, but `AddFgaRequirement` can be used with any additional policy as required. + ### 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,14 +105,14 @@ 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 -If you want to use these attributes, you need to configure how the user's identity is resolved from the `ClaimsPrincipal`. +If you want to use these attributes, you need to configure how the user's identifier is constructed from the users claims. 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!}"; + //'user' should be the name of the user type that you're using within your FGA model + config.SetUserIdentifier("user", principal => principal.Identity!.Name!); }); ``` @@ -155,6 +178,23 @@ An additional pre-made attribute that allows all tuple values to be hardcoded st This package registers both the `OpenFgaApi` and `OpenFgaClient` types in the DI container. `OpenFgaClient` is a higher level abstraction and preferred over `OpenFgaApi` for general use. +## Testing + +When running tests against your API or service collection, you likely want a different client configuration than usual. You can achieve this by calling `PostConfigureFgaClient` on your services configuration: + +```cs +// Replaces existing configuration +services.PostConfigureFgaClient(config => +{ + config.SetStoreId(storeId); + config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttp, openFgaUrl); + }); +}); + +``` + ## Worker Service / Generic Host Setup `Fga.Net.DependencyInjection` ships with the `AddOpenFgaClient` service collection extension that handles all required wire-up. @@ -169,16 +209,32 @@ To get started: var host = Host.CreateDefaultBuilder(args) .ConfigureServices((context, services) => { + // Auth0 FGA + services.AddOpenFgaClient(config => + { + config.ConfigureAuth0Fga(x => + { + x.WithAuthentication(context.Configuration["Auth0Fga:ClientId"], context.Configuration["Auth0Fga:ClientSecret"]); + }); + config.SetStoreId(context.Configuration["Auth0Fga:StoreId"]); + }); + + // OpenFGA services.AddOpenFgaClient(config => { - // Auth0 FGA - config.WithAuth0FgaDefaults(context.Configuration["Auth0Fga:ClientId"], context.Configuration["Auth0Fga:ClientSecret"]); - config.StoreId = context.Configuration["Auth0Fga:StoreId"]; - - // OpenFGA - config.ApiScheme = context.Configuration["Fga:ApiScheme"]; - config.ApiHost = context.Configuration["Fga:ApiHost"]; - config.StoreId = context.Configuration["Fga:StoreId"]; + config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + + // Optionally add authentication settings + x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); + x.WithOidcAuthentication( + context.Configuration["Fga:ClientId"], + context.Configuration["Fga:ClientSecret"], + context.Configuration["Fga:Issuer"], + context.Configuration["Fga:Audience"]); + }); + config.SetStoreId(context.Configuration["Fga:StoreId"]); }); services.AddHostedService(); diff --git a/build/Build.cs b/build/Build.cs index 9516af1..4b04d6c 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,3 +1,4 @@ +using System; using Nuke.Common; using Nuke.Common.CI; using Nuke.Common.CI.GitHubActions; @@ -40,7 +41,7 @@ class Build : NukeBuild .Before(Restore) .Executes(() => { - EnsureCleanDirectory(ArtifactsDirectory); + ArtifactsDirectory.CreateOrCleanDirectory(); }); Target Restore => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index ee1f14c..54b9d91 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -11,7 +11,7 @@ - + diff --git a/samples/Fga.Example.AspNetCore/Program.cs b/samples/Fga.Example.AspNetCore/Program.cs index 0f65cc2..da88f78 100644 --- a/samples/Fga.Example.AspNetCore/Program.cs +++ b/samples/Fga.Example.AspNetCore/Program.cs @@ -1,8 +1,6 @@ using System.Security.Claims; -using Fga.Example.AspNetCore; using Fga.Net.AspNetCore; using Fga.Net.AspNetCore.Authorization; -using Fga.Net.AspNetCore.Authorization.Attributes; using Fga.Net.DependencyInjection; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; @@ -30,25 +28,30 @@ // Auth0 FGA -builder.Services.AddOpenFgaClient(clientConfig => +builder.Services.AddOpenFgaClient(config => { - clientConfig.WithAuth0FgaDefaults(builder.Configuration["Auth0Fga:ClientId"]!, - builder.Configuration["Auth0Fga:ClientSecret"]!); - clientConfig.StoreId = builder.Configuration["Auth0Fga:StoreId"]; + config.ConfigureAuth0Fga(x => + { + x.WithAuthentication(builder.Configuration["Auth0Fga:ClientId"]!, builder.Configuration["Auth0Fga:ClientSecret"]!); + }); + config.SetStoreId(builder.Configuration["Auth0Fga:StoreId"]!); }); -// OpenFGA -/*builder.Services.AddOpenFgaClient(x => +/* OpenFGA +builder.Services.AddOpenFgaClient(x => { - x.ApiScheme = builder.Configuration["Fga:ApiScheme"]; - x.ApiHost = builder.Configuration["Fga:ApiHost"]; - x.StoreId = builder.Configuration["Fga:StoreId"]; + x.ConfigureOpenFga(x => + { + x.SetConnection(builder.Configuration["Fga:ApiScheme"]!, builder.Configuration["Fga:ApiHost"]!); + }); + + x.SetStoreId(builder.Configuration["Fga:StoreId"]); });*/ builder.Services.AddOpenFgaMiddleware(middlewareConfig => { - middlewareConfig.UserIdentityResolver = principal => $"user:{principal.Identity!.Name!}"; + middlewareConfig.SetUserIdentifier("user", principal => principal.Identity!.Name!); }); builder.Services.AddAuthorization(options => diff --git a/samples/Fga.Example.AspNetCore/TestControllers/TestAttribute.cs b/samples/Fga.Example.AspNetCore/TestControllers/TestAttribute.cs index e85c0d5..1e00473 100644 --- a/samples/Fga.Example.AspNetCore/TestControllers/TestAttribute.cs +++ b/samples/Fga.Example.AspNetCore/TestControllers/TestAttribute.cs @@ -2,14 +2,8 @@ namespace Fga.Example.AspNetCore.TestControllers; -public class TestAuthorizationAttribute : FgaAttribute +public class TestAuthorizationAttribute : FgaBaseObjectAttribute { - - public override ValueTask GetUser(HttpContext context) - { - return ValueTask.FromResult(context.User.Identity!.Name!); - } - public override ValueTask GetRelation(HttpContext context) { return ValueTask.FromResult("fake-relation"); diff --git a/samples/Fga.Example.AspNetCore/TestControllers/TestController.cs b/samples/Fga.Example.AspNetCore/TestControllers/TestController.cs index f0ed9b8..175c1b0 100644 --- a/samples/Fga.Example.AspNetCore/TestControllers/TestController.cs +++ b/samples/Fga.Example.AspNetCore/TestControllers/TestController.cs @@ -1,4 +1,5 @@ using Fga.Net.AspNetCore.Authorization; +using Fga.Net.AspNetCore.Authorization.Attributes; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/samples/Fga.Example.GenericHost/Program.cs b/samples/Fga.Example.GenericHost/Program.cs index 362d1a1..cbc72e0 100644 --- a/samples/Fga.Example.GenericHost/Program.cs +++ b/samples/Fga.Example.GenericHost/Program.cs @@ -4,16 +4,32 @@ var host = Host.CreateDefaultBuilder(args) .ConfigureServices((context, services) => { + // Auth0 FGA services.AddOpenFgaClient(config => { - // Auth0 FGA - config.WithAuth0FgaDefaults(context.Configuration["Auth0Fga:ClientId"], context.Configuration["Auth0Fga:ClientSecret"]); - config.StoreId = context.Configuration["Auth0Fga:StoreId"]; - - // OpenFGA - config.ApiScheme = context.Configuration["Fga:ApiScheme"]; - config.ApiHost = context.Configuration["Fga:ApiHost"]; - config.StoreId = context.Configuration["Fga:StoreId"]; + config.ConfigureAuth0Fga(x => + { + x.WithAuthentication(context.Configuration["Auth0Fga:ClientId"], context.Configuration["Auth0Fga:ClientSecret"]); + }); + config.SetStoreId(context.Configuration["Auth0Fga:StoreId"]); + }); + + // OpenFGA + services.AddOpenFgaClient(config => + { + config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttp, context.Configuration["Fga:ApiHost"]); + + // Optionally add authentication settings + x.WithApiKeyAuthentication(context.Configuration["Fga:ApiKey"]); + x.WithOidcAuthentication( + context.Configuration["Fga:ClientId"], + context.Configuration["Fga:ClientSecret"], + context.Configuration["Fga:Issuer"], + context.Configuration["Fga:Audience"]); + }); + 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 2757f7b..ed459eb 100644 --- a/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs +++ b/src/Fga.Net.AspNetCore/Authorization/FineGrainedAuthorizationHandler.cs @@ -72,6 +72,12 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext _logger.NullValuesReturned(user, relation, @object); return; } + + if (!Validation.IsValidUser(user)) + { + _logger.InvalidUser(user); + return; + } checks.Add(new ClientCheckRequest { @@ -106,4 +112,6 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext } } } + + } diff --git a/src/Fga.Net.AspNetCore/Authorization/Log.cs b/src/Fga.Net.AspNetCore/Authorization/Log.cs index 7b95ccf..52f4780 100644 --- a/src/Fga.Net.AspNetCore/Authorization/Log.cs +++ b/src/Fga.Net.AspNetCore/Authorization/Log.cs @@ -34,5 +34,10 @@ internal static partial class Log [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); + + [LoggerMessage(3005, LogLevel.Debug, "User was not in a valid format of 'type:id' or '*'. Computed user as '{user}'")] + public static partial void InvalidUser(this ILogger logger, string user); + + } \ No newline at end of file diff --git a/src/Fga.Net.AspNetCore/Authorization/Validation.cs b/src/Fga.Net.AspNetCore/Authorization/Validation.cs new file mode 100644 index 0000000..58cd870 --- /dev/null +++ b/src/Fga.Net.AspNetCore/Authorization/Validation.cs @@ -0,0 +1,37 @@ +namespace Fga.Net.AspNetCore.Authorization; + +internal static class Validation +{ + // Validate the user is either in the type:id format or '*' + public static bool IsValidUser(string user) + { + var strSpan = user.AsSpan(); + + var seperatorCount = 0; + + for (var i = 0; i < strSpan.Length; i++) + { + var c = strSpan[i]; + // if the string contains whitespace then it isn't valid. + if (char.IsWhiteSpace(c)) + return false; + + if (c == ':') + { + // if : is at the start or end it isn't valid + if (i == 0 || i == strSpan.Length - 1) + return false; + seperatorCount++; + } + + } + + if (seperatorCount == 1) + return true; + + if (strSpan.Length == 1 && strSpan[0] == '*') + return true; + + return false; + } +} \ No newline at end of file diff --git a/src/Fga.Net.AspNetCore/FgaAspNetCoreConfiguration.cs b/src/Fga.Net.AspNetCore/FgaAspNetCoreConfiguration.cs index 49acfb1..e4fced9 100644 --- a/src/Fga.Net.AspNetCore/FgaAspNetCoreConfiguration.cs +++ b/src/Fga.Net.AspNetCore/FgaAspNetCoreConfiguration.cs @@ -1,5 +1,23 @@ -using System.Security.Claims; -using System.Security.Principal; +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion + + +using System.Security.Claims; using Fga.Net.AspNetCore.Authorization.Attributes; namespace Fga.Net.AspNetCore; @@ -9,8 +27,23 @@ namespace Fga.Net.AspNetCore; /// public sealed class FgaAspNetCoreConfiguration { + internal Func? UserIdentityResolver { get; private set; } + /// - /// A resolver that fetches the current user's identity based on the users during a network request. Used by all attributes derived from . + /// Configures the user identifier to be used for built-in check requests. + /// + /// SetUserIdentifier("user", principal => principal.Identity!.Name!); + /// + /// + /// Used by all attributes derived from . /// - public Func? UserIdentityResolver { get; set; } + /// The user type within your FGA model, such as user. + /// A resolver that gets the UserId from the requests claims. + public void SetUserIdentifier(string type, Func idResolver) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(idResolver); + + UserIdentityResolver = principal => $"{type}:{idResolver(principal)}"; + } } \ No newline at end of file diff --git a/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs b/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs new file mode 100644 index 0000000..541d098 --- /dev/null +++ b/src/Fga.Net/Configuration/Auth0FgaConnectionBuilder.cs @@ -0,0 +1,93 @@ +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion + + +using OpenFga.Sdk.Configuration; + +namespace Fga.Net.DependencyInjection.Configuration; + +/// +/// Available environments for Auth0 FGA +/// +public enum Auth0Environment +{ + /// + /// US Environment - `fga.us.auth0.com` + /// + Us +} + +internal sealed record Auth0FgaEnvironment(string Scheme, string ApiHost, string ApiTokenIssuer, string ApiAudience); + + +/// +/// Configuration for Auth0 FGA environments +/// +public sealed class Auth0FgaConnectionBuilder +{ + private readonly IReadOnlyDictionary _fgaEnvironments = + new Dictionary() + { + { + Auth0Environment.Us, + new Auth0FgaEnvironment(Uri.UriSchemeHttps, "api.us1.fga.dev", "fga.us.auth0.com", "https://api.us1.fga.dev/") + } + }; + + private readonly Auth0Environment _environment = Auth0Environment.Us; + + private string _clientId = null!; + private string _clientSecret = null!; + + + /// + /// Configure authentication for Auth0 FGA + /// + /// Client Id from your Auth0 FGA Account + /// Client Secret from your Auth0 FGA Account + public void WithAuthentication(string clientId, string clientSecret) + { + ArgumentNullException.ThrowIfNull(clientId); + ArgumentNullException.ThrowIfNull(clientSecret); + + _clientId = clientId; + _clientSecret = clientSecret; + } + + internal FgaConnectionConfiguration Build() + { + if (string.IsNullOrEmpty(_clientId) || string.IsNullOrEmpty(_clientSecret)) + throw new InvalidOperationException("Auth0 FGA ClientId and ClientSecret must be set to non-empty values"); + + var environment = _fgaEnvironments[_environment]; + + var credentials = new Credentials() + { + Method = CredentialsMethod.ClientCredentials, + Config = new CredentialsConfig() + { + ClientId = _clientId, + ClientSecret = _clientSecret, + ApiTokenIssuer = environment.ApiTokenIssuer, + ApiAudience = environment.ApiAudience + } + }; + + return new FgaConnectionConfiguration(environment.Scheme, environment.ApiHost, credentials); + } +} \ No newline at end of file diff --git a/src/Fga.Net/FgaClientConfiguration.cs b/src/Fga.Net/Configuration/FgaClientConfiguration.cs similarity index 93% rename from src/Fga.Net/FgaClientConfiguration.cs rename to src/Fga.Net/Configuration/FgaClientConfiguration.cs index fc42337..67121d7 100644 --- a/src/Fga.Net/FgaClientConfiguration.cs +++ b/src/Fga.Net/Configuration/FgaClientConfiguration.cs @@ -16,15 +16,13 @@ limitations under the License. */ #endregion - using OpenFga.Sdk.Client; -namespace Fga.Net.DependencyInjection; +namespace Fga.Net.DependencyInjection.Configuration; /// /// FGA Authentication/Authorization configuration /// public sealed class FgaClientConfiguration : ClientConfiguration { - -} +} \ No newline at end of file diff --git a/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs b/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs new file mode 100644 index 0000000..261ae3a --- /dev/null +++ b/src/Fga.Net/Configuration/OpenFgaConnectionBuilder.cs @@ -0,0 +1,103 @@ +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion + +using OpenFga.Sdk.Configuration; + +namespace Fga.Net.DependencyInjection.Configuration; + +/// +/// Configuration for OpenFga environments +/// +public sealed class OpenFgaConnectionBuilder +{ + private string _apiScheme = Uri.UriSchemeHttps; + private string? _apiHost; + + /// + /// Sets the connection configuration for the host. + /// + /// API scheme, either http or https. + /// API host, should be in be plain URI format + /// + public OpenFgaConnectionBuilder SetConnection(string apiScheme, string apiHost) + { + _apiScheme = apiScheme; + _apiHost = apiHost; + return this; + } + + private Credentials? _credentials; + + /// + /// Configures the OpenFGA client with API Key authentication. + /// Your FGA instance must be configured to support Key Authentication. See https://openfga.dev/docs/getting-started/setup-openfga/docker#pre-shared-key-authentication + /// + /// The API key to use. + public void WithApiKeyAuthentication(string apiKey) + { + _credentials = new Credentials() + { + Method = CredentialsMethod.ApiToken, + Config = new CredentialsConfig() + { + ApiToken = apiKey + } + }; + } + + /// + /// Configures the OpenFGA client with OIDC authentication (aka Client Credentials flow). + /// Your FGA instance must be configured to support OIDC Authentication. See https://openfga.dev/docs/getting-started/setup-openfga/docker#oidc + /// + /// Client ID + /// Client Secret + /// The token issuer + /// The audience of your FGA instance + public void WithOidcAuthentication(string clientId, string clientSecret, string issuer, string audience) + { + _credentials = new Credentials() + { + Method = CredentialsMethod.ClientCredentials, + Config = new CredentialsConfig() + { + ClientId = clientId, + ClientSecret = clientSecret, + ApiTokenIssuer = issuer, + ApiAudience = audience + } + }; + } + + internal FgaConnectionConfiguration Build() + { + if (string.IsNullOrEmpty(_apiHost)) + throw new InvalidOperationException("API Host cannot be null or empty"); + if (_apiScheme != Uri.UriSchemeHttps && _apiScheme != 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"); + if(_credentials?.Method == CredentialsMethod.ClientCredentials + && (string.IsNullOrEmpty(_credentials.Config?.ClientId) + || string.IsNullOrEmpty(_credentials.Config?.ClientSecret) + || string.IsNullOrEmpty(_credentials.Config?.ApiTokenIssuer) + || string.IsNullOrEmpty(_credentials.Config?.ApiAudience))) + throw new InvalidOperationException("Clients credential configuration cannot be contain missing values."); + + return new FgaConnectionConfiguration(_apiScheme, _apiHost, _credentials); + } +} \ No newline at end of file diff --git a/src/Fga.Net/Constants.cs b/src/Fga.Net/Constants.cs new file mode 100644 index 0000000..18a0036 --- /dev/null +++ b/src/Fga.Net/Constants.cs @@ -0,0 +1,6 @@ +namespace Fga.Net.DependencyInjection; + +internal static class Constants +{ + public const string FgaHttpClient = "FgaHttpClient"; +} \ 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 6e1390e..4bedef8 100644 --- a/src/Fga.Net/Fga.Net.DependencyInjection.csproj +++ b/src/Fga.Net/Fga.Net.DependencyInjection.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Fga.Net/FgaClientConfigurationExtensions.cs b/src/Fga.Net/FgaClientConfigurationExtensions.cs deleted file mode 100644 index 08fb5f1..0000000 --- a/src/Fga.Net/FgaClientConfigurationExtensions.cs +++ /dev/null @@ -1,52 +0,0 @@ -#region License -/* - Copyright 2021-2023 Hawxy - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ -#endregion - -using OpenFga.Sdk.Configuration; - -namespace Fga.Net.DependencyInjection; - -/// -/// Extensions for -/// -public static class FgaClientConfigurationExtensions -{ - - /// - /// Configures the client with connection defaults when using Auth0 FGA - /// - /// An instance of the configuration - /// The Auth0 FGA client ID - /// The Auth0 FGA client secret - public static void WithAuth0FgaDefaults(this FgaClientConfiguration config, string clientId, string clientSecret) - { - //TODO make environment configurable - config.ApiHost = "api.us1.fga.dev"; - config.Credentials = new Credentials() - { - Method = CredentialsMethod.ClientCredentials, - Config = new CredentialsConfig() - { - ClientId = clientId, - ClientSecret = clientSecret, - ApiTokenIssuer = "fga.us.auth0.com", - ApiAudience = "https://api.us1.fga.dev/" - } - }; - } - -} \ No newline at end of file diff --git a/src/Fga.Net/FgaConfigurationBuilder.cs b/src/Fga.Net/FgaConfigurationBuilder.cs new file mode 100644 index 0000000..4df7346 --- /dev/null +++ b/src/Fga.Net/FgaConfigurationBuilder.cs @@ -0,0 +1,102 @@ +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion + +using Fga.Net.DependencyInjection.Configuration; +using OpenFga.Sdk.Client; + +namespace Fga.Net.DependencyInjection; + +/// +/// Base configuration for all Fga implementations +/// +public sealed class FgaConfigurationBuilder +{ + private FgaConnectionConfiguration? _fgaConfiguration; + + private string? _storeId; + + /// + public FgaConfigurationBuilder SetStoreId(string storeId) + { + _storeId = storeId; + return this; + } + + private string? _authorizationModelId; + + /// + public FgaConfigurationBuilder SetAuthorizationModelId(string authorizationModelId) + { + _authorizationModelId = authorizationModelId; + return this; + } + + private int? _maxRetry; + + /// + public FgaConfigurationBuilder SetMaxRetry(int maxRetry) + { + _maxRetry = maxRetry; + return this; + } + + private int? _minWaitInMs; + /// + public FgaConfigurationBuilder SetWaitInMs(int waitInMs) + { + _minWaitInMs = waitInMs; + return this; + } + + + /// + /// Configures the client for use with OpenFga + /// + /// + public void ConfigureOpenFga(Action config) + { + ArgumentNullException.ThrowIfNull(config); + + var configuration = new OpenFgaConnectionBuilder(); + config.Invoke(configuration); + _fgaConfiguration = configuration.Build(); + } + + + /// + /// Configures the client for use with Auth0 Fga + /// + /// + public void ConfigureAuth0Fga(Action config) + { + ArgumentNullException.ThrowIfNull(config); + + var configuration = new Auth0FgaConnectionBuilder(); + config.Invoke(configuration); + _fgaConfiguration = configuration.Build(); + } + internal FgaBuiltConfiguration Build() + { + if (_fgaConfiguration is null) + throw new InvalidOperationException("OpenFga or Auth0 FGA configuration must be set"); + if (string.IsNullOrEmpty(_storeId)) + throw new InvalidOperationException("Store ID must be set"); + + return new FgaBuiltConfiguration(_storeId, _authorizationModelId, _maxRetry, _minWaitInMs, _fgaConfiguration); + } +} \ No newline at end of file diff --git a/src/Fga.Net/FgaConnectionConfiguration.cs b/src/Fga.Net/FgaConnectionConfiguration.cs new file mode 100644 index 0000000..19213ca --- /dev/null +++ b/src/Fga.Net/FgaConnectionConfiguration.cs @@ -0,0 +1,31 @@ +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion + +using OpenFga.Sdk.Configuration; + +namespace Fga.Net.DependencyInjection; + + +internal sealed record FgaBuiltConfiguration( + string StoreId, + string? AuthorizationModelId, + int? MaxRetry, + int? MinWaitInMs, + FgaConnectionConfiguration Connection); + +internal sealed record FgaConnectionConfiguration(string ApiScheme, string ApiHost, Credentials? Credentials); \ No newline at end of file diff --git a/src/Fga.Net/InjectableFgaApi.cs b/src/Fga.Net/InjectableFgaApi.cs index 4b5dae8..a6f211b 100644 --- a/src/Fga.Net/InjectableFgaApi.cs +++ b/src/Fga.Net/InjectableFgaApi.cs @@ -1,4 +1,22 @@ -using Microsoft.Extensions.Options; +#region License +/* + Copyright 2021-2023 Hawxy + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +#endregion +using Fga.Net.DependencyInjection.Configuration; +using Microsoft.Extensions.Options; using OpenFga.Sdk.Api; using OpenFga.Sdk.Client; @@ -6,14 +24,24 @@ namespace Fga.Net.DependencyInjection; internal sealed class InjectableFgaApi : OpenFgaApi { - public InjectableFgaApi(IOptions configuration, HttpClient httpClient) : base(configuration.Value, httpClient) + public InjectableFgaApi(IOptions configuration, IHttpClientFactory httpClientFactory) : base(configuration.Value.PurgeHeader(), httpClientFactory.CreateClient(Constants.FgaHttpClient)) { } } internal sealed class InjectableFgaClient : OpenFgaClient { - public InjectableFgaClient(IOptions configuration, HttpClient? httpClient = null) : base(configuration.Value, httpClient) + public InjectableFgaClient(IOptions configuration, IHttpClientFactory httpClientFactory) : base(configuration.Value.PurgeHeader(), httpClientFactory.CreateClient(Constants.FgaHttpClient)) + { + } +} + +internal static class ConfigurationExtensions +{ + // This fixes an exception when using API Tokens and both clients. + public static FgaClientConfiguration PurgeHeader(this FgaClientConfiguration configuration) { + configuration.DefaultHeaders.Remove("Authorization"); + return configuration; } } \ No newline at end of file diff --git a/src/Fga.Net/ServiceCollectionExtensions.cs b/src/Fga.Net/ServiceCollectionExtensions.cs index f6cfdc3..13187e0 100644 --- a/src/Fga.Net/ServiceCollectionExtensions.cs +++ b/src/Fga.Net/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ limitations under the License. */ #endregion +using Fga.Net.DependencyInjection.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenFga.Sdk.Api; using OpenFga.Sdk.Client; @@ -23,23 +24,74 @@ limitations under the License. namespace Fga.Net.DependencyInjection; /// -/// Extensions for registering Fga features to a .NET environment. +/// Extensions for registering Fga features to a /// public static class ServiceCollectionExtensions { + /// /// Registers and configures an and for the provided service collection. + /// Both clients are registered as singletons. + /// + /// The service collection + /// The lambda that configures the builder + /// An that can be used to further configure the underlying + public static IHttpClientBuilder AddOpenFgaClient(this IServiceCollection collection, Action fgaConfiguration) + { + // Add a HTTP factory instance that's suitable for injection into a singleton service. + var httpClient = collection.AddHttpClient(Constants.FgaHttpClient) + .ConfigurePrimaryHttpMessageHandler(() => + new SocketsHttpHandler() + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2) + }) + .SetHandlerLifetime(Timeout.InfiniteTimeSpan); + + collection.AddSingleton(); + collection.AddSingleton(); + + var configRoot = new FgaConfigurationBuilder(); + fgaConfiguration.Invoke(configRoot); + + var config = configRoot.Build(); + + collection.Configure(x=> + x.ConfigureFgaOptions(config)); + + return httpClient; + } + + /// + /// Replaces the existing FGA configuration options with the new configuration. Useful within test infrastructure. /// - /// - /// - /// An that can be used to configure the . - public static IHttpClientBuilder AddOpenFgaClient(this IServiceCollection collection, Action configuration) + /// The service collection + /// The lambda that configures the builder + public static void PostConfigureFgaClient(this IServiceCollection collection, Action fgaConfiguration) { - ArgumentNullException.ThrowIfNull(configuration); + var configBuilder = new FgaConfigurationBuilder(); + fgaConfiguration.Invoke(configBuilder); + + var config = configBuilder.Build(); - collection.Configure(configuration); - collection.AddHttpClient(); - return collection.AddHttpClient(); + collection.PostConfigure(x=> + x.ConfigureFgaOptions(config)); } + + + private static void ConfigureFgaOptions(this FgaClientConfiguration x, FgaBuiltConfiguration config) + { + x.ApiScheme = config.Connection.ApiScheme; + x.ApiHost = config.Connection.ApiHost; + + x.StoreId = config.StoreId; + x.AuthorizationModelId = config.AuthorizationModelId; + if (config.MaxRetry.HasValue) + x.MaxRetry = config.MaxRetry.Value; + if (config.MinWaitInMs.HasValue) + x.MinWaitInMs = config.MinWaitInMs.Value; + + x.Credentials = config.Connection.Credentials; + } + } diff --git a/tests/Fga.Net.Tests/Client/EndpointTests.cs b/tests/Fga.Net.Tests/Client/EndpointTests.cs index 3fe8dd9..eff4c38 100644 --- a/tests/Fga.Net.Tests/Client/EndpointTests.cs +++ b/tests/Fga.Net.Tests/Client/EndpointTests.cs @@ -9,101 +9,100 @@ using OpenFga.Sdk.Model; using Xunit; -namespace Fga.Net.Tests.Client +namespace Fga.Net.Tests.Client; + +[Collection(nameof(EndpointWebAppCollection))] +public class EndpointTests { - [Collection(nameof(EndpointWebAppCollection))] - public class EndpointTests - { - private readonly IAlbaHost _host; + private readonly IAlbaHost _host; - public EndpointTests(EndpointWebAppFixture fixture) - { - _host = fixture.AlbaHost; - } + public EndpointTests(EndpointWebAppFixture fixture) + { + _host = fixture.AlbaHost; + } - [Fact] - private async Task GetEndpoints_OpenFgaApi_Return_200() - { - using var scope = _host.Services.CreateScope(); - var client = scope.ServiceProvider.GetRequiredService(); - var modelsResponse = await client.ReadAuthorizationModels(); + [Fact] + private async Task GetEndpoints_OpenFgaApi_Return_200() + { + using var scope = _host.Services.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var modelsResponse = await client.ReadAuthorizationModels(); - Assert.NotNull(modelsResponse); - Assert.NotNull(modelsResponse.AuthorizationModels); - Assert.True(modelsResponse.AuthorizationModels?.Count > 0); + Assert.NotNull(modelsResponse); + Assert.NotNull(modelsResponse.AuthorizationModels); + Assert.True(modelsResponse.AuthorizationModels?.Count > 0); - var modelId = modelsResponse.AuthorizationModels?.First().Id!; + var modelId = modelsResponse.AuthorizationModels?.First().Id!; - var modelResponse = await client.ReadAuthorizationModel(modelId); + var modelResponse = await client.ReadAuthorizationModel(modelId); - Assert.NotNull(modelResponse); - Assert.NotNull(modelResponse.AuthorizationModel?.Id); + Assert.NotNull(modelResponse); + Assert.NotNull(modelResponse.AuthorizationModel?.Id); - var assertions = await client.ReadAssertions(modelId); + var assertions = await client.ReadAssertions(modelId); - Assert.NotNull(assertions); - Assert.True(assertions.Assertions?.Count > 0); - var assertion = assertions.Assertions!.First().TupleKey; + Assert.NotNull(assertions); + Assert.True(assertions.Assertions?.Count > 0); + var assertion = assertions.Assertions!.First().TupleKey; - Assert.NotEmpty(assertion!.Object!); - Assert.NotEmpty(assertion.Relation!); - Assert.NotEmpty(assertion.User!); + Assert.NotEmpty(assertion!.Object!); + Assert.NotEmpty(assertion.Relation!); + Assert.NotEmpty(assertion.User!); - var graph = await client.Expand(new ExpandRequest() - { - AuthorizationModelId = modelId, - TupleKey = assertion - }); + var graph = await client.Expand(new ExpandRequest() + { + AuthorizationModelId = modelId, + TupleKey = assertion + }); - Assert.NotNull(graph.Tree); - Assert.NotNull(graph.Tree!.Root!.Name); + Assert.NotNull(graph.Tree); + Assert.NotNull(graph.Tree!.Root!.Name); - var watch = await client.ReadChanges(); - Assert.NotNull(watch); + var watch = await client.ReadChanges(); + Assert.NotNull(watch); - } + } - [Fact] - private async Task GetEndpoints_OpenFgaClient_Return_200() - { - using var scope = _host.Services.CreateScope(); - var client = scope.ServiceProvider.GetRequiredService(); - var modelsResponse = await client.ReadAuthorizationModels(); + [Fact] + private async Task GetEndpoints_OpenFgaClient_Return_200() + { + using var scope = _host.Services.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + var modelsResponse = await client.ReadAuthorizationModels(); - Assert.NotNull(modelsResponse); - Assert.NotNull(modelsResponse.AuthorizationModels); - Assert.True(modelsResponse.AuthorizationModels?.Count > 0); + Assert.NotNull(modelsResponse); + Assert.NotNull(modelsResponse.AuthorizationModels); + Assert.True(modelsResponse.AuthorizationModels?.Count > 0); - var modelId = modelsResponse.AuthorizationModels?.First().Id!; + var modelId = modelsResponse.AuthorizationModels?.First().Id!; - var modelResponse = await client.ReadAuthorizationModel(new ClientReadAuthorizationModelOptions() {AuthorizationModelId = modelId}); + var modelResponse = await client.ReadAuthorizationModel(new ClientReadAuthorizationModelOptions() {AuthorizationModelId = modelId}); - Assert.NotNull(modelResponse); - Assert.NotNull(modelResponse.AuthorizationModel?.Id); + Assert.NotNull(modelResponse); + Assert.NotNull(modelResponse.AuthorizationModel?.Id); - var assertions = await client.ReadAssertions(new ClientReadAssertionsOptions() { AuthorizationModelId = modelId}); + var assertions = await client.ReadAssertions(new ClientReadAssertionsOptions() { AuthorizationModelId = modelId}); - Assert.NotNull(assertions); - Assert.True(assertions.Assertions?.Count > 0); - var assertion = assertions.Assertions!.First().TupleKey; + Assert.NotNull(assertions); + Assert.True(assertions.Assertions?.Count > 0); + var assertion = assertions.Assertions!.First().TupleKey; - Assert.NotEmpty(assertion!.Object!); - Assert.NotEmpty(assertion.Relation!); - Assert.NotEmpty(assertion.User!); + Assert.NotEmpty(assertion!.Object!); + Assert.NotEmpty(assertion.Relation!); + Assert.NotEmpty(assertion.User!); - var graph = await client.Expand(new ClientExpandRequest() - { - Object = assertion.Object!, - Relation = assertion.Relation! - }); - - Assert.NotNull(graph.Tree); - Assert.NotNull(graph.Tree!.Root!.Name); + var graph = await client.Expand(new ClientExpandRequest() + { + Object = assertion.Object!, + Relation = assertion.Relation! + }); - var watch = await client.ReadChanges(new ClientReadChangesRequest() {Type = "document"}); - Assert.NotNull(watch); - } + Assert.NotNull(graph.Tree); + Assert.NotNull(graph.Tree!.Root!.Name); + var watch = await client.ReadChanges(new ClientReadChangesRequest() {Type = "document"}); + Assert.NotNull(watch); } -} + +} \ No newline at end of file diff --git a/tests/Fga.Net.Tests/Middleware/MiddlewareTests.cs b/tests/Fga.Net.Tests/Middleware/MiddlewareTests.cs index 8412079..510390a 100644 --- a/tests/Fga.Net.Tests/Middleware/MiddlewareTests.cs +++ b/tests/Fga.Net.Tests/Middleware/MiddlewareTests.cs @@ -5,36 +5,35 @@ using Alba; using Xunit; -namespace Fga.Net.Tests.Middleware +namespace Fga.Net.Tests.Middleware; + +[Collection(nameof(WebAppCollection))] +public class MiddlewareTests { - [Collection(nameof(WebAppCollection))] - public class MiddlewareTests - { - private readonly IAlbaHost _alba; + private readonly IAlbaHost _alba; - public MiddlewareTests(WebAppFixture fixture) - { - _alba = fixture.AlbaHost; - } - [Fact] - public async Task Authorization_HappyPath_Succeeds() + public MiddlewareTests(WebAppFixture fixture) + { + _alba = fixture.AlbaHost; + } + [Fact] + public async Task Authorization_HappyPath_Succeeds() + { + await _alba.Scenario(_ => { - await _alba.Scenario(_ => - { - _.Get.Url($"/test/{Guid.NewGuid()}"); - _.StatusCodeShouldBeOk(); - }); - } - [Fact] - public async Task Authorization_UnhappyPath_Forbidden() + _.Get.Url($"/test/{Guid.NewGuid()}"); + _.StatusCodeShouldBeOk(); + }); + } + [Fact] + public async Task Authorization_UnhappyPath_Forbidden() + { + await _alba.Scenario(_ => { - await _alba.Scenario(_ => - { - _.RemoveClaim(ClaimTypes.NameIdentifier); - _.WithClaim(new Claim(ClaimTypes.NameIdentifier, MockJwtConfiguration.FakeUser)); - _.Get.Url($"/test/{Guid.NewGuid()}"); - _.StatusCodeShouldBe(HttpStatusCode.Forbidden); - }); - } + _.RemoveClaim(ClaimTypes.NameIdentifier); + _.WithClaim(new Claim(ClaimTypes.NameIdentifier, MockJwtConfiguration.FakeUser)); + _.Get.Url($"/test/{Guid.NewGuid()}"); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); } -} +} \ No newline at end of file diff --git a/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs b/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs index 1a6f63c..eaaa0a0 100644 --- a/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs +++ b/tests/Fga.Net.Tests/Middleware/WebAppFixture.cs @@ -27,7 +27,7 @@ public async Task InitializeAsync() .ReturnsAsync((List res, CancellationToken _) => { var entry = res.First(); - return entry.User == MockJwtConfiguration.DefaultUser + return entry.User == $"user:{MockJwtConfiguration.DefaultUser}" ? new BatchCheckResponse() { Responses = new List() { new(true, entry) } } : new BatchCheckResponse() { Responses = new List() { new(false, entry) } }; }); diff --git a/tests/Fga.Net.Tests/TheoryData.cs b/tests/Fga.Net.Tests/TheoryData.cs new file mode 100644 index 0000000..67d48d3 --- /dev/null +++ b/tests/Fga.Net.Tests/TheoryData.cs @@ -0,0 +1,25 @@ +using System.Collections; +using System.Collections.Generic; + +namespace Fga.Net.Tests; + +// https://andrewlock.net/creating-strongly-typed-xunit-theory-test-data-with-theorydata/ +public abstract class TheoryData : IEnumerable +{ + readonly List data = new List(); + + protected void AddRow(params object[] values) + { + data.Add(values); + } + + public IEnumerator GetEnumerator() + { + return data.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} \ No newline at end of file diff --git a/tests/Fga.Net.Tests/Unit/AttributeTests.cs b/tests/Fga.Net.Tests/Unit/AttributeTests.cs index 8d6d6a2..bba04ae 100644 --- a/tests/Fga.Net.Tests/Unit/AttributeTests.cs +++ b/tests/Fga.Net.Tests/Unit/AttributeTests.cs @@ -63,7 +63,7 @@ public async Task PropertyObjectAttribute_WorksAsExpected() [Fact] public async Task PropertyObjectAttribute_ThrowsOnMissingProperty() { - var data = Encoding.UTF8.GetBytes("{\"Foo\":\"Bar\",\"Array\":[]}"); + var data = "{\"Foo\":\"Bar\",\"Array\":[]}"u8.ToArray(); var httpContext = new HttpContextMock() .SetupUrl("http://localhost:8000/path") diff --git a/tests/Fga.Net.Tests/Unit/ExtensionScenario.cs b/tests/Fga.Net.Tests/Unit/ExtensionScenario.cs new file mode 100644 index 0000000..f63cfe2 --- /dev/null +++ b/tests/Fga.Net.Tests/Unit/ExtensionScenario.cs @@ -0,0 +1,34 @@ +using System; +using Fga.Net.DependencyInjection; +using Xunit.Abstractions; + +namespace Fga.Net.Tests.Unit; + +public sealed class ExtensionScenario : IXunitSerializable +{ + public ExtensionScenario() + { + } + + public ExtensionScenario(string description, Action configuration) + { + Description = description; + Configuration = configuration; + } + + public override string ToString() + { + return Description; + } + + public void Deserialize(IXunitSerializationInfo info) + { } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(Description), Description); + } + + public string Description { get; init; } = null!; + public Action Configuration { get; } = null!; +} \ No newline at end of file diff --git a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs index 16d69db..9e524cd 100644 --- a/tests/Fga.Net.Tests/Unit/ExtensionTests.cs +++ b/tests/Fga.Net.Tests/Unit/ExtensionTests.cs @@ -6,104 +6,206 @@ using Fga.Net.AspNetCore.Authorization; using Fga.Net.AspNetCore.Authorization.Attributes; using Fga.Net.DependencyInjection; +using Fga.Net.DependencyInjection.Configuration; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using OpenFga.Sdk.Api; using OpenFga.Sdk.Client; +using OpenFga.Sdk.Configuration; using Xunit; -namespace Fga.Net.Tests.Unit +namespace Fga.Net.Tests.Unit; + +public class ExtensionTests { - public class ExtensionTests - { - [Fact] - public void ClientExtensions_RegisterCorrectly() + public static TheoryData BadExtensions => + new() { - var collection = new ServiceCollection(); + new ExtensionScenario("Empty Extension Config - Auth0 FGA", + config => config.ConfigureAuth0Fga(x => { })), + new ExtensionScenario("Empty Schema", + config => config.ConfigureOpenFga(x => { x.SetConnection("", "localhost"); })), + new ExtensionScenario("Empty API Key", config => config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttps, "localhost") + .WithApiKeyAuthentication(""); + })), + new ExtensionScenario("Empty OIDC configuration", config => config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttps, "localhost") + .WithOidcAuthentication("clientId", "", "issuer", "audience"); + })), + }; + + [Theory] + [MemberData(nameof(BadExtensions))] + public void InvalidConfiguration_ThrowsException(ExtensionScenario scenario) + { + var collection = new ServiceCollection(); - collection.AddOpenFgaClient(x => + Assert.Throws(() => + collection.AddOpenFgaClient(config => { - x.StoreId = Guid.NewGuid().ToString(); - x.WithAuth0FgaDefaults(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + config.SetStoreId(Guid.NewGuid().ToString()); - }); + scenario.Configuration(config); + })); + } + - var provider = collection.BuildServiceProvider(); + + [Theory] + [MemberData(nameof(WorkingExtensions))] + public void ClientExtensions_RegisterCorrectly(ExtensionScenario scenario) + { + var collection = new ServiceCollection(); - var apiClient = provider.GetService(); - Assert.NotNull(apiClient); - Assert.IsType(apiClient); + collection.AddOpenFgaClient(config => + { + config.SetStoreId(Guid.NewGuid().ToString()); - var fgaClient = provider.GetService(); - Assert.NotNull(fgaClient); - Assert.IsType(fgaClient); - } + scenario.Configuration(config); - [Fact] - public void AspNetCoreServiceExtensions_RegisterCorrectly() - { - var collection = new ServiceCollection(); + }); - collection.AddOpenFgaClient(x => - { - x.StoreId = Guid.NewGuid().ToString(); - x.WithAuth0FgaDefaults(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); - }); + var provider = collection.BuildServiceProvider(); - collection.AddOpenFgaMiddleware(x => - { - x.UserIdentityResolver = principal => principal.Identity!.Name!; - }); + var apiClient = provider.GetService(); + Assert.NotNull(apiClient); + Assert.IsType(apiClient); - var provider = collection.BuildServiceProvider(); + var fgaClient = provider.GetService(); + Assert.NotNull(fgaClient); + Assert.IsType(fgaClient); + } - var col = provider.GetServices(); - Assert.Contains(col, handler => handler is FineGrainedAuthorizationHandler); - } + [Theory] + [MemberData(nameof(WorkingExtensions))] + public void AspNetCoreServiceExtensions_RegisterCorrectly(ExtensionScenario scenario) + { + var collection = new ServiceCollection(); - [Fact] - public void AuthorizationPolicyExtension_RegisterCorrectly() + collection.AddOpenFgaClient(config => { - var policy = new AuthorizationPolicyBuilder().AddFgaRequirement().Build(); + config.SetStoreId(Guid.NewGuid().ToString()); + scenario.Configuration(config); + }); - Assert.Contains(policy.Requirements, requirement => requirement is FineGrainedAuthorizationRequirement); - } + collection.AddOpenFgaMiddleware(x => + { + x.SetUserIdentifier("user", principal => principal.Identity!.Name!); + }); + + var provider = collection.BuildServiceProvider(); + + var col = provider.GetServices(); + + Assert.Contains(col, handler => handler is FineGrainedAuthorizationHandler); + } - [Fact] - public void MinimalExtensions_RegisterAttributesCorrectly() + public static TheoryData WorkingExtensions => + new() { - var builder = new TestEndpointRouteBuilder(); - builder.MapGet("/", () => Task.CompletedTask) - .WithFgaHeaderCheck("x", "y", "z") - .WithFgaRouteCheck("x", "y", "z") - .WithFgaQueryCheck("x", "y", "z") - .WithFgaPropertyCheck("x", "y", "z"); + new ExtensionScenario("Auth0 FGA", + config => config.ConfigureAuth0Fga(x => + { + x.WithAuthentication(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + })), + new ExtensionScenario("OpenFGA - No Credentials", + config => config.ConfigureOpenFga(x => + { x.SetConnection(Uri.UriSchemeHttp, "localhost"); + + })), + new ExtensionScenario("OpenFGA - API Key Auth", + config => config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttps, "localhost") + .WithApiKeyAuthentication("my-special-key"); + })), + new ExtensionScenario("OpenFGA - OIDC Auth", + config => config.ConfigureOpenFga(x => + { + x.SetConnection(Uri.UriSchemeHttps, "localhost") + .WithOidcAuthentication("clientId", "clientSecret", "issuer", "audience"); + })), + }; + + [Fact] + public void AuthorizationPolicyExtension_RegisterCorrectly() + { + var policy = new AuthorizationPolicyBuilder().AddFgaRequirement().Build(); + + 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 endpoint = builder.DataSources.Single().Endpoints.Single(); - var metadata = endpoint.Metadata.GetOrderedMetadata(); + 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); + 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(); } + } + - private class TestEndpointRouteBuilder : IEndpointRouteBuilder + [Fact] + public void PostConfigureOptions_OverridesSettings() + { + var collection = new ServiceCollection(); + + collection.AddOpenFgaClient(config => + { + config.SetStoreId(Guid.NewGuid().ToString()); + + config.ConfigureAuth0Fga(x=> x.WithAuthentication("FgaClientId", "FgaClientSecret")); + + }); + + var openFgaUrl = "localhost:8080"; + collection.PostConfigureFgaClient(config => { - public IServiceProvider ServiceProvider { get; } = new ServiceCollection().BuildServiceProvider(); - public ICollection DataSources { get; } = new List(); - public IApplicationBuilder CreateApplicationBuilder() + config.SetStoreId(Guid.NewGuid().ToString()); + config.ConfigureOpenFga(x => { - throw new NotImplementedException(); - } - } + x.SetConnection(Uri.UriSchemeHttp, openFgaUrl); + }); + }); + + var provider = collection.BuildServiceProvider(); + + var config = provider.GetRequiredService>().Value; + + Assert.Null(config.Credentials); + Assert.Equal(openFgaUrl, config.ApiHost); } -} + +} \ No newline at end of file diff --git a/tests/Fga.Net.Tests/Unit/ValidationTests.cs b/tests/Fga.Net.Tests/Unit/ValidationTests.cs new file mode 100644 index 0000000..5b80422 --- /dev/null +++ b/tests/Fga.Net.Tests/Unit/ValidationTests.cs @@ -0,0 +1,19 @@ +using Fga.Net.AspNetCore.Authorization; +using Xunit; + +namespace Fga.Net.Tests.Unit; + +public class ValidationTests +{ + [Theory] + [InlineData("*", true)] + [InlineData("type:id", true)] + [InlineData("*asdf", false)] + [InlineData("type:", false)] + [InlineData(":user", false)] + [InlineData(":", false)] + public void UserValidation_ValidatesCorrectly(string user, bool expected) + { + Assert.Equal(expected, Validation.IsValidUser(user)); + } +}