diff --git a/Source/HttpsRichardy.Federation.Application/Handlers/User/ListUserAssignedPermissionsHandler.cs b/Source/HttpsRichardy.Federation.Application/Handlers/User/ListUserAssignedPermissionsHandler.cs index c90d81b..075e5d6 100644 --- a/Source/HttpsRichardy.Federation.Application/Handlers/User/ListUserAssignedPermissionsHandler.cs +++ b/Source/HttpsRichardy.Federation.Application/Handlers/User/ListUserAssignedPermissionsHandler.cs @@ -1,6 +1,6 @@ namespace HttpsRichardy.Federation.Application.Handlers.User; -public sealed class ListUserAssignedPermissionsHandler(IUserCollection collection) : +public sealed class ListUserAssignedPermissionsHandler(IUserCollection collection, IGroupCollection groupCollection) : IDispatchHandler>> { public async Task>> HandleAsync( @@ -13,8 +13,26 @@ public async Task>> HandleAs var users = await collection.GetUsersAsync(filters, cancellation); var user = users.FirstOrDefault(); - return user is not null - ? Result>.Success(PermissionMapper.AsResponse(user.Permissions)) - : Result>.Failure(UserErrors.UserDoesNotExist); + if (user is null) + { + return Result>.Failure(UserErrors.UserDoesNotExist); + } + + var identifiers = user.Groups + .Select(group => group.Id) + .ToList(); + + var groupFilters = GroupFilters.WithSpecifications() + .WithIdentifiers([.. identifiers]) + .Build(); + + var groups = await groupCollection.GetGroupsAsync(groupFilters, cancellation); + var permissions = groups + .SelectMany(group => group.Permissions) + .Concat(user.Permissions) + .DistinctBy(permission => permission.Name) + .ToList(); + + return Result>.Success(PermissionMapper.AsResponse(permissions)); } } diff --git a/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/GroupFiltersBuilder.cs b/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/GroupFiltersBuilder.cs index e88092e..9055852 100644 --- a/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/GroupFiltersBuilder.cs +++ b/Source/HttpsRichardy.Federation.Domain/Filtering/Builders/GroupFiltersBuilder.cs @@ -3,6 +3,18 @@ namespace HttpsRichardy.Federation.Domain.Filtering.Builders; public sealed class GroupFiltersBuilder : FiltersBuilderBase { + public GroupFiltersBuilder WithIdentifiers(string[] identifiers) + { + var validIdentifiers = identifiers? + .Where(identifier => !string.IsNullOrWhiteSpace(identifier)) + .ToArray(); + + if (validIdentifiers?.Length > 0) + _filters.Identifiers = validIdentifiers; + + return this; + } + public GroupFiltersBuilder WithRealmId(string? realmId) { _filters.RealmId = realmId; diff --git a/Source/HttpsRichardy.Federation.Domain/Filtering/GroupFilters.cs b/Source/HttpsRichardy.Federation.Domain/Filtering/GroupFilters.cs index 690a0d3..1b0e1a9 100644 --- a/Source/HttpsRichardy.Federation.Domain/Filtering/GroupFilters.cs +++ b/Source/HttpsRichardy.Federation.Domain/Filtering/GroupFilters.cs @@ -4,6 +4,7 @@ public sealed class GroupFilters : Filters { public string? RealmId { get; set; } public string? Name { get; set; } + public string[]? Identifiers { get; set; } public static GroupFiltersBuilder WithSpecifications() => new(); } diff --git a/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs b/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs index 1e182e1..690847b 100644 --- a/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs +++ b/Source/HttpsRichardy.Federation.Infrastructure/Pipelines/GroupFiltersStage.cs @@ -9,6 +9,7 @@ public static PipelineDefinition FilterGroups(this Pipeline var definitions = new List> { FilterDefinitions.MatchIfNotEmpty(Documents.Group.Id, filters.Id), + FilterDefinitions.MustBeInIfNotEmpty(Documents.Group.Id, filters.Identifiers), FilterDefinitions.MatchIfNotEmpty(Documents.Group.Name, filters.Name), FilterDefinitions.MatchIfNotEmpty(Documents.Group.RealmId, realm?.Id), FilterDefinitions.MatchBool(Documents.Group.IsDeleted, filters.IsDeleted), diff --git a/Tests/Integration/Endpoints/UserEndpointTests.cs b/Tests/Integration/Endpoints/UserEndpointTests.cs index aa1aa7b..1cb6fa6 100644 --- a/Tests/Integration/Endpoints/UserEndpointTests.cs +++ b/Tests/Integration/Endpoints/UserEndpointTests.cs @@ -179,6 +179,169 @@ public async Task WhenGetUserPermissions_ShouldReturnAssignedPermissions() } } + [Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions should return both direct and inherited group permissions")] + public async Task WhenGetUserPermissions_ShouldReturnDirectAndInheritedPermissions() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new user */ + var enrollmentCredentials = new IdentityEnrollmentCredentials + { + Username = $"user.inherited.permissions.{Guid.NewGuid()}@email.com", + Password = "TestPassword123!" + }; + + var enrollmentResponse = await httpClient.PostAsJsonAsync("api/v1/identity", enrollmentCredentials); + var user = await enrollmentResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(user); + Assert.Equal(HttpStatusCode.Created, enrollmentResponse.StatusCode); + + /* arrange: create a direct permission */ + var directPermissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.direct.{Guid.NewGuid()}") + .Create(); + + var directPermissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", directPermissionPayload); + var directPermission = await directPermissionResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(directPermission); + Assert.Equal(HttpStatusCode.Created, directPermissionResponse.StatusCode); + + /* arrange: assign direct permission to user */ + var assignDirectPermissionPayload = new AssignUserPermissionScheme + { + PermissionName = directPermission.Name + }; + + var assignDirectPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/permissions", assignDirectPermissionPayload); + Assert.Equal(HttpStatusCode.NoContent, assignDirectPermissionResponse.StatusCode); + + /* arrange: create group and inherited permission */ + var groupPayload = _fixture.Build() + .With(group => group.Name, $"test-group-{Guid.NewGuid()}") + .Create(); + + var groupResponse = await httpClient.PostAsJsonAsync("api/v1/groups", groupPayload); + var group = await groupResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(group); + Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode); + + var inheritedPermissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.inherited.{Guid.NewGuid()}") + .Create(); + + var inheritedPermissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", inheritedPermissionPayload); + var inheritedPermission = await inheritedPermissionResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(inheritedPermission); + Assert.Equal(HttpStatusCode.Created, inheritedPermissionResponse.StatusCode); + + var assignGroupPermissionPayload = new AssignGroupPermissionScheme + { + PermissionName = inheritedPermission.Name + }; + + var assignGroupPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/groups/{group.Id}/permissions", assignGroupPermissionPayload); + Assert.Equal(HttpStatusCode.OK, assignGroupPermissionResponse.StatusCode); + + /* arrange: assign user to group */ + var assignUserToGroupPayload = new AssignUserToGroupScheme + { + GroupId = group.Id + }; + + var assignUserToGroupResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/groups", assignUserToGroupPayload); + Assert.Equal(HttpStatusCode.NoContent, assignUserToGroupResponse.StatusCode); + + /* act: request user permissions */ + var response = await httpClient.GetAsync($"api/v1/users/{user.Id}/permissions"); + var permissions = await response.Content.ReadFromJsonAsync>(); + + /* assert: should return both direct and inherited permissions */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(permissions); + + Assert.Contains(permissions, permission => permission.Name == directPermission.Name); + Assert.Contains(permissions, permission => permission.Name == inheritedPermission.Name); + } + + [Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions and user has no groups should still return direct permissions")] + public async Task WhenGetUserPermissionsWithUserWithoutGroups_ShouldReturnDirectPermissions() + { + /* arrange: authenticate user and get access token */ + var httpClient = factory.HttpClient.WithRealmHeader("master"); + var credentials = new AuthenticationCredentials + { + Username = "federation.testing.user", + Password = "federation.testing.password" + }; + + var authenticationResponse = await httpClient.PostAsJsonAsync("api/v1/identity/authenticate", credentials); + var authenticationResult = await authenticationResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(authenticationResult); + Assert.NotEmpty(authenticationResult.AccessToken); + + httpClient.WithAuthorization(authenticationResult.AccessToken); + + /* arrange: create a new user without assigning any groups */ + var enrollmentCredentials = new IdentityEnrollmentCredentials + { + Username = $"user.no.groups.permissions.{Guid.NewGuid()}@email.com", + Password = "TestPassword123!" + }; + + var enrollmentResponse = await httpClient.PostAsJsonAsync("api/v1/identity", enrollmentCredentials); + var user = await enrollmentResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(user); + Assert.Equal(HttpStatusCode.Created, enrollmentResponse.StatusCode); + + /* arrange: create and assign a direct permission */ + var permissionPayload = _fixture.Build() + .With(permission => permission.Name, $"test.permission.direct.only.{Guid.NewGuid()}") + .Create(); + + var permissionResponse = await httpClient.PostAsJsonAsync("api/v1/permissions", permissionPayload); + var permission = await permissionResponse.Content.ReadFromJsonAsync(); + + Assert.NotNull(permission); + Assert.Equal(HttpStatusCode.Created, permissionResponse.StatusCode); + + var assignPermissionPayload = new AssignUserPermissionScheme + { + PermissionName = permission.Name + }; + + var assignPermissionResponse = await httpClient.PostAsJsonAsync($"api/v1/users/{user.Id}/permissions", assignPermissionPayload); + Assert.Equal(HttpStatusCode.NoContent, assignPermissionResponse.StatusCode); + + /* act: request user permissions */ + var response = await httpClient.GetAsync($"api/v1/users/{user.Id}/permissions"); + var permissions = await response.Content.ReadFromJsonAsync>(); + + /* assert: endpoint should work and return direct permissions even without group membership */ + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(permissions); + Assert.Contains(permissions, assigned => assigned.Name == permission.Name); + } + [Fact(DisplayName = "[e2e] - when GET /users/{id}/permissions with non-existent user should return 404 #ERROR-E6B32")] public async Task WhenGetUserPermissionsWithNonExistentUser_ShouldReturnNotFound() {