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,