diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessActorRole.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessActorRole.cs
new file mode 100644
index 00000000..b31c7e90
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessActorRole.cs
@@ -0,0 +1,6 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+///
+/// Represents a trusted, server-resolved role assigned to the authenticated actor during access evaluation.
+///
+public sealed record AccessActorRole(string Id, string Name);
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
index 0ea44e81..a6c89c2e 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
@@ -13,6 +13,7 @@ public sealed class AccessContext
public bool IsAuthenticated { get; init; }
public bool IsSystemActor { get; init; }
public SessionChainId? ActorChainId { get; }
+ public IReadOnlyList ActorRoles { get; }
// Target
public string? Resource { get; init; }
@@ -45,6 +46,7 @@ internal AccessContext(
bool isAuthenticated,
bool isSystemActor,
SessionChainId? actorChainId,
+ IReadOnlyList actorRoles,
string? resource,
UserKey? targetUserKey,
TenantKey resourceTenant,
@@ -56,6 +58,7 @@ internal AccessContext(
IsAuthenticated = isAuthenticated;
IsSystemActor = isSystemActor;
ActorChainId = actorChainId;
+ ActorRoles = Array.AsReadOnly(actorRoles.ToArray());
Resource = resource;
TargetUserKey = targetUserKey;
@@ -78,6 +81,7 @@ public AccessContext WithAttribute(string key, object value)
IsAuthenticated,
IsSystemActor,
ActorChainId,
+ ActorRoles,
Resource,
TargetUserKey,
ResourceTenant,
diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
index 31de832e..761c7766 100644
--- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs
@@ -16,6 +16,7 @@ public static class Claims
{
public const string Tenant = "uauth:tenant";
public const string Permission = "uauth:permission";
+ public const string ActorRole = "uauth:actor_role";
}
public static class HttpItems
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessActorRoleResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessActorRoleResolver.cs
new file mode 100644
index 00000000..0c39efab
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessActorRoleResolver.cs
@@ -0,0 +1,42 @@
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+
+namespace CodeBeam.UltimateAuth.Server.Auth;
+
+internal sealed class AccessActorRoleResolver(IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory)
+{
+ public async Task ResolveAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default)
+ {
+ var userRoleStore = userRoleFactory.Create(tenant);
+ var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct);
+ var roleIds = assignments.Select(x => x.RoleId).Distinct().ToArray();
+
+ if (roleIds.Length == 0)
+ return new ResolvedActorRoles([], roleIds);
+
+ var roleStore = roleFactory.Create(tenant);
+
+ var roles = await roleStore.GetByIdsAsync(roleIds, ct);
+
+ var activeRoles = roles
+ .Where(x => !x.IsDeleted && x.Tenant == tenant)
+ .ToArray();
+
+ var actorRoles = activeRoles
+ .Select(x => new AccessActorRole(x.Id.ToString(), x.Name))
+ .ToArray();
+
+ var activeRoleIds = activeRoles
+ .Select(x => x.Id)
+ .ToArray();
+
+ return new ResolvedActorRoles(actorRoles, activeRoleIds);
+ }
+}
+
+internal sealed record ResolvedActorRoles(
+ IReadOnlyList ActorRoles,
+ IReadOnlyCollection RoleIds);
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs
index 762e02b1..81dc07ad 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs
@@ -9,12 +9,12 @@ namespace CodeBeam.UltimateAuth.Server.Auth;
internal sealed class AccessContextFactory : IAccessContextFactory
{
- private readonly IUserRoleStoreFactory _userRoleFactory;
+ private readonly AccessActorRoleResolver _roleResolver;
private readonly IUserIdConverterResolver _converterResolver;
- public AccessContextFactory(IUserRoleStoreFactory userRoleFactory, IUserIdConverterResolver converterResolver)
+ public AccessContextFactory(AccessActorRoleResolver roleResolver, IUserIdConverterResolver converterResolver)
{
- _userRoleFactory = userRoleFactory;
+ _roleResolver = roleResolver;
_converterResolver = converterResolver;
}
@@ -39,17 +39,19 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow,
if (string.IsNullOrWhiteSpace(resource))
throw new ArgumentException("Resource is required.", nameof(resource));
+ IReadOnlyList actorRoles = Array.Empty();
+
var attrs = attributes != null
? new Dictionary(attributes)
: new Dictionary();
+ attrs.Remove("roles");
+
if (authFlow.IsAuthenticated && authFlow.UserKey is not null)
{
- var userRoleStore = _userRoleFactory.Create(authFlow.Tenant);
- var assignments = await userRoleStore.GetAssignmentsAsync(authFlow.UserKey.Value, ct);
- var roleIds = assignments.Select(x => x.RoleId).ToArray();
-
- attrs["roles"] = roleIds;
+ var resolved = await _roleResolver.ResolveAsync(authFlow.Tenant, authFlow.UserKey.Value, ct);
+ actorRoles = resolved.ActorRoles;
+ attrs["roles"] = resolved.RoleIds.ToArray();
}
UserKey? targetUserKey = null;
@@ -71,6 +73,7 @@ private async Task CreateInternalAsync(AuthFlowContext authFlow,
isAuthenticated: authFlow.IsAuthenticated,
isSystemActor: authFlow.Tenant.IsSystem,
actorChainId: authFlow.Session?.ChainId,
+ actorRoles: actorRoles,
resource: resource,
targetUserKey: targetUserKey,
resourceTenant: resourceTenant,
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs
index 1b18149b..d1b8fe2f 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs
@@ -1,31 +1,102 @@
-using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Defaults;
+using CodeBeam.UltimateAuth.Core.Runtime;
+using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Extensions;
+using CodeBeam.UltimateAuth.Server.Runtime;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
+using System.Collections.ObjectModel;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Server.Authorization;
internal static class ResourceAccessContextBuilder
{
- public static AccessContext Create(HttpContext http, string action)
+ public static async Task CreateAsync(HttpContext http, string action, CancellationToken ct = default)
{
var user = http.RequestServices.GetRequiredService();
+ var tenant = http.GetTenant();
+
+ IReadOnlyList actorRoles = [];
+ IReadOnlyDictionary attributes = EmptyAttributes.Instance;
+
+ if (user.IsAuthenticated)
+ {
+ RoleId[] roleIds;
+
+ var runtime = http.RequestServices.GetRequiredService();
+
+ if (runtime is ResourceRuntimeMarker)
+ {
+ // Remote session validation has already produced
+ // the authenticated principal.
+ var identity = http.User.Identities.SingleOrDefault(x => x.IsAuthenticated && x.AuthenticationType == UAuthConstants.SchemeDefaults.GlobalScheme);
+
+ if (identity is null)
+ throw new InvalidOperationException("Trusted UltimateAuth identity is missing.");
+
+ var roles = new List();
+ var ids = new List();
+
+ foreach (var claim in identity.FindAll(UAuthConstants.Claims.ActorRole))
+ {
+ AccessActorRole? role;
+
+ try
+ {
+ role = JsonSerializer.Deserialize(claim.Value);
+ }
+ catch (JsonException)
+ {
+ throw new InvalidOperationException("Invalid actor role claim.");
+ }
+
+ if (role is null ||
+ string.IsNullOrWhiteSpace(role.Name) ||
+ !RoleId.TryParse(role.Id, null, out var roleId))
+ {
+ throw new InvalidOperationException("Invalid actor role claim.");
+ }
+
+ roles.Add(role);
+ ids.Add(roleId);
+ }
+
+ actorRoles = roles;
+ roleIds = ids.ToArray();
+ }
+ else
+ {
+ var resolver = http.RequestServices.GetRequiredService();
+ var resolved = await resolver.ResolveAsync(tenant, user.UserKey, ct);
+
+ actorRoles = resolved.ActorRoles;
+ roleIds = resolved.RoleIds.ToArray();
+ }
+
+ attributes = new ReadOnlyDictionary(
+ new Dictionary
+ {
+ ["roles"] = roleIds
+ });
+ }
+
return new AccessContext(
actorUserKey: user.IsAuthenticated ? user.UserKey : null,
- actorTenant: http.GetTenant(),
+ actorTenant: tenant,
isAuthenticated: user.IsAuthenticated,
isSystemActor: false,
actorChainId: null,
-
+ actorRoles: actorRoles,
resource: ResolveResource(action),
targetUserKey: null,
- resourceTenant: http.GetTenant(),
-
+ resourceTenant: tenant,
action: action,
- attributes: EmptyAttributes.Instance
- );
+ attributes: attributes);
}
private static string ResolveResource(string action)
diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs
index 9adc5db6..8d53b742 100644
--- a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs
@@ -21,7 +21,7 @@ public UAuthAuthorizationHandler(
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, UAuthActionRequirement requirement)
{
var http = _httpContextAccessor.HttpContext!;
- var accessContext = ResourceAccessContextBuilder.Create(http, requirement.Action);
+ var accessContext = await ResourceAccessContextBuilder.CreateAsync(http, requirement.Action, http.RequestAborted);
try
{
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index 650a6837..8b487a53 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -237,6 +237,7 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddScoped();
services.TryAddScoped();
services.TryAddScoped();
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs
index 7c50a600..5086fb1d 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs
@@ -1,8 +1,10 @@
using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using System.Security.Claims;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Authorization;
@@ -32,8 +34,11 @@ public async Task GetClaimsAsync(TenantKey tenant, UserKey userK
builder.Add(UAuthConstants.Claims.Tenant, tenant.Value);
- foreach (var role in roles)
+ foreach (var role in roles.Where(x => !x.IsDeleted && x.Tenant == tenant))
+ {
builder.Add(ClaimTypes.Role, role.Name);
+ builder.Add(UAuthConstants.Claims.ActorRole, JsonSerializer.Serialize(new AccessActorRole(role.Id.ToString(), role.Name)));
+ }
foreach (var perm in perms)
builder.Add(UAuthConstants.Claims.Permission, perm.Value);
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs
index 89d3d961..e11741dc 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration.EfCore/Infrastructure/TestAccessContext.cs
@@ -14,6 +14,7 @@ public static AccessContext WithAction(string action)
isAuthenticated: false,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "test",
targetUserKey: null,
resourceTenant: TenantKey.Single,
@@ -37,6 +38,7 @@ public static AccessContext ForUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: userKey,
resourceTenant: t,
@@ -61,6 +63,7 @@ public static AccessContext ForTargetUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: targetUserKey,
resourceTenant: t,
@@ -83,6 +86,7 @@ public static AccessContext ForUserCreation(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: t,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs
index f4a33983..6303151e 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/TestAccessContext.cs
@@ -14,6 +14,7 @@ public static AccessContext WithAction(string action)
isAuthenticated: false,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "test",
targetUserKey: null,
resourceTenant: TenantKey.Single,
@@ -37,6 +38,7 @@ public static AccessContext ForUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: userKey,
resourceTenant: t,
@@ -61,6 +63,7 @@ public static AccessContext ForTargetUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: targetUserKey,
resourceTenant: t,
@@ -83,6 +86,7 @@ public static AccessContext ForUserCreation(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: t,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs
index bca72585..af287e0e 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/UserLifecycleTests.cs
@@ -592,6 +592,7 @@ await profileFactory
isAuthenticated: true,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: actor,
resourceTenant: TenantKeys.Single,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs
index 0d90850d..d20659d9 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs
@@ -14,6 +14,7 @@ public static AccessContext WithAction(string action)
isAuthenticated: false,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "test",
targetUserKey: null,
resourceTenant: TenantKey.Single,
@@ -37,6 +38,7 @@ public static AccessContext ForUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: userKey,
resourceTenant: t,
@@ -61,6 +63,7 @@ public static AccessContext ForTargetUser(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: resource,
targetUserKey: targetUserKey,
resourceTenant: t,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs
index 85d71f3e..5ce023e3 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/AccessPoliciesTests.cs
@@ -103,6 +103,7 @@ public async Task Decide_CrossTenant_ShouldDeny()
isAuthenticated: true,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: resourceTenant,
@@ -246,6 +247,7 @@ public async Task Decide_SystemActor_ShouldAllow()
isAuthenticated: false,
isSystemActor: true,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: TenantKey.Single,
@@ -756,6 +758,7 @@ public void AppliesTo_SystemActor_ShouldReturnFalse()
isAuthenticated: true,
isSystemActor: true,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: TenantKey.System,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs
new file mode 100644
index 00000000..2504f2fe
--- /dev/null
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/AccessContextFactoryTests.cs
@@ -0,0 +1,179 @@
+using AwesomeAssertions;
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Domain;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Server.Auth;
+using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
+using Moq;
+
+namespace CodeBeam.UltimateAuth.Tests.Unit.Server;
+
+public sealed class AccessContextFactoryTests
+{
+ [Fact]
+ public async Task CreateAsync_ShouldResolveTrustedActorRoles()
+ {
+ var tenant = TenantKey.Single;
+ var user = UserKey.New();
+
+ var ownerId = RoleId.New();
+ var memberId = RoleId.New();
+ var deletedId = RoleId.New();
+
+ var userRoleStore = new Mock();
+ var roleStore = new Mock();
+
+ var userRoleFactory = new Mock();
+ var roleFactory = new Mock();
+
+ userRoleFactory
+ .Setup(x => x.Create(tenant))
+ .Returns(userRoleStore.Object);
+
+ roleFactory
+ .Setup(x => x.Create(tenant))
+ .Returns(roleStore.Object);
+
+ userRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ user,
+ It.IsAny()))
+ .ReturnsAsync(new[]
+ {
+ new UserRole
+ {
+ Tenant = tenant,
+ UserKey = user,
+ RoleId = ownerId
+ },
+ new UserRole
+ {
+ Tenant = tenant,
+ UserKey = user,
+ RoleId = memberId
+ },
+ new UserRole
+ {
+ Tenant = tenant,
+ UserKey = user,
+ RoleId = deletedId
+ }
+ });
+
+ var now = DateTimeOffset.UtcNow;
+
+ var owner = Role.Create(ownerId, tenant, "Owner", null, now);
+ var member = Role.Create(memberId, tenant, "Member", null, now);
+ var deleted = Role.Create(deletedId, tenant, "Deleted", null, now).MarkDeleted(now);
+
+ roleStore
+ .Setup(x => x.GetByIdsAsync(
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(new[] { owner, member, deleted });
+
+ var resolver = new AccessActorRoleResolver(
+ userRoleFactory.Object,
+ roleFactory.Object);
+
+ var sut = new AccessContextFactory(
+ resolver,
+ Mock.Of());
+
+ var flow = AuthFlowTestFactory.New(
+ tenant: tenant,
+ userKey: user);
+
+ var context = await sut.CreateAsync(
+ flow,
+ "users.get.self",
+ "users");
+
+ // Only active roles are exposed.
+ context.ActorRoles.Should().HaveCount(2);
+
+ context.ActorRoles.Should().ContainSingle(
+ x => x.Id == ownerId.ToString() &&
+ x.Name == "Owner");
+
+ context.ActorRoles.Should().ContainSingle(
+ x => x.Id == memberId.ToString() &&
+ x.Name == "Member");
+
+ context.ActorRoles.Should().NotContain(
+ x => x.Id == deletedId.ToString());
+
+ // Existing attributes contract remains available.
+ context.Attributes.Should().ContainKey("roles");
+
+ var attributeRoles =
+ (RoleId[])context.Attributes["roles"];
+
+ attributeRoles.Should().BeEquivalentTo(new[] { ownerId, memberId });
+ attributeRoles.Should().NotContain(deletedId);
+
+ // Both stores must resolve against the actor tenant.
+ userRoleFactory.Verify(
+ x => x.Create(tenant),
+ Times.Once);
+
+ roleFactory.Verify(
+ x => x.Create(tenant),
+ Times.Once);
+
+ roleStore.Verify(
+ x => x.GetByIdsAsync(
+ It.Is>(
+ ids => ids.Count == 3 &&
+ ids.Contains(ownerId) &&
+ ids.Contains(memberId) &&
+ ids.Contains(deletedId)),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task CreateAsync_ShouldIgnoreUntrustedRolesForAnonymousActor()
+ {
+ var userRoleFactory = new Mock();
+ var roleFactory = new Mock();
+
+ var resolver = new AccessActorRoleResolver(
+ userRoleFactory.Object,
+ roleFactory.Object);
+
+ var sut = new AccessContextFactory(
+ resolver,
+ Mock.Of());
+
+ var flow = AuthFlowTestFactory.New(
+ isAuthenticated: false);
+
+ var attributes = new Dictionary
+ {
+ ["roles"] = new[] { "Admin" },
+ ["custom"] = "value"
+ };
+
+ var context = await sut.CreateAsync(
+ flow,
+ "users.get.anonymous",
+ "users",
+ attributes: attributes);
+
+ // Anonymous actors must never receive trusted roles.
+ context.ActorRoles.Should().BeEmpty();
+
+ // Caller-supplied role metadata must be discarded.
+ context.Attributes.Should().NotContainKey("roles");
+
+ // Unrelated attributes remain available.
+ context.Attributes["custom"].Should().Be("value");
+
+ // No role stores should be accessed.
+ userRoleFactory.VerifyNoOtherCalls();
+ roleFactory.VerifyNoOtherCalls();
+ }
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs
index 2a128759..3938678b 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ResourceAccessContextBuilderTests.cs
@@ -1,30 +1,33 @@
-using CodeBeam.UltimateAuth.Core;
+using AwesomeAssertions;
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Runtime;
+using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Authorization;
-using AwesomeAssertions;
+using CodeBeam.UltimateAuth.Server.Runtime;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
+using System.Security.Claims;
+using System.Text.Json;
namespace CodeBeam.UltimateAuth.Tests.Unit.Server.Authorization;
public sealed class ResourceAccessContextBuilderTests
{
[Fact]
- public void Create_WithAuthenticatedUser_ShouldSetActor()
+ public async Task Create_WithAuthenticatedUser_ShouldSetActor()
{
var userKey = CreateUserKey();
- var http = CreateHttpContext(
- authenticated: true,
- userKey);
+ var http = CreateHttpContext(authenticated: true, userKey);
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- "users.read");
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read");
result.IsAuthenticated.Should().BeTrue();
result.ActorUserKey.Should().Be(userKey);
@@ -33,15 +36,11 @@ public void Create_WithAuthenticatedUser_ShouldSetActor()
}
[Fact]
- public void Create_WithAnonymousUser_ShouldNotSetActorUserKey()
+ public async Task Create_WithAnonymousUser_ShouldNotSetActorUserKey()
{
- var http = CreateHttpContext(
- authenticated: false);
+ var http = CreateHttpContext(authenticated: false);
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- "users.read");
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read");
result.IsAuthenticated.Should().BeFalse();
result.ActorUserKey.Should().BeNull();
@@ -53,73 +52,51 @@ public void Create_WithAnonymousUser_ShouldNotSetActorUserKey()
[InlineData("users.security.sessions.revoke", "users")]
[InlineData("roles.assign", "roles")]
[InlineData("sessions.revoke", "sessions")]
- public void Create_ShouldDeriveResourceFromAction(
- string action,
- string expectedResource)
+ public async Task Create_ShouldDeriveResourceFromAction(string action, string expectedResource)
{
- var http = CreateHttpContext(
- authenticated: false);
+ var http = CreateHttpContext(authenticated: false);
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- action);
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, action);
result.Resource.Should().Be(expectedResource);
result.Action.Should().Be(action);
}
[Fact]
- public void Create_ShouldNotTreatCurrentUserAsSystemActor()
+ public async Task Create_ShouldNotTreatCurrentUserAsSystemActor()
{
- var http = CreateHttpContext(
- authenticated: true,
- CreateUserKey());
+ var http = CreateHttpContext(authenticated: true, CreateUserKey());
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- "users.read");
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read");
result.IsSystemActor.Should().BeFalse();
}
[Fact]
- public void Create_ShouldNotSetTargetUser()
+ public async Task Create_ShouldNotSetTargetUser()
{
- var http = CreateHttpContext(
- authenticated: true,
- CreateUserKey());
+ var http = CreateHttpContext(authenticated: true, CreateUserKey());
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- "users.read");
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read");
result.TargetUserKey.Should().BeNull();
}
[Fact]
- public void Create_ShouldPropagateTenantToActorAndResource()
+ public async Task Create_ShouldPropagateTenantToActorAndResource()
{
var tenant = TenantKey.FromExternal("tenant-a");
- var http = CreateHttpContext(
- authenticated: true,
- userKey: CreateUserKey(),
- tenant: tenant);
+ var http = CreateHttpContext(authenticated: true, userKey: CreateUserKey(), tenant: tenant);
- var result =
- ResourceAccessContextBuilder.Create(
- http,
- "users.read");
+ var result = await ResourceAccessContextBuilder.CreateAsync(http, "users.read");
result.ActorTenant.Should().Be(tenant);
result.ResourceTenant.Should().Be(tenant);
}
[Fact]
- public void Create_WithoutTenantContext_ShouldThrow()
+ public async Task Create_WithoutTenantContext_ShouldThrow()
{
var currentUser = new Mock();
@@ -135,38 +112,213 @@ public void Create_WithoutTenantContext_ShouldThrow()
RequestServices = services.BuildServiceProvider()
};
- var act = () =>
- ResourceAccessContextBuilder.Create(
+ Func act = () =>
+ ResourceAccessContextBuilder.CreateAsync(
http,
"users.read");
- act.Should()
- .Throw()
+ await act.Should()
+ .ThrowAsync()
.WithMessage("*TenantContext is missing*");
}
- private static DefaultHttpContext CreateHttpContext(bool authenticated, UserKey? userKey = null, TenantKey? tenant = null)
+ [Fact]
+ public async Task Create_WithResourceRuntime_ShouldResolveTrustedActorRolesFromClaims()
{
+ // Arrange
+ var tenant = TenantKey.FromExternal("tenant-a");
+ var userKey = UserKey.New();
+
+ var ownerId = RoleId.New();
+ var memberId = RoleId.New();
+
+ var owner = new AccessActorRole(ownerId.ToString(), "Owner");
+ var member = new AccessActorRole(memberId.ToString(), "Member");
+
+ var claims = new[]
+ {
+ new Claim(
+ UAuthConstants.Claims.ActorRole,
+ JsonSerializer.Serialize(owner)),
+
+ new Claim(
+ UAuthConstants.Claims.ActorRole,
+ JsonSerializer.Serialize(member))
+ };
+
+ var identity = new ClaimsIdentity(
+ claims,
+ UAuthConstants.SchemeDefaults.GlobalScheme);
+
+ var principal = new ClaimsPrincipal(identity);
+
var currentUser = new Mock();
currentUser
.SetupGet(x => x.IsAuthenticated)
- .Returns(authenticated);
+ .Returns(true);
+
+ currentUser
+ .SetupGet(x => x.UserKey)
+ .Returns(userKey);
+
+ var services = new ServiceCollection();
+
+ services.AddSingleton(currentUser.Object);
+ services.AddSingleton();
+
+ var http = new DefaultHttpContext
+ {
+ RequestServices = services.BuildServiceProvider(),
+ User = principal
+ };
+
+ http.Items[UAuthConstants.HttpItems.TenantContextKey] =
+ UAuthTenantContext.Resolved(tenant);
+
+ // Act
+ var context = await ResourceAccessContextBuilder.CreateAsync(
+ http,
+ "users.read");
+
+ // Assert
+ context.IsAuthenticated.Should().BeTrue();
+ context.ActorUserKey.Should().Be(userKey);
+ context.ActorTenant.Should().Be(tenant);
+
+ context.ActorRoles.Should().HaveCount(2);
+
+ context.ActorRoles.Should().ContainSingle(
+ x => x.Id == ownerId.ToString() &&
+ x.Name == "Owner");
+
+ context.ActorRoles.Should().ContainSingle(
+ x => x.Id == memberId.ToString() &&
+ x.Name == "Member");
+
+ context.Attributes.Should().ContainKey("roles");
+
+ var roleIds = (RoleId[])context.Attributes["roles"];
+
+ roleIds.Should().BeEquivalentTo(
+ new[] { ownerId, memberId });
+ }
+
+ [Fact]
+ public async Task Create_WithResourceRuntime_ShouldIgnoreRolesFromOtherIdentities()
+ {
+ // Arrange
+ var tenant = TenantKey.FromExternal("tenant-a");
+ var userKey = UserKey.New();
+
+ var trustedRoleId = RoleId.New();
+ var untrustedRoleId = RoleId.New();
+
+ var trustedIdentity = new ClaimsIdentity(
+ [
+ new Claim(UAuthConstants.Claims.ActorRole, JsonSerializer.Serialize(new AccessActorRole(trustedRoleId.ToString(), "Member")))
+ ],
+ UAuthConstants.SchemeDefaults.GlobalScheme);
+
+ var untrustedIdentity = new ClaimsIdentity(
+ [
+ new Claim(UAuthConstants.Claims.ActorRole, JsonSerializer.Serialize(new AccessActorRole(untrustedRoleId.ToString(), "Admin")))
+ ],
+ "ExternalScheme");
+
+ var principal = new ClaimsPrincipal([trustedIdentity, untrustedIdentity]);
+
+ var currentUser = new Mock();
+
+ currentUser
+ .SetupGet(x => x.IsAuthenticated)
+ .Returns(true);
+
+ currentUser
+ .SetupGet(x => x.UserKey)
+ .Returns(userKey);
+
+ var services = new ServiceCollection();
+
+ services.AddSingleton(currentUser.Object);
+ services.AddSingleton();
+
+ var http = new DefaultHttpContext
+ {
+ RequestServices = services.BuildServiceProvider(),
+ User = principal
+ };
+
+ http.Items[UAuthConstants.HttpItems.TenantContextKey] =
+ UAuthTenantContext.Resolved(tenant);
+
+ // Act
+ var context = await ResourceAccessContextBuilder.CreateAsync(
+ http,
+ "users.read");
+
+ // Assert
+ context.ActorRoles.Should().ContainSingle();
+
+ context.ActorRoles.Should().ContainSingle(
+ x => x.Id == trustedRoleId.ToString() &&
+ x.Name == "Member");
+
+ context.ActorRoles.Should().NotContain(
+ x => x.Id == untrustedRoleId.ToString());
+
+ var roleIds = (RoleId[])context.Attributes["roles"];
+
+ roleIds.Should().ContainSingle()
+ .Which.Should().Be(trustedRoleId);
+ }
+
+
+ private static DefaultHttpContext CreateHttpContext(bool authenticated, UserKey? userKey = null, TenantKey? tenant = null)
+ {
+ var currentUser = new Mock();
+ currentUser.SetupGet(x => x.IsAuthenticated).Returns(authenticated);
if (authenticated)
{
if (userKey is null)
throw new ArgumentNullException(nameof(userKey));
- currentUser
- .SetupGet(x => x.UserKey)
- .Returns(userKey.Value);
+ currentUser.SetupGet(x => x.UserKey).Returns(userKey.Value);
}
+ var userRoleStore = new Mock();
+ var roleStore = new Mock();
+
+ var userRoleFactory = new Mock();
+ var roleFactory = new Mock();
+
+ userRoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(userRoleStore.Object);
+
+ roleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(roleStore.Object);
+
+ userRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
+ // Register all dependencies before building the provider.
var services = new ServiceCollection();
services.AddSingleton(currentUser.Object);
+ services.AddSingleton();
+
+ services.AddSingleton(userRoleFactory.Object);
+ services.AddSingleton(roleFactory.Object);
+
+ services.AddScoped();
+ // Build the provider only after completing registrations.
var http = new DefaultHttpContext
{
RequestServices = services.BuildServiceProvider()
@@ -185,4 +337,4 @@ private static UserKey CreateUserKey()
{
return UserKey.New();
}
-}
\ No newline at end of file
+}
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs
index 99cfacef..f9ed17f2 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs
@@ -815,6 +815,7 @@ private static AccessContext Context(
isAuthenticated: true,
isSystemActor: false,
actorChainId: actorChainId,
+ actorRoles: Array.Empty(),
resource: "session",
targetUserKey: user,
resourceTenant: TenantKey.Single,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs
index 8fbc07bb..913aefca 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs
@@ -654,6 +654,7 @@ private static AccessContext CreateAccess(
isAuthenticated: flow.IsAuthenticated,
isSystemActor: false,
actorChainId: flow.Session?.ChainId,
+ actorRoles: Array.Empty(),
resource: "sessions",
targetUserKey: targetUser,
resourceTenant: flow.Tenant,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs
index b5dbd9bc..32e1f34f 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthAuthorizationHandlerTests.cs
@@ -1,12 +1,16 @@
-using CodeBeam.UltimateAuth.Core;
+using AwesomeAssertions;
+using CodeBeam.UltimateAuth.Authorization;
+using CodeBeam.UltimateAuth.Core;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Runtime;
+using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Authorization;
using CodeBeam.UltimateAuth.Server.Infrastructure;
-using AwesomeAssertions;
+using CodeBeam.UltimateAuth.Server.Runtime;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
@@ -199,13 +203,44 @@ private static DefaultHttpContext CreateHttpContext(bool authenticated, UserKey?
.Returns(userKey.Value);
}
+ var userRoleStore = new Mock();
+ var roleStore = new Mock();
+
+ var userRoleFactory = new Mock();
+ var roleFactory = new Mock();
+
+ userRoleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(userRoleStore.Object);
+
+ roleFactory
+ .Setup(x => x.Create(It.IsAny()))
+ .Returns(roleStore.Object);
+
+ userRoleStore
+ .Setup(x => x.GetAssignmentsAsync(
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(Array.Empty());
+
var services = new ServiceCollection();
services.AddSingleton(currentUser.Object);
+ // Normal UltimateAuth Server runtime
+ services.AddSingleton();
+
+ // Trusted actor role resolution
+ services.AddSingleton(userRoleFactory.Object);
+ services.AddSingleton(roleFactory.Object);
+ services.AddScoped();
+
+ // Build provider after all registrations
+ var provider = services.BuildServiceProvider();
+
var http = new DefaultHttpContext
{
- RequestServices = services.BuildServiceProvider()
+ RequestServices = provider
};
var resolvedTenant =
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs
index 55bd2e99..b3b93559 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthResourceAccessOrchestratorTests.cs
@@ -469,6 +469,7 @@ private static AccessContext CreateContext()
isAuthenticated: true,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: null,
resourceTenant: default,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs
index c0206bf4..d1cb169c 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs
@@ -1916,6 +1916,7 @@ private static AccessContext CreateContext(
isAuthenticated: true,
isSystemActor: false,
actorChainId: null,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: targetUserKey ?? actorUserKey,
resourceTenant: TenantKey.Single,
diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs
index 1b5c1fbc..538d85ba 100644
--- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs
+++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs
@@ -1789,6 +1789,7 @@ private static AccessContext CreateAccessContext(Fixture f, UserKey? targetUserK
isAuthenticated: f.Flow.IsAuthenticated,
isSystemActor: false,
actorChainId: f.Flow.Session?.ChainId,
+ actorRoles: Array.Empty(),
resource: "users",
targetUserKey: targetUserKey ?? f.Flow.UserKey,
resourceTenant: f.Flow.Tenant,