From 061c9a1314a4ae546db7b982d332653509e198b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sun, 10 May 2026 22:42:45 +0300 Subject: [PATCH] UAuthStateView Component: Change MatchAll to MatchMode Parameter --- .../Components/Pages/AuthorizedTestPage.razor | 5 + .../{Access => Authorization}/AccessScope.cs | 0 .../Authorization/AuthorizationMatchMode.cs | 8 ++ .../Components/UAuthStateView.razor.cs | 100 +++++++++++++----- .../Bunit/UAuthStateViewTests.cs | 47 +++++++- .../Helpers/TestAuthState.cs | 40 ++++++- 6 files changed, 168 insertions(+), 32 deletions(-) rename src/CodeBeam.UltimateAuth.Core/Contracts/{Access => Authorization}/AccessScope.cs (100%) create mode 100644 src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor index d0a06c06..d8614a0d 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -1,4 +1,5 @@ @page "/authorized-test" +@using CodeBeam.UltimateAuth.Core.Defaults @attribute [UAuthAuthorize] @inherits UAuthFlowPageBase @@ -19,6 +20,10 @@ + + This is admin view content. + + UltimateAuth protects this resource based on your session and permissions. diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs new file mode 100644 index 00000000..8711835c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum AuthorizationMatchMode +{ + Any = 0, + All = 1, + Category = 2 +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index e488b329..e5368825 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; @@ -40,11 +41,29 @@ public partial class UAuthStateView : UAuthReactiveComponentBase public string? Policy { get; set; } /// - /// Gets or sets a value indicating whether all set conditions must be matched for the operation to succeed. - /// Null parameters don't count as condition. + /// Determines how authorization conditions are evaluated. + /// + /// + /// : + /// Any configured condition may succeed. + /// + /// + /// + /// : + /// All configured conditions and values must succeed. + /// + /// + /// + /// : + /// At least one value from each configured category must succeed. + /// For example: + /// one matching role AND one matching permission. + /// + /// + /// Null or empty parameters are ignored. /// [Parameter] - public bool MatchAll { get; set; } = true; + public AuthorizationMatchMode MatchMode { get; set; } = AuthorizationMatchMode.Category; [Parameter] public bool RequireActive { get; set; } = true; @@ -92,34 +111,67 @@ private async Task EvaluateAuthorizationAsync() if (!AuthState.IsAuthenticated) return false; - var roles = _rolesParsed; - var permissions = _permissionsParsed; + var roleResults = _rolesParsed + .Select(AuthState.IsInRole) + .ToList(); - var results = new List(); + var permissionResults = _permissionsParsed + .Select(AuthState.HasPermission) + .ToList(); - if (roles.Count > 0) + bool? policyResult = null; + + if (!string.IsNullOrWhiteSpace(Policy)) { - results.Add(MatchAll - ? roles.All(AuthState.IsInRole) - : roles.Any(AuthState.IsInRole)); + policyResult = await EvaluatePolicyAsync(); } - if (permissions.Count > 0) + return MatchMode switch { - results.Add(MatchAll - ? permissions.All(AuthState.HasPermission) - : permissions.Any(AuthState.HasPermission)); - } + AuthorizationMatchMode.Any + => EvaluateAny(roleResults, permissionResults, policyResult), - if (!string.IsNullOrWhiteSpace(Policy)) - results.Add(await EvaluatePolicyAsync()); + AuthorizationMatchMode.All + => EvaluateAll(roleResults, permissionResults, policyResult), - if (results.Count == 0) - return true; + AuthorizationMatchMode.Category + => EvaluateCategory(roleResults, permissionResults, policyResult), + + _ => false + }; + } + + private static bool EvaluateAny(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + return roles.Any(x => x) || permissions.Any(x => x) || policy == true; + } + + private static bool EvaluateAll(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + if (roles.Count > 0 && roles.Any(x => !x)) + return false; + + if (permissions.Count > 0 && permissions.Any(x => !x)) + return false; + + if (policy.HasValue && !policy.Value) + return false; + + return true; + } + + private static bool EvaluateCategory(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + if (roles.Count > 0 && !roles.Any(x => x)) + return false; + + if (permissions.Count > 0 && !permissions.Any(x => x)) + return false; + + if (policy.HasValue && !policy.Value) + return false; - return MatchAll - ? results.All(x => x) - : results.Any(x => x); + return true; } private void EvaluateSessionState() @@ -171,6 +223,6 @@ private async Task EvaluatePolicyAsync() private string BuildAuthKey() { - return $"{Roles}|{Permissions}|{Policy}|{MatchAll}"; + return $"{Roles}|{Permissions}|{Policy}|{MatchMode}"; } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs index e53b5840..277146e7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs @@ -1,15 +1,14 @@ using Bunit; -using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.Reference; using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Moq; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -113,7 +112,7 @@ public void Should_Require_All_When_MatchAll_True() var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") - .Add(x => x.MatchAll, true) + .Add(x => x.MatchMode, AuthorizationMatchMode.All) .Add(x => x.NotAuthorized, Html("
no
")) ); @@ -129,7 +128,47 @@ public void Should_Allow_Any_When_MatchAll_False() var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") - .Add(x => x.MatchAll, false) + .Add(x => x.MatchMode, AuthorizationMatchMode.Any) + .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) + ); + + cut.Markup.Should().Contain("ok"); + } + + [Fact] + public void Should_Fail_When_One_Category_Does_Not_Match_In_Category_Mode() + { + using var ctx = new BunitContext(); + + var state = TestAuthState.WithRoles("admin"); + + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.Permissions, "write") + .Add(x => x.MatchMode, AuthorizationMatchMode.Category) + .Add(x => x.NotAuthorized, Html("
no
")) + ); + + cut.Markup.Should().Contain("no"); + } + + [Fact] + public void Should_Require_At_Least_One_Match_Per_Category_When_MatchMode_Is_Category() + { + using var ctx = new BunitContext(); + + var state = TestAuthState.Create( + roles: ["admin"], + permissions: ["write"]); + + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.Permissions, "write,delete") + .Add(x => x.MatchMode, AuthorizationMatchMode.Category) .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) ); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs index 8c087d34..c8559f1f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs @@ -68,10 +68,7 @@ public static UAuthState WithSession(SessionState sessionState) return state; } - public static UAuthState Full( - string userId, - string[] roles, - string[] permissions) + public static UAuthState Full(string userId, string[] roles, string[] permissions) { var claims = new List<(string, string)>(); @@ -80,4 +77,39 @@ public static UAuthState Full( return Authenticated(userId, claims.ToArray()); } + + public static UAuthState Create(string userId = "user-1", string[]? roles = null, string[]? permissions = null, SessionState sessionState = SessionState.Active, UserStatus userStatus = UserStatus.Active) + { + var claims = new List<(string Type, string Value)>(); + + if (roles is not null) + { + claims.AddRange( + roles.Select(x => (ClaimTypes.Role, x))); + } + + if (permissions is not null) + { + claims.AddRange( + permissions.Select(x => ("uauth:permission", x))); + } + + var state = Authenticated(userId, claims.ToArray()); + + var identity = state.Identity! with + { + SessionState = sessionState, + UserStatus = userStatus + }; + + var snapshot = new AuthStateSnapshot + { + Identity = identity, + Claims = state.Claims + }; + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + return state; + } }