Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<IStartupFilter, TestRemoteIpStartupFilter>();
services.AddSingleton<IStartupFilter, TestPermissionEndpointStartupFilter>();

// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// No production endpoint passes more than one permission to <c>.RequirePermission()</c>, so this
/// maps one that does. It appends to the live route builder after <c>next(app)</c> has run
/// <c>UseRouting()</c>, the same way <c>Integration.Middleware.Tests</c> maps <c>/__test/throw</c>.
/// </summary>
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<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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);
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <c>.RequirePermission(a, b)</c> must demand every listed permission. Probed through
/// <see cref="TestPermissionEndpointStartupFilter.TwoPermissionsPath"/>, a test-only endpoint,
/// because no production endpoint lists more than one.
/// </summary>
[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<HttpClient> 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<RoleDto> 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<RoleDto>();
}

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()}");
}

/// <summary>
/// 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.
/// </summary>
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<IMultiTenantStore<AppTenantInfo>>()
.GetAsync(TestConstants.RootTenantId);
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var userManager = scope.ServiceProvider.GetRequiredService<UserManager<FshUser>>();
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);
}
}
Loading