From d8460526d8191b7a55f4113f21c2f9734dded31f Mon Sep 17 00:00:00 2001 From: Sanath Madhav Date: Fri, 25 Sep 2026 13:22:06 +0000 Subject: [PATCH 1/4] Release 1.6.0 final file changes --- pom.xml | 2 +- .../auth/request/dto/ClientFilterDto.java | 91 ++++++++ .../constants/ApiConstants.java | 20 ++ .../constants/LocalizationKey.java | 1 + .../ClientRegistrationController.java | 68 ++++++ .../controller/UsersController.java | 131 +++++++++++ .../ClientRegistrationResponseMessage.java | 1 + .../usermanagement/enums/ClientStatus.java | 25 +++ .../handler/GlobalExceptionHandler.java | 7 +- .../repository/ClientRepository.java | 4 +- .../UserAttributeValueRepository.java | 2 + .../service/ClientRegistration.java | 18 ++ .../usermanagement/service/UsersService.java | 48 ++++ .../impl/ClientRegistrationServiceImpl.java | 159 +++++++++++-- .../service/impl/UsersServiceImpl.java | 212 +++++++++++++++--- .../response/dto/ClientFilterResponse.java | 51 +++++ .../utilities/ClientSearchSpecification.java | 94 ++++++++ .../controller/ClientRegistrationTest.java | 160 +++++++++++++ .../service/UsersServiceTest.java | 57 +++++ .../ClientSearchSpecificationTest.java | 135 +++++++++++ 20 files changed, 1231 insertions(+), 55 deletions(-) create mode 100755 src/main/java/org/eclipse/ecsp/uidam/usermanagement/auth/request/dto/ClientFilterDto.java create mode 100755 src/main/java/org/eclipse/ecsp/uidam/usermanagement/user/response/dto/ClientFilterResponse.java create mode 100755 src/main/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecification.java create mode 100755 src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java diff --git a/pom.xml b/pom.xml index 4330ef3..e3b48a7 100644 --- a/pom.xml +++ b/pom.xml @@ -122,7 +122,7 @@ ${project.build.directory}/coverage-reports/jacoco-ut.exec - 1.2.0 + 1.2.3 0.9.14 3.3.1 diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/auth/request/dto/ClientFilterDto.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/auth/request/dto/ClientFilterDto.java new file mode 100755 index 0000000..382a0bc --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/auth/request/dto/ClientFilterDto.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2023 - 2024 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package org.eclipse.ecsp.uidam.usermanagement.auth.request.dto; + +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.eclipse.ecsp.uidam.usermanagement.enums.ClientStatus; +import java.util.Set; +import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.INVALID_ELEMENT_LENGTH; +import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.INVALID_LENGTH; +import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.INVALID_NULL_ELEMENT; + +/** + * Filter criteria used to search registered oauth2 clients. + */ +@NoArgsConstructor +@Getter +@Setter +@ToString +public class ClientFilterDto { + + public static final int MIN_PER_SET = 1; + public static final int MAX_PER_SET = 50; + public static final int MIN_ELEMENT_LENGTH = 1; + public static final int MAX_CLIENT_ID_LENGTH = 255; + public static final int MAX_CLIENT_NAME_LENGTH = 255; + + @Schema(description = "List of client ids") + @Size(min = MIN_PER_SET, max = MAX_PER_SET, message = INVALID_LENGTH) + @Valid + private Set<@NotNull(message = INVALID_NULL_ELEMENT) + @Size(min = MIN_ELEMENT_LENGTH, max = MAX_CLIENT_ID_LENGTH, + message = INVALID_ELEMENT_LENGTH) String> clientIds; + + @Schema(description = "List of client names") + @Size(min = MIN_PER_SET, max = MAX_PER_SET, message = INVALID_LENGTH) + @Valid + private Set<@NotNull(message = INVALID_NULL_ELEMENT) + @Size(min = MIN_ELEMENT_LENGTH, max = MAX_CLIENT_NAME_LENGTH, + message = INVALID_ELEMENT_LENGTH) String> clientNames; + + @ArraySchema(schema = @Schema(implementation = ClientStatus.class, + allowableValues = {"approved", "deleted", "registered", "rejected"})) + @Size(min = MIN_PER_SET, max = MAX_PER_SET, message = INVALID_LENGTH) + @Valid + private Set<@NotNull(message = INVALID_NULL_ELEMENT) ClientStatus> statuses; + + /** + * Client entity fields available for filtering and sorting. + */ + public enum ClientFilterDtoEnum { + CLIENT_IDS("clientId"), + CLIENT_NAMES("clientName"), + STATUS("status"), + CREATED_DATE("createDate"), + UPDATED_DATE("updateDate"); + + private final String field; + + ClientFilterDtoEnum(String field) { + this.field = field; + } + + public String getField() { + return field; + } + } +} diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/ApiConstants.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/ApiConstants.java index 51108a1..09b24b3 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/ApiConstants.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/ApiConstants.java @@ -115,6 +115,8 @@ private ApiConstants() { public static final String RESULTS = "results"; public static final String USER = "User"; public static final String USERS = "Users"; + public static final String USER_ATTRIBUTE = "UserAttribute"; + public static final String USER_ATTRIBUTE_VALUE = "UserAttributeValue"; public static final String STATUS_FIELD_DESCRIPTION = "Status. Allowed values are: [PENDING, REJECTED, ACTIVE, DEACTIVATED]"; public static final String IS_EXTERNAL_USER_DESCRIPTION = @@ -126,6 +128,11 @@ private ApiConstants() { public static final String SUMMARY_GET_EXTERNAL_USER = "Get external user"; public static final String SUMMARY_GET_USER_ATTRIBUTES = "Get user attributes"; public static final String SUMMARY_PUT_USER_ATTRIBUTES = "Add/Modify additional attributes to user"; + public static final String SUMMARY_GET_ADDITIONAL_USER_ATTRIBUTES = "Get all additional attribute definitions"; + public static final String SUMMARY_DELETE_USER_ATTRIBUTE = "Delete an additional attribute definition"; + public static final String SUMMARY_GET_USER_ATTRIBUTE_VALUES = "Get a user's additional attribute values"; + public static final String SUMMARY_PUT_USER_ATTRIBUTE_VALUES = "Add/Modify a user's additional attribute values"; + public static final String SUMMARY_DELETE_USER_ATTRIBUTE_VALUE = "Delete a user's additional attribute value"; public static final String SUMMARY_GET_SELF_USER = "Get my user data"; public static final String SUMMARY_ADD_SELF_USER = "Create end user."; @@ -147,6 +154,12 @@ private ApiConstants() { public static final String BUILDER_NAME = "custom"; public static final String PATH_VARIABLE_USERNAME = "/{userName}"; public static final String PATH_USER_ATTRIBUTES = "/attributes"; + public static final String PATH_VARIABLE_ATTRIBUTE_NAME = "/{attributeName}"; + public static final String ATTRIBUTE_NAME = "attributeName"; + public static final String PATH_USER_ATTRIBUTES_ADDITIONAL = PATH_USER_ATTRIBUTES + "/additional"; + public static final String PATH_USER_ATTRIBUTE_DEFINITION = PATH_USER_ATTRIBUTES + PATH_VARIABLE_ATTRIBUTE_NAME; + public static final String PATH_USER_ATTRIBUTE_VALUES = PATH_VARIABLE_ID + PATH_USER_ATTRIBUTES + "/values"; + public static final String PATH_USER_ATTRIBUTE_VALUE = PATH_USER_ATTRIBUTE_VALUES + PATH_VARIABLE_ATTRIBUTE_NAME; public static final String CAPTCHA_REQUIRED = "required"; public static final String CAPTCHA_ENFORCE_AFTER_NO_OF_FAILURES = "enforceAfterNoOfFailures"; public static final String PATH_BY_USERNAME = "/byUserName"; @@ -293,6 +306,13 @@ private ApiConstants() { public static final String ROLES_SCOPES_FILTER_PATH = "/filter"; public static final String CLIENT_RESOURCE_PATH = "/oauth2/client"; + public static final String CLIENT_FILTER_PATH = "/filter"; + public static final String SORT_BY_DEFAULT_FOR_FILTER_CLIENTS = "CLIENT_NAMES"; + public static final String IGNORE_CASE_DEFAULT_FOR_FILTER_CLIENTS = "false"; + public static final String SEARCH_TYPE_DEFAULT_FOR_FILTER_CLIENTS = "EQUAL"; + public static final int MIN_PAGE_NUMBER = 0; + public static final int MIN_PAGE_SIZE = 1; + public static final int MAX_PAGE_SIZE = 100; public static final String SPACE = " "; public static final String COMMA = ","; diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/LocalizationKey.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/LocalizationKey.java index 2be894f..a8c382f 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/LocalizationKey.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/constants/LocalizationKey.java @@ -101,6 +101,7 @@ public interface LocalizationKey { String FIELD_NOT_FOUND = "field.not.found"; String ACTION_FORBIDDEN = "action.forbidden"; String DELETE_OPERATION_FAILED = "delete.operation.failed"; + String ATTRIBUTE_HAS_REFERENCED_VALUES = "attribute.has.referenced.values"; String USER_ROLES_NOT_FOUND = "user.roles.not.found"; String FIELD_CANNOT_BE_MODIFIED = "field.cannot.be.modified"; String MISSING_CORRELATION_ID = "missing.correlation.id"; diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationController.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationController.java index 1bbdb12..dc5ef45 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationController.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationController.java @@ -19,23 +19,33 @@ package org.eclipse.ecsp.uidam.usermanagement.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto; import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.RegisteredClientDetails; import org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants; +import org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey; import org.eclipse.ecsp.uidam.usermanagement.enums.ClientRegistrationResponseCode; import org.eclipse.ecsp.uidam.usermanagement.enums.ClientRegistrationResponseMessage; +import org.eclipse.ecsp.uidam.usermanagement.enums.SearchType; +import org.eclipse.ecsp.uidam.usermanagement.enums.SortOrder; import org.eclipse.ecsp.uidam.usermanagement.service.ClientRegistration; import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.BaseResponse; +import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.ClientFilterResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -52,6 +62,7 @@ * Rest controller for client registration. */ @RestController +@Validated @RequestMapping(value = ApiConstants.API_VERSION + ApiConstants.CLIENT_RESOURCE_PATH, produces = APPLICATION_JSON_VALUE) public class ClientRegistrationController { private static Logger logger = LoggerFactory.getLogger(ClientRegistrationController.class); @@ -146,6 +157,63 @@ public ResponseEntity deleteClient(@PathVariable("clientId") Strin return buildResponse(ClientRegistrationResponseCode.SP_SUCCESS.getCode(), response.get(), null, HttpStatus.OK); } + /** + * Filter clients api. + * + * @param pageNumber zero based index of the page to retrieve + * @param pageSize number of clients to display per page + * @param sortBy client attribute the result is ordered by + * @param sortOrder order results in ASC or DESC order + * @param ignoreCase make search case-sensitive/case-insensitive + * @param searchType match values as PREFIX, SUFFIX, CONTAINS or EQUAL + * @param clientFilterDto filter criteria for each field + * @return response with the matching clients, client secret excluded + */ + @PostMapping(ApiConstants.CLIENT_FILTER_PATH) + @Operation(summary = "Filter clients", description = "Retrieve clients matching defined criteria", responses = { + @ApiResponse(responseCode = "200", description = "Success", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BaseResponse.class))) }) + @SecurityRequirement(name = "JwtAuthValidator", scopes = { "OAuth2ClientMgmt" }) + public ResponseEntity filterClients( + @RequestParam(name = ApiConstants.PAGE_NUMBER, required = false, + defaultValue = ApiConstants.PAGE_NUMBER_DEFAULT) + @Parameter(description = ApiConstants.PAGE_NUMBER_DESCRIPTION) + @Min(value = ApiConstants.MIN_PAGE_NUMBER, message = LocalizationKey.INVALID_LENGTH) Integer pageNumber, + @RequestParam(name = ApiConstants.PAGE_SIZE, required = false, + defaultValue = ApiConstants.PAGE_SIZE_DEFAULT) + @Parameter(description = ApiConstants.PAGE_SIZE_DESCRIPTION) + @Min(value = ApiConstants.MIN_PAGE_SIZE, message = LocalizationKey.INVALID_LENGTH) + @Max(value = ApiConstants.MAX_PAGE_SIZE, message = LocalizationKey.INVALID_LENGTH) Integer pageSize, + @RequestParam(name = ApiConstants.SORT_BY, required = false, + defaultValue = ApiConstants.SORT_BY_DEFAULT_FOR_FILTER_CLIENTS) + @Parameter(description = ApiConstants.SORT_BY_DESCRIPTION) ClientFilterDto.ClientFilterDtoEnum sortBy, + @RequestParam(name = ApiConstants.SORT_ORDER, required = false, defaultValue = ApiConstants.DESCENDING) + @Parameter(description = ApiConstants.SORT_ORDER_DESCRIPTION, schema = @Schema( + allowableValues = { ApiConstants.DESCENDING, ApiConstants.ASCENDING })) SortOrder sortOrder, + @RequestParam(name = ApiConstants.IGNORE_CASE, required = false, + defaultValue = ApiConstants.IGNORE_CASE_DEFAULT_FOR_FILTER_CLIENTS) + @Parameter(description = ApiConstants.IGNORE_CASE_DESCRIPTION, + schema = @Schema(allowableValues = { "true", "false" })) boolean ignoreCase, + @RequestParam(name = ApiConstants.SEARCH_TYPE, required = false, + defaultValue = ApiConstants.SEARCH_TYPE_DEFAULT_FOR_FILTER_CLIENTS) + @Parameter(description = ApiConstants.SEARCH_TYPE_DESCRIPTION, schema = @Schema( + allowableValues = { "PREFIX", "SUFFIX", "CONTAINS", "EQUAL" })) SearchType searchType, + @Valid @RequestBody @Parameter(name = "Request payload", + description = "Parameters and values by which to filter. To get all clients, leave empty.") + ClientFilterDto clientFilterDto) { + logger.debug("#Filter clients request, page: {}, size: {}", pageNumber, pageSize); + ClientFilterResponse clients = clientRegistrationService.filterClients(clientFilterDto, pageNumber, pageSize, + sortBy.getField(), sortOrder.sortOrderLowerCase(), ignoreCase, searchType); + // Exclude client secret from the response for security reasons + if (clients.getItems() != null) { + clients.getItems().forEach(client -> client.setClientSecret("")); + } + return buildResponse(ClientRegistrationResponseCode.SP_SUCCESS.getCode(), + ClientRegistrationResponseMessage.SP_REGISTRATION_FILTER_SUCCESS_200_MSG.getMessage(), clients, + HttpStatus.OK); + } + /** * Method to build client registration response. * diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java index e483519..c9cab4f 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java @@ -85,10 +85,12 @@ import java.io.IOException; import java.math.BigInteger; import java.util.List; +import java.util.Map; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ACCOUNT_NAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ACCOUNT_ROLE_ASSOCIATION; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ASCENDING; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ASSOCIATE_USERS_TO_ROLE_PATH; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ATTRIBUTE_NAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.DESCENDING; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.END_USER_TAG; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.EXTERNAL_USER; @@ -110,6 +112,10 @@ import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_FILTER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_GET_EXTERNAL_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ATTRIBUTES; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ATTRIBUTES_ADDITIONAL; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ATTRIBUTE_DEFINITION; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ATTRIBUTE_VALUE; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ATTRIBUTE_VALUES; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_USER_ID; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_VARIABLE_ID; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.PATH_VARIABLE_USERNAME; @@ -130,14 +136,19 @@ import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_DELETE_EXTERNAL_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_DELETE_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_DELETE_USERS_BY_FILTER; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_DELETE_USER_ATTRIBUTE; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_DELETE_USER_ATTRIBUTE_VALUE; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_EDIT_EXTERNAL_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_EDIT_USER; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_ADDITIONAL_USER_ATTRIBUTES; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_EXTERNAL_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_SELF_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_USERS_BY_FILTER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_USER_ATTRIBUTES; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_GET_USER_ATTRIBUTE_VALUES; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_PUT_USER_ATTRIBUTES; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_PUT_USER_ATTRIBUTE_VALUES; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_SELF_DELETE_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_SELF_EDIT_USER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.SUMMARY_SELF_RESET; @@ -367,6 +378,126 @@ public ResponseEntity> putUserAttributes(@Valid @Requ return new ResponseEntity<>(usersService.putUserMetaData(userMetaDataRequests), HttpStatus.OK); } + /** + * API to get metadata for every additional attribute defined in the user_attributes table + * (both dynamic and static-defined custom attributes). + * + * @return List of additional attribute metadata. + */ + @Operation(summary = SUMMARY_GET_ADDITIONAL_USER_ATTRIBUTES, + description = "Get all additional attribute definitions from the user_attributes table.", + tags = {USERS_TAG}, + responses = { + @ApiResponse(responseCode = "200", description = "Success") + } + ) + @SecurityRequirement(name = "JwtAuthValidator", scopes = {"ViewUsers", "ManageUsers"}) + @GetMapping(value = PATH_USER_ATTRIBUTES_ADDITIONAL) + public ResponseEntity> getAdditionalUserAttributes() { + LOGGER.info("Get additional user attributes"); + return new ResponseEntity<>(usersService.getAllUserAttributes(), HttpStatus.OK); + } + + /** + * API to delete an additional attribute definition. Rejected if any user has a stored value for it. + * + * @param attributeName name of the attribute definition to delete. + * @throws ResourceNotFoundException if no attribute definition exists with the given name. + */ + @Operation(summary = SUMMARY_DELETE_USER_ATTRIBUTE, + description = "Deletes an additional attribute definition. Fails with 409 if any user " + + "has a stored value for it.", + tags = {USERS_TAG}, + responses = { + @ApiResponse(responseCode = "204", description = "Success"), + @ApiResponse(responseCode = "409", description = "Attribute has values referenced by users") + } + ) + @SecurityRequirement(name = "JwtAuthValidator", scopes = {"ManageUsers"}) + @DeleteMapping(value = PATH_USER_ATTRIBUTE_DEFINITION) + public ResponseEntity deleteUserAttribute( + @PathVariable(value = ATTRIBUTE_NAME) @Parameter(description = "Attribute name", required = true) + String attributeName) throws ResourceNotFoundException { + LOGGER.info("Delete user attribute definition request received for: {}", attributeName); + usersService.deleteUserAttribute(attributeName); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + /** + * API to get a single user's additional attribute values. + * + * @param id user id. + * @return map of attribute name to value. + * @throws ResourceNotFoundException if the user does not exist. + */ + @Operation(summary = SUMMARY_GET_USER_ATTRIBUTE_VALUES, + description = "Get a user's additional attribute name/value pairs.", + tags = {USERS_TAG}, + responses = { + @ApiResponse(responseCode = "200", description = "Success") + } + ) + @SecurityRequirement(name = "JwtAuthValidator", scopes = {"ViewUsers", "ManageUsers"}) + @GetMapping(value = PATH_USER_ATTRIBUTE_VALUES) + public ResponseEntity> getUserAttributeValues( + @PathVariable(value = ID) @Parameter(description = "User ID", required = true) BigInteger id) + throws ResourceNotFoundException { + LOGGER.info("Get user attribute values request received for user id: {}", id); + return new ResponseEntity<>(usersService.getUserAttributeValues(id), HttpStatus.OK); + } + + /** + * API to add/modify a single user's additional attribute values. + * + * @param id user id. + * @param attributeValues map of attribute name to value. + * @return map of the user's attribute name to value after the update. + * @throws ResourceNotFoundException if the user does not exist. + */ + @Operation(summary = SUMMARY_PUT_USER_ATTRIBUTE_VALUES, + description = "Add/Modify a user's additional attribute values.", + tags = {USERS_TAG}, + responses = { + @ApiResponse(responseCode = "200", description = "Success") + } + ) + @SecurityRequirement(name = "JwtAuthValidator", scopes = {"ManageUsers"}) + @PutMapping(value = PATH_USER_ATTRIBUTE_VALUES, consumes = APPLICATION_JSON_VALUE) + public ResponseEntity> putUserAttributeValues( + @PathVariable(value = ID) @Parameter(description = "User ID", required = true) BigInteger id, + @RequestBody @Parameter(name = "Request payload", description = "Attribute name/value pairs to add/modify") + Map attributeValues) throws ResourceNotFoundException { + LOGGER.info("Put user attribute values request received for user id: {}", id); + return new ResponseEntity<>(usersService.updateUserAttributeValues(id, attributeValues), HttpStatus.OK); + } + + /** + * API to delete a single user's stored value for one additional attribute. + * + * @param id user id. + * @param attributeName name of the attribute value to delete. + * @throws ResourceNotFoundException if the user, the attribute definition, or the stored + * value does not exist. + */ + @Operation(summary = SUMMARY_DELETE_USER_ATTRIBUTE_VALUE, + description = "Deletes a user's stored value for one additional attribute.", + tags = {USERS_TAG}, + responses = { + @ApiResponse(responseCode = "204", description = "Success") + } + ) + @SecurityRequirement(name = "JwtAuthValidator", scopes = {"ManageUsers"}) + @DeleteMapping(value = PATH_USER_ATTRIBUTE_VALUE) + public ResponseEntity deleteUserAttributeValue( + @PathVariable(value = ID) @Parameter(description = "User ID", required = true) BigInteger id, + @PathVariable(value = ATTRIBUTE_NAME) @Parameter(description = "Attribute name", required = true) + String attributeName) throws ResourceNotFoundException { + LOGGER.info("Delete user attribute value request received for user id: {}, attribute: {}", id, + attributeName); + usersService.deleteUserAttributeValue(id, attributeName); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + /** * API to update user details by passing user id. * diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientRegistrationResponseMessage.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientRegistrationResponseMessage.java index baf497d..2d6350c 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientRegistrationResponseMessage.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientRegistrationResponseMessage.java @@ -26,6 +26,7 @@ public enum ClientRegistrationResponseMessage { SP_REGISTRATION_SUCCESS_201_MSG("Client registered successfully!!"), SP_REGISTRATION_UPDATE_SUCCESS_200_MSG("Client updated successfully!!"), SP_REGISTRATION_RETRIEVE_SUCCESS_200_MSG("Client details retrieved successfully."), + SP_REGISTRATION_FILTER_SUCCESS_200_MSG("Client details filtered successfully."), SP_REGISTRATION_DELETE_SUCCESS_200_MSG("Client deleted successfully!!"); private String message; diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientStatus.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientStatus.java index c5aa2e0..9eb2479 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientStatus.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/enums/ClientStatus.java @@ -18,9 +18,14 @@ package org.eclipse.ecsp.uidam.usermanagement.enums; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; + /** * ENUM with client status. */ +@Schema(type = "string", allowableValues = {"approved", "deleted", "registered", "rejected"}) public enum ClientStatus { APPROVED("approved"), DELETED("deleted"), REGISTERED("registered"), REJECTED("rejected"); @@ -31,8 +36,28 @@ private ClientStatus(String value) { this.value = value; } + @JsonValue public String getValue() { return value; } + /** + * Resolve a client status from its JSON value or enum name. + * + * @param value client status value from the request payload. + * @return matching ClientStatus enum. + */ + @JsonCreator + public static ClientStatus fromValue(String value) { + if (value == null) { + return null; + } + for (ClientStatus status : values()) { + if (status.value.equalsIgnoreCase(value) || status.name().equalsIgnoreCase(value)) { + return status; + } + } + throw new IllegalArgumentException("Invalid client status: " + value); + } + } diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/exception/handler/GlobalExceptionHandler.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/exception/handler/GlobalExceptionHandler.java index 9d1e54e..74a0ea2 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/exception/handler/GlobalExceptionHandler.java @@ -39,7 +39,6 @@ import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.BaseRepresentation; import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.BaseResponse; import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.ResponseMessage; -import org.hibernate.validator.internal.engine.path.PathImpl; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -374,8 +373,10 @@ public ResponseEntity exceptionHandler(ValidationException e while (iterator.hasNext()) { ConstraintViolation constraintViolation = iterator.next(); - PathImpl propertyPath = (PathImpl) constraintViolation.getPropertyPath(); + Path propertyPath = constraintViolation.getPropertyPath(); + String leafNodeName = null; for (Path.Node node : propertyPath) { + leafNodeName = node.getName(); if (node.getKey() instanceof ResponseMessage response) { baseRepresentation.addMessage(response); } @@ -383,7 +384,7 @@ public ResponseEntity exceptionHandler(ValidationException e if (baseRepresentation.getMessages().isEmpty()) { baseRepresentation.addMessage(new ResponseMessage(constraintViolation.getMessageTemplate(), "field value [" + constraintViolation.getInvalidValue() + "]", - propertyPath.getLeafNode().getName())); + leafNodeName)); } } } else { diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/ClientRepository.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/ClientRepository.java index 8807ac7..763a126 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/ClientRepository.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/ClientRepository.java @@ -20,6 +20,7 @@ import org.eclipse.ecsp.uidam.usermanagement.entity.ClientEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -30,7 +31,8 @@ * Repository class for client registration. */ @Repository -public interface ClientRepository extends JpaRepository { +public interface ClientRepository extends JpaRepository, + JpaSpecificationExecutor { @Query("SELECT c FROM ClientEntity c WHERE c.clientId = :clientId") Optional findByClientId(@Param("clientId") String clientId); diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/UserAttributeValueRepository.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/UserAttributeValueRepository.java index d560abd..1554722 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/UserAttributeValueRepository.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/repository/UserAttributeValueRepository.java @@ -34,4 +34,6 @@ public interface UserAttributeValueRepository extends JpaRepository findAllByUserIdAndAttributeIdIn(BigInteger userId, List attributeIds); UserAttributeValueEntity findByUserIdAndAttributeId(BigInteger userId, BigInteger attributeId); + + boolean existsByAttributeId(BigInteger attributeId); } diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/ClientRegistration.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/ClientRegistration.java index 6f0d0ed..5c24391 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/ClientRegistration.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/ClientRegistration.java @@ -18,7 +18,10 @@ package org.eclipse.ecsp.uidam.usermanagement.service; +import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto; import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.RegisteredClientDetails; +import org.eclipse.ecsp.uidam.usermanagement.enums.SearchType; +import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.ClientFilterResponse; import java.util.Optional; /** @@ -34,4 +37,19 @@ public interface ClientRegistration { Optional updateRegisteredClient(String clientId, RegisteredClientDetails request); + /** + * Retrieve the clients matching the given filter criteria, page by page. + * + * @param clientFilterDto criteria each client attribute must match. + * @param pageNumber zero based index of the page to retrieve. + * @param pageSize number of clients per page. + * @param sortBy client entity attribute used to sort the result. + * @param sortOrder asc or desc sorting order. + * @param ignoreCase perform a case-insensitive match on string attributes. + * @param searchType match the value as PREFIX, SUFFIX, CONTAINS or EQUAL. + * @return paginated clients matching the criteria, without their client secret. + */ + ClientFilterResponse filterClients(ClientFilterDto clientFilterDto, Integer pageNumber, Integer pageSize, + String sortBy, String sortOrder, boolean ignoreCase, SearchType searchType); + } diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersService.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersService.java index 83663b5..fc4a6e6 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersService.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersService.java @@ -44,6 +44,7 @@ import java.math.BigInteger; import java.net.MalformedURLException; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -76,10 +77,57 @@ List getUsers(UsersGetFilterBase userGetFilter, Integer pageNu List getSignupAttributes(Boolean dynamicAttribute); + /** + * Returns metadata for every additional attribute defined in the user_attributes table + * (both dynamic and static-defined custom attributes). + * + * @return list of additional attribute metadata. + */ + List getAllUserAttributes(); + UserEventResponseDto addUserEvent(UserEventsDto userEventsDto, String userId); List putUserMetaData(List userMetaDataRequests); + /** + * Deletes an additional attribute definition from the user_attributes table, along with + * every stored value for that attribute in user_attribute_values. + * + * @param attributeName name of the attribute definition to delete. + * @throws ResourceNotFoundException if no attribute definition exists with the given name. + */ + void deleteUserAttribute(String attributeName) throws ResourceNotFoundException; + + /** + * Returns a single user's additional attribute name/value pairs from user_attribute_values. + * + * @param userId user id. + * @return map of attribute name to value. + * @throws ResourceNotFoundException if the user does not exist. + */ + Map getUserAttributeValues(BigInteger userId) throws ResourceNotFoundException; + + /** + * Adds/updates a single user's additional attribute values in user_attribute_values. + * + * @param userId user id. + * @param attributeValues map of attribute name to value. + * @return map of the user's attribute name to value after the update. + * @throws ResourceNotFoundException if the user does not exist. + */ + Map updateUserAttributeValues(BigInteger userId, Map attributeValues) + throws ResourceNotFoundException; + + /** + * Deletes a single user's stored value for one additional attribute from user_attribute_values. + * + * @param userId user id. + * @param attributeName name of the attribute value to delete. + * @throws ResourceNotFoundException if the user, the attribute definition, or the stored value + * does not exist. + */ + void deleteUserAttributeValue(BigInteger userId, String attributeName) throws ResourceNotFoundException; + void updateUserPasswordUsingRecoverySecret(UserUpdatePasswordDto userUpdatePasswordDto) throws ResourceNotFoundException, RecoverySecretExpireException; diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/ClientRegistrationServiceImpl.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/ClientRegistrationServiceImpl.java index de961e5..1b32898 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/ClientRegistrationServiceImpl.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/ClientRegistrationServiceImpl.java @@ -19,44 +19,61 @@ package org.eclipse.ecsp.uidam.usermanagement.service.impl; import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto; import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.RegisteredClientDetails; import org.eclipse.ecsp.uidam.usermanagement.entity.ClientEntity; import org.eclipse.ecsp.uidam.usermanagement.enums.ClientRegistrationResponseCode; import org.eclipse.ecsp.uidam.usermanagement.enums.ClientRegistrationResponseMessage; import org.eclipse.ecsp.uidam.usermanagement.enums.ClientStatus; +import org.eclipse.ecsp.uidam.usermanagement.enums.SearchType; import org.eclipse.ecsp.uidam.usermanagement.exception.ClientRegistrationException; import org.eclipse.ecsp.uidam.usermanagement.repository.ClientRepository; import org.eclipse.ecsp.uidam.usermanagement.service.ClientRegistration; import org.eclipse.ecsp.uidam.usermanagement.service.TenantConfigurationService; +import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.ClientFilterResponse; import org.eclipse.ecsp.uidam.usermanagement.utilities.AesEncryptionDecryption; +import org.eclipse.ecsp.uidam.usermanagement.utilities.ClientSearchSpecification; +import org.eclipse.ecsp.uidam.usermanagement.utilities.SearchCriteria; import org.eclipse.ecsp.uidam.usermanagement.utilities.ValidationUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.time.Instant; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; +import static org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto.ClientFilterDtoEnum.CLIENT_IDS; +import static org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto.ClientFilterDtoEnum.CLIENT_NAMES; +import static org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.ClientFilterDto.ClientFilterDtoEnum.STATUS; /** * Service class for client registration including all crud methods. */ @Service +@RequiredArgsConstructor public class ClientRegistrationServiceImpl implements ClientRegistration { private static Logger logger = LoggerFactory.getLogger(ClientRegistrationServiceImpl.class); private static final String DEFAULT_TENANT_ID = "default_tenant"; private static final String DEFAULT_CLIENT_AUTHENTICATION_METHODS = "client_secret_basic,client_secret_post"; - @Autowired - TenantConfigurationService tenantConfigurationService; - @Autowired - ClientRepository clientRepository; + private final TenantConfigurationService tenantConfigurationService; - @Autowired - AesEncryptionDecryption aesEncryptionDecryption; + private final ClientRepository clientRepository; + + private final AesEncryptionDecryption aesEncryptionDecryption; /** * This method is used to add new client in the database. @@ -153,6 +170,79 @@ public boolean isClientExist(String clientId) { return clientRepository.existsByClientId(clientId); } + /** + * Retrieve the clients matching the given filter criteria, page by page. + * + * @return paginated clients matching the criteria, without their client secret. + **/ + @Override + public ClientFilterResponse filterClients(ClientFilterDto clientFilterDto, Integer pageNumber, Integer pageSize, + String sortBy, String sortOrder, boolean ignoreCase, SearchType searchType) { + Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.fromString(sortOrder), sortBy)); + Page clients = clientRepository + .findAll(createFilterQuery(clientFilterDto, ignoreCase, searchType), pageable); + logger.debug("filtered {} clients out of {} matching the given criteria", clients.getNumberOfElements(), + clients.getTotalElements()); + ClientFilterResponse response = new ClientFilterResponse(); + response.setItems(clients.getContent().stream().map(this::toClientSummary).toList()); + response.setPage(clients.getNumber()); + response.setPageSize(clients.getSize()); + response.setTotalItems(clients.getTotalElements()); + response.setTotalPages(clients.getTotalPages()); + return response; + } + + /** + * Method to build the search specification out of the client filter criteria. + * + * @param clientFilterDto client attribute names and values to filter on. + * @param ignoreCase flag for case-sensitive and case-insensitive search. + * @param searchType match values as prefix, suffix, contains or equal. + * @return specification query for client, null when no criteria was provided. + */ + private Specification createFilterQuery(ClientFilterDto clientFilterDto, boolean ignoreCase, + SearchType searchType) { + return createFilterMap(clientFilterDto).entrySet().stream() + .filter(entry -> !CollectionUtils.isEmpty(entry.getValue())) + .map(entry -> { + SearchCriteria searchCriteria = new SearchCriteria(entry.getKey(), searchType, ignoreCase); + searchCriteria.setStringValue(entry.getValue()); + return searchCriteria; + }) + .filter(searchCriteria -> !searchCriteria.getValue().isEmpty()) + .map(searchCriteria -> (Specification) new ClientSearchSpecification(searchCriteria)) + .reduce(Specification::and).orElse(null); + } + + /** + * Method to map the client filter criteria to the client entity attributes. + * + * @param clientFilterDto client attribute names and values to filter on. + * @return map of client entity attributes and the values to search for. + */ + private Map> createFilterMap(ClientFilterDto clientFilterDto) { + Map> filterMap = new HashMap<>(); + filterMap.put(CLIENT_IDS.getField(), clientFilterDto.getClientIds()); + filterMap.put(CLIENT_NAMES.getField(), clientFilterDto.getClientNames()); + if (!CollectionUtils.isEmpty(clientFilterDto.getStatuses())) { + filterMap.put(STATUS.getField(), + clientFilterDto.getStatuses().stream().map(ClientStatus::getValue).collect(Collectors.toSet())); + } + return filterMap; + } + + /** + * Method to map clientEntity to RegisteredClientDetails, leaving out the client secret. + * + * @param client client entity input. + * @return RegisteredClientDetails object without the client secret. + */ + private RegisteredClientDetails toClientSummary(ClientEntity client) { + RegisteredClientDetails registeredClientDetails = toServiceProvider(client, false); + registeredClientDetails.setStatus(client.getStatus()); + return registeredClientDetails; + } + /** * This method is used to convert request to entity return Client. **/ @@ -161,7 +251,7 @@ private ClientEntity toClient(RegisteredClientDetails request) { client.setClientId(request.getClientId()); client.setSecret(aesEncryptionDecryption.encrypt(request.getClientSecret())); client.setClientName(request.getClientName()); - client.setAdditionalInformation(request.getAdditionalInformation()); + client.setAdditionalInformation(normalizeAdditionalInformation(request.getAdditionalInformation())); client.setAuthenticationMethods(Optional.ofNullable(request.getClientAuthenticationMethods()).isPresent() ? request.getClientAuthenticationMethods().stream().map(Object::toString) .collect(Collectors.joining(",")) @@ -206,15 +296,26 @@ private ClientEntity toClient(RegisteredClientDetails request) { * @return RegisteredClientDetails object. */ private RegisteredClientDetails toServiceProvider(ClientEntity client) { + return toServiceProvider(client, true); + } + + /** + * Method to map clientEntity to RegisteredClientDetails. + * + * @param client client entity input. + * @param includeClientSecret whether the decrypted client secret must be exposed. + * @return RegisteredClientDetails object. + */ + private RegisteredClientDetails toServiceProvider(ClientEntity client, boolean includeClientSecret) { RegisteredClientDetails registeredClientDetails = new RegisteredClientDetails(); registeredClientDetails.setClientId(client.getClientId()); - registeredClientDetails.setClientSecret(aesEncryptionDecryption.decrypt(client.getSecret())); + if (includeClientSecret) { + registeredClientDetails.setClientSecret(aesEncryptionDecryption.decrypt(client.getSecret())); + } registeredClientDetails.setClientName(client.getClientName()); registeredClientDetails.setAccessTokenValidity((int) client.getAccessTokenValidity()); registeredClientDetails.setAdditionalInformation(client.getAdditionalInformation()); - registeredClientDetails - .setClientAuthenticationMethods(Arrays.asList(client.getAuthenticationMethods().split(",")).stream() - .map(Object::toString).toList()); + registeredClientDetails.setClientAuthenticationMethods(splitToList(client.getAuthenticationMethods())); if (Optional.ofNullable(client.getRedirectUrls()).isPresent() && !Optional.ofNullable(client.getRedirectUrls()).isEmpty()) { registeredClientDetails.setRedirectUris(Arrays.asList(client.getRedirectUrls().split(",")).stream() @@ -225,10 +326,8 @@ private RegisteredClientDetails toServiceProvider(ClientEntity client) { registeredClientDetails.setPostLogoutRedirectUris(Arrays .asList(client.getPostLogoutRedirectUris().split(",")).stream().map(Object::toString).toList()); } - registeredClientDetails.setAuthorizationGrantTypes(Arrays.asList(client.getGrantTypes().split(",")).stream() - .map(Object::toString).toList()); - registeredClientDetails.setScopes(Arrays.asList(client.getScopes().split(",")).stream().map(Object::toString) - .collect(Collectors.toSet())); + registeredClientDetails.setAuthorizationGrantTypes(splitToList(client.getGrantTypes())); + registeredClientDetails.setScopes(Set.copyOf(splitToList(client.getScopes()))); registeredClientDetails.setRequireAuthorizationConsent(client.isRequiredAuthorizationConsent()); registeredClientDetails.setRefreshTokenValidity((int) client.getRefreshTokenValidity()); registeredClientDetails.setAuthorizationCodeValidity((int) client.getAuthorizationCodeValidity()); @@ -236,6 +335,30 @@ private RegisteredClientDetails toServiceProvider(ClientEntity client) { return registeredClientDetails; } + /** + * Method to split a comma separated column value into its elements. + * + * @param value comma separated column value, may be null. + * @return list of elements, empty when the column holds no value. + */ + private static List splitToList(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + return Arrays.asList(value.split(",")); + } + + /** + * Method to normalize additionalInformation so blank input is never sent to the jsonb column, + * since Postgres rejects an empty string as invalid JSON. + * + * @param additionalInformation raw value from the request. + * @return the value unchanged, or null when blank. + */ + private static String normalizeAdditionalInformation(String additionalInformation) { + return StringUtils.hasText(additionalInformation) ? additionalInformation : null; + } + /** * Method to update client details. * @@ -244,14 +367,14 @@ private RegisteredClientDetails toServiceProvider(ClientEntity client) { * @return clientEntity. */ private ClientEntity toUpdateClient(ClientEntity client, RegisteredClientDetails request) { - if (Optional.ofNullable(request.getClientSecret()).isPresent()) { + if (StringUtils.hasText(request.getClientSecret())) { client.setSecret(aesEncryptionDecryption.encrypt(request.getClientSecret())); } if (Optional.ofNullable(request.getClientName()).isPresent()) { client.setClientName(request.getClientName()); } if (Optional.ofNullable(request.getAdditionalInformation()).isPresent()) { - client.setAdditionalInformation(request.getAdditionalInformation()); + client.setAdditionalInformation(normalizeAdditionalInformation(request.getAdditionalInformation())); } if (Optional.ofNullable(request.getClientAuthenticationMethods()).isPresent() && !request.getClientAuthenticationMethods().isEmpty()) { diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java index c21b94f..505c523 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java @@ -190,6 +190,7 @@ import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.INVALID_PAYLOAD_ERROR_MESSAGE; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.LASTNAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.MFA_REQUIRED_ATTRIBUTE; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.NAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.NO_ROLEID_FOR_FILTER; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.OPERATION; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.ORIGINAL_USERNAME; @@ -210,6 +211,8 @@ import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ACCOUNT_ROLE_ASSOCIATION_CODE; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ACCOUNT_ROLE_MAPPING_TABLE_NAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ADDRESS_ENTITY_TABLE_NAME; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ATTRIBUTE; +import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ATTRIBUTE_VALUE; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_DISASSOCIATE_ERROR_MSG; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ENTITY_TABLE_NAME; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ID_NOT_FOUND_MESSAGE; @@ -217,6 +220,7 @@ import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.USER_ID_VARIABLE; import static org.eclipse.ecsp.uidam.usermanagement.constants.ApiConstants.VALUE; import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.ACTION_FORBIDDEN; +import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.ATTRIBUTE_HAS_REFERENCED_VALUES; import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.ATTRIBUTE_METADATA_IS_MISSING; import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.ATTRIBUTE_NAME_RESERVED; import static org.eclipse.ecsp.uidam.usermanagement.constants.LocalizationKey.FIELD_CANNOT_BE_MODIFIED; @@ -258,6 +262,7 @@ import static org.eclipse.ecsp.uidam.usermanagement.utilities.SearchCriteria.RootParam.USER_ADDRESS_ROOT; import static org.eclipse.ecsp.uidam.usermanagement.utilities.SearchCriteria.RootParam.USER_ROOT; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.OK; /** @@ -281,7 +286,8 @@ public class UsersServiceImpl implements UsersService { Map.entry("float4", Float.class), Map.entry("float8", Double.class), Map.entry("money", Double.class), Map.entry("name", String.class), Map.entry("text", String.class), Map.entry("date", Date.class), Map.entry("time", Time.class), Map.entry("timetz", Time.class), Map.entry("timestamp", Timestamp.class), - Map.entry("_abc", List.class), Map.entry("uuid", UUID.class), Map.entry("json", String.class), + Map.entry("_abc", List.class), Map.entry("_text", List.class), Map.entry("uuid", UUID.class), + Map.entry("json", String.class), Map.entry("jsonb", JsonNode.class)); private static final int DEFAULT_MAX_LOCK_ATTEMPTS = 5; @@ -392,11 +398,7 @@ private String sanitizeForLogging(String input) { * @return UserManagementTenantProperties for current tenant */ private UserManagementTenantProperties getTenantProperties() { - UserManagementTenantProperties tenantProperties = tenantConfigurationService.getTenantProperties(); - if (tenantProperties == null) { - throw new IllegalStateException("Tenant configuration is not available for the current tenant"); - } - return tenantProperties; + return tenantConfigurationService.getTenantProperties(); } /** @@ -488,9 +490,8 @@ && isValidAdditionalAttributes(userDto.getAdditionalAttributes(), userAttributeE private void resolveUserStatus(UserDtoBase userDto, boolean isSelfAddUser) { if (!(isSelfAddUser && userDto.getStatus() != null)) { - UserManagementTenantProperties tenantProperties = getTenantProperties(); - if (BooleanUtils.isTrue(tenantProperties.getIsUserStatusLifeCycleEnabled()) - || BooleanUtils.isTrue(tenantProperties.getIsEmailVerificationEnabled())) { + if (getTenantProperties().getIsUserStatusLifeCycleEnabled().booleanValue() + || BooleanUtils.isTrue(getTenantProperties().getIsEmailVerificationEnabled())) { userDto.setStatus(UserStatus.PENDING); } else { userDto.setStatus(UserStatus.ACTIVE); @@ -714,13 +715,17 @@ public Map> persistAdditionalAttributes(UserDtoB */ public String parseAdditionalAttributeValue(Map userAttributeEntityByNameMap, String additionalAttribute, Object value) { - Class targetClass = DATA_TYPE_MAP.get( - userAttributeEntityByNameMap.get(additionalAttribute.toLowerCase(Locale.ROOT)).getTypes() - .toLowerCase(Locale.ROOT)); + UserAttributeEntity userAttributeEntity = userAttributeEntityByNameMap.get( + additionalAttribute.toLowerCase(Locale.ROOT)); + String dataType = userAttributeEntity.getTypes().toLowerCase(Locale.ROOT); + Class targetClass = DATA_TYPE_MAP.get(dataType); if (targetClass.equals(JsonNode.class)) { return ObjectConverter.jsonNodeObjectToString(value); } else if (targetClass.equals(List.class)) { - return String.join(",", new ArrayList<>((List) value)); + if (value instanceof String stringValue) { + return String.join(",", ObjectConverter.stringToList(stringValue)); + } + return ((List) value).stream().map(String::valueOf).collect(Collectors.joining(",")); } else { return String.valueOf(value); } @@ -913,18 +918,26 @@ private Set invalidAttributeValue(List userAttribut */ private Boolean isObjectCastable(String dataType, Object value) { try { - if (DATA_TYPE_MAP.get(dataType).equals(JsonNode.class) + Class targetClass = DATA_TYPE_MAP.get(dataType.toLowerCase(Locale.ROOT)); + if (Objects.isNull(targetClass)) { + return false; + } + if (targetClass.equals(JsonNode.class) && ObjectConverter.jsonNodeObjectToString(value) != null) { return true; } else if (dataType.equalsIgnoreCase("bit")) { Boolean.valueOf(String.valueOf(value)); return true; - } else if (DATA_TYPE_MAP.get(dataType).equals(List.class)) { - new ArrayList<>((List) value); + } else if (targetClass.equals(List.class)) { + if (value instanceof String stringValue) { + ObjectConverter.stringToList(stringValue); + } else { + new ArrayList<>((List) value); + } return true; } else { String data = String.valueOf(value); - ObjectConverter.convert(data, DATA_TYPE_MAP.get(dataType)); + ObjectConverter.convert(data, targetClass); return true; } } catch (Exception exception) { @@ -1202,8 +1215,8 @@ private boolean isTemporaryLockActive(Boolean temporaryLockEnabled, Timestamp lo */ private void handleTemporaryLock(String userName, Timestamp lockTimestamp) throws InActiveUserException { - Instant lockUntil = lockTimestamp.toInstant(); - Instant now = Instant.now(); + LocalDateTime lockUntil = lockTimestamp.toLocalDateTime(); + LocalDateTime now = LocalDateTime.now(); long minutesLeft = ChronoUnit.MINUTES.between(now, lockUntil); if (LOGGER.isDebugEnabled()) { @@ -1251,22 +1264,22 @@ private boolean checkAndUnlockIfEligible(UserEntity userEntity) { } // Check if lock period has expired based on temporary_lock_timestamp - Instant lockUntil = lockTimestamp.toInstant(); - Instant now = Instant.now(); + LocalDateTime lockUntil = lockTimestamp.toLocalDateTime(); + LocalDateTime now = LocalDateTime.now(); LOGGER.debug("User {} lock expires at: {}. Current time: {}", userEntity.getUserName(), lockUntil, now); // Check if current time is past the lock expiration - if (!now.isBefore(lockUntil)) { + if (now.isAfter(lockUntil) || now.equals(lockUntil)) { UserStatus previousStatus = userEntity.getStatus(); // Unlock user using common method - unlockBlockedUser(userEntity, previousStatus, LocalDateTime.now()); + unlockBlockedUser(userEntity, previousStatus, now); LOGGER.info("Successfully unlocked user {} during login attempt (lock expired at {})", userEntity.getUserName(), lockUntil); return true; } else { - long remainingMinutes = ChronoUnit.MINUTES.between(now, lockUntil); + long remainingMinutes = java.time.Duration.between(now, lockUntil).toMinutes(); LOGGER.debug("User {} still within lock period. Remaining: {} minutes", userEntity.getUserName(), remainingMinutes); return false; @@ -1988,6 +2001,135 @@ public List putUserMetaData(List user return savedAttributes.stream().map(UserMapper.USER_MAPPER::mapToMetaDataResponse).toList(); } + /** + * Method to get metadata for every additional attribute defined in the user_attributes + * table, regardless of its dynamicAttribute flag. + * + * @return list of additional attribute metadata. + */ + @Override + public List getAllUserAttributes() { + return userAttributeRepository.findAll().stream() + .map(UserMapper.USER_MAPPER::mapToMetaDataResponse) + .filter(UserManagementUtils.distinctByKey(UserMetaDataResponse::getName)).toList(); + } + + /** + * Method to delete an additional attribute definition. Refuses to delete while any user has a + * stored value for it, to avoid silently losing that data. + * + * @param attributeName name of the attribute definition to delete. + * @throws ResourceNotFoundException if no attribute definition exists with the given name. + */ + @Override + @Transactional + public void deleteUserAttribute(String attributeName) throws ResourceNotFoundException { + UserAttributeEntity userAttributeEntity = findUserAttributeByName(attributeName); + if (userAttributeValueRepository.existsByAttributeId(userAttributeEntity.getId())) { + throw new ApplicationRuntimeException(ATTRIBUTE_HAS_REFERENCED_VALUES, CONFLICT, attributeName); + } + userAttributeRepository.delete(userAttributeEntity); + LOGGER.info("Deleted user attribute definition '{}'", attributeName); + } + + /** + * Method to get a single user's additional attribute name/value pairs. + * + * @param userId user id. + * @return map of attribute name to value. + * @throws ResourceNotFoundException if the user does not exist. + */ + @Override + public Map getUserAttributeValues(BigInteger userId) throws ResourceNotFoundException { + getUserEntity(userId); + Map> additionalAttributes = findAdditionalAttributeData(List.of(userId)); + return ObjectUtils.isEmpty(additionalAttributes) + ? Collections.emptyMap() : additionalAttributes.getOrDefault(userId, Collections.emptyMap()); + } + + /** + * Method to add/update a single user's additional attribute values. + * + * @param userId user id. + * @param attributeValues map of attribute name to value. + * @return map of the user's attribute name to value after the update. + * @throws ResourceNotFoundException if the user does not exist. + */ + @Override + @Transactional + @Modifying + public Map updateUserAttributeValues(BigInteger userId, Map attributeValues) + throws ResourceNotFoundException { + getUserEntity(userId); + if (ObjectUtils.isEmpty(attributeValues)) { + return getUserAttributeValues(userId); + } + List userAttributeEntities = userAttributeRepository.findAll(); + Set badDtoAttributes = findBadDtoAttributes(userAttributeEntities, attributeValues.keySet()); + if (!ObjectUtils.isEmpty(badDtoAttributes)) { + throw new ApplicationRuntimeException(FIELD_NOT_FOUND, BAD_REQUEST, String.valueOf(badDtoAttributes)); + } + Map userAttributeEntitiesMap = groupUserAttributeEntityByName( + userAttributeEntities); + Set readOnlyAttributes = attributeValues.keySet().stream() + .filter(key -> Boolean.TRUE.equals( + userAttributeEntitiesMap.get(key.toLowerCase(Locale.ROOT)).getReadOnly())) + .collect(Collectors.toSet()); + if (!ObjectUtils.isEmpty(readOnlyAttributes)) { + throw new ApplicationRuntimeException(ACTION_FORBIDDEN, BAD_REQUEST, + String.valueOf(readOnlyAttributes), FIELD_CANNOT_BE_MODIFIED); + } + isValidAdditionalAttributes(attributeValues, userAttributeEntities, false); + List attributeIds = attributeValues.keySet().stream() + .map(key -> userAttributeEntitiesMap.get(key.toLowerCase(Locale.ROOT)).getId()).toList(); + UserEntity userEntity = getUserEntity(userId); + patchAdditionalAttribute(attributeValues, userEntity, userAttributeEntitiesMap, attributeIds); + LOGGER.info("Updated additional attribute value(s) {} for userId {}", attributeValues.keySet(), userId); + return getUserAttributeValues(userId); + } + + /** + * Method to delete a single user's stored value for one additional attribute. + * + * @param userId user id. + * @param attributeName name of the attribute value to delete. + * @throws ResourceNotFoundException if the user, the attribute definition, or the stored + * value does not exist. + */ + @Override + @Transactional + public void deleteUserAttributeValue(BigInteger userId, String attributeName) throws ResourceNotFoundException { + getUserEntity(userId); + UserAttributeEntity userAttributeEntity = findUserAttributeByName(attributeName); + if (Boolean.TRUE.equals(userAttributeEntity.getReadOnly())) { + throw new ApplicationRuntimeException(ACTION_FORBIDDEN, BAD_REQUEST, attributeName, + FIELD_CANNOT_BE_MODIFIED); + } + UserAttributeValueEntity userAttributeValueEntity = userAttributeValueRepository + .findByUserIdAndAttributeId(userId, userAttributeEntity.getId()); + if (userAttributeValueEntity == null) { + throw new ResourceNotFoundException(USER_ATTRIBUTE_VALUE, USER_ID_VARIABLE, String.valueOf(userId)); + } + userAttributeValueRepository.delete(userAttributeValueEntity); + LOGGER.info("Deleted attribute value '{}' for userId {}", attributeName, userId); + } + + /** + * Method to find an attribute definition by name (case-insensitive). + * + * @param attributeName name of the attribute definition. + * @return matching attribute definition. + * @throws ResourceNotFoundException if no attribute definition exists with the given name. + */ + private UserAttributeEntity findUserAttributeByName(String attributeName) throws ResourceNotFoundException { + UserAttributeEntity userAttributeEntity = groupUserAttributeEntityByName(userAttributeRepository.findAll()) + .get(attributeName.toLowerCase(Locale.ROOT)); + if (userAttributeEntity == null) { + throw new ResourceNotFoundException(USER_ATTRIBUTE, NAME, attributeName); + } + return userAttributeEntity; + } + /** * Method to map and return user attribute with user attribute metadata as * response. @@ -2117,16 +2259,21 @@ private void patchAdditionalAttribute(Map additionalAttributes, } Map finalUserAttributeValueEntitiesMap = userAttributeValueEntitiesMap; additionalAttributes.forEach((key, value) -> { + String normalizedKey = key.toLowerCase(Locale.ROOT); + UserAttributeEntity userAttributeEntity = userAttributeEntitiesMap.get(normalizedKey); + if (Objects.isNull(userAttributeEntity)) { + throw new ApplicationRuntimeException(FIELD_NOT_FOUND, BAD_REQUEST, key); + } if (!finalUserAttributeValueEntitiesMap.isEmpty() - && finalUserAttributeValueEntitiesMap.containsKey(userAttributeEntitiesMap.get(key).getId())) { + && finalUserAttributeValueEntitiesMap.containsKey(userAttributeEntity.getId())) { UserAttributeValueEntity userAttributeValueEntity = finalUserAttributeValueEntitiesMap - .get(userAttributeEntitiesMap.get(key).getId()); + .get(userAttributeEntity.getId()); userAttributeValueEntity.setValue(parseAdditionalAttributeValue(userAttributeEntitiesMap, key, value)); finalAttributeValueEntities.add(userAttributeValueEntity); } else { UserAttributeValueEntity userAttributeValueEntity = new UserAttributeValueEntity(); userAttributeValueEntity.setUserId(userEntity.getId()); - userAttributeValueEntity.setAttributeId(userAttributeEntitiesMap.get(key).getId()); + userAttributeValueEntity.setAttributeId(userAttributeEntity.getId()); userAttributeValueEntity.setValue(parseAdditionalAttributeValue(userAttributeEntitiesMap, key, value)); finalAttributeValueEntities.add(userAttributeValueEntity); } @@ -2590,8 +2737,8 @@ private long lockUserAccount(UserEntity currentUser, int allowedLoginAttempts) { // Calculate lock duration with exponential backoff lockDurationMinutes = calculateLockDuration(lockCount, tenantProperties); - Instant lockUntil = Instant.now().plus(lockDurationMinutes, ChronoUnit.MINUTES); - currentUser.setTemporaryLockTimestamp(Timestamp.from(lockUntil)); + LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(lockDurationMinutes); + currentUser.setTemporaryLockTimestamp(Timestamp.valueOf(lockUntil)); LOGGER.info("User {} blocked temporarily (attempt {}/{}). Lock duration: {} minutes. Lock expires at: {}", currentUser.getId(), lockCount, maxLockAttempts, lockDurationMinutes, lockUntil); @@ -2615,10 +2762,10 @@ private long lockUserAccount(UserEntity currentUser, int allowedLoginAttempts) { * @return remaining lock duration in minutes */ private long calculateRemainingLockDuration(UserEntity currentUser) { - Instant lockUntil = currentUser.getTemporaryLockTimestamp().toInstant(); - Instant now = Instant.now(); + LocalDateTime lockUntil = currentUser.getTemporaryLockTimestamp().toLocalDateTime(); + LocalDateTime now = LocalDateTime.now(); if (lockUntil.isAfter(now)) { - long remaining = ChronoUnit.MINUTES.between(now, lockUntil); + long remaining = java.time.Duration.between(now, lockUntil).toMinutes(); LOGGER.debug("User {} still blocked. Remaining lock duration: {} minutes", currentUser.getId(), remaining); return remaining; @@ -2913,6 +3060,7 @@ private void revokeUserTokens(String username) { } String uidamAuthToken = cacheTokenService.getAccessToken(); + //dont remove this. check why not able to create a token here later if (StringUtils.isNotEmpty(uidamAuthToken)) { BaseResponseFromAuthorization response = authorizationServerClient.revokeTokenByAdmin(uidamAuthToken, username); diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/user/response/dto/ClientFilterResponse.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/user/response/dto/ClientFilterResponse.java new file mode 100755 index 0000000..1031f14 --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/user/response/dto/ClientFilterResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 - 2024 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package org.eclipse.ecsp.uidam.usermanagement.user.response.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.eclipse.ecsp.uidam.usermanagement.auth.request.dto.RegisteredClientDetails; +import java.util.List; + +/** + * Paginated response of the client filter api. + */ +@Getter +@Setter +@JsonInclude(Include.NON_NULL) +public class ClientFilterResponse { + + @Schema(description = "Clients matching the filter criteria, client secret is never returned") + private List items; + + @Schema(description = "Zero based index of the returned page") + private int page; + + @Schema(description = "Number of items per page") + private int pageSize; + + @Schema(description = "Total number of clients matching the filter criteria") + private long totalItems; + + @Schema(description = "Total number of pages available for the filter criteria") + private int totalPages; +} diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecification.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecification.java new file mode 100755 index 0000000..b39401f --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecification.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2023 - 2024 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package org.eclipse.ecsp.uidam.usermanagement.utilities; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import org.eclipse.ecsp.uidam.usermanagement.entity.ClientEntity; +import org.eclipse.ecsp.uidam.usermanagement.enums.SearchType; +import org.springframework.data.jpa.domain.Specification; +import java.io.Serial; +import java.util.List; +import java.util.Locale; +import static org.eclipse.ecsp.uidam.usermanagement.enums.SearchType.CONTAINS; +import static org.eclipse.ecsp.uidam.usermanagement.enums.SearchType.PREFIX; +import static org.eclipse.ecsp.uidam.usermanagement.enums.SearchType.SUFFIX; + +/** + * Specification building the search predicates for {@link ClientEntity}. + */ +@AllArgsConstructor +@NoArgsConstructor +public class ClientSearchSpecification implements Specification { + + @Serial + private static final long serialVersionUID = 8264398160145279234L; + + private static final char ESCAPE_CHARACTER = '\\'; + private static final String WILDCARD = "%"; + + private SearchCriteria criteria; + + /** + * Build the predicate matching any of the values configured for the search criteria field. + * + * @param root the client entity root + * @param query the criteria query + * @param builder the criteria builder + * @return predicate combining all criteria values with OR + */ + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder builder) { + Path path = root.get((String) criteria.getKey()); + boolean ignoreCase = Boolean.TRUE.equals(criteria.getIgnoreCase()); + Expression searchPath = ignoreCase ? builder.upper(path) : path; + List predicates = criteria.getValue().stream() + .map(value -> builder.like(searchPath, buildSearchValue(String.valueOf(value), ignoreCase), + ESCAPE_CHARACTER)) + .toList(); + return builder.or(predicates.toArray(new Predicate[0])); + } + + private String buildSearchValue(String value, boolean ignoreCase) { + String escapedValue = escapeWildcards(value); + String searchValue = ignoreCase ? escapedValue.toUpperCase(Locale.ROOT) : escapedValue; + SearchType searchType = criteria.getSearchType(); + StringBuilder searchValueBuilder = new StringBuilder(searchValue); + if (SUFFIX.equals(searchType) || CONTAINS.equals(searchType)) { + searchValueBuilder.insert(0, WILDCARD); + } + if (PREFIX.equals(searchType) || CONTAINS.equals(searchType)) { + searchValueBuilder.append(WILDCARD); + } + return searchValueBuilder.toString(); + } + + /** + * Escape the wildcards so that user supplied values are matched literally. + */ + private static String escapeWildcards(String value) { + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} diff --git a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationTest.java b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationTest.java index 5bc83d9..18c9ea1 100644 --- a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationTest.java +++ b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/controller/ClientRegistrationTest.java @@ -33,10 +33,15 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; @@ -50,6 +55,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; @@ -96,6 +102,7 @@ class ClientRegistrationTest { private static final int ONE_HUNDRED_INT = 100; private static final Long TWO_THOUSAND = 2000L; private static final int TWO_THOUSAN_INT = 2000; + private static final int DEFAULT_PAGE_SIZE = 20; @BeforeEach public void setup() { @@ -498,6 +505,159 @@ void testDeleteClient_clientAlreadyDeleted() { }).exchange().expectStatus().isEqualTo(HttpStatus.NOT_FOUND); } + @Test + void testFilterClients() { + mockFilterResult(getClient()); + + webTestClient.post() + .uri("/v1/oauth2/client/filter") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.OK) + .expectBody() + .jsonPath("$.data.items[0].clientId").isEqualTo("testClient") + .jsonPath("$.data.items[0].status").isEqualTo("approved") + .jsonPath("$.data.items[0].clientSecret").isEqualTo("") + .jsonPath("$.data.totalItems").isEqualTo(1) + .jsonPath("$.data.totalPages").isEqualTo(1) + .jsonPath("$.data.page").isEqualTo(0); + } + + @Test + void testFilterClientsByCriteria() { + mockFilterResult(getClient()); + + webTestClient.post() + .uri("/v1/oauth2/client/filter?pageNumber=0&pageSize=10&sortBy=CLIENT_IDS&sortOrder=ASC" + + "&ignoreCase=true&searchType=CONTAINS") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{\"clientIds\":[\"testClient\"],\"clientNames\":[\"name\"],\"statuses\":[\"APPROVED\"]}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.OK) + .expectBody() + .jsonPath("$.data.items[0].clientId").isEqualTo("testClient") + .jsonPath("$.data.items[0].clientSecret").isEqualTo(""); + } + + @Test + void testFilterClientsWithLowerCaseStatuses() { + mockFilterResult(getClient()); + + webTestClient.post() + .uri("/v1/oauth2/client/filter") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{\"statuses\":[\"approved\",\"registered\",\"rejected\"]}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.OK) + .expectBody() + .jsonPath("$.data.items[0].status").isEqualTo("approved") + .jsonPath("$.data.items[0].clientSecret").isEqualTo(""); + } + + @Test + void testFilterClientsNoMatch() { + mockFilterResult(); + + webTestClient.post() + .uri("/v1/oauth2/client/filter") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{\"clientNames\":[\"unknown\"]}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.OK) + .expectBody() + .jsonPath("$.data.items").isEmpty() + .jsonPath("$.data.totalItems").isEqualTo(0); + } + + @Test + void testFilterClientsClientWithoutOptionalAttributes() { + ClientEntity client = getClient(); + client.setRedirectUrls(null); + client.setAuthenticationMethods(null); + client.setScopes(""); + client.setPostLogoutRedirectUris("http://logout.com/test"); + mockFilterResult(client); + + webTestClient.post() + .uri("/v1/oauth2/client/filter") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.OK) + .expectBody() + .jsonPath("$.data.items[0].postLogoutRedirectUris[0]").isEqualTo("http://logout.com/test") + .jsonPath("$.data.items[0].redirectUris").doesNotExist(); + } + + @Test + void testFilterClients_invalidPageNumber() { + webTestClient.post() + .uri("/v1/oauth2/client/filter?pageNumber=-1") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void testFilterClients_pageSizeAboveLimit() { + webTestClient.post() + .uri("/v1/oauth2/client/filter?pageSize=101") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void testFilterClients_invalidStatus() { + webTestClient.post() + .uri("/v1/oauth2/client/filter") + .headers(http -> { + http.add("Content-Type", "application/json"); + http.add("Accept", "application/json"); + http.add(ApiConstants.CORRELATION_ID, "12345"); + }) + .bodyValue("{\"statuses\":[\"UNKNOWN\"]}") + .exchange() + .expectStatus().isEqualTo(HttpStatus.BAD_REQUEST); + } + + private void mockFilterResult(ClientEntity... clients) { + List content = List.of(clients); + when(clientRepository.findAll(ArgumentMatchers.>any(), any(Pageable.class))) + .thenReturn(new PageImpl<>(content, PageRequest.of(0, DEFAULT_PAGE_SIZE), content.size())); + } + private ClientEntity getClient() { ClientEntity c = new ClientEntity(); c.setId(new BigInteger("1")); diff --git a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java index 54223a1..d4b3e15 100644 --- a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java +++ b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java @@ -203,6 +203,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.OK; @@ -1771,6 +1772,18 @@ void putUserAttributeAddReservedMandatoryFieldNameFailure() { () -> usersService.putUserMetaData(List.of(userMetaDataRequest))); } + @Test + void deleteUserAttributeRejectedWhenValuesExist() throws ResourceNotFoundException { + when(userAttributeRepository.findAll()).thenReturn(createUserAttributeMetaData()); + when(userAttributeValueRepository.existsByAttributeId(ATTR_ID_VALUE_1)).thenReturn(true); + + ApplicationRuntimeException exception = assertThrows(ApplicationRuntimeException.class, + () -> usersService.deleteUserAttribute("mandatoryAttribute")); + + assertEquals(CONFLICT, exception.getHttpStatus()); + verify(userAttributeRepository, never()).delete(any(UserAttributeEntity.class)); + } + List createUserAttributeMetaData() { UserAttributeEntity mandatoryEntityMetaData = new UserAttributeEntity(ATTR_ID_VALUE_1, "mandatoryAttribute", true, false, @@ -3559,6 +3572,50 @@ void testIsValidAdditionalAttributesRejectsNonCastableValue() { assertEquals(FIELD_DATA_IS_INVALID, exception.getKey()); } + @Test + void testUpdateUserAttributeValuesUsesNormalizedAttributeLookup() throws ResourceNotFoundException { + UserEntity userEntity = new UserEntity(); + userEntity.setId(USER_ID_VALUE); + + UserAttributeEntity testDateAttribute = new UserAttributeEntity(ATTR_ID_VALUE, "testDate", false, false, + false, true, true, "date", ".*", null, "system", null, "system", null); + Map attributeValues = new HashMap<>(); + attributeValues.put("testDate", "2026-09-18"); + + when(userRepository.findByIdAndStatusNot(USER_ID_VALUE, UserStatus.DELETED)).thenReturn(userEntity); + when(userAttributeRepository.findAll()).thenReturn(List.of(testDateAttribute)); + when(userAttributeValueRepository.findAllByUserIdAndAttributeIdIn(eq(USER_ID_VALUE), anyList())) + .thenReturn(Collections.emptyList()); + when(userAttributeValueRepository.findAllByUserIdIn(anyList())).thenReturn(Collections.emptyList()); + when(userAttributeValueRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertDoesNotThrow(() -> usersService.updateUserAttributeValues(USER_ID_VALUE, attributeValues)); + + verify(userAttributeValueRepository).saveAll(anyList()); + } + + @Test + void testUpdateUserAttributeValuesAcceptsCommaDelimitedTextList() throws ResourceNotFoundException { + UserEntity userEntity = new UserEntity(); + userEntity.setId(USER_ID_VALUE); + + UserAttributeEntity testListAttribute = new UserAttributeEntity(ATTR_ID_VALUE, "testList", false, false, + false, true, true, "_text", ".*", null, "system", null, "system", null); + Map attributeValues = new HashMap<>(); + attributeValues.put("testList", "v1,v2,v3"); + + when(userRepository.findByIdAndStatusNot(USER_ID_VALUE, UserStatus.DELETED)).thenReturn(userEntity); + when(userAttributeRepository.findAll()).thenReturn(List.of(testListAttribute)); + when(userAttributeValueRepository.findAllByUserIdAndAttributeIdIn(eq(USER_ID_VALUE), anyList())) + .thenReturn(Collections.emptyList()); + when(userAttributeValueRepository.findAllByUserIdIn(anyList())).thenReturn(Collections.emptyList()); + when(userAttributeValueRepository.saveAll(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + + assertDoesNotThrow(() -> usersService.updateUserAttributeValues(USER_ID_VALUE, attributeValues)); + + verify(userAttributeValueRepository).saveAll(anyList()); + } + @Test void testGetUserByUserNameSetsAccountIdMfaRequiredAndMergesCustomAttributes() throws ResourceNotFoundException, InActiveUserException { diff --git a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java new file mode 100755 index 0000000..495dcee --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2023 - 2024 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * + */ + +package org.eclipse.ecsp.uidam.usermanagement.utilities; + +import io.prometheus.client.CollectorRegistry; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import org.eclipse.ecsp.uidam.usermanagement.entity.ClientEntity; +import org.eclipse.ecsp.uidam.usermanagement.enums.SearchType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import java.util.List; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyChar; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Test cases for ClientSearchSpecification. + */ +class ClientSearchSpecificationTest { + + private static final int TWO = 2; + private static final String CLIENT_NAME_FIELD = "clientName"; + + private Root clientEntityRoot; + private CriteriaQuery criteriaQuery; + private CriteriaBuilder builder; + private Path path; + private Expression expression; + private Predicate predicate; + + @BeforeEach + void setUp() { + CollectorRegistry.defaultRegistry.clear(); + clientEntityRoot = Mockito.mock(Root.class); + criteriaQuery = Mockito.mock(CriteriaQuery.class); + builder = Mockito.mock(CriteriaBuilder.class); + path = Mockito.mock(Path.class); + expression = Mockito.mock(Expression.class); + predicate = Mockito.mock(Predicate.class); + Mockito.when(clientEntityRoot.get(anyString())).thenReturn(path); + Mockito.when(builder.upper(path)).thenReturn(expression); + Mockito.when(builder.like(any(Expression.class), anyString(), anyChar())).thenReturn(predicate); + Mockito.when(builder.or(any(Predicate[].class))).thenReturn(predicate); + } + + @Test + void equalSearchTypeMatchesTheLiteralValue() { + assertEquals(predicate, toPredicate(Set.of("uidam-portal"), SearchType.EQUAL, false)); + verify(builder, never()).upper(path); + assertEquals(List.of("uidam-portal"), capturedSearchValues()); + } + + @Test + void nullSearchTypeMatchesTheLiteralValue() { + assertEquals(predicate, toPredicate(Set.of("uidam-portal"), null, false)); + assertEquals(List.of("uidam-portal"), capturedSearchValues()); + } + + @Test + void prefixSearchTypeAppendsWildcard() { + toPredicate(Set.of("uidam"), SearchType.PREFIX, false); + assertEquals(List.of("uidam%"), capturedSearchValues()); + } + + @Test + void suffixSearchTypePrependsWildcard() { + toPredicate(Set.of("portal"), SearchType.SUFFIX, false); + assertEquals(List.of("%portal"), capturedSearchValues()); + } + + @Test + void containsSearchTypeSurroundsValueWithWildcards() { + toPredicate(Set.of("dam"), SearchType.CONTAINS, false); + assertEquals(List.of("%dam%"), capturedSearchValues()); + } + + @Test + void ignoreCaseUppercasesBothValueAndColumn() { + toPredicate(Set.of("Uidam"), SearchType.CONTAINS, true); + verify(builder).upper(path); + assertEquals(List.of("%UIDAM%"), capturedSearchValues()); + } + + @Test + void wildcardCharactersInTheValueAreEscaped() { + toPredicate(Set.of("a%_b\\c"), SearchType.EQUAL, false); + assertEquals(List.of("a\\%\\_b\\\\c"), capturedSearchValues()); + } + + @Test + void allCriteriaValuesAreCombinedWithOr() { + toPredicate(Set.of("first", "second"), SearchType.EQUAL, false); + verify(builder, times(TWO)).like(any(Expression.class), anyString(), anyChar()); + verify(builder).or(any(Predicate[].class)); + } + + private Predicate toPredicate(Set values, SearchType searchType, boolean ignoreCase) { + SearchCriteria searchCriteria = new SearchCriteria(CLIENT_NAME_FIELD, values, searchType, ignoreCase); + return new ClientSearchSpecification(searchCriteria).toPredicate(clientEntityRoot, criteriaQuery, builder); + } + + private List capturedSearchValues() { + ArgumentCaptor valueCaptor = ArgumentCaptor.forClass(String.class); + verify(builder).like(any(Expression.class), valueCaptor.capture(), anyChar()); + return valueCaptor.getAllValues(); + } +} From 34782153e62b8e390a9e8daed00dbacee5faf963 Mon Sep 17 00:00:00 2001 From: Sanath Madhav Date: Mon, 28 Sep 2026 03:15:05 +0000 Subject: [PATCH 2/4] Black duck issues fixes --- pom.xml | 30 +- .../ecsp/utils/logger/IgniteLoggerImpl.java | 307 ++++++++++++++++++ src/main/resources/logback-spring.xml | 156 +++++++++ .../utils/logger/IgniteLoggerImplTest.java | 214 ++++++++++++ 4 files changed, 703 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImpl.java create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java diff --git a/pom.xml b/pom.xml index e3b48a7..ba23ac1 100644 --- a/pom.xml +++ b/pom.xml @@ -65,7 +65,7 @@ 25 - 4.0.6 + 4.1.1 uidam-user-management ${project.version} 1.2.3 @@ -137,8 +137,12 @@ true 1.6.13 true - 4.2.15.Final - 11.0.22 + 4.2.18.Final + 11.0.26 + 2.22.2 + 3.2.2 + 5.12.0 + 1.6.4 @@ -149,6 +153,20 @@ pom import + + org.mongodb + mongodb-driver-bom + ${mongodb.version} + pom + import + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + org.springframework.boot spring-boot-starter-parent @@ -181,7 +199,7 @@ org.springframework.cloud spring-cloud-starter-config - 5.0.3 + 5.0.5 org.bouncycastle @@ -455,6 +473,10 @@ io.github.classgraph classgraph + + org.mongodb + mongodb-driver-legacy + diff --git a/src/main/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImpl.java b/src/main/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImpl.java new file mode 100644 index 0000000..238a5ee --- /dev/null +++ b/src/main/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImpl.java @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2023 - 2026 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.ecsp.utils.logger; + +import ch.qos.logback.classic.PatternLayout; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Logback 1.6 compatible implementation of the Ignite logger. + * + *

This class overrides the implementation supplied by {@code ecsp-utils} until that library + * stops using Logback's removed {@code PatternLayout.defaultConverterMap} field.

+ */ +public class IgniteLoggerImpl implements IgniteLogger { + + private static final String MESSAGE = "message"; + private static final Map IGNITE_LOGGERS_MAP = new ConcurrentHashMap<>(); + + private Logger logger; + + private IgniteLoggerImpl(Class clazz) { + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.put("caller", IgniteCallerDataConverter::new); + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.put("ex", IgniteThrowableProxyConverter::new); + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.put("exception", IgniteThrowableProxyConverter::new); + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.put("throwable", IgniteThrowableProxyConverter::new); + logger = LoggerFactory.getLogger(clazz); + } + + /** + * Sets the logger instance. + * + * @param logger the logger instance + */ + public void setLogger(Logger logger) { + this.logger = logger; + } + + /** + * Returns an Ignite logger for the requested class. + * + * @param clazz class that owns the logger + * @return cached logger instance + */ + protected static IgniteLogger getIgniteLoggerInstance(Class clazz) { + IGNITE_LOGGERS_MAP.computeIfAbsent(clazz.getName(), key -> new IgniteLoggerImpl(clazz)); + return IGNITE_LOGGERS_MAP.get(clazz.getName()); + } + + static IgniteLoggerImpl getIgniteLoggerImplInstance(Class clazz) { + IGNITE_LOGGERS_MAP.computeIfAbsent(clazz.getName(), key -> new IgniteLoggerImpl(clazz)); + return IGNITE_LOGGERS_MAP.get(clazz.getName()); + } + + @Override + public boolean isTraceEnabled() { + return logger.isTraceEnabled(); + } + + @Override + public boolean isDebugEnabled() { + return logger.isDebugEnabled(); + } + + @Override + public boolean isInfoEnabled() { + return logger.isInfoEnabled(); + } + + @Override + public boolean isWarnEnabled() { + return logger.isWarnEnabled(); + } + + @Override + public boolean isErrorEnabled() { + return logger.isErrorEnabled(); + } + + @Override + public void trace(IgniteEvent event, String msg) { + if (logger.isTraceEnabled()) { + logger.trace(getMessageWithHeader(event, msg)); + } + } + + @Override + public void trace(IgniteEvent event, String format, Object... arguments) { + if (logger.isTraceEnabled()) { + logger.trace(getMessageWithHeader(event, format), arguments); + } + } + + @Override + public void trace(IgniteEvent event, String msg, Throwable throwable) { + if (logger.isTraceEnabled()) { + logger.trace(getMessageWithHeader(event, msg), throwable); + } + } + + @Override + public void trace(String msg) { + if (logger.isTraceEnabled()) { + logger.trace(msg); + } + } + + @Override + public void trace(String format, Object... arguments) { + if (logger.isTraceEnabled()) { + logger.trace(format, arguments); + } + } + + @Override + public void trace(String msg, Throwable throwable) { + if (logger.isTraceEnabled()) { + logger.trace(msg, throwable); + } + } + + @Override + public void debug(IgniteEvent event, String msg) { + if (logger.isDebugEnabled()) { + logger.debug(getMessageWithHeader(event, msg)); + } + } + + @Override + public void debug(IgniteEvent event, String format, Object... arguments) { + if (logger.isDebugEnabled()) { + logger.debug(getMessageWithHeader(event, format), arguments); + } + } + + @Override + public void debug(IgniteEvent event, String msg, Throwable throwable) { + if (logger.isDebugEnabled()) { + logger.debug(getMessageWithHeader(event, msg), throwable); + } + } + + @Override + public void debug(String msg) { + if (logger.isDebugEnabled()) { + logger.debug(msg); + } + } + + @Override + public void debug(String format, Object... arguments) { + if (logger.isDebugEnabled()) { + logger.debug(format, arguments); + } + } + + @Override + public void debug(String msg, Throwable throwable) { + if (logger.isDebugEnabled()) { + logger.debug(msg, throwable); + } + } + + @Override + public void info(IgniteEvent event, String msg) { + if (logger.isInfoEnabled()) { + logger.info(getMessageWithHeader(event, msg)); + } + } + + @Override + public void info(IgniteEvent event, String format, Object... arguments) { + if (logger.isInfoEnabled()) { + logger.info(getMessageWithHeader(event, format), arguments); + } + } + + @Override + public void info(IgniteEvent event, String msg, Throwable throwable) { + if (logger.isInfoEnabled()) { + logger.info(getMessageWithHeader(event, msg), throwable); + } + } + + @Override + public void info(String msg) { + if (logger.isInfoEnabled()) { + logger.info(msg); + } + } + + @Override + public void info(String format, Object... arguments) { + if (logger.isInfoEnabled()) { + logger.info(format, arguments); + } + } + + @Override + public void info(String msg, Throwable throwable) { + if (logger.isInfoEnabled()) { + logger.info(msg, throwable); + } + } + + @Override + public void warn(IgniteEvent event, String msg) { + logger.warn(getMessageWithHeader(event, msg)); + } + + @Override + public void warn(IgniteEvent event, String format, Object... arguments) { + logger.warn(getMessageWithHeader(event, format), arguments); + } + + @Override + public void warn(IgniteEvent event, String msg, Throwable throwable) { + logger.warn(getMessageWithHeader(event, msg), throwable); + } + + @Override + public void warn(String msg) { + logger.warn(msg); + } + + @Override + public void warn(String format, Object... arguments) { + logger.warn(format, arguments); + } + + @Override + public void warn(String msg, Throwable throwable) { + logger.warn(msg, throwable); + } + + @Override + public void error(IgniteEvent event, String msg) { + logger.error(getMessageWithHeader(event, msg)); + } + + @Override + public void error(IgniteEvent event, String format, Object... arguments) { + logger.error(getMessageWithHeader(event, format), arguments); + } + + @Override + public void error(IgniteEvent event, String msg, Throwable throwable) { + logger.error(getMessageWithHeader(event, msg), throwable); + } + + @Override + public void error(String msg) { + logger.error(msg); + } + + @Override + public void error(String format, Object... arguments) { + logger.error(format, arguments); + } + + @Override + public void error(String msg, Throwable throwable) { + logger.error(msg, throwable); + } + + private String getMessageWithHeader(IgniteEvent event, String format) { + StringBuilder formatBuilder = new StringBuilder(); + formatBuilder.append("Timestamp:").append(event.getTimestamp()); + formatBuilder.append(" , RequestId:").append(event.getRequestId()); + formatBuilder.append(" , MessageId:").append(event.getMessageId()); + formatBuilder.append(" , BizTransactionId:").append(event.getBizTransactionId()); + formatBuilder.append(" , VehicleID:").append(event.getVehicleId()); + formatBuilder.append(" , EventID:").append(event.getEventId()); + formatBuilder.append(" , Version:").append(event.getSchemaVersion()); + formatBuilder.append(" , SourceDeviceID:").append(event.getSourceDeviceId()); + Optional correlationId = Optional.ofNullable(event.getCorrelationId()); + if (correlationId.isPresent()) { + formatBuilder.append(" , CorrelationId:").append(correlationId); + } + formatBuilder.append(" ,").append(MESSAGE).append(":").append(format); + return formatBuilder.toString(); + } + + Map getIgniteLoggersMap() { + return IGNITE_LOGGERS_MAP; + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..fe1afd1 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + ${GRAYLOG_HOST} + ${GRAYLOG_PORT} + 15000 + 300 + 10 + 300 + 2 + 5000 + + false + true + true + false + false + false + + NA + + + ${PATTERN_PROPERTY} + ${MASK} + %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n + + app:uidam-user-management + ms:uidam-user-management + msCtype:${msCtype} + node_name:${NODE_NAME} + cName:${HOSTNAME} + + + + + 100000 + 0 + ${NEVER_BLOCK_FOR_GRAYLOG:-TRUE} + + + + + + + ${PATTERN_PROPERTY} + ${MASK} + %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n + + + + + + 1000000 + 0 + true + + + + + ${LOG_FOLDER}/uidam-user-management.log + true + + ${LOG_FOLDER}/uidam-user-management.%d{yyyy-MM-dd}.log + + 30 + + + + ${PATTERN_PROPERTY} + ${MASK} + %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n + + + + + + GRAYLOG_ENABLED + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java b/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java new file mode 100644 index 0000000..08f5a89 --- /dev/null +++ b/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2023 - 2026 Harman International + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.eclipse.ecsp.utils.logger; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.PatternLayout; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import org.eclipse.ecsp.entities.IgniteEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Regression coverage for the ECSP logger override and its Logback integration. + */ +class IgniteLoggerImplTest { + + private static final String HEADER = "Timestamp:0 , RequestId:request-1 , MessageId:message-1" + + " , BizTransactionId:transaction-1 , VehicleID:vehicle-1 , EventID:event-1" + + " , Version:null , SourceDeviceID:device-1"; + + private LoggerContext context; + private Logger delegate; + private ListAppender appender; + private IgniteLoggerImpl logger; + private IgniteEvent event; + private final RuntimeException failure = new IllegalStateException("logging failure"); + + @BeforeEach + void setUp() { + context = new LoggerContext(); + delegate = context.getLogger(getClass()); + delegate.setLevel(Level.TRACE); + delegate.setAdditive(false); + appender = new ListAppender<>(); + appender.setContext(context); + appender.start(); + delegate.addAppender(appender); + logger = assertInstanceOf(IgniteLoggerImpl.class, IgniteLoggerFactory.getLogger(getClass())); + logger.setLogger(delegate); + event = mock(IgniteEvent.class); + when(event.getRequestId()).thenReturn("request-1"); + when(event.getMessageId()).thenReturn("message-1"); + when(event.getBizTransactionId()).thenReturn("transaction-1"); + when(event.getVehicleId()).thenReturn("vehicle-1"); + when(event.getEventId()).thenReturn("event-1"); + when(event.getSourceDeviceId()).thenReturn("device-1"); + } + + @AfterEach + void tearDown() { + if (logger != null) { + logger.getIgniteLoggersMap().remove(getClass().getName()); + logger.getIgniteLoggersMap().remove(OtherLoggerOwner.class.getName()); + } + appender.stop(); + context.stop(); + } + + @Test + void factoryCachesInstancesPerClass() { + assertSame(logger, IgniteLoggerFactory.getLogger(getClass())); + assertSame(logger, IgniteLoggerImpl.getIgniteLoggerImplInstance(getClass())); + IgniteLogger other = IgniteLoggerFactory.getLogger(OtherLoggerOwner.class); + assertNotSame(logger, other); + assertSame(other, IgniteLoggerFactory.getLogger(OtherLoggerOwner.class)); + } + + @Test + void registersConvertersUsingLogbackSupplierApi() { + assertInstanceOf(IgniteCallerDataConverter.class, + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.get("caller").get()); + for (String keyword : List.of("ex", "exception", "throwable")) { + assertInstanceOf(IgniteThrowableProxyConverter.class, + PatternLayout.DEFAULT_CONVERTER_SUPPLIER_MAP.get(keyword).get()); + } + } + + @ParameterizedTest(name = "{0}, event={1}, overload={2}") + @MethodSource("loggingCases") + void preservesLevelMessageArgumentsAndThrowable(String level, boolean withEvent, Overload overload) + throws ReflectiveOperationException { + log(level, withEvent ? event : null, overload); + + assertEquals(1, appender.list.size()); + ILoggingEvent logged = appender.list.getFirst(); + assertEquals(Level.toLevel(level.toUpperCase(Locale.ROOT)), logged.getLevel()); + String prefix = withEvent ? HEADER + " ,message:" : ""; + assertEquals(prefix + "message value", logged.getFormattedMessage()); + if (overload == Overload.THROWABLE) { + ThrowableProxy proxy = assertInstanceOf(ThrowableProxy.class, logged.getThrowableProxy()); + assertSame(failure, proxy.getThrowable()); + } else { + assertNull(logged.getThrowableProxy()); + } + } + + @ParameterizedTest(name = "disabled {0}, event={1}, overload={2}") + @MethodSource("loggingCases") + void disabledLevelsDoNotEmitMessages(String level, boolean withEvent, Overload overload) + throws ReflectiveOperationException { + delegate.setLevel(Level.OFF); + IgniteEvent unreadEvent = mock(IgniteEvent.class); + + log(level, withEvent ? unreadEvent : null, overload); + + assertTrue(appender.list.isEmpty()); + if (List.of("trace", "debug", "info").contains(level)) { + verifyNoInteractions(unreadEvent); + } + } + + @ParameterizedTest + @ValueSource(strings = {"Trace", "Debug", "Info", "Warn", "Error"}) + void enabledChecksFollowDelegateLevel(String level) throws ReflectiveOperationException { + assertEquals(true, IgniteLogger.class.getMethod("is" + level + "Enabled").invoke(logger)); + delegate.setLevel(Level.OFF); + assertEquals(false, IgniteLogger.class.getMethod("is" + level + "Enabled").invoke(logger)); + } + + @Test + void includesCorrelationIdWhenPresent() { + when(event.getCorrelationId()).thenReturn("correlation-1"); + + logger.info(event, "message value"); + + // Preserve the existing ECSP header format, including Optional's representation. + assertEquals(HEADER + " , CorrelationId:Optional[correlation-1] ,message:message value", + appender.list.getFirst().getFormattedMessage()); + } + + @Test + void omitsCorrelationIdWhenAbsent() { + logger.info(event, "message value"); + + assertEquals(HEADER + " ,message:message value", appender.list.getFirst().getFormattedMessage()); + assertFalse(appender.list.getFirst().getFormattedMessage().contains("CorrelationId")); + } + + static Stream loggingCases() { + return Stream.of("trace", "debug", "info", "warn", "error") + .flatMap(level -> Stream.of(false, true) + .flatMap(withEvent -> Arrays.stream(Overload.values()) + .map(overload -> Arguments.of(level, withEvent, overload)))); + } + + private void log(String level, IgniteEvent logEvent, Overload overload) throws ReflectiveOperationException { + // Exercise every public overload against the same observable logging contract. + List> signature = new ArrayList<>(); + List arguments = new ArrayList<>(); + if (logEvent != null) { + signature.add(IgniteEvent.class); + arguments.add(logEvent); + } + signature.add(String.class); + arguments.add(overload == Overload.ARGUMENTS ? "message {}" : "message value"); + if (overload == Overload.ARGUMENTS) { + signature.add(Object[].class); + arguments.add(new Object[]{"value"}); + } else if (overload == Overload.THROWABLE) { + signature.add(Throwable.class); + arguments.add(failure); + } + IgniteLogger.class.getMethod(level, signature.toArray(Class[]::new)) + .invoke(logger, arguments.toArray()); + } + + private enum Overload { + MESSAGE, ARGUMENTS, THROWABLE + } + + private static final class OtherLoggerOwner { + } +} From a84deaa853b8283705b2af9f4bfdefa5faeccb77 Mon Sep 17 00:00:00 2001 From: Sanath Madhav Date: Mon, 28 Sep 2026 03:17:22 +0000 Subject: [PATCH 3/4] Black duck issues fixes 2 --- src/main/resources/logback.xml | 183 --------------------------------- 1 file changed, 183 deletions(-) delete mode 100644 src/main/resources/logback.xml diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index afdf72a..0000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - ${GRAYLOG_HOST} - ${GRAYLOG_PORT} - 15000 - 300 - 10 - 300 - 2 - 5000 - - false - true - true - false - false - false - - NA - - - ${PATTERN_PROPERTY} - ${MASK} - %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n - - app:uidam-user-management - ms:uidam-user-management - msCtype:${msCtype} - node_name:${NODE_NAME} - cName:${HOSTNAME} - - - - - 100000 - 0 - ${NEVER_BLOCK_FOR_GRAYLOG:-TRUE} - - - - - - - ${PATTERN_PROPERTY} - ${MASK} - %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n - - - - - - 1000000 - 0 - true - - - - - ${LOG_FOLDER}/uidam-user-management.log - true - - ${LOG_FOLDER}/uidam-user-management.%d{yyyy-MM-dd}.log - - 30 - - - - ${PATTERN_PROPERTY} - ${MASK} - %d{yyyy-MM-dd HH:mm:ss.SSS} %thread [%X{tenantId}] [%X{sourceIp}] [%X{correlationId}] %-5level %logger{0} - %crlf(%msg){}%n - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 5111e03d150245d4a7660dce2b8453fb61a49fde Mon Sep 17 00:00:00 2001 From: Sanath Madhav Date: Mon, 28 Sep 2026 05:18:24 +0000 Subject: [PATCH 4/4] Sonar issue fixes --- .../controller/UsersController.java | 5 ++- .../service/impl/UsersServiceImpl.java | 42 ++++++++++--------- .../ecsp/utils/logger/IgniteLoggerImpl.java | 27 ++++++++---- .../service/UsersServiceTest.java | 6 +-- .../ClientSearchSpecificationTest.java | 23 +++++----- .../utils/logger/IgniteLoggerImplTest.java | 4 +- 6 files changed, 61 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java index c9cab4f..b07e7c3 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/controller/UsersController.java @@ -63,6 +63,7 @@ import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.UserEventResponseDto; import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.UserMetaDataResponse; import org.eclipse.ecsp.uidam.usermanagement.user.response.dto.UserResponseV1; +import org.eclipse.ecsp.uidam.usermanagement.utilities.InputSanitizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeanUtils; @@ -418,7 +419,7 @@ public ResponseEntity> getAdditionalUserAttributes() public ResponseEntity deleteUserAttribute( @PathVariable(value = ATTRIBUTE_NAME) @Parameter(description = "Attribute name", required = true) String attributeName) throws ResourceNotFoundException { - LOGGER.info("Delete user attribute definition request received for: {}", attributeName); + LOGGER.info("Delete user attribute definition request received for: {}", InputSanitizer.forLog(attributeName)); usersService.deleteUserAttribute(attributeName); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @@ -493,7 +494,7 @@ public ResponseEntity deleteUserAttributeValue( @PathVariable(value = ATTRIBUTE_NAME) @Parameter(description = "Attribute name", required = true) String attributeName) throws ResourceNotFoundException { LOGGER.info("Delete user attribute value request received for user id: {}, attribute: {}", id, - attributeName); + InputSanitizer.forLog(attributeName)); usersService.deleteUserAttributeValue(id, attributeName); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } diff --git a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java index 505c523..9e8ed5b 100644 --- a/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java +++ b/src/main/java/org/eclipse/ecsp/uidam/usermanagement/service/impl/UsersServiceImpl.java @@ -149,6 +149,7 @@ import java.text.MessageFormat; import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; @@ -398,7 +399,8 @@ private String sanitizeForLogging(String input) { * @return UserManagementTenantProperties for current tenant */ private UserManagementTenantProperties getTenantProperties() { - return tenantConfigurationService.getTenantProperties(); + return Objects.requireNonNull(tenantConfigurationService.getTenantProperties(), + "Tenant properties are not configured"); } /** @@ -1215,8 +1217,8 @@ private boolean isTemporaryLockActive(Boolean temporaryLockEnabled, Timestamp lo */ private void handleTemporaryLock(String userName, Timestamp lockTimestamp) throws InActiveUserException { - LocalDateTime lockUntil = lockTimestamp.toLocalDateTime(); - LocalDateTime now = LocalDateTime.now(); + Instant lockUntil = lockTimestamp.toInstant(); + Instant now = Instant.now(); long minutesLeft = ChronoUnit.MINUTES.between(now, lockUntil); if (LOGGER.isDebugEnabled()) { @@ -1264,8 +1266,8 @@ private boolean checkAndUnlockIfEligible(UserEntity userEntity) { } // Check if lock period has expired based on temporary_lock_timestamp - LocalDateTime lockUntil = lockTimestamp.toLocalDateTime(); - LocalDateTime now = LocalDateTime.now(); + Instant lockUntil = lockTimestamp.toInstant(); + Instant now = Instant.now(); LOGGER.debug("User {} lock expires at: {}. Current time: {}", userEntity.getUserName(), lockUntil, now); @@ -1274,12 +1276,13 @@ private boolean checkAndUnlockIfEligible(UserEntity userEntity) { if (now.isAfter(lockUntil) || now.equals(lockUntil)) { UserStatus previousStatus = userEntity.getStatus(); // Unlock user using common method - unlockBlockedUser(userEntity, previousStatus, now); + unlockBlockedUser(userEntity, previousStatus, + LocalDateTime.ofInstant(now, ZoneId.systemDefault())); LOGGER.info("Successfully unlocked user {} during login attempt (lock expired at {})", userEntity.getUserName(), lockUntil); return true; } else { - long remainingMinutes = java.time.Duration.between(now, lockUntil).toMinutes(); + long remainingMinutes = ChronoUnit.MINUTES.between(now, lockUntil); LOGGER.debug("User {} still within lock period. Remaining: {} minutes", userEntity.getUserName(), remainingMinutes); return false; @@ -1344,7 +1347,7 @@ public int processBlockedUsersForScheduledUnlock(List blockedUsers, LOGGER.debug("Processing {} blocked users for scheduled unlock", blockedUsers.size()); int unlockedCount = 0; - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault()); for (UserEntity user : blockedUsers) { try { @@ -2022,14 +2025,14 @@ public List getAllUserAttributes() { * @throws ResourceNotFoundException if no attribute definition exists with the given name. */ @Override - @Transactional + @Transactional(rollbackFor = ResourceNotFoundException.class) public void deleteUserAttribute(String attributeName) throws ResourceNotFoundException { UserAttributeEntity userAttributeEntity = findUserAttributeByName(attributeName); if (userAttributeValueRepository.existsByAttributeId(userAttributeEntity.getId())) { throw new ApplicationRuntimeException(ATTRIBUTE_HAS_REFERENCED_VALUES, CONFLICT, attributeName); } userAttributeRepository.delete(userAttributeEntity); - LOGGER.info("Deleted user attribute definition '{}'", attributeName); + LOGGER.info("Deleted user attribute definition '{}'", sanitizeForLogging(attributeName)); } /** @@ -2056,7 +2059,7 @@ public Map getUserAttributeValues(BigInteger userId) throws Reso * @throws ResourceNotFoundException if the user does not exist. */ @Override - @Transactional + @Transactional(rollbackFor = ResourceNotFoundException.class) @Modifying public Map updateUserAttributeValues(BigInteger userId, Map attributeValues) throws ResourceNotFoundException { @@ -2084,7 +2087,8 @@ public Map updateUserAttributeValues(BigInteger userId, Map userAttributeEntitiesMap.get(key.toLowerCase(Locale.ROOT)).getId()).toList(); UserEntity userEntity = getUserEntity(userId); patchAdditionalAttribute(attributeValues, userEntity, userAttributeEntitiesMap, attributeIds); - LOGGER.info("Updated additional attribute value(s) {} for userId {}", attributeValues.keySet(), userId); + LOGGER.info("Updated additional attribute value(s) {} for userId {}", + attributeValues.keySet().stream().map(this::sanitizeForLogging).toList(), userId); return getUserAttributeValues(userId); } @@ -2097,7 +2101,7 @@ public Map updateUserAttributeValues(BigInteger userId, Map clazz) { } static IgniteLoggerImpl getIgniteLoggerImplInstance(Class clazz) { - IGNITE_LOGGERS_MAP.computeIfAbsent(clazz.getName(), key -> new IgniteLoggerImpl(clazz)); - return IGNITE_LOGGERS_MAP.get(clazz.getName()); + return (IgniteLoggerImpl) getIgniteLoggerInstance(clazz); } @Override @@ -225,17 +224,23 @@ public void info(String msg, Throwable throwable) { @Override public void warn(IgniteEvent event, String msg) { - logger.warn(getMessageWithHeader(event, msg)); + if (logger.isWarnEnabled()) { + logger.warn(getMessageWithHeader(event, msg)); + } } @Override public void warn(IgniteEvent event, String format, Object... arguments) { - logger.warn(getMessageWithHeader(event, format), arguments); + if (logger.isWarnEnabled()) { + logger.warn(getMessageWithHeader(event, format), arguments); + } } @Override public void warn(IgniteEvent event, String msg, Throwable throwable) { - logger.warn(getMessageWithHeader(event, msg), throwable); + if (logger.isWarnEnabled()) { + logger.warn(getMessageWithHeader(event, msg), throwable); + } } @Override @@ -255,17 +260,23 @@ public void warn(String msg, Throwable throwable) { @Override public void error(IgniteEvent event, String msg) { - logger.error(getMessageWithHeader(event, msg)); + if (logger.isErrorEnabled()) { + logger.error(getMessageWithHeader(event, msg)); + } } @Override public void error(IgniteEvent event, String format, Object... arguments) { - logger.error(getMessageWithHeader(event, format), arguments); + if (logger.isErrorEnabled()) { + logger.error(getMessageWithHeader(event, format), arguments); + } } @Override public void error(IgniteEvent event, String msg, Throwable throwable) { - logger.error(getMessageWithHeader(event, msg), throwable); + if (logger.isErrorEnabled()) { + logger.error(getMessageWithHeader(event, msg), throwable); + } } @Override diff --git a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java index d4b3e15..29ea34d 100644 --- a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java +++ b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/service/UsersServiceTest.java @@ -1773,7 +1773,7 @@ void putUserAttributeAddReservedMandatoryFieldNameFailure() { } @Test - void deleteUserAttributeRejectedWhenValuesExist() throws ResourceNotFoundException { + void deleteUserAttributeRejectedWhenValuesExist() { when(userAttributeRepository.findAll()).thenReturn(createUserAttributeMetaData()); when(userAttributeValueRepository.existsByAttributeId(ATTR_ID_VALUE_1)).thenReturn(true); @@ -3573,7 +3573,7 @@ void testIsValidAdditionalAttributesRejectsNonCastableValue() { } @Test - void testUpdateUserAttributeValuesUsesNormalizedAttributeLookup() throws ResourceNotFoundException { + void testUpdateUserAttributeValuesUsesNormalizedAttributeLookup() { UserEntity userEntity = new UserEntity(); userEntity.setId(USER_ID_VALUE); @@ -3595,7 +3595,7 @@ void testUpdateUserAttributeValuesUsesNormalizedAttributeLookup() throws Resourc } @Test - void testUpdateUserAttributeValuesAcceptsCommaDelimitedTextList() throws ResourceNotFoundException { + void testUpdateUserAttributeValuesAcceptsCommaDelimitedTextList() { UserEntity userEntity = new UserEntity(); userEntity.setId(USER_ID_VALUE); diff --git a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java index 495dcee..af52d4f 100755 --- a/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java +++ b/src/test/java/org/eclipse/ecsp/uidam/usermanagement/utilities/ClientSearchSpecificationTest.java @@ -30,16 +30,17 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; import java.util.List; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyChar; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Test cases for ClientSearchSpecification. @@ -59,16 +60,16 @@ class ClientSearchSpecificationTest { @BeforeEach void setUp() { CollectorRegistry.defaultRegistry.clear(); - clientEntityRoot = Mockito.mock(Root.class); - criteriaQuery = Mockito.mock(CriteriaQuery.class); - builder = Mockito.mock(CriteriaBuilder.class); - path = Mockito.mock(Path.class); - expression = Mockito.mock(Expression.class); - predicate = Mockito.mock(Predicate.class); - Mockito.when(clientEntityRoot.get(anyString())).thenReturn(path); - Mockito.when(builder.upper(path)).thenReturn(expression); - Mockito.when(builder.like(any(Expression.class), anyString(), anyChar())).thenReturn(predicate); - Mockito.when(builder.or(any(Predicate[].class))).thenReturn(predicate); + clientEntityRoot = mock(Root.class); + criteriaQuery = mock(CriteriaQuery.class); + builder = mock(CriteriaBuilder.class); + path = mock(Path.class); + expression = mock(Expression.class); + predicate = mock(Predicate.class); + when(clientEntityRoot.get(anyString())).thenReturn(path); + when(builder.upper(path)).thenReturn(expression); + when(builder.like(any(Expression.class), anyString(), anyChar())).thenReturn(predicate); + when(builder.or(any(Predicate[].class))).thenReturn(predicate); } @Test diff --git a/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java b/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java index 08f5a89..dc73f42 100644 --- a/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java +++ b/src/test/java/org/eclipse/ecsp/utils/logger/IgniteLoggerImplTest.java @@ -145,9 +145,7 @@ void disabledLevelsDoNotEmitMessages(String level, boolean withEvent, Overload o log(level, withEvent ? unreadEvent : null, overload); assertTrue(appender.list.isEmpty()); - if (List.of("trace", "debug", "info").contains(level)) { - verifyNoInteractions(unreadEvent); - } + verifyNoInteractions(unreadEvent); } @ParameterizedTest