-
Notifications
You must be signed in to change notification settings - Fork 108
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Protected Resource Builder (#89)
* feat: Add RequireProtectedResource for endpoints builder * Handle multiple registrations * Add IgnoreProtectedResource; Add Dynamic Resources
- Loading branch information
1 parent
686ed17
commit 627c38e
Showing
20 changed files
with
1,063 additions
and
59 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
23
src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
23 changes: 23 additions & 0 deletions
23
src/Keycloak.AuthServices.Authorization/IgnoreProtectedResourceAttribute.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
} |
34 changes: 34 additions & 0 deletions
34
src/Keycloak.AuthServices.Authorization/ProtectedResourceAttribute.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | ||
} |
133 changes: 133 additions & 0 deletions
133
...ycloak.AuthServices.Authorization/ProtectedResourceEndpointConventionBuilderExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); | ||
} |
Oops, something went wrong.