Skip to content

Commit

Permalink
v0.9 - Use batch checks in middleware, add Minimal API extensions (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
Hawxy authored Apr 14, 2023
1 parent a5ed24f commit 63ed2ae
Show file tree
Hide file tree
Showing 14 changed files with 185 additions and 59 deletions.
9 changes: 6 additions & 3 deletions .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -71,7 +72,8 @@
"Compile",
"NugetPack",
"NugetPush",
"Restore"
"Restore",
"Test"
]
}
},
Expand All @@ -89,7 +91,8 @@
"Compile",
"NugetPack",
"NugetPush",
"Restore"
"Restore",
"Test"
]
}
},
Expand Down
42 changes: 21 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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]
Expand All @@ -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
Expand All @@ -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<string> GetUser(HttpContext context)
=> ValueTask.FromResult(context.User.Identity!.Name!);


public override ValueTask<string> GetRelation(HttpContext context)
=> ValueTask.FromResult(context.Request.Method switch
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,9 +13,6 @@ public ComputedRelationshipAttribute(string type, string routeValue)
_routeValue = routeValue;
}

public override ValueTask<string> GetUser(HttpContext context)
=> ValueTask.FromResult(context.User.Identity!.Name!);

public override ValueTask<string> GetRelation(HttpContext context)
=> ValueTask.FromResult(context.Request.Method switch
{
Expand Down
4 changes: 2 additions & 2 deletions samples/Fga.Example.AspNetCore/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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();
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Debug",
"Microsoft.AspNetCore": "Debug"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class FgaRouteObjectAttribute : FgaBaseObjectAttribute
/// </summary>
/// <param name="relation">The relationship to check, such as writer or viewer</param>
/// <param name="type">The relation between the user and object</param>
/// <param name="routeKey">The query key to get the value from. Will throw an exception if not present.</param>
/// <param name="routeKey">The route key to get the value from. Will throw an exception if not present.</param>
public FgaRouteObjectAttribute(string relation, string type, string routeKey)
{
_relation = relation;
Expand Down
5 changes: 3 additions & 2 deletions src/Fga.Net.AspNetCore/Authorization/FgaCheckDecorator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,20 @@ public FgaCheckDecorator(OpenFgaClient auth0FgaApi)
/// <param name="request"></param>
/// <param name="ct"></param>
/// <returns></returns>
public virtual Task<CheckResponse> Check(ClientCheckRequest request, CancellationToken ct) => _auth0FgaApi.Check(request, cancellationToken: ct);
public virtual Task<BatchCheckResponse> BatchCheck(List<ClientCheckRequest> request, CancellationToken ct) => _auth0FgaApi.BatchCheck(request, cancellationToken: ct);
}

/// <summary>
/// Temporary wrapper to allow for easier testing of middleware. Don't take a dependency on this.
/// </summary>
public interface IFgaCheckDecorator
{

/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="ct"></param>
/// <returns></returns>
Task<CheckResponse> Check(ClientCheckRequest request, CancellationToken ct);
Task<BatchCheckResponse> BatchCheck(List<ClientCheckRequest> request, CancellationToken ct);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientCheckRequest>();

foreach (var attribute in attributes)
{
string? user;
Expand All @@ -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);
}
}
}
}
12 changes: 8 additions & 4 deletions src/Fga.Net.AspNetCore/Authorization/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

}
62 changes: 62 additions & 0 deletions src/Fga.Net.AspNetCore/Authorization/MinimalApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Fga.Net.AspNetCore.Authorization.Attributes;
using Microsoft.AspNetCore.Builder;

namespace Fga.Net.AspNetCore.Authorization;

/// <summary>
/// Fga extensions for use with Minimal APIs
/// </summary>
public static class MinimalApiExtensions
{
/// <summary>
/// <inheritdoc cref="FgaHeaderObjectAttribute"/>
/// </summary>
/// <param name="builder">The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder"/>.</param>
/// <param name="relation">The relationship to check, such as writer or viewer</param>
/// <param name="type">The relation between the user and object</param>
/// <param name="headerKey">The header to get the value from. Will throw an exception if not present.</param>
/// <returns>The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder" />.</returns>
public static TBuilder WithFgaHeaderCheck<TBuilder>(this TBuilder builder, string relation, string type, string headerKey) where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new FgaHeaderObjectAttribute(relation, type, headerKey));
}

/// <summary>
/// <inheritdoc cref="FgaPropertyObjectAttribute"/>
/// </summary>
/// <param name="builder">The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder"/>.</param>
/// <param name="relation">The relationship to check, such as writer or viewer</param>
/// <param name="type">The relation between the user and object</param>
/// <param name="property">The JSON property to get the value from. Must be a string or number. Will throw an exception if not present.</param>
/// <returns>The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder" />.</returns>
public static TBuilder WithFgaPropertyCheck<TBuilder>(this TBuilder builder, string relation, string type, string property) where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new FgaPropertyObjectAttribute(relation, type, property));
}

/// <summary>
/// <inheritdoc cref="FgaQueryObjectAttribute"/>
/// </summary>
/// <param name="builder">The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder"/>.</param>
/// <param name="relation">The relationship to check, such as writer or viewer</param>
/// <param name="type">The relation between the user and object</param>
/// <param name="queryKey">The query key to get the value from. Will throw an exception if not present.</param>
/// <returns>The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder" />.</returns>
public static TBuilder WithFgaQueryCheck<TBuilder>(this TBuilder builder, string relation, string type, string queryKey) where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new FgaQueryObjectAttribute(relation, type, queryKey));
}

/// <summary>
/// <inheritdoc cref="FgaRouteObjectAttribute"/>
/// </summary>
/// <param name="builder">The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder"/>.</param>
/// <param name="relation">The relationship to check, such as writer or viewer</param>
/// <param name="type">The relation between the user and object</param>
/// <param name="routeKey">The route key to get the value from. Will throw an exception if not present.</param>
/// <returns>The <see cref="T:Microsoft.AspNetCore.Builder.IEndpointConventionBuilder" />.</returns>
public static TBuilder WithFgaRouteCheck<TBuilder>(this TBuilder builder, string relation, string type, string routeKey) where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new FgaRouteObjectAttribute(relation, type, routeKey));
}
}
2 changes: 1 addition & 1 deletion src/Fga.Net/Fga.Net.DependencyInjection.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="DotNet.ReproducibleBuilds" Version="1.1.1" PrivateAssets="All" />
<PackageReference Include="OpenFga.Sdk" Version="0.2.2" />
<PackageReference Include="OpenFga.Sdk" Version="0.2.3" />
</ItemGroup>

<Import Project="../../Package.Build.props" />
Expand Down
7 changes: 1 addition & 6 deletions tests/Fga.Net.Tests/Client/EndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -103,12 +103,7 @@ private async Task GetEndpoints_OpenFgaClient_Return_200()

var watch = await client.ReadChanges(new ClientReadChangesRequest() {Type = "document"});
Assert.NotNull(watch);


}




}
}
20 changes: 13 additions & 7 deletions tests/Fga.Net.Tests/Middleware/WebAppFixture.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,12 +22,16 @@ public async Task InitializeAsync()
var authorizationClientMock = new Mock<IFgaCheckDecorator>();

authorizationClientMock.Setup(c =>
c.Check(It.IsAny<ClientCheckRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ClientCheckRequest res, CancellationToken _) =>
res.User == MockJwtConfiguration.DefaultUser
? new CheckResponse() { Allowed = true }
: new CheckResponse() { Allowed = false });
c.BatchCheck(It.IsAny<List<ClientCheckRequest>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((List<ClientCheckRequest> res, CancellationToken _) =>
{
var entry = res.First();
return entry.User == MockJwtConfiguration.DefaultUser
? new BatchCheckResponse() { Responses = new List<BatchCheckSingleResponse>() { new(true, entry) } }
: new BatchCheckResponse() { Responses = new List<BatchCheckSingleResponse>() { new(false, entry) } };
});



AlbaHost = await Alba.AlbaHost.For<Program>(builder =>
Expand Down
Loading

0 comments on commit 63ed2ae

Please sign in to comment.