From 627c38ebc5028de0f1d3f36cc169ed2281c6ca47 Mon Sep 17 00:00:00 2001 From: Oleksii Nikiforov Date: Tue, 7 May 2024 16:48:01 +0300 Subject: [PATCH] feat: Add Protected Resource Builder (#89) * feat: Add RequireProtectedResource for endpoints builder * Handle multiple registrations * Add IgnoreProtectedResource; Add Dynamic Resources --- docs/.vitepress/config.mts | 1 + docs/admin-rest-api/admin-api.spec.md | 5 +- .../protected-resource-builder.md | 99 +++++++ .../IProtectedResourceData.cs | 23 ++ .../IgnoreProtectedResourceAttribute.cs | 23 ++ .../LogExtensions.cs | 37 +++ .../ProtectedResourceAttribute.cs | 34 +++ ...urceEndpointConventionBuilderExtensions.cs | 133 +++++++++ .../Requirements/DecisionRequirement.cs | 64 ++--- ...rameterizedProtectedResourceRequirement.cs | 242 ++++++++++++++++ .../Requirements/RealmAccessRequirement.cs | 14 +- .../Requirements/ResourceAccessRequirement.cs | 9 +- .../ServiceCollectionExtensions.cs | 9 + .../KeycloakConfiguration/Test-realm.json | 18 +- .../KeycloakConfiguration/Test-users-0.json | 2 +- .../ProtectedResourcePolicyTests.cs | 261 ++++++++++++++++++ .../README.md | 8 + .../docker-compose.yml | 4 +- tests/TestWebApi/Program.cs | 133 +++++++++ tests/TestWebApi/TestWebApi.csproj | 3 + 20 files changed, 1063 insertions(+), 59 deletions(-) create mode 100644 docs/authorization/protected-resource-builder.md create mode 100644 src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs create mode 100644 src/Keycloak.AuthServices.Authorization/IgnoreProtectedResourceAttribute.cs create mode 100644 src/Keycloak.AuthServices.Authorization/LogExtensions.cs create mode 100644 src/Keycloak.AuthServices.Authorization/ProtectedResourceAttribute.cs create mode 100644 src/Keycloak.AuthServices.Authorization/ProtectedResourceEndpointConventionBuilderExtensions.cs create mode 100644 src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs create mode 100644 tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourcePolicyTests.cs diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index bd5ebdb9..205fa882 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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' }, ] }, diff --git a/docs/admin-rest-api/admin-api.spec.md b/docs/admin-rest-api/admin-api.spec.md index 778fedec..7e6dc27f 100644 --- a/docs/admin-rest-api/admin-api.spec.md +++ b/docs/admin-rest-api/admin-api.spec.md @@ -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 { diff --git a/docs/authorization/protected-resource-builder.md b/docs/authorization/protected-resource-builder.md new file mode 100644 index 00000000..e9074920 --- /dev/null +++ b/docs/authorization/protected-resource-builder.md @@ -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: + +## 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(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. diff --git a/src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs b/src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs new file mode 100644 index 00000000..666dac66 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/IProtectedResourceData.cs @@ -0,0 +1,23 @@ +namespace Keycloak.AuthServices.Authorization; + +/// +/// Defines the set of data required to apply authorization rules to a resource. +/// +public interface IProtectedResourceData +{ + /// + /// Gets or sets resource name + /// + string Resource { get; } + + /// + /// Get or sets scopes + /// + string[]? Scopes { get; } + + /// + /// + /// + public string GetScopesExpression() => + this.Scopes is not null ? string.Join(',', this.Scopes) : string.Empty; +} diff --git a/src/Keycloak.AuthServices.Authorization/IgnoreProtectedResourceAttribute.cs b/src/Keycloak.AuthServices.Authorization/IgnoreProtectedResourceAttribute.cs new file mode 100644 index 00000000..c3f75ced --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/IgnoreProtectedResourceAttribute.cs @@ -0,0 +1,23 @@ +namespace Keycloak.AuthServices.Authorization; + +/// +/// Specifies that the class or method that this attribute is applied to requires the specified authorization. +/// +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Method, + AllowMultiple = true, + Inherited = true +)] +public sealed class IgnoreProtectedResourceAttribute : Attribute, IProtectedResourceData +{ + /// + /// Initializes a new instance of the class with the specified policy. + /// + public IgnoreProtectedResourceAttribute(string resource) => this.Resource = resource; + + /// + public string Resource { get; set; } + + /// + public string[]? Scopes { get; set; } +} diff --git a/src/Keycloak.AuthServices.Authorization/LogExtensions.cs b/src/Keycloak.AuthServices.Authorization/LogExtensions.cs new file mode 100644 index 00000000..3a9c7c86 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/LogExtensions.cs @@ -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 + ); +} diff --git a/src/Keycloak.AuthServices.Authorization/ProtectedResourceAttribute.cs b/src/Keycloak.AuthServices.Authorization/ProtectedResourceAttribute.cs new file mode 100644 index 00000000..8fc62e4e --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/ProtectedResourceAttribute.cs @@ -0,0 +1,34 @@ +namespace Keycloak.AuthServices.Authorization; + +/// +/// Specifies that the class or method that this attribute is applied to requires the specified authorization. +/// +[AttributeUsage( + AttributeTargets.Class | AttributeTargets.Method, + AllowMultiple = true, + Inherited = true +)] +public sealed class ProtectedResourceAttribute : Attribute, IProtectedResourceData +{ + /// + /// Initializes a new instance of the class with the specified policy. + /// + public ProtectedResourceAttribute(string resource, string? scope) + : this(resource, string.IsNullOrWhiteSpace(scope) ? Array.Empty() : new[] { scope }) + { } + + /// + /// Initializes a new instance of the class with the specified policy. + /// + public ProtectedResourceAttribute(string resource, string[] scopes) + { + this.Resource = resource; + this.Scopes = scopes; + } + + /// + public string Resource { get; set; } + + /// + public string[]? Scopes { get; set; } +} diff --git a/src/Keycloak.AuthServices.Authorization/ProtectedResourceEndpointConventionBuilderExtensions.cs b/src/Keycloak.AuthServices.Authorization/ProtectedResourceEndpointConventionBuilderExtensions.cs new file mode 100644 index 00000000..00710cae --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/ProtectedResourceEndpointConventionBuilderExtensions.cs @@ -0,0 +1,133 @@ +namespace Keycloak.AuthServices.Authorization; + +using Keycloak.AuthServices.Authorization.Requirements; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; + +/// +/// Authorization extension methods for . +/// +public static class ProtectedResourceEndpointConventionBuilderExtensions +{ + /// + /// Adds to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// The original convention builder parameter. + public static TBuilder RequireProtectedResource( + this TBuilder builder, + string resource + ) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(resource); + + RequireAuthorizationCore( + builder, + new ProtectedResourceAttribute[] { new(resource, string.Empty) } + ); + + return builder; + } + + /// + /// Adds to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// + /// The original convention builder parameter. + public static TBuilder RequireProtectedResource( + this TBuilder builder, + string resource, + string scope + ) + where TBuilder : IEndpointConventionBuilder => + builder.RequireProtectedResource(resource, new string[] { scope }); + + /// + /// Adds to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// + /// The original convention builder parameter. + public static TBuilder RequireProtectedResource( + 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; + } + + /// + /// Adds to the endpoint(s). + /// + /// The endpoint convention builder. + /// + /// The original convention builder parameter. + public static TBuilder IgnoreProtectedResource(this TBuilder builder, string resource) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(resource); + + RequireAuthorizationCore(builder, new IgnoreProtectedResourceAttribute[] { new(resource) }); + + return builder; + } + + /// + /// Adds to the endpoint(s). + /// + /// The endpoint convention builder. + /// The original convention builder parameter. + public static TBuilder IgnoreProtectedResources(this TBuilder builder) + where TBuilder : IEndpointConventionBuilder + { + ArgumentNullException.ThrowIfNull(builder); + + RequireAuthorizationCore( + builder, + new IgnoreProtectedResourceAttribute[] { new(string.Empty) } + ); + + return builder; + } + + private static void RequireAuthorizationCore( + TBuilder builder, + IEnumerable 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); + } + }); +} diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs index 6714d08c..0687b12e 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/DecisionRequirement.cs @@ -7,7 +7,7 @@ namespace Keycloak.AuthServices.Authorization.Requirements; /// /// Decision requirement /// -public class DecisionRequirement : IAuthorizationRequirement +public class DecisionRequirement : IAuthorizationRequirement, IProtectedResourceData { /// /// Resource name @@ -57,12 +57,7 @@ public DecisionRequirement(string resource, string id, string scope) /// public override string ToString() => - $"{nameof(DecisionRequirement)}: {this.Resource}#{this.GetScopesExpression()}"; - - /// - /// - /// - public string GetScopesExpression() => string.Join(',', this.Scopes); + $"{nameof(DecisionRequirement)}: {this.Resource}#{(this as IProtectedResourceData).GetScopesExpression()}"; } /// @@ -86,50 +81,43 @@ ILogger logger this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - [LoggerMessage( - 103, - LogLevel.Debug, - "[{Requirement}] Access outcome {Outcome} for user {UserName}" - )] - partial void DecisionAuthorizationResult(string requirement, bool outcome, string? userName); - /// protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, DecisionRequirement requirement ) { - if (context.User.Identity?.IsAuthenticated ?? false) + if (!(context.User.Identity?.IsAuthenticated ?? false)) { - var success = await this.client.VerifyAccessToResource( - requirement.Resource, - requirement.GetScopesExpression(), - requirement.ScopesValidationMode, - CancellationToken.None - ); - - this.DecisionAuthorizationResult( - requirement.ToString(), - success, + this.logger.LogRequirementSkipped( + nameof(ParameterizedProtectedResourceRequirementHandler), + "User is not Authenticated", context.User.Identity?.Name ); - if (success) - { - context.Succeed(requirement); - } - else - { - context.Fail(); - } + return; + } + + var success = await this.client.VerifyAccessToResource( + requirement.Resource, + (requirement as IProtectedResourceData).GetScopesExpression(), + requirement.ScopesValidationMode, + CancellationToken.None + ); + + this.logger.LogAuthorizationResult( + requirement.ToString(), + success, + context.User.Identity?.Name + ); + + if (success) + { + context.Succeed(requirement); } else { - this.DecisionAuthorizationResult( - requirement.ToString(), - false, - context.User.Identity?.Name - ); + context.Fail(); } } } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs new file mode 100644 index 00000000..1ae1ff47 --- /dev/null +++ b/src/Keycloak.AuthServices.Authorization/Requirements/ParameterizedProtectedResourceRequirement.cs @@ -0,0 +1,242 @@ +namespace Keycloak.AuthServices.Authorization.Requirements; + +using System.Collections; +using System.Globalization; +using System.Text; +using Keycloak.AuthServices.Authorization.AuthorizationServer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +/// +/// Decision requirement +/// +public class ParameterizedProtectedResourceRequirement : IAuthorizationRequirement +{ + /// + /// Internal name for global policy + /// + public const string DynamicProtectedResourcePolicy = "$DynamicProtectedResourcePolicy"; +} + +/// +/// +public partial class ParameterizedProtectedResourceRequirementHandler + : AuthorizationHandler +{ + private readonly IAuthorizationServerClient client; + private readonly IHttpContextAccessor httpContextAccessor; + private readonly ILogger logger; + + /// + /// + /// + /// + /// + /// + public ParameterizedProtectedResourceRequirementHandler( + IAuthorizationServerClient client, + IHttpContextAccessor httpContextAccessor, + ILogger logger + ) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.httpContextAccessor = httpContextAccessor; + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + ParameterizedProtectedResourceRequirement requirement + ) + { + if (!(context.User.Identity?.IsAuthenticated ?? false)) + { + this.logger.LogRequirementSkipped( + nameof(ParameterizedProtectedResourceRequirementHandler), + "User is not Authenticated", + context.User.Identity?.Name + ); + + return; + } + + var endpoint = this.httpContextAccessor.HttpContext?.GetEndpoint(); + var userName = context.User.Identity?.Name; + + var requirementData = + endpoint?.Metadata?.GetOrderedMetadata() + ?? Array.Empty(); + + var verificationPlan = new VerificationPlan(); + verificationPlan.AddRange(requirementData); + + if (requirementData.Count > 0) + { + foreach (var entry in verificationPlan) + { + var scopes = entry.GetScopesExpression(); + + var resource = ResolveResource( + entry.Resource, + this.httpContextAccessor.HttpContext + ); + + var success = await this.client.VerifyAccessToResource( + resource, + scopes, + CancellationToken.None + ); + + verificationPlan.Complete(entry.Resource, success); + + if (!success) + { + this.logger.LogVerification(verificationPlan.ToString(), userName); + this.logger.LogAuthorizationResult( + nameof(ParameterizedProtectedResourceRequirementHandler), + false, + userName + ); + context.Fail(); + + return; + } + } + + this.logger.LogVerification(verificationPlan.ToString(), userName); + this.logger.LogAuthorizationResult( + nameof(ParameterizedProtectedResourceRequirementHandler), + true, + userName + ); + + context.Succeed(requirement); + } + } + + private static string ResolveResource(string resource, HttpContext? httpContext) + { + if (httpContext is null) + { + return resource; + } + + var pathParameters = httpContext.GetRouteData()?.Values; + + if (pathParameters != null && resource.Contains('}') && resource.Contains('{')) + { + foreach (var parameter in pathParameters) + { + var parameterName = parameter.Key; + + if (resource.Contains($"{{{parameterName}}}")) + { + var parameterValue = parameter.Value?.ToString(); + resource = resource.Replace($"{{{parameterName}}}", parameterValue); + } + } + } + + return resource; + } + + private sealed class VerificationPlan : IEnumerable + { + public List Resources { get; } = new(); + private Dictionary> resourceToScopes = new(); + private Dictionary resourceToOutcomes = new(); + + public void AddRange(IEnumerable protectedResources) + { + foreach (var item in protectedResources) + { + if (item is IgnoreProtectedResourceAttribute) + { + this.Remove(item.Resource); + } + else + { + this.Add(item.Resource, item.GetScopesExpression()); + } + } + } + + public void Add(string resource, string scopes) + { + if (this.resourceToScopes.ContainsKey(resource)) + { + this.resourceToScopes[resource].Add(scopes); + } + else + { + this.Resources.Add(resource); + + this.resourceToScopes[resource] = new List() { scopes }; + } + } + + public bool Remove(string resource) + { + if (resource == string.Empty) + { + this.resourceToScopes = new(); + this.resourceToOutcomes = new(); + this.Resources.RemoveAll(_ => true); + + return true; + } + else if (this.resourceToScopes.ContainsKey(resource)) + { + this.resourceToScopes.Remove(resource); + this.resourceToOutcomes.Remove(resource); + this.Resources.Remove(resource); + + return true; + } + + return false; + } + + public void Complete(string resource, bool result) => + this.resourceToOutcomes[resource] = result; + + public override string ToString() + { + var sb = new StringBuilder(Environment.NewLine); + + sb.AppendLine( + CultureInfo.InvariantCulture, + $"Resource: {string.Empty, -5} Scopes: {string.Empty, -7}" + ); + + foreach (var data in this) + { + var executed = this.resourceToOutcomes.TryGetValue(data.Resource, out var outcome); + + sb.AppendLine( + CultureInfo.InvariantCulture, + $"{data.Resource, -15} {data.GetScopesExpression(), -20} {(executed ? outcome : string.Empty), -9}" + ); + } + + return sb.ToString(); + } + + public IEnumerator GetEnumerator() + { + var resources = new List(); + + foreach (var resource in this.Resources) + { + resources.Add(new(resource, this.resourceToScopes[resource].ToArray())); + } + + return resources.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + } +} diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs index a39a8b7a..355d0495 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/RealmAccessRequirement.cs @@ -37,13 +37,6 @@ public partial class RealmAccessRequirementHandler : AuthorizationHandler logger) => this.logger = logger; - [LoggerMessage( - 100, - LogLevel.Debug, - "[{Requirement}] Access outcome {Outcome} for user {UserName}" - )] - partial void RealmAuthorizationResult(string requirement, bool outcome, string? userName); - /// protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, @@ -51,6 +44,7 @@ RealmAccessRequirement requirement ) { var success = false; + if (context.User.Claims.TryGetRealmResource(out var resourceAccess)) { success = resourceAccess.Roles.Intersect(requirement.Roles).Any(); @@ -61,7 +55,11 @@ RealmAccessRequirement requirement } } - this.RealmAuthorizationResult(requirement.ToString(), success, context.User.Identity?.Name); + this.logger.LogAuthorizationResult( + requirement.ToString(), + success, + context.User.Identity?.Name + ); return Task.CompletedTask; } diff --git a/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs b/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs index 230e50d8..dcd6c82b 100644 --- a/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs +++ b/src/Keycloak.AuthServices.Authorization/Requirements/ResourceAccessRequirement.cs @@ -58,13 +58,6 @@ ILogger logger this.logger = logger; } - [LoggerMessage( - 101, - LogLevel.Debug, - "[{Requirement}] Access outcome {Outcome} for user {UserName}" - )] - partial void ResourceAuthorizationResult(string requirement, bool outcome, string? userName); - /// protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, @@ -99,7 +92,7 @@ ResourceAccessRequirement requirement } } - this.ResourceAuthorizationResult( + this.logger.LogAuthorizationResult( requirement.ToString(), success, context.User.Identity?.Name diff --git a/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs b/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs index ddbe3870..643285b8 100644 --- a/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs +++ b/src/Keycloak.AuthServices.Authorization/ServiceCollectionExtensions.cs @@ -133,6 +133,15 @@ public static IHttpClientBuilder AddAuthorizationServer( services.AddHttpContextAccessor(); + services.AddAuthorization(options => + options.AddPolicy( + ParameterizedProtectedResourceRequirement.DynamicProtectedResourcePolicy, + p => p.AddRequirements(new ParameterizedProtectedResourceRequirement()) + ) + ); + + services.AddScoped(); + // TODO: determine correct lifetime. services.AddSingleton(); // (!) resolved locally, will not work with PostConfigure and IOptions pattern diff --git a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json index d518acc6..e498165b 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json +++ b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-realm.json @@ -726,6 +726,20 @@ "name" : "workspace:read" } ], "icon_uri" : "" + }, { + "name" : "workspaces", + "type" : "urn:workspaces", + "ownerManagedAccess" : false, + "displayName" : "", + "attributes" : { }, + "_id" : "767170b6-af5b-4e47-bb0d-a89c6c7f7573", + "uris" : [ ], + "scopes" : [ { + "name" : "workspace:delete" + }, { + "name" : "workspace:read" + } ], + "icon_uri" : "" } ], "policies" : [ { "id" : "04034aae-7d80-4ad4-902f-c8e57211b4c7", @@ -1341,7 +1355,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-address-mapper" ] + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper", "saml-role-list-mapper", "saml-user-property-mapper" ] } }, { "id" : "df4acae2-494f-4c6b-8c75-1a64296e47db", @@ -1360,7 +1374,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-address-mapper", "saml-role-list-mapper" ] } }, { "id" : "79c2f0c9-65fd-4c62-83a3-0ede96795205", diff --git a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-users-0.json b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-users-0.json index 1fe8df0f..2b7297a0 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-users-0.json +++ b/tests/Keycloak.AuthServices.IntegrationTests/KeycloakConfiguration/Test-users-0.json @@ -11,7 +11,7 @@ "credentials" : [ ], "disableableCredentialTypes" : [ ], "requiredActions" : [ ], - "realmRoles" : [ "default-roles-test" ], + "realmRoles" : [ "default-roles-test", "offline_access", "uma_authorization" ], "clientRoles" : { "test-client" : [ "uma_protection" ] }, diff --git a/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourcePolicyTests.cs b/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourcePolicyTests.cs new file mode 100644 index 00000000..8ddf33dc --- /dev/null +++ b/tests/Keycloak.AuthServices.IntegrationTests/ProtectedResourcePolicyTests.cs @@ -0,0 +1,261 @@ +namespace Keycloak.AuthServices.IntegrationTests; + +using System.Net; +using Alba; +using Alba.Security; +using Keycloak.AuthServices.Authentication; +using Keycloak.AuthServices.Authorization; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using static Keycloak.AuthServices.IntegrationTests.Utils; + +public class ProtectedResourcePolicyTests(ITestOutputHelper testOutputHelper) + : AuthenticationScenarioNoKeycloak +{ + private static readonly string AppSettings = "appsettings.json"; + + [Fact] + public async Task RequireProtectedResource_SingleResourceSingleScopeSingleEndpoint_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + var requestUrl = RunEndpoint( + "SingleResourceSingleScopeSingleEndpoint", + "workspaces#workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_SingleResourceMultipleScopesSingleEndpoint_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + var requestUrl = RunEndpoint( + "SingleResourceMultipleScopesSingleEndpoint", + "workspaces#workspace:read", + "workspaces#workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_SingleResourceMultipleScopesEndpointHierarchy_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + var requestUrl = RunEndpoint( + "SingleResourceMultipleScopesEndpointHierarchy", + "workspaces#workspace:read", + "workspaces#workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_MultipleResourcesMultipleScopesSingleEndpoint_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + // tests/TestWebApi/Program.cs#MultipleResourcesMultipleScopesSingleEndpoint + var requestUrl = RunEndpoint( + "MultipleResourcesMultipleScopesSingleEndpoint", + "workspaces#workspace:read", + "my-workspace#workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_MultipleResourcesMultipleScopesEndpointHierarchy_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + // tests/TestWebApi/Program.cs#MultipleResourcesMultipleScopesEndpointHierarchy + var requestUrl = RunEndpoint( + "MultipleResourcesMultipleScopesEndpointHierarchy", + "workspaces#workspace:read", + "my-workspace#workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_SingleResourceIgnoreAllResourcesEndpointHierarchy_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + // tests/TestWebApi/Program.cs#SingleResourceIgnoreProtectedResourceEndpointHierarchy + var requestUrl1 = RunEndpoint( + "SingleResourceIgnoreProtectedResourceEndpointHierarchy1", + "my-workspace#workspace:read" + ); + var requestUrl2 = RunEndpoint( + "SingleResourceIgnoreProtectedResourceEndpointHierarchy2", + "my-workspace#workspace:delete,workspace:read" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl1); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl1); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl2); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl2); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + [Fact] + public async Task RequireProtectedResource_SingleDynamicResourceSingleScopeSingleEndpoint_Verified() + { + await using var host = await AlbaHost.For( + SetupAuthorizationServer(testOutputHelper), + UserPasswordFlow(ReadKeycloakAuthenticationOptions(AppSettings)) + ); + + // tests/TestWebApi/Program.cs#MultipleResourcesMultipleScopesEndpointHierarchy + var requestUrl = RunEndpoint( + "SingleDynamicResourceSingleScopeSingleEndpoint/my-workspace", + "my-workspace:workspace:delete" + ); + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Admin.UserName, TestUsers.Admin.Password); + _.StatusCodeShouldBe(HttpStatusCode.OK); + }); + + await host.Scenario(_ => + { + _.Get.Url(requestUrl); + _.UserAndPasswordIs(TestUsers.Tester.UserName, TestUsers.Tester.Password); + _.StatusCodeShouldBe(HttpStatusCode.Forbidden); + }); + } + + private static Action SetupAuthorizationServer( + ITestOutputHelper testOutputHelper + ) => + x => + { + x.WithLogging(testOutputHelper); + x.WithConfiguration(AppSettings); + + x.ConfigureServices( + (context, services) => + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddKeycloakWebApi(context.Configuration); + + services.AddAuthorization().AddKeycloakAuthorization(); + + services.AddAuthorizationServer(context.Configuration); + + services.PostConfigure(options => + options.WithLocalKeycloakInstallation() + ); + } + ); + }; + + private static string RunEndpoint(string path, params string[] resources) => + $"/pr/{path}?resource={string.Join(';', resources)}"; +} diff --git a/tests/Keycloak.AuthServices.IntegrationTests/README.md b/tests/Keycloak.AuthServices.IntegrationTests/README.md index c7d04196..4250d42b 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/README.md +++ b/tests/Keycloak.AuthServices.IntegrationTests/README.md @@ -8,6 +8,14 @@ Inside docker container run: /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm Test ``` +## Test + +```bash +dotnet test \ + --logger:"console;verbosity=detailed" \ + --filter NAME +``` + ## User Registry in Test Realm ```csharp diff --git a/tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml b/tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml index 36992f0d..920f1f8c 100644 --- a/tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml +++ b/tests/Keycloak.AuthServices.IntegrationTests/docker-compose.yml @@ -9,7 +9,9 @@ services: command: [ 'start-dev', - '--import-realm' + '--import-realm', + '--log-level=DEBUG,org.hibernate:info,org.keycloak.transaction.JtaTransactionWrapper:info', + '--log-console-color=true' ] volumes: - ./KeycloakConfiguration/:/opt/keycloak/data/import/ diff --git a/tests/TestWebApi/Program.cs b/tests/TestWebApi/Program.cs index 69a5bdea..3d9de28a 100644 --- a/tests/TestWebApi/Program.cs +++ b/tests/TestWebApi/Program.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Keycloak.AuthServices.Authorization; using Microsoft.AspNetCore.Authorization; var builder = WebApplication.CreateBuilder(args); @@ -19,6 +20,47 @@ endpoints.MapGet("1", () => new { Success = true }); endpoints.MapGet("RunPolicyBuyName", AuthorizeAsync); +var protectedResources = app.MapGroup("/pr"); + +SingleResourceSingleScopeSingleEndpoint( + protectedResources, + nameof(SingleResourceSingleScopeSingleEndpoint) +); + +SingleResourceMultipleScopesSingleEndpoint( + protectedResources, + nameof(SingleResourceMultipleScopesSingleEndpoint) +); + +SingleResourceMultipleScopesSingleEndpointV2( + protectedResources, + nameof(SingleResourceMultipleScopesSingleEndpointV2) +); + +SingleResourceMultipleScopesEndpointHierarchy( + protectedResources, + nameof(SingleResourceMultipleScopesEndpointHierarchy) +); + +MultipleResourcesMultipleScopesSingleEndpoint( + protectedResources, + nameof(MultipleResourcesMultipleScopesSingleEndpoint) +); + +MultipleResourcesMultipleScopesEndpointHierarchy( + protectedResources, + nameof(MultipleResourcesMultipleScopesEndpointHierarchy) +); + +SingleResourceIgnoreProtectedResourceEndpointHierarchy( + protectedResources, + nameof(SingleResourceIgnoreProtectedResourceEndpointHierarchy) +); + +SingleDynamicResourceSingleScopeSingleEndpoint( + protectedResources, + nameof(SingleDynamicResourceSingleScopeSingleEndpoint) +); app.Run(); static async Task AuthorizeAsync( @@ -37,4 +79,95 @@ IAuthorizationService authorizationService return TypedResults.Ok(new { Success = result.Succeeded }); } +static Response Run(string? resource, string? scopes) => new(true, resource, scopes); + +static void SingleResourceSingleScopeSingleEndpoint(RouteGroupBuilder app, string path) => + #region SingleResourceSingleScopeSingleEndpoint + app.MapGet(path, Run).RequireProtectedResource("workspaces", "workspace:delete"); + #endregion SingleResourceSingleScopeSingleEndpoint + +static void SingleResourceMultipleScopesSingleEndpoint(RouteGroupBuilder app, string path) => + #region SingleResourceMultipleScopesSingleEndpoint + app.MapGet(path, Run) + .RequireProtectedResource("workspaces", "workspace:read") + .RequireProtectedResource("workspaces", "workspace:delete"); + #endregion SingleResourceMultipleScopesSingleEndpoint + +static void SingleResourceMultipleScopesSingleEndpointV2(RouteGroupBuilder app, string path) => + #region SingleResourceMultipleScopesSingleEndpointV2 + app.MapGet(path, Run) + .RequireProtectedResource("workspaces", ["workspace:read", "workspace:delete"]); + #endregion SingleResourceMultipleScopesSingleEndpointV2 + +static void SingleResourceMultipleScopesEndpointHierarchy(RouteGroupBuilder app, string path) +{ + #region SingleResourceMultipleScopesEndpointHierarchy + var endpoints = app.MapGroup(string.Empty) + .RequireProtectedResource("workspaces", "workspace:read"); + + // requires workspaces#workspace:read,workspace:delete + endpoints.MapGet(path, Run).RequireProtectedResource("workspaces", "workspace:delete"); + // requires workspaces#workspace:read, inherited from parent group + endpoints.MapGet("other-endpoint", Run); + #endregion SingleResourceMultipleScopesEndpointHierarchy +} + +static void MultipleResourcesMultipleScopesSingleEndpoint(RouteGroupBuilder app, string path) => + #region MultipleResourcesMultipleScopesSingleEndpoint + app.MapGet(path, Run) + .RequireProtectedResource("workspaces", "workspace:read") + .RequireProtectedResource("my-workspace", "workspace:delete"); + #endregion MultipleResourcesMultipleScopesSingleEndpoint + +static void MultipleResourcesMultipleScopesEndpointHierarchy(RouteGroupBuilder app, string path) +{ + #region MultipleResourcesMultipleScopesEndpointHierarchy + + var endpoints = app.MapGroup(string.Empty) + .RequireProtectedResource("workspaces", "workspace:read"); + + // requires workspaces#workspace:read;my-workspace#workspace:delete + endpoints.MapGet(path, Run).RequireProtectedResource("my-workspace", "workspace:delete"); + #endregion MultipleResourcesMultipleScopesEndpointHierarchy +} + +static void SingleResourceIgnoreProtectedResourceEndpointHierarchy( + RouteGroupBuilder app, + string path +) +{ + #region SingleResourceIgnoreProtectedResourceEndpointHierarchy + var endpoints = app.MapGroup(string.Empty) + .RequireProtectedResource("workspaces", "workspace:read"); + + var childrenEndpoints = endpoints + .MapGroup(string.Empty) + .RequireProtectedResource("my-workspace", "workspace:delete"); + + // requires my-workspace#workspace:read + childrenEndpoints + .MapGet($"{path}1", Run) + .IgnoreProtectedResources() + .RequireProtectedResource("my-workspace", "workspace:read"); + + // requires my-workspace#workspace:delete,workspace:read + childrenEndpoints + .MapGet($"{path}2", Run) + .IgnoreProtectedResource("workspaces") + .RequireProtectedResource("my-workspace", "workspace:read"); + + #endregion SingleResourceIgnoreProtectedResourceEndpointHierarchy +} + +static void SingleDynamicResourceSingleScopeSingleEndpoint(RouteGroupBuilder app, string path) => + #region SingleDynamicResourceSingleScopeSingleEndpoint + app.MapGet($"{path}/{{id}}", (string id) => "Hello World!") + .RequireProtectedResource("{id}", "workspace:delete"); + #endregion SingleDynamicResourceSingleScopeSingleEndpoint + + +#pragma warning disable CA1050 // Declare types in namespaces +public record Response(bool Success, string? Resource, string? Scopes); + public partial class Program { } +#pragma warning restore CA1050 // Declare types in namespaces diff --git a/tests/TestWebApi/TestWebApi.csproj b/tests/TestWebApi/TestWebApi.csproj index 3c003374..2d9d006d 100644 --- a/tests/TestWebApi/TestWebApi.csproj +++ b/tests/TestWebApi/TestWebApi.csproj @@ -1,4 +1,7 @@ + + + CS7022