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;
+ }
}