Skip to content

Commit e685b9b

Browse files
merge pull request #2 from https-richardy/fix/01-user-permissions-missing-group-permissions
[#1] - fix missing inherited group permissions in GET /users/{id}/permissions
2 parents 9393231 + 76bbe3b commit e685b9b

5 files changed

Lines changed: 199 additions & 4 deletions

File tree

Source/HttpsRichardy.Federation.Application/Handlers/User/ListUserAssignedPermissionsHandler.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
namespace HttpsRichardy.Federation.Application.Handlers.User;
22

3-
public sealed class ListUserAssignedPermissionsHandler(IUserCollection collection) :
3+
public sealed class ListUserAssignedPermissionsHandler(IUserCollection collection, IGroupCollection groupCollection) :
44
IDispatchHandler<ListUserAssignedPermissionsParameters, Result<IReadOnlyCollection<PermissionDetailsScheme>>>
55
{
66
public async Task<Result<IReadOnlyCollection<PermissionDetailsScheme>>> HandleAsync(
@@ -13,8 +13,26 @@ public async Task<Result<IReadOnlyCollection<PermissionDetailsScheme>>> HandleAs
1313
var users = await collection.GetUsersAsync(filters, cancellation);
1414
var user = users.FirstOrDefault();
1515

16-
return user is not null
17-
? Result<IReadOnlyCollection<PermissionDetailsScheme>>.Success(PermissionMapper.AsResponse(user.Permissions))
18-
: Result<IReadOnlyCollection<PermissionDetailsScheme>>.Failure(UserErrors.UserDoesNotExist);
16+
if (user is null)
17+
{
18+
return Result<IReadOnlyCollection<PermissionDetailsScheme>>.Failure(UserErrors.UserDoesNotExist);
19+
}
20+
21+
var identifiers = user.Groups
22+
.Select(group => group.Id)
23+
.ToList();
24+
25+
var groupFilters = GroupFilters.WithSpecifications()
26+
.WithIdentifiers([.. identifiers])
27+
.Build();
28+
29+
var groups = await groupCollection.GetGroupsAsync(groupFilters, cancellation);
30+
var permissions = groups
31+
.SelectMany(group => group.Permissions)
32+
.Concat(user.Permissions)
33+
.DistinctBy(permission => permission.Name)
34+
.ToList();
35+
36+
return Result<IReadOnlyCollection<PermissionDetailsScheme>>.Success(PermissionMapper.AsResponse(permissions));
1937
}
2038
}

Source/HttpsRichardy.Federation.Domain/Filtering/Builders/GroupFiltersBuilder.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ namespace HttpsRichardy.Federation.Domain.Filtering.Builders;
33
public sealed class GroupFiltersBuilder :
44
FiltersBuilderBase<GroupFilters, GroupFiltersBuilder>
55
{
6+
public GroupFiltersBuilder WithIdentifiers(string[] identifiers)
7+
{
8+
var validIdentifiers = identifiers?
9+
.Where(identifier => !string.IsNullOrWhiteSpace(identifier))
10+
.ToArray();
11+
12+
if (validIdentifiers?.Length > 0)
13+
_filters.Identifiers = validIdentifiers;
14+
15+
return this;
16+
}
17+
618
public GroupFiltersBuilder WithRealmId(string? realmId)
719
{
820
_filters.RealmId = realmId;

Source/HttpsRichardy.Federation.Domain/Filtering/GroupFilters.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ public sealed class GroupFilters : Filters
44
{
55
public string? RealmId { get; set; }
66
public string? Name { get; set; }
7+
public string[]? Identifiers { get; set; }
78

89
public static GroupFiltersBuilder WithSpecifications() => new();
910
}

Source/HttpsRichardy.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public static PipelineDefinition<Group, BsonDocument> FilterGroups(this Pipeline
99
var definitions = new List<FilterDefinition<BsonDocument>>
1010
{
1111
FilterDefinitions.MatchIfNotEmpty(Documents.Group.Id, filters.Id),
12+
FilterDefinitions.MustBeInIfNotEmpty(Documents.Group.Id, filters.Identifiers),
1213
FilterDefinitions.MatchIfNotEmpty(Documents.Group.Name, filters.Name),
1314
FilterDefinitions.MatchIfNotEmpty(Documents.Group.RealmId, realm?.Id),
1415
FilterDefinitions.MatchBool(Documents.Group.IsDeleted, filters.IsDeleted),

Tests/Integration/Endpoints/UserEndpointTests.cs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,169 @@ public async Task WhenGetUserPermissions_ShouldReturnAssignedPermissions()
179179
}
180180
}
181181

182+
[Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions should return both direct and inherited group permissions")]
183+
public async Task WhenGetUserPermissions_ShouldReturnDirectAndInheritedPermissions()
184+
{
185+
/* arrange: authenticate user and get access token */
186+
var httpClient = factory.HttpClient.WithRealmHeader("master");
187+
var credentials = new AuthenticationCredentials
188+
{
189+
Username = "federation.testing.user",
190+
Password = "federation.testing.password"
191+
};
192+
193+
var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials);
194+
var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync<AuthenticationResult>();
195+
196+
Assert.NotNull(authenticationResult);
197+
Assert.NotEmpty(authenticationResult.AccessToken);
198+
199+
httpClient.WithAuthorization(authenticationResult.AccessToken);
200+
201+
/* arrange: create a new user */
202+
var enrollmentCredentials = new IdentityEnrollmentCredentials
203+
{
204+
Username = $"user.inherited.permissions.{Guid.NewGuid()}@email.com",
205+
Password = "TestPassword123!"
206+
};
207+
208+
var enrollmentResponse = await httpClient.PostAsJsonAsync("api/v1/identity", enrollmentCredentials);
209+
var user = await enrollmentResponse.Content.ReadFromJsonAsync<UserDetailsScheme>();
210+
211+
Assert.NotNull(user);
212+
Assert.Equal(HttpStatusCode.Created, enrollmentResponse.StatusCode);
213+
214+
/* arrange: create a direct permission */
215+
var directPermissionPayload = _fixture.Build<PermissionCreationScheme>()
216+
.With(permission => permission.Name, $"test.permission.direct.{Guid.NewGuid()}")
217+
.Create();
218+
219+
var directPermissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", directPermissionPayload);
220+
var directPermission = await directPermissionResponse.Content.ReadFromJsonAsync<PermissionDetailsScheme>();
221+
222+
Assert.NotNull(directPermission);
223+
Assert.Equal(HttpStatusCode.Created, directPermissionResponse.StatusCode);
224+
225+
/* arrange: assign direct permission to user */
226+
var assignDirectPermissionPayload = new AssignUserPermissionScheme
227+
{
228+
PermissionName = directPermission.Name
229+
};
230+
231+
var assignDirectPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/permissions", assignDirectPermissionPayload);
232+
Assert.Equal(HttpStatusCode.NoContent, assignDirectPermissionResponse.StatusCode);
233+
234+
/* arrange: create group and inherited permission */
235+
var groupPayload = _fixture.Build<GroupCreationScheme>()
236+
.With(group => group.Name, $"test-group-{Guid.NewGuid()}")
237+
.Create();
238+
239+
var groupResponse = await httpClient.PostAsJsonAsync("api/v1/groups", groupPayload);
240+
var group = await groupResponse.Content.ReadFromJsonAsync<GroupDetailsScheme>();
241+
242+
Assert.NotNull(group);
243+
Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
244+
245+
var inheritedPermissionPayload = _fixture.Build<PermissionCreationScheme>()
246+
.With(permission => permission.Name, $"test.permission.inherited.{Guid.NewGuid()}")
247+
.Create();
248+
249+
var inheritedPermissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", inheritedPermissionPayload);
250+
var inheritedPermission = await inheritedPermissionResponse.Content.ReadFromJsonAsync<PermissionDetailsScheme>();
251+
252+
Assert.NotNull(inheritedPermission);
253+
Assert.Equal(HttpStatusCode.Created, inheritedPermissionResponse.StatusCode);
254+
255+
var assignGroupPermissionPayload = new AssignGroupPermissionScheme
256+
{
257+
PermissionName = inheritedPermission.Name
258+
};
259+
260+
var assignGroupPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/groups/{group.Id}/permissions", assignGroupPermissionPayload);
261+
Assert.Equal(HttpStatusCode.OK, assignGroupPermissionResponse.StatusCode);
262+
263+
/* arrange: assign user to group */
264+
var assignUserToGroupPayload = new AssignUserToGroupScheme
265+
{
266+
GroupId = group.Id
267+
};
268+
269+
var assignUserToGroupResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/groups", assignUserToGroupPayload);
270+
Assert.Equal(HttpStatusCode.NoContent, assignUserToGroupResponse.StatusCode);
271+
272+
/* act: request user permissions */
273+
var response = await httpClient.GetAsync($"api/v1/users/{user.Id}/permissions");
274+
var permissions = await response.Content.ReadFromJsonAsync<IReadOnlyCollection<PermissionDetailsScheme>>();
275+
276+
/* assert: should return both direct and inherited permissions */
277+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
278+
Assert.NotNull(permissions);
279+
280+
Assert.Contains(permissions, permission => permission.Name == directPermission.Name);
281+
Assert.Contains(permissions, permission => permission.Name == inheritedPermission.Name);
282+
}
283+
284+
[Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions and user has no groups should still return direct permissions")]
285+
public async Task WhenGetUserPermissionsWithUserWithoutGroups_ShouldReturnDirectPermissions()
286+
{
287+
/* arrange: authenticate user and get access token */
288+
var httpClient = factory.HttpClient.WithRealmHeader("master");
289+
var credentials = new AuthenticationCredentials
290+
{
291+
Username = "federation.testing.user",
292+
Password = "federation.testing.password"
293+
};
294+
295+
var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials);
296+
var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync<AuthenticationResult>();
297+
298+
Assert.NotNull(authenticationResult);
299+
Assert.NotEmpty(authenticationResult.AccessToken);
300+
301+
httpClient.WithAuthorization(authenticationResult.AccessToken);
302+
303+
/* arrange: create a new user without assigning any groups */
304+
var enrollmentCredentials = new IdentityEnrollmentCredentials
305+
{
306+
Username = $"user.no.groups.permissions.{Guid.NewGuid()}@email.com",
307+
Password = "TestPassword123!"
308+
};
309+
310+
var enrollmentResponse = await httpClient.PostAsJsonAsync("api/v1/identity", enrollmentCredentials);
311+
var user = await enrollmentResponse.Content.ReadFromJsonAsync<UserDetailsScheme>();
312+
313+
Assert.NotNull(user);
314+
Assert.Equal(HttpStatusCode.Created, enrollmentResponse.StatusCode);
315+
316+
/* arrange: create and assign a direct permission */
317+
var permissionPayload = _fixture.Build<PermissionCreationScheme>()
318+
.With(permission => permission.Name, $"test.permission.direct.only.{Guid.NewGuid()}")
319+
.Create();
320+
321+
var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload);
322+
var permission = await permissionResponse.Content.ReadFromJsonAsync<PermissionDetailsScheme>();
323+
324+
Assert.NotNull(permission);
325+
Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode);
326+
327+
var assignPermissionPayload = new AssignUserPermissionScheme
328+
{
329+
PermissionName = permission.Name
330+
};
331+
332+
var assignPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/permissions", assignPermissionPayload);
333+
Assert.Equal(HttpStatusCode.NoContent, assignPermissionResponse.StatusCode);
334+
335+
/* act: request user permissions */
336+
var response = await httpClient.GetAsync($"api/v1/users/{user.Id}/permissions");
337+
var permissions = await response.Content.ReadFromJsonAsync<IReadOnlyCollection<PermissionDetailsScheme>>();
338+
339+
/* assert: endpoint should work and return direct permissions even without group membership */
340+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
341+
Assert.NotNull(permissions);
342+
Assert.Contains(permissions, assigned => assigned.Name == permission.Name);
343+
}
344+
182345
[Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions with non-existent user should return 404 #ERROR-E6B32")]
183346
public async Task WhenGetUserPermissionsWithNonExistentUser_ShouldReturnNotFound()
184347
{

0 commit comments

Comments
 (0)