FINERACT-2185: User management improvement

This commit is contained in:
Marta Jankovics
2025-04-23 09:56:05 +02:00
committed by Adam Saghy
parent 8d83b5547c
commit 79c006774e
28 changed files with 506 additions and 95 deletions
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.commands.domain;
import jakarta.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
@@ -41,8 +42,21 @@ public enum CommandProcessingResultType {
private final Integer value;
private final String code;
@NotNull
public static CommandProcessingResultType fromInt(final Integer value) {
CommandProcessingResultType transactionType = BY_ID.get(value);
return transactionType == null ? INVALID : transactionType;
}
public boolean isProcessed() {
return this == PROCESSED;
}
public boolean isAwaitingApproval() {
return this == AWAITING_APPROVAL;
}
public boolean isRejected() {
return this == REJECTED;
}
}
@@ -23,6 +23,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
@@ -136,9 +137,11 @@ public class CommandSource extends AbstractPersistableCustom<Long> {
@Column(name = "loan_external_id", length = 100)
private ExternalId loanExternalId;
public static CommandSource fullEntryFrom(final CommandWrapper wrapper, final JsonCommand command, final AppUser maker,
String idempotencyKey, Integer status) {
@Column(name = "is_sanitized", nullable = false)
private boolean sanitized;
public static CommandSource fullEntryFrom(final CommandWrapper wrapper, final JsonCommand command, final AppUser maker,
String idempotencyKey, Integer status, boolean sanitized) {
return CommandSource.builder() //
.actionName(wrapper.actionName()) //
.entityName(wrapper.entityName()) //
@@ -159,31 +162,52 @@ public class CommandSource extends AbstractPersistableCustom<Long> {
.transactionId(command.getTransactionId()) //
.creditBureauId(command.getCreditBureauId()) //
.organisationCreditBureauId(command.getOrganisationCreditBureauId()) //
.loanExternalId(command.getLoanExternalId()).build(); //
.loanExternalId(command.getLoanExternalId()).sanitized(sanitized).build(); //
}
public String getPermissionCode() {
return this.actionName + "_" + this.entityName;
}
public void markAsAwaitingApproval() {
this.status = CommandProcessingResultType.AWAITING_APPROVAL.getValue();
@NotNull
public CommandProcessingResultType getStatusEnum() {
return CommandProcessingResultType.fromInt(status);
}
public boolean isMarkedAsAwaitingApproval() {
return this.status.equals(CommandProcessingResultType.AWAITING_APPROVAL.getValue());
public void setStatus(@NotNull CommandProcessingResultType status) {
this.status = status.getValue();
}
public void markAsAwaitingApproval() {
setStatus(CommandProcessingResultType.AWAITING_APPROVAL);
}
public boolean isAwaitingApproval() {
return getStatusEnum().isAwaitingApproval();
}
public boolean isProcessed() {
return getStatusEnum().isProcessed();
}
public void markAsChecked(final AppUser checker) {
this.checker = checker;
this.checkedOnDate = DateUtils.getAuditOffsetDateTime();
this.status = CommandProcessingResultType.PROCESSED.getValue();
setStatus(CommandProcessingResultType.PROCESSED);
}
public boolean isChecked() {
return checker != null && isProcessed();
}
public void markAsRejected(final AppUser checker) {
this.checker = checker;
this.checkedOnDate = DateUtils.getAuditOffsetDateTime();
this.status = CommandProcessingResultType.REJECTED.getValue();
setStatus(CommandProcessingResultType.REJECTED);
}
public boolean isRejected() {
return checker != null && isRejected();
}
public void updateForAudit(final CommandProcessingResult result) {
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.commands.domain;
import java.util.Set;
import lombok.Getter;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.useradministration.api.PasswordPreferencesApiConstants;
@@ -45,6 +46,7 @@ public class CommandWrapper {
private final Long organisationCreditBureauId;
private final String jobName;
private final ExternalId loanExternalId;
private final Set<String> sanitizeJsonKeys;
private final String idempotencyKey;
@@ -67,7 +69,7 @@ public class CommandWrapper {
final ExternalId loanExternalId) {
return new CommandWrapper(commandId, actionName, entityName, resourceId, subresourceId, resourceGetUrl, productId, officeId,
groupId, clientId, loanId, savingsId, transactionId, creditBureauId, organisationCreditBureauId, idempotencyKey,
loanExternalId);
loanExternalId, null);
}
private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId,
@@ -92,12 +94,14 @@ public class CommandWrapper {
this.jobName = null;
this.idempotencyKey = null;
this.loanExternalId = null;
this.sanitizeJsonKeys = null;
}
public CommandWrapper(final Long officeId, final Long groupId, final Long clientId, final Long loanId, final Long savingsId,
final String actionName, final String entityName, final Long entityId, final Long subentityId, final String href,
final String json, final String transactionId, final Long productId, final Long templateId, final Long creditBureauId,
final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId) {
final Long organisationCreditBureauId, final String jobName, final String idempotencyKey, final ExternalId loanExternalId,
final Set<String> sanitizeJsonKeys) {
this.commandId = null;
this.officeId = officeId;
@@ -120,12 +124,14 @@ public class CommandWrapper {
this.jobName = jobName;
this.idempotencyKey = idempotencyKey;
this.loanExternalId = loanExternalId;
this.sanitizeJsonKeys = sanitizeJsonKeys;
}
private CommandWrapper(final Long commandId, final String actionName, final String entityName, final Long resourceId,
final Long subresourceId, final String resourceGetUrl, final Long productId, final Long officeId, final Long groupId,
final Long clientId, final Long loanId, final Long savingsId, final String transactionId, final Long creditBureauId,
final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId) {
final Long organisationCreditBureauId, final String idempotencyKey, final ExternalId loanExternalId,
final Set<String> sanitizeJsonKeys) {
this.commandId = commandId;
this.officeId = officeId;
@@ -147,6 +153,7 @@ public class CommandWrapper {
this.jobName = null;
this.idempotencyKey = idempotencyKey;
this.loanExternalId = loanExternalId;
this.sanitizeJsonKeys = sanitizeJsonKeys;
}
public boolean isCreate() {
@@ -200,21 +207,24 @@ public class CommandWrapper {
return isnoteResource;
}
public boolean isUpdateOfOwnUserDetails(final Long loggedInUserId) {
return isUserResource() && isUpdate() && loggedInUserId.equals(this.entityId);
public boolean isChangeOfOwnUserDetails(final Long loggedInUserId) {
return isUserResource() && loggedInUserId.equals(this.entityId) && (isUpdate() || isChangePasswordOperation());
}
public boolean isUpdate() {
// permissions resource has special update which involves no resource.
return (isPermissionResource() && isUpdateOperation()) || (isCurrencyResource() && isUpdateOperation())
|| (isCacheResource() && isUpdateOperation()) || (isWorkingDaysResource() && isUpdateOperation())
|| (isPasswordPreferencesResource() && isUpdateOperation()) || (isUpdateOperation() && (this.entityId != null));
// some resources have special update which involves no resource identifier.
return isUpdateOperation() && (this.entityId != null || isPermissionResource() || isCurrencyResource() || isCacheResource()
|| isWorkingDaysResource() || isPasswordPreferencesResource());
}
public boolean isCacheResource() {
return this.entityName.equalsIgnoreCase("CACHE");
}
public boolean isChangePasswordOperation() {
return this.actionName.equalsIgnoreCase("CHANGEPWD");
}
public boolean isUpdateOperation() {
return this.actionName.equalsIgnoreCase("UPDATE");
}
@@ -20,6 +20,9 @@ package org.apache.fineract.commands.service;
import static org.apache.fineract.commands.domain.CommandProcessingResultType.UNDER_PROCESSING;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.batch.exception.ErrorInfo;
import org.apache.fineract.commands.domain.CommandSource;
@@ -28,10 +31,13 @@ import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.exception.CommandNotFoundException;
import org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.exception.ErrorHandler;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessUnderProcessingException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.useradministration.domain.AppUser;
import org.jetbrains.annotations.NotNull;
import org.springframework.orm.jpa.JpaSystemException;
@@ -49,8 +55,13 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
public class CommandSourceService {
public static final String COMMAND_MASK_VALUE = "***";
public static final String COMMAND_SANITIZE_ALL = "SANITIZE_ALL";
private final ConfigurationDomainService configurationDomainService;
private final CommandSourceRepository commandSourceRepository;
private final ErrorHandler errorHandler;
private final FromJsonHelper fromApiJsonHelper;
@NotNull
@Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.REPEATABLE_READ)
@@ -110,7 +121,8 @@ public class CommandSourceService {
public CommandSource getInitialCommandSource(CommandWrapper wrapper, JsonCommand jsonCommand, AppUser maker, String idempotencyKey) {
CommandSource commandSourceResult = CommandSource.fullEntryFrom(wrapper, jsonCommand, maker, idempotencyKey,
UNDER_PROCESSING.getValue());
UNDER_PROCESSING.getValue(), false);
sanitizeJson(commandSourceResult, wrapper.getSanitizeJsonKeys());
if (commandSourceResult.getCommandAsJson() == null) {
commandSourceResult.setCommandAsJson("{}");
}
@@ -119,13 +131,51 @@ public class CommandSourceService {
@Transactional
public CommandProcessingResult processCommand(NewCommandSourceHandler handler, JsonCommand command, CommandSource commandSource,
AppUser user, boolean isApprovedByChecker, boolean isMakerChecker) {
AppUser user, boolean isApprovedByChecker) {
final CommandProcessingResult result = handler.processCommand(command);
boolean isRollback = !isApprovedByChecker && !user.isCheckerSuperUser() && (isMakerChecker || result.isRollbackTransaction());
if (isRollback) {
commandSource.markAsAwaitingApproval();
throw new RollbackTransactionNotApprovedException(commandSource.getId(), commandSource.getResourceId());
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
throw new GeneralPlatformDomainRuleException("error.msg.invalid.sanitization",
"Maker-checker command can not be sanitized, please change the permission configuration", permission);
}
commandSource.markAsAwaitingApproval();
throw new RollbackTransactionNotApprovedException(commandSource.getId(), commandSource.getResourceId());
}
}
return result;
}
private void sanitizeJson(@NotNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
}
String commandAsJson = commandSource.getCommandAsJson();
if (commandAsJson == null || commandAsJson.isEmpty()) {
return;
}
final JsonElement parsedCommand = this.fromApiJsonHelper.parse(commandAsJson);
if (!parsedCommand.isJsonObject()) {
return;
}
String sanitizedJson;
if (sanitizeKeys.contains(COMMAND_SANITIZE_ALL)) {
sanitizedJson = "";
} else {
JsonObject jsonObject = parsedCommand.getAsJsonObject();
for (String key : sanitizeKeys) {
if (jsonObject.has(key)) {
jsonObject.addProperty(key, COMMAND_MASK_VALUE);
}
}
sanitizedJson = jsonObject.toString();
}
commandSource.setCommandAsJson(sanitizedJson);
commandSource.setSanitized(true);
}
}
@@ -18,7 +18,13 @@
*/
package org.apache.fineract.commands.service;
import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD;
import static org.apache.fineract.useradministration.service.AppUserConstants.REPEAT_PASSWORD;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.infrastructure.accountnumberformat.service.AccountNumberFormatConstants;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
@@ -49,18 +55,21 @@ public class CommandWrapperBuilder {
private String jobName;
private String idempotencyKey;
private ExternalId loanExternalId;
private Set<String> sanitizeJsonKeys;
@SuppressFBWarnings(value = "UWF_UNWRITTEN_FIELD", justification = "TODO: fix this!")
public CommandWrapper build() {
return new CommandWrapper(this.officeId, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName,
this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, this.templateId,
this.creditBureauId, this.organisationCreditBureauId, this.jobName, this.idempotencyKey, this.loanExternalId);
this.creditBureauId, this.organisationCreditBureauId, this.jobName, this.idempotencyKey, this.loanExternalId,
this.sanitizeJsonKeys);
}
public CommandWrapper build(String idempotencyKey) {
return new CommandWrapper(this.officeId, this.groupId, this.clientId, this.loanId, this.savingsId, this.actionName, this.entityName,
this.entityId, this.subentityId, this.href, this.json, this.transactionId, this.productId, this.templateId,
this.creditBureauId, this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId);
this.creditBureauId, this.organisationCreditBureauId, this.jobName, idempotencyKey, this.loanExternalId,
this.sanitizeJsonKeys);
}
public CommandWrapperBuilder updateCreditBureau() {
@@ -264,6 +273,16 @@ public class CommandWrapperBuilder {
this.entityName = "USER";
this.entityId = null;
this.href = "/users/template";
this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
return this;
}
public CommandWrapperBuilder changeUserPassword(final Long userId) {
this.actionName = "CHANGEPWD";
this.entityName = "USER";
this.entityId = userId;
this.href = "/users/" + userId + "/pwd";
this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
return this;
}
@@ -272,6 +291,7 @@ public class CommandWrapperBuilder {
this.entityName = "USER";
this.entityId = userId;
this.href = "/users/" + userId;
this.sanitizeJsonKeys = new HashSet<>(Arrays.asList(PASSWORD, REPEAT_PASSWORD));
return this;
}
@@ -55,7 +55,7 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
boolean isApprovedByChecker = false;
// check if is update of own account details
if (wrapper.isUpdateOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) {
if (wrapper.isChangeOfOwnUserDetails(this.context.authenticatedUser(wrapper).getId())) {
// then allow this operation to proceed.
// maker checker doesnt mean anything here.
isApprovedByChecker = true; // set to true in case permissions have
@@ -117,7 +117,7 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
private CommandSource validateMakerCheckerTransaction(final Long makerCheckerId) {
final CommandSource commandSource = this.commandSourceRepository.findById(makerCheckerId)
.orElseThrow(() -> new CommandNotFoundException(makerCheckerId));
if (!commandSource.isMarkedAsAwaitingApproval()) {
if (!commandSource.isAwaitingApproval()) {
throw new CommandNotAwaitingApprovalException(makerCheckerId);
}
AppUser appUser = this.context.authenticatedUser();
@@ -117,16 +117,11 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
storeCommandIdInContext(commandSource); // Store command id as a request attribute
}
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(wrapper.taskPermissionName());
if (isApprovedByChecker || (isMakerChecker && user.isCheckerSuperUser())) {
commandSource.markAsChecked(user);
}
setIdempotencyKeyStoreFlag(true);
final CommandProcessingResult result;
try {
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker,
isMakerChecker);
result = commandSourceService.processCommand(findCommandHandler(wrapper), command, commandSource, user, isApprovedByChecker);
} catch (Throwable t) { // NOSONAR
RuntimeException mappable = ErrorHandler.getMappable(t);
ErrorInfo errorInfo = commandSourceService.generateErrorInfo(mappable);
@@ -134,7 +129,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
commandSource.setResultStatusCode(statusCode);
commandSource.setResult(errorInfo.getMessage());
if (statusCode != SC_OK) {
commandSource.setStatus(ERROR.getValue());
commandSource.setStatus(ERROR);
}
if (!isEnclosingTransaction) { // TODO: temporary solution
commandSource = commandSourceService.saveResultNewTransaction(commandSource);
@@ -148,7 +143,7 @@ public class SynchronousCommandProcessingService implements CommandProcessingSer
commandSource.setResultStatusCode(SC_OK);
commandSource.updateForAudit(result);
commandSource.setResult(toApiResultJsonSerializer.serializeResult(result));
commandSource.setStatus(PROCESSED.getValue());
commandSource.setStatus(PROCESSED);
commandSource = commandSourceService.saveResultSameTransaction(commandSource);
storeCommandIdInContext(commandSource); // Store command id as a request attribute
@@ -18,6 +18,8 @@
*/
package org.apache.fineract.useradministration.domain;
import static org.apache.fineract.useradministration.service.AppUserConstants.PASSWORD;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -195,8 +197,27 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
return organisationalRole;
}
public Map<String, Object> changePassword(final JsonCommand command, final PlatformPasswordEncoder platformPasswordEncoder) {
// unencoded password provided
final Map<String, Object> actualChanges = new LinkedHashMap<>(1);
updatePassword(command, platformPasswordEncoder, actualChanges);
return actualChanges;
}
private void updatePassword(JsonCommand command, PlatformPasswordEncoder platformPasswordEncoder, Map<String, Object> actualChanges) {
final String passwordParamName = PASSWORD;
if (command.hasParameter(passwordParamName)) {
if (command.isChangeInPasswordParameterNamed(passwordParamName, this.password, platformPasswordEncoder, getId())) {
final String passwordEncodedValue = command.passwordValueOfParameterNamed(passwordParamName, platformPasswordEncoder,
getId());
actualChanges.put(passwordParamName, true);
updatePassword(passwordEncodedValue);
}
}
}
public void updatePassword(final String encodePassword) {
if (cannotChangePassword != null && cannotChangePassword == true) {
if (Boolean.TRUE.equals(cannotChangePassword)) {
throw new NoAuthorizationException("Password of this user may not be modified");
}
@@ -223,27 +244,10 @@ public class AppUser extends AbstractPersistableCustom<Long> implements Platform
public Map<String, Object> update(final JsonCommand command, final PlatformPasswordEncoder platformPasswordEncoder,
final Collection<Client> clients) {
final Map<String, Object> actualChanges = new LinkedHashMap<>(7);
// unencoded password provided
final String passwordParamName = "password";
final String passwordEncodedParamName = "passwordEncoded";
if (command.hasParameter(passwordParamName)) {
if (command.isChangeInPasswordParameterNamed(passwordParamName, this.password, platformPasswordEncoder, getId())) {
final String passwordEncodedValue = command.passwordValueOfParameterNamed(passwordParamName, platformPasswordEncoder,
getId());
updatePassword(passwordEncodedValue);
}
}
if (command.hasParameter(passwordEncodedParamName)) {
if (command.isChangeInStringParameterNamed(passwordEncodedParamName, this.password)) {
final String newValue = command.stringValueOfParameterNamed(passwordEncodedParamName);
updatePassword(newValue);
}
}
updatePassword(command, platformPasswordEncoder, actualChanges);
final String officeIdParamName = "officeId";
if (command.isChangeInLongParameterNamed(officeIdParamName, this.office.getId())) {
final Long newValue = command.longValueOfParameterNamed(officeIdParamName);
@@ -24,6 +24,8 @@ public final class AppUserConstants {
}
public static final String PASSWORD = "password";
public static final String REPEAT_PASSWORD = "repeatPassword";
public static final String PASSWORD_NEVER_EXPIRES = "passwordNeverExpires";
public static final String IS_SELF_SERVICE_USER = "isSelfServiceUser";
public static final String CLIENTS = "clients";
@@ -58,8 +58,8 @@ public class IdempotencyCommandProcessFailedExceptionTest {
public void testInconsistentStatus() {
IdempotentCommandExceptionMapper mapper = new IdempotentCommandExceptionMapper();
CommandWrapper command = new CommandWrapper(null, null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null);
CommandSource source = CommandSource.fullEntryFrom(command, JsonCommand.from("{}"), null, "dummy-key", null);
null, null, null, null, null, null);
CommandSource source = CommandSource.fullEntryFrom(command, JsonCommand.from("{}"), null, "dummy-key", null, false);
IdempotentCommandProcessFailedException exception = new IdempotentCommandProcessFailedException(command, null, source);
Response result = mapper.toResponse(exception);
assertEquals(500, result.getStatus());