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 @@ -23,8 +23,10 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext

// IMPORTANT: resolve IRequiredPermissionMetadata from FSH.Framework.Shared.Identity.Authorization (the
// interface the attribute implements) — a duplicate would silently fail-open every .RequirePermission().
var requiredPermissions = endpoint?.Metadata.GetMetadata<IRequiredPermissionMetadata>()?.RequiredPermissions;
if (requiredPermissions == null)
// Read every instance, not just the most specific one: a route group's .RequirePermission(A) and an
// endpoint's .RequirePermission(B) are both gates, so the caller must hold the union of them (#1429).
var permissionMetadata = endpoint?.Metadata.GetOrderedMetadata<IRequiredPermissionMetadata>() ?? [];
if (permissionMetadata.Count == 0)
{
// there are no permission requirements set by the endpoint
// hence, authorize requests
Expand All @@ -33,11 +35,17 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext
}

// 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)
if (permissionMetadata.Any(m => m.RequiredPermissions.Count == 0) || context.User?.GetUserId() is not { } userId)
{
return;
}

var requiredPermissions = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var metadata in permissionMetadata)
{
requiredPermissions.UnionWith(metadata.RequiredPermissions);
}

var cancellationToken = httpContext?.RequestAborted ?? CancellationToken.None;
foreach (var permission in requiredPermissions)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@
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
/// No production endpoint passes more than one permission to <c>.RequirePermission()</c> or gates a
/// route group with it, so this maps endpoints that do. 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 GroupGatedPath = "/__test/permissions/group/endpoint";
public const string FirstPermission = IdentityPermissions.Groups.Create;
public const string SecondPermission = IdentityPermissions.Groups.Delete;

Expand All @@ -34,6 +35,14 @@ public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
routeBuilder.MapGet(TwoPermissionsPath, () => Results.Ok())
.RequireAuthorization()
.RequirePermission(FirstPermission, SecondPermission);

// Group gate (first) + endpoint gate (second): both must apply, the endpoint's
// metadata must not replace the group's.
routeBuilder.MapGroup("/__test/permissions/group")
.RequireAuthorization()
.RequirePermission(FirstPermission)
.MapGet("/endpoint", () => Results.Ok())
.RequirePermission(SecondPermission);
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
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.
/// <c>.RequirePermission(a, b)</c> must demand every listed permission, and a route group's
/// <c>.RequirePermission(a)</c> must still apply when an endpoint inside it adds <c>.RequirePermission(b)</c>.
/// Probed through test-only endpoints from <see cref="TestPermissionEndpointStartupFilter"/>, because no
/// production endpoint lists more than one permission or gates a group.
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class RequirePermissionAllOfTests
Expand Down Expand Up @@ -51,6 +52,44 @@ public async Task TwoPermissionEndpoint_Should_Return200_When_UserHoldsBothPermi
$"Holding every required permission must open the gate: {await response.Content.ReadAsStringAsync()}");
}

[Fact]
public async Task GroupGatedEndpoint_Should_Return403_When_UserHoldsOnlyTheEndpointPermission()
{
using var userClient = await CreateUserClientWithPermissionsAsync(
"group-endpoint-only", TestPermissionEndpointStartupFilter.SecondPermission);

var response = await userClient.GetAsync(TestPermissionEndpointStartupFilter.GroupGatedPath);

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden,
"The endpoint's RequirePermission must not replace the route group's gate.");
}

[Fact]
public async Task GroupGatedEndpoint_Should_Return403_When_UserHoldsOnlyTheGroupPermission()
{
using var userClient = await CreateUserClientWithPermissionsAsync(
"group-group-only", TestPermissionEndpointStartupFilter.FirstPermission);

var response = await userClient.GetAsync(TestPermissionEndpointStartupFilter.GroupGatedPath);

response.StatusCode.ShouldBe(HttpStatusCode.Forbidden,
"Holding only the group's permission must not open an endpoint that adds its own gate.");
}

[Fact]
public async Task GroupGatedEndpoint_Should_Return200_When_UserHoldsGroupAndEndpointPermissions()
{
using var userClient = await CreateUserClientWithPermissionsAsync(
"group-both",
TestPermissionEndpointStartupFilter.FirstPermission,
TestPermissionEndpointStartupFilter.SecondPermission);

var response = await userClient.GetAsync(TestPermissionEndpointStartupFilter.GroupGatedPath);

response.StatusCode.ShouldBe(HttpStatusCode.OK,
$"Holding both the group and endpoint permissions must open the gate: {await response.Content.ReadAsStringAsync()}");
}

// ─── helpers ─────────────────────────────────────────────────────

private async Task<HttpClient> CreateUserClientWithPermissionsAsync(string handlePrefix, params string[] permissions)
Expand Down
Loading