diff --git a/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs index f0a9bbc09e..4bc389bec4 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/RequiredPermissionAuthorizationHandler.cs @@ -32,10 +32,21 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext return; } + // An empty set comes from a blank RequirePermission("") and must deny, not fall through to Succeed. + if (requiredPermissions.Count == 0 || context.User?.GetUserId() is not { } userId) + { + return; + } + var cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None; - if (context.User?.GetUserId() is { } userId && await userService.HasPermissionAsync(userId, requiredPermissions.First(), cancellationToken).ConfigureAwait(false)) + foreach (var permission in requiredPermissions) { - context.Succeed(requirement); + if (!await userService.HasPermissionAsync(userId, permission, cancellationToken).ConfigureAwait(false)) + { + return; + } } + + context.Succeed(requirement); } } \ No newline at end of file diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index d7b227bc48..5b8df11df5 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -175,6 +175,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { // Stamp the connection IP from a test header so forwarded-headers trust checks are testable. services.AddSingleton(); + services.AddSingleton(); // The production TrustedProxyOptions read happens eagerly, before the test config overlay // applies (same quirk as storage below), so bind the trusted upstream here instead. Note what diff --git a/src/Tests/Integration.Tests/Infrastructure/TestPermissionEndpointStartupFilter.cs b/src/Tests/Integration.Tests/Infrastructure/TestPermissionEndpointStartupFilter.cs new file mode 100644 index 0000000000..1c80946e2c --- /dev/null +++ b/src/Tests/Integration.Tests/Infrastructure/TestPermissionEndpointStartupFilter.cs @@ -0,0 +1,40 @@ +using FSH.Framework.Shared.Identity.Authorization; +using FSH.Modules.Identity.Contracts.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Integration.Tests.Infrastructure; + +/// +/// No production endpoint passes more than one permission to .RequirePermission(), so this +/// maps one that does. It appends to the live route builder after next(app) has run +/// UseRouting(), the same way Integration.Middleware.Tests maps /__test/throw. +/// +public sealed class TestPermissionEndpointStartupFilter : IStartupFilter +{ + public const string TwoPermissionsPath = "/__test/permissions/two"; + public const string FirstPermission = IdentityPermissions.Groups.Create; + public const string SecondPermission = IdentityPermissions.Groups.Delete; + + private const string EndpointRouteBuilderKey = "__EndpointRouteBuilder"; + + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + return app => + { + next(app); + + if (app.Properties.TryGetValue(EndpointRouteBuilderKey, out var value) + && value is IEndpointRouteBuilder routeBuilder) + { + routeBuilder.MapGet(TwoPermissionsPath, () => Results.Ok()) + .RequireAuthorization() + .RequirePermission(FirstPermission, SecondPermission); + } + }; + } +} diff --git a/src/Tests/Integration.Tests/Tests/Authorization/RequirePermissionAllOfTests.cs b/src/Tests/Integration.Tests/Tests/Authorization/RequirePermissionAllOfTests.cs new file mode 100644 index 0000000000..5856fc306d --- /dev/null +++ b/src/Tests/Integration.Tests/Tests/Authorization/RequirePermissionAllOfTests.cs @@ -0,0 +1,142 @@ +using Finbuckle.MultiTenant; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Domain; +using Integration.Tests.Infrastructure; +using Integration.Tests.Infrastructure.Extensions; +using Microsoft.AspNetCore.Identity; + +namespace Integration.Tests.Tests.Authorization; + +/// +/// .RequirePermission(a, b) must demand every listed permission. Probed through +/// , a test-only endpoint, +/// because no production endpoint lists more than one. +/// +[Collection(FshCollectionDefinition.Name)] +public sealed class RequirePermissionAllOfTests +{ + private readonly FshWebApplicationFactory _factory; + private readonly AuthHelper _auth; + + public RequirePermissionAllOfTests(FshWebApplicationFactory factory) + { + _factory = factory; + _auth = new AuthHelper(factory); + } + + [Fact] + public async Task TwoPermissionEndpoint_Should_Return403_When_UserHoldsOnlyTheFirstPermission() + { + using var userClient = await CreateUserClientWithPermissionsAsync( + "allof-first", TestPermissionEndpointStartupFilter.FirstPermission); + + var response = await userClient.GetAsync(TestPermissionEndpointStartupFilter.TwoPermissionsPath); + + response.StatusCode.ShouldBe(HttpStatusCode.Forbidden, + "Holding only the first of two required permissions must not open the gate."); + } + + [Fact] + public async Task TwoPermissionEndpoint_Should_Return200_When_UserHoldsBothPermissions() + { + using var userClient = await CreateUserClientWithPermissionsAsync( + "allof-both", + TestPermissionEndpointStartupFilter.FirstPermission, + TestPermissionEndpointStartupFilter.SecondPermission); + + var response = await userClient.GetAsync(TestPermissionEndpointStartupFilter.TwoPermissionsPath); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Holding every required permission must open the gate: {await response.Content.ReadAsStringAsync()}"); + } + + // ─── helpers ───────────────────────────────────────────────────── + + private async Task CreateUserClientWithPermissionsAsync(string handlePrefix, params string[] permissions) + { + using var adminClient = await _auth.CreateRootAdminClientAsync(); + var uniqueId = Guid.NewGuid().ToString("N")[..8]; + + var role = await CreateRoleAsync(adminClient, $"AllOfRole-{uniqueId}"); + await SetRolePermissionsAsync(adminClient, role.Id, permissions); + + var (email, password, userId) = await CreateActiveUserAsync($"{handlePrefix}-{uniqueId}"); + await AssignRoleAsync(adminClient, userId, role.Name); + + return await _auth.CreateAuthenticatedClientAsync(email, password); + } + + private static async Task CreateRoleAsync(HttpClient adminClient, string name) + { + var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/roles", new + { + id = string.Empty, + name, + description = "require-permission all-of test role" + }); + return await response.DeserializeAsync(); + } + + private static async Task SetRolePermissionsAsync(HttpClient adminClient, string roleId, string[] permissions) + { + var response = await adminClient.PutAsJsonAsync( + $"{TestConstants.IdentityBasePath}/{roleId}/permissions", new + { + roleId, + permissions + }); + response.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Set role permissions failed: {await response.Content.ReadAsStringAsync()}"); + } + + private static async Task AssignRoleAsync(HttpClient adminClient, string userId, string roleName) + { + var response = await adminClient.PostAsJsonAsync( + $"{TestConstants.IdentityBasePath}/users/{userId}/roles", new + { + userId, + userRoles = new[] + { + new { roleName, enabled = true } + } + }); + response.StatusCode.ShouldBe(HttpStatusCode.OK, + $"Assign role failed: {await response.Content.ReadAsStringAsync()}"); + } + + /// + /// Seeds a confirmed + active user directly via UserManager. The Finbuckle tenant context is + /// set INLINE because it is AsyncLocal; an awaited helper would lose it. + /// + private async Task<(string Email, string Password, string UserId)> CreateActiveUserAsync(string handle) + { + const string password = TestConstants.DefaultPassword; + var email = $"{handle}@example.com"; + + using var scope = _factory.Services.CreateScope(); + + var tenant = await scope.ServiceProvider + .GetRequiredService>() + .GetAsync(TestConstants.RootTenantId); + scope.ServiceProvider.GetRequiredService() + .MultiTenantContext = new MultiTenantContext(tenant); + + var userManager = scope.ServiceProvider.GetRequiredService>(); + var user = new FshUser + { + FirstName = "AllOf", + LastName = "Probe", + Email = email, + UserName = handle, + EmailConfirmed = true, + IsActive = true, + }; + + var result = await userManager.CreateAsync(user, password); + result.Succeeded.ShouldBeTrue( + $"Seeding active user failed: {string.Join(", ", result.Errors.Select(e => e.Description))}"); + + return (email, password, user.Id); + } +}