Skip to content

Commit be91d4b

Browse files
iammukeshmclaude
andcommitted
fix(identity): group-derived roles confer permissions, not just JWT claims
UserPermissionService computed the effective permission set from a user's DIRECT roles only, while IdentityService.AddRoleClaimsAsync already unions direct + group-derived roles when minting the JWT. Result: a user whose only role comes via a UserGroup saw the role in their token but failed every .RequirePermission() gate (and GET /identity/permissions under-reported). Union direct roles with roles reachable via UserGroups -> GroupRoles before resolving permissions (group mutations already invalidate this cache entry). Query-only — no schema change. Tests: - GroupRolePermissionTests (integration): a group-only user's own-permissions include the group role's grants and pass the gated endpoint. - AuthorizationMetadataTests (architecture): RequiredPermissionAttribute exists exactly once implementing IRequiredPermissionMetadata, so gates can't silently fail open via a duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 66496f8 commit be91d4b

3 files changed

Lines changed: 286 additions & 1 deletion

File tree

src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,25 @@ private static async ValueTask<PermissionSet> LoadPermissionsAsync(FactoryState
7070

7171
var userRoles = await s.UserManager.GetRolesAsync(user).ConfigureAwait(false);
7272

73-
var roleIds = await s.RoleManager.Roles
73+
var directRoleIds = await s.RoleManager.Roles
7474
.Where(r => userRoles.Contains(r.Name!))
7575
.Select(r => r.Id)
7676
.ToListAsync(ct).ConfigureAwait(false);
7777

78+
// Group-derived roles confer permissions too — the JWT already unions them
79+
// (IdentityService.AddRoleClaimsAsync) and every group mutation invalidates this
80+
// cache entry, so the effective set must include roles reachable via UserGroups.
81+
var groupRoleIds = await s.Db.GroupRoles
82+
.Where(gr => s.Db.UserGroups
83+
.Where(ug => ug.UserId == s.UserId)
84+
.Select(ug => ug.GroupId)
85+
.Contains(gr.GroupId))
86+
.Select(gr => gr.RoleId)
87+
.Distinct()
88+
.ToListAsync(ct).ConfigureAwait(false);
89+
90+
var roleIds = directRoleIds.Union(groupRoleIds, StringComparer.Ordinal).ToList();
91+
7892
if (roleIds.Count == 0)
7993
{
8094
return PermissionSet.Empty;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
using FSH.Framework.Shared.Identity.Authorization;
2+
using Shouldly;
3+
using System.Reflection;
4+
using Xunit;
5+
6+
namespace Architecture.Tests;
7+
8+
/// <summary>
9+
/// Guards the permission-metadata contract between <c>RequiredPermissionAttribute</c> and
10+
/// <c>RequiredPermissionAuthorizationHandler</c>. The handler resolves endpoint permissions via
11+
/// <see cref="IRequiredPermissionMetadata"/>; if a duplicate <c>RequiredPermissionAttribute</c>
12+
/// appears in another assembly without implementing that interface, endpoints decorated with it
13+
/// carry no recognizable metadata and every <c>.RequirePermission()</c> gate silently fails open.
14+
/// </summary>
15+
public class AuthorizationMetadataTests
16+
{
17+
private const string AttributeName = "RequiredPermissionAttribute";
18+
private const string ExpectedNamespace = "FSH.Framework.Shared.Identity.Authorization";
19+
20+
[Fact]
21+
public void RequiredPermissionAttribute_Should_Exist_Exactly_Once_Across_All_FSH_Assemblies()
22+
{
23+
var matches = GetAllFshAssemblies()
24+
.SelectMany(GetLoadableTypes)
25+
.Where(t => string.Equals(t.Name, AttributeName, StringComparison.Ordinal))
26+
.ToArray();
27+
28+
matches.ShouldNotBeEmpty(
29+
$"{AttributeName} was not found in any FSH assembly. " +
30+
"The permission authorization pipeline depends on it.");
31+
32+
matches.Length.ShouldBe(1,
33+
$"Exactly one {AttributeName} must exist across all FSH assemblies. " +
34+
"A duplicate that does not implement IRequiredPermissionMetadata silently disables " +
35+
$"every .RequirePermission() gate. Found: {string.Join(", ", matches.Select(t => $"{t.FullName} ({t.Assembly.GetName().Name})"))}");
36+
37+
matches[0].Namespace.ShouldBe(ExpectedNamespace,
38+
$"{AttributeName} must live in {ExpectedNamespace}, where " +
39+
"RequiredPermissionAuthorizationHandler resolves its metadata from.");
40+
}
41+
42+
[Fact]
43+
public void RequiredPermissionAttribute_Should_Implement_IRequiredPermissionMetadata()
44+
{
45+
var attributeType = typeof(RequiredPermissionAttribute);
46+
47+
typeof(IRequiredPermissionMetadata).IsAssignableFrom(attributeType).ShouldBeTrue(
48+
$"{attributeType.FullName} must implement IRequiredPermissionMetadata. " +
49+
"RequiredPermissionAuthorizationHandler discovers endpoint permissions through that " +
50+
"interface; without it, every .RequirePermission() gate silently fails open.");
51+
}
52+
53+
/// <summary>
54+
/// Loads every FSH.* assembly deployed alongside the tests so the duplicate sweep covers
55+
/// BuildingBlocks, all modules (including Contracts), and host assemblies — not just the
56+
/// runtime module assemblies that ModuleAssemblyDiscovery returns.
57+
/// </summary>
58+
private static Assembly[] GetAllFshAssemblies()
59+
{
60+
string baseDir = AppContext.BaseDirectory;
61+
62+
var assemblies = new List<Assembly>();
63+
64+
foreach (var file in Directory.GetFiles(baseDir, "FSH.*.dll"))
65+
{
66+
try
67+
{
68+
var assemblyName = AssemblyName.GetAssemblyName(file);
69+
assemblies.Add(Assembly.Load(assemblyName));
70+
}
71+
#pragma warning disable CA1031 // Do not catch general exception types
72+
catch (Exception)
73+
{
74+
// Skip if not a valid .NET assembly or other load error
75+
}
76+
#pragma warning restore CA1031
77+
}
78+
79+
assemblies.ShouldNotBeEmpty(
80+
"No FSH.* assemblies were found in the test output directory; the duplicate sweep would be a no-op.");
81+
82+
return [.. assemblies];
83+
}
84+
85+
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
86+
{
87+
try
88+
{
89+
return assembly.GetTypes();
90+
}
91+
catch (ReflectionTypeLoadException ex)
92+
{
93+
return ex.Types.Where(t => t is not null)!;
94+
}
95+
}
96+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using Finbuckle.MultiTenant;
2+
using Finbuckle.MultiTenant.Abstractions;
3+
using FSH.Framework.Shared.Multitenancy;
4+
using FSH.Modules.Identity.Contracts.Authorization;
5+
using FSH.Modules.Identity.Domain;
6+
using Integration.Tests.Infrastructure;
7+
using Integration.Tests.Infrastructure.Extensions;
8+
using Microsoft.AspNetCore.Identity;
9+
10+
namespace Integration.Tests.Tests.Groups;
11+
12+
/// <summary>
13+
/// Proves group-derived roles confer PERMISSIONS, not just JWT role claims.
14+
/// <c>IdentityService.AddRoleClaimsAsync</c> unions direct roles with
15+
/// <c>IGroupRoleService.GetUserGroupRolesAsync</c> when minting tokens, and every group
16+
/// mutation handler invalidates the permission cache — so <c>UserPermissionService</c>
17+
/// must resolve the same union, or a user whose only role comes via a group fails every
18+
/// <c>.RequirePermission()</c> gate despite their JWT showing the role.
19+
/// </summary>
20+
[Collection(FshCollectionDefinition.Name)]
21+
public sealed class GroupRolePermissionTests
22+
{
23+
private const string ProbePermission = IdentityPermissions.Groups.Create;
24+
25+
private readonly FshWebApplicationFactory _factory;
26+
private readonly AuthHelper _auth;
27+
28+
public GroupRolePermissionTests(FshWebApplicationFactory factory)
29+
{
30+
_factory = factory;
31+
_auth = new AuthHelper(factory);
32+
}
33+
34+
[Fact]
35+
public async Task GetOwnPermissions_Should_IncludeGroupRolePermissions_When_UserHasNoDirectRoles()
36+
{
37+
// Arrange — custom role with the probe permission, attached to a GROUP (never to the user).
38+
using var adminClient = await _auth.CreateRootAdminClientAsync();
39+
var uniqueId = Guid.NewGuid().ToString("N")[..8];
40+
41+
var role = await CreateRoleWithPermissionAsync(adminClient, $"GrpPermRole-{uniqueId}", ProbePermission);
42+
var groupId = await CreateGroupWithRoleAsync(adminClient, $"PermGroup-{uniqueId}", role.Id);
43+
44+
var (email, password, userId) = await CreateActiveUserAsync($"grpperm-{uniqueId}");
45+
await AddUserToGroupAsync(adminClient, groupId, userId);
46+
47+
using var userClient = await _auth.CreateAuthenticatedClientAsync(email, password);
48+
49+
// Act — read the user's own effective permissions (auth-only endpoint, backed by UserPermissionService).
50+
var response = await userClient.GetAsync($"{TestConstants.IdentityBasePath}/permissions");
51+
response.StatusCode.ShouldBe(HttpStatusCode.OK,
52+
$"Get current-user permissions failed: {await response.Content.ReadAsStringAsync()}");
53+
var permissions = await response.DeserializeAsync<List<string>>();
54+
55+
// Assert — the group-derived role's permission must be in the effective set.
56+
permissions.ShouldContain(ProbePermission,
57+
"UserPermissionService only resolves DIRECT role assignments: a role granted via group membership " +
58+
"confers no permissions, even though the JWT carries the role claim.");
59+
}
60+
61+
[Fact]
62+
public async Task PermissionGate_Should_Open_When_UsersOnlyRoleComesViaGroup()
63+
{
64+
// Arrange — same setup, but probe through a real .RequirePermission() gate
65+
// (POST /groups requires Groups.Create), exercising the authorization handler path.
66+
using var adminClient = await _auth.CreateRootAdminClientAsync();
67+
var uniqueId = Guid.NewGuid().ToString("N")[..8];
68+
69+
var role = await CreateRoleWithPermissionAsync(adminClient, $"GrpGateRole-{uniqueId}", ProbePermission);
70+
var groupId = await CreateGroupWithRoleAsync(adminClient, $"GateGroup-{uniqueId}", role.Id);
71+
72+
var (email, password, userId) = await CreateActiveUserAsync($"grpgate-{uniqueId}");
73+
await AddUserToGroupAsync(adminClient, groupId, userId);
74+
75+
using var userClient = await _auth.CreateAuthenticatedClientAsync(email, password);
76+
77+
// Act — hit the gated endpoint with the group-conferred permission.
78+
var response = await userClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/groups", new
79+
{
80+
name = $"grp-by-member-{uniqueId}",
81+
description = "created via group-derived permission",
82+
isDefault = false,
83+
roleIds = new List<string>()
84+
});
85+
86+
// Assert — the gate must honor the group-derived role.
87+
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden,
88+
"RequiredPermissionAuthorizationHandler denied a user whose group membership grants a role holding the required permission.");
89+
response.StatusCode.ShouldBe(HttpStatusCode.Created);
90+
}
91+
92+
// ─── helpers ─────────────────────────────────────────────────────
93+
94+
private static async Task<RoleDto> CreateRoleWithPermissionAsync(HttpClient adminClient, string name, string permission)
95+
{
96+
var createResponse = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/roles", new
97+
{
98+
id = string.Empty,
99+
name,
100+
description = "group-role permission test role"
101+
});
102+
var role = await createResponse.DeserializeAsync<RoleDto>();
103+
104+
var permResponse = await adminClient.PutAsJsonAsync(
105+
$"{TestConstants.IdentityBasePath}/{role.Id}/permissions", new
106+
{
107+
roleId = role.Id,
108+
permissions = new[] { permission }
109+
});
110+
permResponse.StatusCode.ShouldBe(HttpStatusCode.OK,
111+
$"Set role permissions failed: {await permResponse.Content.ReadAsStringAsync()}");
112+
113+
return role;
114+
}
115+
116+
private static async Task<Guid> CreateGroupWithRoleAsync(HttpClient adminClient, string name, string roleId)
117+
{
118+
var response = await adminClient.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/groups", new
119+
{
120+
name,
121+
description = "group-role permission test group",
122+
isDefault = false,
123+
roleIds = new List<string> { roleId }
124+
});
125+
response.StatusCode.ShouldBe(HttpStatusCode.Created,
126+
$"Create group failed: {await response.Content.ReadAsStringAsync()}");
127+
var group = await response.DeserializeAsync<GroupDto>();
128+
return group.Id;
129+
}
130+
131+
private static async Task AddUserToGroupAsync(HttpClient adminClient, Guid groupId, string userId)
132+
{
133+
var response = await adminClient.PostAsJsonAsync(
134+
$"{TestConstants.IdentityBasePath}/groups/{groupId}/members",
135+
new { userIds = new[] { userId } });
136+
response.StatusCode.ShouldBe(HttpStatusCode.OK,
137+
$"Add user to group failed: {await response.Content.ReadAsStringAsync()}");
138+
}
139+
140+
/// <summary>
141+
/// Seeds a confirmed + active user with NO role assignments. The Finbuckle tenant
142+
/// context is set INLINE because it is AsyncLocal — setting it in an awaited helper
143+
/// would lose it and the tenant query filter would throw.
144+
/// </summary>
145+
private async Task<(string Email, string Password, string UserId)> CreateActiveUserAsync(string handle)
146+
{
147+
const string password = TestConstants.DefaultPassword;
148+
var email = $"{handle}@example.com";
149+
150+
using var scope = _factory.Services.CreateScope();
151+
152+
var tenant = await scope.ServiceProvider
153+
.GetRequiredService<IMultiTenantStore<AppTenantInfo>>()
154+
.GetAsync(TestConstants.RootTenantId);
155+
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
156+
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);
157+
158+
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<FshUser>>();
159+
var user = new FshUser
160+
{
161+
FirstName = "GroupRole",
162+
LastName = "Probe",
163+
Email = email,
164+
UserName = handle,
165+
EmailConfirmed = true,
166+
IsActive = true,
167+
};
168+
169+
var result = await userManager.CreateAsync(user, password);
170+
result.Succeeded.ShouldBeTrue(
171+
$"Seeding active user failed: {string.Join(", ", result.Errors.Select(e => e.Description))}");
172+
173+
return (email, password, user.Id);
174+
}
175+
}

0 commit comments

Comments
 (0)