Skip to content

Commit

Permalink
feat: Add Protected Resource Builder (#89)
Browse files Browse the repository at this point in the history
* feat: Add RequireProtectedResource for endpoints builder

* Handle multiple registrations

* Add IgnoreProtectedResource; Add Dynamic Resources
  • Loading branch information
NikiforovAll authored May 7, 2024
1 parent 686ed17 commit 627c38e
Show file tree
Hide file tree
Showing 20 changed files with 1,063 additions and 59 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default withMermaid({
collapsed: true,
items: [
{ text: 'ASP.NET Core Integration', link: '/authorization/resources-api' },
{ text: 'Protected Resource Builder', link: '/authorization/protected-resource-builder' },
{ text: 'Policy Provider', link: '/authorization/policy-provider' },
]
},
Expand Down
5 changes: 4 additions & 1 deletion docs/admin-rest-api/admin-api.spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# admin-api (Service Client)

Here is an configuration file for admin-api client. Note, you might need to assign master realm roles after export separately.
Here is an configuration file for admin-api client.

> [!IMPORTANT]
> You need to assign master realm roles after export separately, otherwise you will get 403 error.
```json
{
Expand Down
99 changes: 99 additions & 0 deletions docs/authorization/protected-resource-builder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Protected Resource Builder

Using *Policies* is a standard and common approach. However, as the number of resources grows, organizing and managing these policies can become a challenge. To address this issue, we suggest using the **Protected Resource Builder** approach. This builder provides a convenient way to authorize resources, making it easier to manage and maintain authorization rules.

::: info
In most cases, we don't really need to build policies when working with *Authorization Server*, the authorization responsibility is delegated.
:::

## Add to your code

Here is an example of how to migrate from *Policies* to **Protected Resource Builder**:

```csharp
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;

services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddKeycloakWebApi(builder.Configuration);

services
.AddAuthorization()
.AddKeycloakAuthorization()
.AddAuthorizationServer(builder.Configuration);
services. // [!code --]
.AddAuthorizationBuilder() // [!code --]
.AddPolicy( // [!code --]
"WorkspaceRead", // [!code --]
policy => policy.RequireProtectedResource( // [!code --]
resource: "workspaces", // [!code --]
scope: "workspace:read" // [!code --]
) // [!code --]
); // [!code --]
var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/workspaces", () => "Hello World!").RequireAuthorization("WorkspaceRead"); // [!code --]
app.MapGet("/workspaces", () => "Hello World!") // [!code ++]
.RequireProtectedResource("workspaces", "workspace:read"); // [!code ++]
app.Run();
```

With just one line, we can authorize access for `"workspaces#workspace:read"`, no policy registrations needed. 🚀

## Dynamic Resources

You can use path parameters in resource names by enclosing parameter name in '{}'.

In example below, we are substituting resource with value `{id}` with the actual value of path parameter.

<<< @/../tests/TestWebApi/Program.cs#SingleDynamicResourceSingleScopeSingleEndpoint

> [!NOTE]
> ☝️Currently, it is not possible to use body or query parameters. Please create an issue if it is something that you are interested in adding.
## Multiple Scopes

Here is how to check for multiple scopes simultaneously:

<<< @/../tests/TestWebApi/Program.cs#SingleResourceMultipleScopesSingleEndpointV2

Chained calls:

<<< @/../tests/TestWebApi/Program.cs#SingleResourceMultipleScopesSingleEndpoint

Endpoint hierarchy:

<<< @/../tests/TestWebApi/Program.cs#SingleResourceMultipleScopesEndpointHierarchy

Basically, you can define *Group-level* protected resources and *Endpoint-level* protected resources.

> [!NOTE]
> 💡 `RequireProtectedResource` is extension method over `IEndpointConventionBuilder`. It means you can use it outside of Minimal API. E.g.: MVC, RazorPages, etc. Here is the original design document: <https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/issues/87>
## Multiple Resources

You are not limited to use single resource:

<<< @/../tests/TestWebApi/Program.cs#MultipleResourcesMultipleScopesSingleEndpoint

Here is how to define top-level rule for "workspaces" in general and specific rule for particular workspace.

<<< @/../tests/TestWebApi/Program.cs#MultipleResourcesMultipleScopesEndpointHierarchy

## Ignore Resources

Similarly, to `AllowAnonymous` from `Microsoft.AspNetCore.Authorization` namespace, there are two methods to ignore what has been registered for protected resources: `IgnoreProtectedResources`, `IgnoreProtectedResource`.

```csharp
public static TBuilder AllowAnonymous<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder;
```

<<< @/../tests/TestWebApi/Program.cs#SingleResourceIgnoreProtectedResourceEndpointHierarchy

> [!TIP]
> See the integration tests [ProtectedResourcePolicyTests](https://github.com/NikiforovAll/keycloak-authorization-services-dotnet/tree/main/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourcePolicyTests.cs) for more details.
23 changes: 23 additions & 0 deletions src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Keycloak.AuthServices.Authorization;

/// <summary>
/// Defines the set of data required to apply authorization rules to a resource.
/// </summary>
public interface IProtectedResourceData
{
/// <summary>
/// Gets or sets resource name
/// </summary>
string Resource { get; }

/// <summary>
/// Get or sets scopes
/// </summary>
string[]? Scopes { get; }

/// <summary>
/// </summary>
/// <returns></returns>
public string GetScopesExpression() =>
this.Scopes is not null ? string.Join(',', this.Scopes) : string.Empty;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Keycloak.AuthServices.Authorization;

/// <summary>
/// Specifies that the class or method that this attribute is applied to requires the specified authorization.
/// </summary>
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = true,
Inherited = true
)]
public sealed class IgnoreProtectedResourceAttribute : Attribute, IProtectedResourceData
{
/// <summary>
/// Initializes a new instance of the <see cref="ProtectedResourceAttribute"/> class with the specified policy.
/// </summary>
public IgnoreProtectedResourceAttribute(string resource) => this.Resource = resource;

/// <inheritdoc/>
public string Resource { get; set; }

/// <inheritdoc/>
public string[]? Scopes { get; set; }
}
37 changes: 37 additions & 0 deletions src/Keycloak.AuthServices.Authorization/LogExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Keycloak.AuthServices.Authorization;

using Microsoft.Extensions.Logging;

internal static partial class LogExtensions
{
[LoggerMessage(
100,
LogLevel.Debug,
"[{Requirement}] Access outcome '{Outcome}' for user '{UserName}'"
)]
public static partial void LogAuthorizationResult(
this ILogger logger,
string requirement,
bool outcome,
string? userName
);

[LoggerMessage(
101,
LogLevel.Warning,
"[{Requirement}] Has been skipped because of '{Reason}' for user '{UserName}'"
)]
public static partial void LogRequirementSkipped(
this ILogger logger,
string requirement,
string reason,
string? userName
);

[LoggerMessage(102, LogLevel.Debug, "User - '{UserName}' has verification table: {Verification}")]
public static partial void LogVerification(
this ILogger logger,
string verification,
string? userName
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Keycloak.AuthServices.Authorization;

/// <summary>
/// Specifies that the class or method that this attribute is applied to requires the specified authorization.
/// </summary>
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = true,
Inherited = true
)]
public sealed class ProtectedResourceAttribute : Attribute, IProtectedResourceData
{
/// <summary>
/// Initializes a new instance of the <see cref="ProtectedResourceAttribute"/> class with the specified policy.
/// </summary>
public ProtectedResourceAttribute(string resource, string? scope)
: this(resource, string.IsNullOrWhiteSpace(scope) ? Array.Empty<string>() : new[] { scope })
{ }

/// <summary>
/// Initializes a new instance of the <see cref="ProtectedResourceAttribute"/> class with the specified policy.
/// </summary>
public ProtectedResourceAttribute(string resource, string[] scopes)
{
this.Resource = resource;
this.Scopes = scopes;
}

/// <inheritdoc/>
public string Resource { get; set; }

/// <inheritdoc/>
public string[]? Scopes { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
namespace Keycloak.AuthServices.Authorization;

using Keycloak.AuthServices.Authorization.Requirements;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;

/// <summary>
/// Authorization extension methods for <see cref="IEndpointConventionBuilder"/>.
/// </summary>
public static class ProtectedResourceEndpointConventionBuilderExtensions
{
/// <summary>
/// Adds <see cref="ProtectedResourceAttribute"/> to the endpoint(s).
/// </summary>
/// <param name="builder">The endpoint convention builder.</param>
/// <param name="resource"></param>
/// <returns>The original convention builder parameter.</returns>
public static TBuilder RequireProtectedResource<TBuilder>(
this TBuilder builder,
string resource
)
where TBuilder : IEndpointConventionBuilder
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(resource);

RequireAuthorizationCore(
builder,
new ProtectedResourceAttribute[] { new(resource, string.Empty) }
);

return builder;
}

/// <summary>
/// Adds <see cref="ProtectedResourceAttribute"/> to the endpoint(s).
/// </summary>
/// <param name="builder">The endpoint convention builder.</param>
/// <param name="resource"></param>
/// <param name="scope"></param>
/// <returns>The original convention builder parameter.</returns>
public static TBuilder RequireProtectedResource<TBuilder>(
this TBuilder builder,
string resource,
string scope
)
where TBuilder : IEndpointConventionBuilder =>
builder.RequireProtectedResource(resource, new string[] { scope });

/// <summary>
/// Adds <see cref="ProtectedResourceAttribute"/> to the endpoint(s).
/// </summary>
/// <param name="builder">The endpoint convention builder.</param>
/// <param name="resource"></param>
/// <param name="scopes"></param>
/// <returns>The original convention builder parameter.</returns>
public static TBuilder RequireProtectedResource<TBuilder>(
this TBuilder builder,
string resource,
string[] scopes
)
where TBuilder : IEndpointConventionBuilder
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(scopes);

RequireAuthorizationCore(
builder,
new ProtectedResourceAttribute[] { new(resource, scopes) }
);

return builder;
}

/// <summary>
/// Adds <see cref="IgnoreProtectedResourceAttribute"/> to the endpoint(s).
/// </summary>
/// <param name="builder">The endpoint convention builder.</param>
/// <param name="resource"></param>
/// <returns>The original convention builder parameter.</returns>
public static TBuilder IgnoreProtectedResource<TBuilder>(this TBuilder builder, string resource)
where TBuilder : IEndpointConventionBuilder
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(resource);

RequireAuthorizationCore(builder, new IgnoreProtectedResourceAttribute[] { new(resource) });

return builder;
}

/// <summary>
/// Adds <see cref="IgnoreProtectedResourceAttribute"/> to the endpoint(s).
/// </summary>
/// <param name="builder">The endpoint convention builder.</param>
/// <returns>The original convention builder parameter.</returns>
public static TBuilder IgnoreProtectedResources<TBuilder>(this TBuilder builder)
where TBuilder : IEndpointConventionBuilder
{
ArgumentNullException.ThrowIfNull(builder);

RequireAuthorizationCore(
builder,
new IgnoreProtectedResourceAttribute[] { new(string.Empty) }
);

return builder;
}

private static void RequireAuthorizationCore<TBuilder>(
TBuilder builder,
IEnumerable<IProtectedResourceData> authorizeData
)
where TBuilder : IEndpointConventionBuilder =>
builder.Add(endpointBuilder =>
{
// avoid multiple requirements registration
if (!endpointBuilder.Metadata.Any(m => m is IProtectedResourceData))
{
endpointBuilder.Metadata.Add(
new AuthorizeAttribute(
ParameterizedProtectedResourceRequirement.DynamicProtectedResourcePolicy
)
);
}
foreach (var data in authorizeData)
{
endpointBuilder.Metadata.Add(data);
}
});
}
Loading

0 comments on commit 627c38e

Please sign in to comment.