diff --git a/readme.md b/readme.md index bc640089..fd9edd1f 100644 --- a/readme.md +++ b/readme.md @@ -135,11 +135,9 @@ ldap.security.principal= (Active directory username) ldap.security.credentials= (Active directory password) ldap.provider.url= (Active directory url) ad.containers.main= (Main container) -ad.groups.users= (Main user group) ad.groups.ec= (Group for employees) ad.groups.b2b= (Group for associates) ad.groups.admin= (Group for admins) -ad.identifiers.team=Team,Guild (Postfixes for group names. Here we have distinction for teams and guilds) azure.ad.clientId= (Client Id from azure AD registration) azure.ad.tenantId= (Tenant Id from azure AD registration) diff --git a/src/main/java/info/fingo/urlopia/api/v2/authentication/oauth/OAuthRedirectService.java b/src/main/java/info/fingo/urlopia/api/v2/authentication/oauth/OAuthRedirectService.java index 0c2cab1e..feeedcf1 100644 --- a/src/main/java/info/fingo/urlopia/api/v2/authentication/oauth/OAuthRedirectService.java +++ b/src/main/java/info/fingo/urlopia/api/v2/authentication/oauth/OAuthRedirectService.java @@ -32,14 +32,12 @@ private Set pickTeamsInfo(User user) { Set teams = new HashSet<>(); for (var team : user.getTeams()) { var teamName = team.getName(); - var allUsersLeader = userService.getAllUsersLeader(); - var leader = user.equals(team.getLeader()) ? allUsersLeader : team.getLeader(); - - if (leader != null) { - var leaderName = leader.getFirstName() + " " + leader.getLastName(); - var teamInfo = new TeamInfo(teamName, leaderName); - teams.add(teamInfo); - } + var leader = userService.getTeamLeader(user, team); + var leaderName = leader != null + ? leader.getFirstName() + " " + leader.getLastName() + : "-"; + var teamInfo = new TeamInfo(teamName, leaderName); + teams.add(teamInfo); } return teams; } diff --git a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryObjectClass.java b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryObjectClass.java index 2848e7a7..7e791225 100644 --- a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryObjectClass.java +++ b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryObjectClass.java @@ -1,5 +1,17 @@ package info.fingo.urlopia.config.ad; public enum ActiveDirectoryObjectClass { - Person, Group + PERSON("person"), + GROUP("group"), + ORGANIZATIONAL_UNIT("organizationalUnit"); + + private final String key; + + ActiveDirectoryObjectClass(String key) { + this.key = key; + } + + public String getKey() { + return key; + } } diff --git a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectorySearcher.java b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectorySearcher.java index 20fd6144..3b6d4bac 100644 --- a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectorySearcher.java +++ b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectorySearcher.java @@ -1,97 +1,121 @@ -package info.fingo.urlopia.config.ad; - -import info.fingo.urlopia.config.authentication.LDAPConnectionService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; - -import javax.naming.NamingException; -import javax.naming.directory.DirContext; -import javax.naming.directory.SearchControls; -import javax.naming.directory.SearchResult; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; - -@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) -public class ActiveDirectorySearcher { - private static final Logger LOGGER = LoggerFactory.getLogger(ActiveDirectorySearcher.class); - - private final StringBuilder filter = new StringBuilder("(&"); - private final String mainContainer; - private final LDAPConnectionService ldapConnectionService; - - public ActiveDirectorySearcher(String mainContainer, - LDAPConnectionService ldapConnectionService) { - this.mainContainer = mainContainer; - this.ldapConnectionService = ldapConnectionService; - } - - public ActiveDirectorySearcher objectClass(ActiveDirectoryObjectClass objectClass) { - var value = String.format("(objectClass=%s)", objectClass.name()); - filter.append(value); - return this; - } - - public ActiveDirectorySearcher memberOf(String group) { - var value = String.format("(memberOf=%s)", group); - filter.append(value); - return this; - } - - public ActiveDirectorySearcher mail(String mail) { - var value = String.format("(mail=%s)", mail); - filter.append(value); - return this; - } - - public ActiveDirectorySearcher name(String name) { - var value = String.format("(name=%s)", name); - filter.append(value); - return this; - } - - public ActiveDirectorySearcher distinguishedName(String distinguishedName) { - var value = String.format("(distinguishedName=%s)", distinguishedName); - filter.append(value); - return this; - } - - public ActiveDirectorySearcher isDisabled(){ - var builder = new StringBuilder("(|"); - for (var disableKey: ActiveDirectoryUtils.DISABLED_STATUS){ - var value = String.format("(%s=%s)",Attribute.USER_ACCOUNT_CONTROL.getKey(), disableKey); - builder.append(value); - } - builder.append(")"); - filter.append(builder); - return this; - } - - public List search() { - var filter = this.filter.append(")").toString(); - List result = new LinkedList<>(); - - var controls = new SearchControls(); - controls.setSearchScope(SearchControls.SUBTREE_SCOPE); - - // connecting to AD and getting data - DirContext ad = null; - try { - ad = ldapConnectionService.getContext(); - result = Collections.list(ad.search(mainContainer, filter, controls)); - } catch (NamingException e) { - LOGGER.error("Exception when trying to search in Active Directory", e); - } finally { - try { - if (ad != null) { - ad.close(); - } - } catch (NamingException e) { - LOGGER.error("Exception when trying to close the LDAP connection", e); - } - } - - return result; - } -} +package info.fingo.urlopia.config.ad; + +import info.fingo.urlopia.config.authentication.LDAPConnectionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import javax.naming.NamingException; +import javax.naming.directory.DirContext; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) +public class ActiveDirectorySearcher { + private static final Logger LOGGER = LoggerFactory.getLogger(ActiveDirectorySearcher.class); + + private final StringBuilder filter = new StringBuilder("(&"); + private final String mainContainer; + private final LDAPConnectionService ldapConnectionService; + + public ActiveDirectorySearcher(String mainContainer, + LDAPConnectionService ldapConnectionService) { + this.mainContainer = mainContainer; + this.ldapConnectionService = ldapConnectionService; + } + + public ActiveDirectorySearcher objectClass(ActiveDirectoryObjectClass objectClass) { + var value = String.format("(objectClass=%s)", objectClass.getKey()); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher objectClasses(List objectClasses) { + var value = objectClasses.stream() + .map(objClass -> String.format("(objectClass=%s)", objClass.getKey())) + .collect(Collectors.joining("", "(|", ")")); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher memberOf(String group) { + var value = String.format("(memberOf=%s)", group); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher principalName(String principalName) { + var value = String.format("(userPrincipalName=%s)", principalName); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher mail(String mail) { + var value = String.format("(mail=%s)", mail); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher name(String name) { + var value = String.format("(name=%s)", name); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher distinguishedName(String distinguishedName) { + var value = String.format("(distinguishedName=%s)", distinguishedName); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher excludeDistinguishedName(String distinguishedName) { + var value = String.format("(!(distinguishedName=%s))", distinguishedName); + filter.append(value); + return this; + } + + public ActiveDirectorySearcher isDisabled(){ + var builder = new StringBuilder("(|"); + for (var disableKey: ActiveDirectoryUtils.DISABLED_STATUS){ + var value = String.format("(%s=%s)",Attribute.USER_ACCOUNT_CONTROL.getKey(), disableKey); + builder.append(value); + } + builder.append(")"); + filter.append(builder); + return this; + } + + public List search() { + var controls = new SearchControls(); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + return search(controls); + } + + public List search(SearchControls controls) { + var filter = this.filter.append(")").toString(); + List result = new LinkedList<>(); + + // connecting to AD and getting data + DirContext ad = null; + try { + ad = ldapConnectionService.getContext(); + result = Collections.list(ad.search(mainContainer, filter, controls)); + } catch (NamingException e) { + LOGGER.error("Exception when trying to search in Active Directory", e); + } finally { + try { + if (ad != null) { + ad.close(); + } + } catch (NamingException e) { + LOGGER.error("Exception when trying to close the LDAP connection", e); + } + } + + return result; + } +} diff --git a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryUtils.java b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryUtils.java index f678c23a..43947528 100644 --- a/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryUtils.java +++ b/src/main/java/info/fingo/urlopia/config/ad/ActiveDirectoryUtils.java @@ -5,6 +5,7 @@ import javax.naming.directory.SearchResult; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.List; import java.util.Optional; @@ -43,4 +44,39 @@ public static boolean isDisabled(SearchResult searchResult){ return DISABLED_STATUS.contains(accountStatus); } + public static String getRelativeDN(String distinguishedName, + String base) { + if (distinguishedName.equals(base)) { + return ""; + } else if(distinguishedName.endsWith(base) && !base.isBlank()) { + return distinguishedName.substring(0, distinguishedName.length() - base.length() - 1); // -1 for comma + } else { + return distinguishedName; + } + } + + public static String getParentDN(String distinguishedName) { + var dnParts = Arrays.stream(distinguishedName.split(",")).toList(); + if (dnParts.size() == 1) { + return ""; + } else { + return String.join(",", dnParts.subList(1, dnParts.size())); + } + } + + public static boolean isOU(SearchResult object) { + return isObjectClass(object, ActiveDirectoryObjectClass.ORGANIZATIONAL_UNIT); + } + + public static boolean isPerson(SearchResult object) { + return isObjectClass(object, ActiveDirectoryObjectClass.PERSON); + } + + public static boolean isObjectClass(SearchResult object, ActiveDirectoryObjectClass objectClass) { + var objectClasses = pickAttribute(object, info.fingo.urlopia.config.ad.Attribute.OBJECT_CLASS); + return Arrays.stream(objectClasses.split(",")) + .map(String::trim) + .anyMatch(oc -> oc.equalsIgnoreCase(objectClass.getKey())); + } + } diff --git a/src/main/java/info/fingo/urlopia/config/ad/Attribute.java b/src/main/java/info/fingo/urlopia/config/ad/Attribute.java index e885c1db..ad9cc73f 100644 --- a/src/main/java/info/fingo/urlopia/config/ad/Attribute.java +++ b/src/main/java/info/fingo/urlopia/config/ad/Attribute.java @@ -14,7 +14,8 @@ public enum Attribute { CREATED_TIME("whenCreated"), CHANGED_TIME("whenChanged"), USER_ACCOUNT_CONTROL("userAccountControl"), - ACCOUNT_NAME("sAMAccountName"); + ACCOUNT_NAME("sAMAccountName"), + OBJECT_CLASS("objectClass"); private String key; diff --git a/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryNode.java b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryNode.java new file mode 100644 index 00000000..ee771fc7 --- /dev/null +++ b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryNode.java @@ -0,0 +1,57 @@ +package info.fingo.urlopia.config.ad.tree; + +import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; +import info.fingo.urlopia.config.ad.Attribute; + +import javax.naming.directory.SearchResult; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ActiveDirectoryNode { + + private final String relativeDN; + private final SearchResult object; + private final Map children; + + protected ActiveDirectoryNode(SearchResult object) { + this.relativeDN = getRDN(object); + this.object = object; + this.children = new HashMap<>(); + } + + protected ActiveDirectoryNode(String distinguishedName) { + this.relativeDN = getRDN(distinguishedName); + this.object = null; + this.children = new HashMap<>(); + } + + public void add(ActiveDirectoryNode child) { + children.put(child.relativeDN, child); + } + + public Optional getChild(String childRelativeDistinguishedName) { + return Optional.ofNullable(children.get(childRelativeDistinguishedName)); + } + + private static String getRDN(SearchResult object) { + var distinguishedName = ActiveDirectoryUtils.pickAttribute(object, Attribute.DISTINGUISHED_NAME); + return getRDN(distinguishedName); + } + + private static String getRDN(String distinguishedName) { + return distinguishedName.split(",", 2)[0]; + } + + public List getDirectChildrenObjects() { + return children.values().stream() + .map(child -> child.object) + .toList(); + } + + public SearchResult getObject() { + return object; + } + +} diff --git a/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTree.java b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTree.java new file mode 100644 index 00000000..706c212e --- /dev/null +++ b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTree.java @@ -0,0 +1,69 @@ +package info.fingo.urlopia.config.ad.tree; + +import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; +import info.fingo.urlopia.config.ad.Attribute; + +import javax.naming.directory.SearchResult; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +public class ActiveDirectoryTree { + + private final String mainContainerDn; + private final ActiveDirectoryNode root; + + public ActiveDirectoryTree(String mainContainerDn) { + this.mainContainerDn = mainContainerDn; + this.root = new ActiveDirectoryNode(mainContainerDn); + } + + public void put(SearchResult object) { + var childDn = ActiveDirectoryUtils.pickAttribute(object, Attribute.DISTINGUISHED_NAME); + var parentDn = ActiveDirectoryUtils.getParentDN(childDn); + var parentNode = searchNode(parentDn); + parentNode.ifPresentOrElse( + pNode -> { + var childNode = new ActiveDirectoryNode(object); + pNode.add(childNode); + }, + () -> { + throw ActiveDirectoryTreeException.missingParent(childDn); + } + ); + } + + public List searchDirectChildrenObjectsOf(String distinguishedName) { + return searchNode(distinguishedName) + .map(ActiveDirectoryNode::getDirectChildrenObjects) + .orElse(List.of()); + } + + public Optional search(String distinguishedName) { + return searchNode(distinguishedName).map(ActiveDirectoryNode::getObject); + } + + private Optional searchNode(String distinguishedName) { + var relativeDn = ActiveDirectoryUtils.getRelativeDN(distinguishedName, mainContainerDn); + if (relativeDn.isBlank()) { + return Optional.of(root); + } + var dnParts = Arrays.stream(relativeDn.split(",")).toList(); + return searchNode(root, dnParts); + } + + private Optional searchNode(ActiveDirectoryNode node, + List dnParts) { + if (dnParts.isEmpty()) { + return Optional.of(node); + } + var topDnPartIdx = dnParts.size() - 1; + var topDnPart = dnParts.get(topDnPartIdx); + var topNode = node.getChild(topDnPart); + var bottomDnParts = dnParts.stream() + .limit(topDnPartIdx) + .toList(); + return topNode.flatMap(n -> searchNode(n, bottomDnParts)); + } + +} diff --git a/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTreeException.java b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTreeException.java new file mode 100644 index 00000000..eece76d4 --- /dev/null +++ b/src/main/java/info/fingo/urlopia/config/ad/tree/ActiveDirectoryTreeException.java @@ -0,0 +1,15 @@ +package info.fingo.urlopia.config.ad.tree; + +public class ActiveDirectoryTreeException extends RuntimeException { + + private static final String MISSING_PARENT = "Missing parent node for object %s"; + + private ActiveDirectoryTreeException(String message) { + super(message); + } + + public static ActiveDirectoryTreeException missingParent(String child) { + return new ActiveDirectoryTreeException(MISSING_PARENT.formatted(child)); + } + +} diff --git a/src/main/java/info/fingo/urlopia/config/authentication/oauth/JwtUtils.java b/src/main/java/info/fingo/urlopia/config/authentication/oauth/JwtUtils.java index 5c53fb43..6f819f54 100644 --- a/src/main/java/info/fingo/urlopia/config/authentication/oauth/JwtUtils.java +++ b/src/main/java/info/fingo/urlopia/config/authentication/oauth/JwtUtils.java @@ -18,7 +18,7 @@ public static JsonObject decodeTokenPayloadToJsonObject(DecodedJWT decodedJWT) { try { String payloadAsString = decodedJWT.getPayload(); return new Gson().fromJson( - new String(Base64.getDecoder().decode(payloadAsString), StandardCharsets.UTF_8), + new String(Base64.getUrlDecoder().decode(payloadAsString), StandardCharsets.UTF_8), JsonObject.class); } catch (RuntimeException exception) { throw new InvalidTokenException("Invalid JWT or JSON format of each of the jwt parts", exception); diff --git a/src/main/java/info/fingo/urlopia/request/Request.java b/src/main/java/info/fingo/urlopia/request/Request.java index 28796e3e..a94996a7 100644 --- a/src/main/java/info/fingo/urlopia/request/Request.java +++ b/src/main/java/info/fingo/urlopia/request/Request.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.Collections; import java.util.Set; import java.util.stream.Collectors; @@ -181,7 +182,7 @@ public void setWorkingDays(Integer workingDays) { } public Set getAcceptances() { - return acceptances; + return acceptances == null ? Collections.emptySet() : acceptances; } @Transient diff --git a/src/main/java/info/fingo/urlopia/request/normal/NormalRequestService.java b/src/main/java/info/fingo/urlopia/request/normal/NormalRequestService.java index b26823db..6884fb2e 100644 --- a/src/main/java/info/fingo/urlopia/request/normal/NormalRequestService.java +++ b/src/main/java/info/fingo/urlopia/request/normal/NormalRequestService.java @@ -1,223 +1,209 @@ -package info.fingo.urlopia.request.normal; - -import info.fingo.urlopia.acceptance.AcceptanceService; -import info.fingo.urlopia.acceptance.StatusNotSupportedException; -import info.fingo.urlopia.api.v2.user.PendingDaysOutput; -import info.fingo.urlopia.api.v2.user.VacationDaysOutput; -import info.fingo.urlopia.request.absence.BaseRequestInput; -import info.fingo.urlopia.history.HistoryLogService; -import info.fingo.urlopia.holidays.WorkingDaysCalculator; -import info.fingo.urlopia.request.*; -import info.fingo.urlopia.request.absence.InvalidDatesOrderException; -import info.fingo.urlopia.request.normal.events.NormalRequestAccepted; -import info.fingo.urlopia.request.normal.events.NormalRequestCanceled; -import info.fingo.urlopia.request.normal.events.NormalRequestCreated; -import info.fingo.urlopia.request.normal.events.NormalRequestRejected; -import info.fingo.urlopia.team.TeamService; -import info.fingo.urlopia.user.NoSuchUserException; -import info.fingo.urlopia.user.User; -import info.fingo.urlopia.user.UserRepository; -import info.fingo.urlopia.user.UserService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.List; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.stream.DoubleStream; - -@Service("normalRequestService") -@Transactional -@Slf4j -@RequiredArgsConstructor -public class NormalRequestService implements RequestTypeService { - - private final RequestRepository requestRepository; - - private final UserRepository userRepository; - - private final HistoryLogService historyLogService; - - private final WorkingDaysCalculator workingDaysCalculator; - - private final ApplicationEventPublisher publisher; - - private final AcceptanceService acceptanceService; - - private final UserService userService; - - - @Override - public Request create(Long userId, BaseRequestInput requestInput) { - User user = userRepository - .findById(userId) - .orElseThrow(() -> { - log.error("Could not create new normal request: user with id: {} does not exist", userId); - return NoSuchUserException.invalidId(); - }); - int workingDays = workingDaysCalculator.calculate(requestInput.getStartDate(), requestInput.getEndDate()); - float workingHours = workingDays * user.getWorkTime(); - - this.ensureUserOwnRequiredHoursNumber(user, workingHours); - Request request = this.createRequestObject(user, requestInput, workingDays); - this.validateRequest(request); - - request = requestRepository.save(request); - this.createAcceptances(user, request); - - publisher.publishEvent(new NormalRequestCreated(request)); - - var loggerInfo = "New normal request with id: %d has been created" - .formatted(request.getId()); - log.info(loggerInfo); - var requestId = request.getId(); - - return requestRepository - .findById(requestId) - .orElseThrow(() -> { - log.error("Request with id: {} does not exist", requestId); - return new NoSuchElementException(); - }); - } - - private void ensureUserOwnRequiredHoursNumber(User user, float requiredHours) { - double hoursRemaining = historyLogService.countRemainingHours(user.getId()); - double pendingRequestsHours = countPendingRequestsHours(user); - boolean userOwnRequiredHoursNumber = - (hoursRemaining - pendingRequestsHours) >= requiredHours; - if (!userOwnRequiredHoursNumber) { - var userId = user.getId(); - log.error("New normal request could not be created for user with id: {}. Reason: not enough days", userId); - throw new NotEnoughDaysException(); - } - } - - private double countPendingRequestsHours(User user) { - Long requesterId = user.getId(); - return requestRepository.findByRequesterId(requesterId).stream() - .filter(Request::isPending) - .filter(Request::isNormal) - .map(request -> request.getWorkingDays() * request.getRequester().getWorkTime()) - .flatMapToDouble(DoubleStream::of) - .sum(); - } - - public DayHourTime getPendingRequestsTime(Long userId) { - var user = getUserById(userId); - double pendingRequestsHours = countPendingRequestsHours(user); - float userWorkTime = user.getWorkTime(); - int days = (int) Math.floor(pendingRequestsHours / userWorkTime); - double hours = Math.round((pendingRequestsHours % userWorkTime) * 100.0) / 100.0; - return DayHourTime.of(days, hours); - } - - public PendingDaysOutput getPendingRequestsTimeV2(Long userId) { - var user = getUserById(userId); - double pendingRequestsHours = countPendingRequestsHours(user); - float userWorkTime = user.getWorkTime(); - int days = (int) Math.floor(pendingRequestsHours / userWorkTime); - double hours = Math.round((pendingRequestsHours % userWorkTime) * 100.0) / 100.0; - if (userWorkTime == 8) { - return new PendingDaysOutput(days, hours); - } - return new PendingDaysOutput(0, pendingRequestsHours); - } - - private User getUserById(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> { - log.error("Could not get pending requests time for a non-existent user with id: {}", userId); - return NoSuchUserException.invalidId(); - }); - } - - private Request createRequestObject(User user, BaseRequestInput requestInput, int workingDays) { - return new Request(user, - requestInput.getStartDate(), - requestInput.getEndDate(), - workingDays, - RequestType.NORMAL, - null, - Request.Status.PENDING); - } - - private void validateRequest(Request request) { - var requesterId = request.getRequester().getId(); - if (request.getEndDate().isBefore(request.getStartDate())) { - log.error("Could not create new normal request for user with id: {} because dates are in invalid order", - requesterId); - throw InvalidDatesOrderException.invalidDatesOrder(); - } - if (isOverlapping(request)) { - log.error("Could not create normal request for user with id: {} because it is overlapping other requests", - requesterId); - throw new RequestOverlappingException(); - } - } - - private boolean isOverlapping(Request newRequest) { - Long userId = newRequest.getRequester().getId(); - List requests = requestRepository.findByRequesterId(userId); - return requests.stream() - .filter(Request::isAffecting) - .anyMatch(request -> request.isOverlapping(newRequest)); - } - - private void createAcceptances(User user, Request request) { - var allUsersLeader = userService.getAllUsersLeader(); - user.getTeams().stream() - .map(team -> user.equals(team.getLeader()) ? allUsersLeader : team.getLeader()) - .filter(Objects::nonNull) - .distinct() - .forEach(leader -> this.acceptanceService.create(request, leader)); - } - - @Override - public void accept(Request request) { - this.validateStatus(request.getStatus(), Request.Status.PENDING); - request = this.changeStatus(request, Request.Status.ACCEPTED); - publisher.publishEvent(new NormalRequestAccepted(request)); //TODO: Create general Event RequestAccepted instead of Normal/OccasionalRequestAccepted - var loggerInfo = "Request with id: %d has been accepted" - .formatted(request.getId()); - log.info(loggerInfo); - } - - @Override - public void reject(Request request) { - this.validateStatus(request.getStatus(), Request.Status.PENDING); - request = this.changeStatus(request, Request.Status.REJECTED); - publisher.publishEvent(new NormalRequestRejected(request)); - var loggerInfo = "Request with id: %d has been rejected" - .formatted(request.getId()); - log.info(loggerInfo); - } - - @Override - public void cancel(Request request) { - Request.Status[] supportedStatuses = {Request.Status.PENDING, Request.Status.ACCEPTED}; - this.validateStatus(request.getStatus(), supportedStatuses); - request = this.changeStatus(request, Request.Status.CANCELED); - publisher.publishEvent(new NormalRequestCanceled(request)); - var loggerInfo = "Request with id: %d has been canceled" - .formatted(request.getId()); - log.info(loggerInfo); - } - - private void validateStatus(Request.Status status, Request.Status... supportedStatuses) { - List supported = Arrays.asList(supportedStatuses); - if (!supported.contains(status)) { - log.error("Status: {} does not exist", status.toString()); - throw StatusNotSupportedException.invalidStatus(status.toString()); - } - } - - private Request changeStatus(Request request, Request.Status status) { - request.getAcceptances().forEach(acceptance -> acceptanceService.expire(acceptance.getId())); - request.setStatus(status); - return requestRepository.save(request); - } - -} +package info.fingo.urlopia.request.normal; + +import info.fingo.urlopia.acceptance.AcceptanceService; +import info.fingo.urlopia.acceptance.StatusNotSupportedException; +import info.fingo.urlopia.api.v2.user.PendingDaysOutput; +import info.fingo.urlopia.history.HistoryLogService; +import info.fingo.urlopia.holidays.WorkingDaysCalculator; +import info.fingo.urlopia.request.*; +import info.fingo.urlopia.request.absence.BaseRequestInput; +import info.fingo.urlopia.request.absence.InvalidDatesOrderException; +import info.fingo.urlopia.request.normal.events.NormalRequestAccepted; +import info.fingo.urlopia.request.normal.events.NormalRequestCanceled; +import info.fingo.urlopia.request.normal.events.NormalRequestCreated; +import info.fingo.urlopia.request.normal.events.NormalRequestRejected; +import info.fingo.urlopia.user.NoSuchUserException; +import info.fingo.urlopia.user.User; +import info.fingo.urlopia.user.UserRepository; +import info.fingo.urlopia.user.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.DoubleStream; + +@Service("normalRequestService") +@Transactional +@Slf4j +@RequiredArgsConstructor +public class NormalRequestService implements RequestTypeService { + + private final RequestRepository requestRepository; + + private final UserRepository userRepository; + + private final HistoryLogService historyLogService; + + private final WorkingDaysCalculator workingDaysCalculator; + + private final ApplicationEventPublisher publisher; + + private final AcceptanceService acceptanceService; + + private final UserService userService; + + + @Override + public Request create(Long userId, BaseRequestInput requestInput) { + User user = userRepository + .findById(userId) + .orElseThrow(() -> { + log.error("Could not create new normal request: user with id: {} does not exist", userId); + return NoSuchUserException.invalidId(); + }); + int workingDays = workingDaysCalculator.calculate(requestInput.getStartDate(), requestInput.getEndDate()); + float workingHours = workingDays * user.getWorkTime(); + + this.ensureUserOwnRequiredHoursNumber(user, workingHours); + Request request = this.createRequestObject(user, requestInput, workingDays); + this.validateRequest(request); + + request = requestRepository.save(request); + publisher.publishEvent(new NormalRequestCreated(request)); + log.info("New normal request with id: %d has been created".formatted(request.getId())); + + var leader = userService.getAcceptanceLeaderForUser(user); + if (leader != null) { + this.acceptanceService.create(request, leader); + } else { + this.accept(request); + } + + return requestRepository + .findById(request.getId()) + .orElseThrow(NoSuchElementException::new); + } + + private void ensureUserOwnRequiredHoursNumber(User user, float requiredHours) { + double hoursRemaining = historyLogService.countRemainingHours(user.getId()); + double pendingRequestsHours = countPendingRequestsHours(user); + boolean userOwnRequiredHoursNumber = + (hoursRemaining - pendingRequestsHours) >= requiredHours; + if (!userOwnRequiredHoursNumber) { + var userId = user.getId(); + log.error("New normal request could not be created for user with id: {}. Reason: not enough days", userId); + throw new NotEnoughDaysException(); + } + } + + private double countPendingRequestsHours(User user) { + Long requesterId = user.getId(); + return requestRepository.findByRequesterId(requesterId).stream() + .filter(Request::isPending) + .filter(Request::isNormal) + .map(request -> request.getWorkingDays() * request.getRequester().getWorkTime()) + .flatMapToDouble(DoubleStream::of) + .sum(); + } + + public DayHourTime getPendingRequestsTime(Long userId) { + var user = getUserById(userId); + double pendingRequestsHours = countPendingRequestsHours(user); + float userWorkTime = user.getWorkTime(); + int days = (int) Math.floor(pendingRequestsHours / userWorkTime); + double hours = Math.round((pendingRequestsHours % userWorkTime) * 100.0) / 100.0; + return DayHourTime.of(days, hours); + } + + public PendingDaysOutput getPendingRequestsTimeV2(Long userId) { + var user = getUserById(userId); + double pendingRequestsHours = countPendingRequestsHours(user); + float userWorkTime = user.getWorkTime(); + int days = (int) Math.floor(pendingRequestsHours / userWorkTime); + double hours = Math.round((pendingRequestsHours % userWorkTime) * 100.0) / 100.0; + if (userWorkTime == 8) { + return new PendingDaysOutput(days, hours); + } + return new PendingDaysOutput(0, pendingRequestsHours); + } + + private User getUserById(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> { + log.error("Could not get pending requests time for a non-existent user with id: {}", userId); + return NoSuchUserException.invalidId(); + }); + } + + private Request createRequestObject(User user, BaseRequestInput requestInput, int workingDays) { + return new Request(user, + requestInput.getStartDate(), + requestInput.getEndDate(), + workingDays, + RequestType.NORMAL, + null, + Request.Status.PENDING); + } + + private void validateRequest(Request request) { + var requesterId = request.getRequester().getId(); + if (request.getEndDate().isBefore(request.getStartDate())) { + log.error("Could not create new normal request for user with id: {} because dates are in invalid order", + requesterId); + throw InvalidDatesOrderException.invalidDatesOrder(); + } + if (isOverlapping(request)) { + log.error("Could not create normal request for user with id: {} because it is overlapping other requests", + requesterId); + throw new RequestOverlappingException(); + } + } + + private boolean isOverlapping(Request newRequest) { + Long userId = newRequest.getRequester().getId(); + List requests = requestRepository.findByRequesterId(userId); + return requests.stream() + .filter(Request::isAffecting) + .anyMatch(request -> request.isOverlapping(newRequest)); + } + + @Override + public void accept(Request request) { + this.validateStatus(request.getStatus(), Request.Status.PENDING); + request = this.changeStatus(request, Request.Status.ACCEPTED); + publisher.publishEvent(new NormalRequestAccepted(request)); //TODO: Create general Event RequestAccepted instead of Normal/OccasionalRequestAccepted + var loggerInfo = "Request with id: %d has been accepted" + .formatted(request.getId()); + log.info(loggerInfo); + } + + @Override + public void reject(Request request) { + this.validateStatus(request.getStatus(), Request.Status.PENDING); + request = this.changeStatus(request, Request.Status.REJECTED); + publisher.publishEvent(new NormalRequestRejected(request)); + var loggerInfo = "Request with id: %d has been rejected" + .formatted(request.getId()); + log.info(loggerInfo); + } + + @Override + public void cancel(Request request) { + Request.Status[] supportedStatuses = {Request.Status.PENDING, Request.Status.ACCEPTED}; + this.validateStatus(request.getStatus(), supportedStatuses); + request = this.changeStatus(request, Request.Status.CANCELED); + publisher.publishEvent(new NormalRequestCanceled(request)); + var loggerInfo = "Request with id: %d has been canceled" + .formatted(request.getId()); + log.info(loggerInfo); + } + + private void validateStatus(Request.Status status, Request.Status... supportedStatuses) { + List supported = Arrays.asList(supportedStatuses); + if (!supported.contains(status)) { + log.error("Status: {} does not exist", status.toString()); + throw StatusNotSupportedException.invalidStatus(status.toString()); + } + } + + private Request changeStatus(Request request, Request.Status status) { + request.getAcceptances().forEach(acceptance -> acceptanceService.expire(acceptance.getId())); + request.setStatus(status); + return requestRepository.save(request); + } + +} diff --git a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryAllUsersLeaderProvider.java b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryAllUsersLeaderProvider.java deleted file mode 100644 index 8aa84609..00000000 --- a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryAllUsersLeaderProvider.java +++ /dev/null @@ -1,38 +0,0 @@ -package info.fingo.urlopia.team; - -import info.fingo.urlopia.config.ad.ActiveDirectory; -import info.fingo.urlopia.config.ad.ActiveDirectoryObjectClass; -import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; -import info.fingo.urlopia.config.ad.Attribute; -import info.fingo.urlopia.user.User; -import info.fingo.urlopia.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) -public class ActiveDirectoryAllUsersLeaderProvider implements AllUsersLeaderProvider{ - - @Value("${ad.groups.users}") - private String usersGroup; - private final ActiveDirectory activeDirectory; - private final UserRepository userRepository; - - - @Override - public User getAllUsersLeader() { - var groups = activeDirectory.newSearch() - .objectClass(ActiveDirectoryObjectClass.Group) - .distinguishedName(usersGroup) - .search(); - - return groups.stream() - .map(group -> ActiveDirectoryUtils.pickAttribute(group, Attribute.MANAGED_BY)) - .findFirst() - .flatMap(userRepository::findFirstByAdName) - .orElse(null); - } -} diff --git a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProvider.java b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProvider.java new file mode 100644 index 00000000..9c344ec5 --- /dev/null +++ b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProvider.java @@ -0,0 +1,47 @@ +package info.fingo.urlopia.team; + +import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; +import info.fingo.urlopia.config.ad.Attribute; +import info.fingo.urlopia.config.ad.tree.ActiveDirectoryTree; +import info.fingo.urlopia.user.User; +import info.fingo.urlopia.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import javax.naming.directory.SearchResult; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) +public class ActiveDirectoryTeamLeaderProvider { + + private final UserRepository userRepository; + + public Optional getTeamLeader(String adTeamDN, + ActiveDirectoryTree adTeamsTree) { + return adTeamsTree.search(adTeamDN) + .flatMap(adTeam -> { + var managedBy = getManagedBy(adTeam); + return managedBy + .map(this::getUser) + .orElseGet(() -> checkParentTeam(adTeamDN, adTeamsTree)); + }); + } + + private Optional getManagedBy(SearchResult adTeam) { + return Optional.ofNullable(ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.MANAGED_BY)) + .filter(managedBy -> !managedBy.isBlank()); + } + + private Optional checkParentTeam(String adTeamDN, + ActiveDirectoryTree adTeamsTree) { + var parentTeamDN = ActiveDirectoryUtils.getParentDN(adTeamDN); + return getTeamLeader(parentTeamDN, adTeamsTree); + } + + private Optional getUser(String userDN) { + return userRepository.findFirstByAdName(userDN); + } +} diff --git a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamMapper.java b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamMapper.java index 9f40261c..d9b2d8a3 100644 --- a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamMapper.java +++ b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamMapper.java @@ -2,55 +2,42 @@ import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; import info.fingo.urlopia.config.ad.Attribute; +import info.fingo.urlopia.config.ad.tree.ActiveDirectoryTree; import info.fingo.urlopia.user.User; -import info.fingo.urlopia.user.UserRepository; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import javax.naming.directory.SearchResult; -import java.util.List; @Component @ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) public class ActiveDirectoryTeamMapper { - @Value("${ad.identifiers.team}") - private List teamIdentifiers; + private final ActiveDirectoryTeamLeaderProvider activeDirectoryTeamLeaderProvider; - private final UserRepository userRepository; - - public ActiveDirectoryTeamMapper(UserRepository userRepository) { - this.userRepository = userRepository; + public ActiveDirectoryTeamMapper(ActiveDirectoryTeamLeaderProvider activeDirectoryTeamLeaderProvider) { + this.activeDirectoryTeamLeaderProvider = activeDirectoryTeamLeaderProvider; } - Team mapToTeam(SearchResult adTeam) { - return this.mapToTeam(adTeam, new Team()); + Team mapToTeam(SearchResult adTeam, + ActiveDirectoryTree adTeamsTree) { + return this.mapToTeam(adTeam, new Team(), adTeamsTree); } - Team mapToTeam(SearchResult adTeam, Team team) { + Team mapToTeam(SearchResult adTeam, + Team team, + ActiveDirectoryTree adTeamsTree) { team.setAdName(ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.DISTINGUISHED_NAME)); - team.setName(normalizeName(ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.NAME))); - team.setLeader(findUser(ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.MANAGED_BY))); + team.setName(ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.NAME)); + team.setLeader(findTeamLeader(adTeam, adTeamsTree)); return team; } - private User findUser(String adName) { - return userRepository - .findFirstByAdName(adName) + private User findTeamLeader(SearchResult adTeam, + ActiveDirectoryTree adTeamsTree) { + var adTeamDN = ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.DISTINGUISHED_NAME); + return activeDirectoryTeamLeaderProvider + .getTeamLeader(adTeamDN, adTeamsTree) .orElse(null); } - - private String normalizeName(String adName) { - return teamIdentifiers.stream() - .filter(adName::contains) - .findFirst() - .map(teamIdentifier -> normalizeName(adName, teamIdentifier)) - .orElse(""); - } - - private String normalizeName(String adName, String teamIdentifier) { - var end = adName.length() - teamIdentifier.length() - 1; // -1 for space between name and identifier - return adName.substring(0, end); - } } diff --git a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamSynchronizer.java b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamSynchronizer.java index c7a5291c..53f37f71 100644 --- a/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamSynchronizer.java +++ b/src/main/java/info/fingo/urlopia/team/ActiveDirectoryTeamSynchronizer.java @@ -1,9 +1,7 @@ package info.fingo.urlopia.team; -import info.fingo.urlopia.config.ad.ActiveDirectory; -import info.fingo.urlopia.config.ad.ActiveDirectoryObjectClass; -import info.fingo.urlopia.config.ad.ActiveDirectoryUtils; -import info.fingo.urlopia.config.ad.Attribute; +import info.fingo.urlopia.config.ad.*; +import info.fingo.urlopia.config.ad.tree.ActiveDirectoryTree; import info.fingo.urlopia.user.User; import info.fingo.urlopia.user.UserRepository; import org.slf4j.Logger; @@ -21,11 +19,8 @@ public class ActiveDirectoryTeamSynchronizer { private static final Logger LOGGER = LoggerFactory.getLogger(ActiveDirectoryTeamSynchronizer.class); - @Value("${ad.groups.users}") - private String usersGroup; - - @Value("${ad.identifiers.team}") - private List teamIdentifiers; + @Value("${ad.containers.main}") + private String mainContainer; private final TeamRepository teamRepository; private final UserRepository userRepository; @@ -44,22 +39,15 @@ public ActiveDirectoryTeamSynchronizer(TeamRepository teamRepository, public void addNewTeams() { var dbTeams = teamRepository.findAllAdNames(); - pickTeamsFromAD().stream() - .filter(adTeam -> - !isTeamAUsersGroup( - adTeam)) - .filter(adTeam -> - !dbTeams.contains( - adNameOf(adTeam))) - .map(teamMapper::mapToTeam) + var adTeams = pickTeamsFromAD(); + var adTeamsTree = buildTree(adTeams); + adTeams.stream() + .filter(adTeam -> !dbTeams.contains(adNameOf(adTeam))) + .map(adTeam -> teamMapper.mapToTeam(adTeam, adTeamsTree)) .forEach(teamRepository::save); LOGGER.info("Synchronisation succeed: find new teams"); } - private boolean isTeamAUsersGroup(SearchResult adTeam) { - return adNameOf(adTeam).equals(usersGroup); - } - private String adNameOf(SearchResult searchResult) { return ActiveDirectoryUtils.pickAttribute(searchResult, Attribute.DISTINGUISHED_NAME); } @@ -76,10 +64,12 @@ public void removeDeletedTeams() { } public void synchronize() { - pickTeamsFromAD().forEach(adTeam -> { + var adTeams = pickTeamsFromAD(); + var adTeamsTree = buildTree(adTeams); + adTeams.forEach(adTeam -> { var adName = adNameOf(adTeam); teamRepository.findFirstByAdName(adName).ifPresent(team -> { - var updatedTeam = teamMapper.mapToTeam(adTeam, team); + var updatedTeam = teamMapper.mapToTeam(adTeam, team, adTeamsTree); teamRepository.save(updatedTeam); }); }); @@ -87,11 +77,16 @@ public void synchronize() { } public void assignUsersToTeams() { - this.pickTeamsFromAD().forEach(adTeam -> { + var adTeamsAndUsers = pickTeamsAndUsersFromAD(); + var adTree = buildTree(adTeamsAndUsers); + var adTeams = adTeamsAndUsers.stream() + .filter(ActiveDirectoryUtils::isOU) + .toList(); + adTeams.forEach(adTeam -> { var adName = adNameOf(adTeam); teamRepository.findFirstByAdName(adName).ifPresent(team -> { - var membersAsString = ActiveDirectoryUtils.pickAttribute(adTeam, Attribute.MEMBER); - var members = splitMembers(membersAsString); + var membersDn = getTeamMembersDn(adTree, adName); + var members = getMembers(membersDn); team.setUsers(members); teamRepository.save(team); }); @@ -99,9 +94,31 @@ public void assignUsersToTeams() { LOGGER.info("Synchronisation succeed: assign users to teams"); } - private Set splitMembers(String members) { - var groups = ActiveDirectoryUtils.split(members); - return Arrays.stream(groups) + private ActiveDirectoryTree buildTree(List adTeamsAndUsers) { + // Sort objects to make sure that all of them are placed inside tree, because tree impl allows replacements + var sortedObjects = adTeamsAndUsers.stream() + .sorted(Comparator.comparingLong(o -> { + var distinguishedName = ActiveDirectoryUtils.pickAttribute(o, Attribute.DISTINGUISHED_NAME); + return distinguishedName.length(); + })) + .toList(); + var adTree = new ActiveDirectoryTree(mainContainer); + for (var obj : sortedObjects) { + adTree.put(obj); + } + return adTree; + } + + private List getTeamMembersDn(ActiveDirectoryTree tree, + String teamDn) { + return tree.searchDirectChildrenObjectsOf(teamDn).stream() + .filter(ActiveDirectoryUtils::isPerson) + .map(teamMember -> ActiveDirectoryUtils.pickAttribute(teamMember, Attribute.DISTINGUISHED_NAME)) + .toList(); + } + + private Set getMembers(List membersDn) { + return membersDn.stream() .map(userRepository::findFirstByAdName) .filter(Optional::isPresent) .map(Optional::get) @@ -109,16 +126,19 @@ private Set splitMembers(String members) { } private List pickTeamsFromAD() { - return teamIdentifiers.stream() - .map(this::pickTeamsFromAD) - .flatMap(Collection::stream) - .toList(); + return activeDirectory.newSearch() + .excludeDistinguishedName(mainContainer) + .objectClass(ActiveDirectoryObjectClass.ORGANIZATIONAL_UNIT) + .search(); } - private List pickTeamsFromAD(String teamIdentifier) { + private List pickTeamsAndUsersFromAD() { return activeDirectory.newSearch() - .objectClass(ActiveDirectoryObjectClass.Group) - .name(String.format("*%s", teamIdentifier)) + .excludeDistinguishedName(mainContainer) + .objectClasses(List.of( + ActiveDirectoryObjectClass.ORGANIZATIONAL_UNIT, + ActiveDirectoryObjectClass.PERSON + )) .search(); } } diff --git a/src/main/java/info/fingo/urlopia/team/AllUsersLeaderProvider.java b/src/main/java/info/fingo/urlopia/team/AllUsersLeaderProvider.java deleted file mode 100644 index 06501997..00000000 --- a/src/main/java/info/fingo/urlopia/team/AllUsersLeaderProvider.java +++ /dev/null @@ -1,9 +0,0 @@ -package info.fingo.urlopia.team; - -import info.fingo.urlopia.user.User; - -public interface AllUsersLeaderProvider { - - User getAllUsersLeader(); - -} diff --git a/src/main/java/info/fingo/urlopia/team/NoAuthAllUsersLeaderProvider.java b/src/main/java/info/fingo/urlopia/team/NoAuthAllUsersLeaderProvider.java deleted file mode 100644 index 5347a914..00000000 --- a/src/main/java/info/fingo/urlopia/team/NoAuthAllUsersLeaderProvider.java +++ /dev/null @@ -1,14 +0,0 @@ -package info.fingo.urlopia.team; - -import info.fingo.urlopia.user.User; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; - -@Component -@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "false") -public class NoAuthAllUsersLeaderProvider implements AllUsersLeaderProvider{ - @Override - public User getAllUsersLeader() { - return null; //in this mode we don't need this - } -} diff --git a/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserLeaderProvider.java b/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserLeaderProvider.java new file mode 100644 index 00000000..35acfc08 --- /dev/null +++ b/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserLeaderProvider.java @@ -0,0 +1,97 @@ +package info.fingo.urlopia.user; + +import info.fingo.urlopia.config.ad.ActiveDirectory; +import info.fingo.urlopia.config.ad.Attribute; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import javax.naming.NameClassPair; +import javax.naming.NamingException; +import javax.naming.directory.SearchControls; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "ad.configuration.enabled", havingValue = "true", matchIfMissing = true) +public class ActiveDirectoryUserLeaderProvider { + private static final Logger LOGGER = LoggerFactory.getLogger(ActiveDirectoryUserLeaderProvider.class); + + private final ActiveDirectory activeDirectory; + private final UserRepository userRepository; + + public User getUserLeader(User user) { + try { + return getUserLeaderUnsafe(user); + } catch (NamingException e) { + LOGGER.error("NamingException when trying to get a leader for user", e); + return null; + } + } + + private User getUserLeaderUnsafe(User user) throws NamingException { + // Step 1: Get distinguished names of OUs user belongs to + var userSearch = activeDirectory.newSearch().principalName(user.getPrincipalName()).search(); + var userDN = userSearch.stream().findFirst().map(NameClassPair::getNameInNamespace).orElse(""); + var organizationalUnits = extractOrganizationalUnitsDNs(userDN); + + // Step 2: Find first existing and valid OU manager. + for (var ouDn : organizationalUnits) { + var controls = new SearchControls(); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + var ouSearch = activeDirectory.newSearch().distinguishedName(ouDn).search(controls); + for (var result : ouSearch) { + var attributes = result.getAttributes(); + var managedBy = attributes.get(Attribute.MANAGED_BY.getKey()); + var managedByDN = managedBy != null ? (String) managedBy.get() : ""; + if (!managedByDN.isBlank() && !managedByDN.equals(userDN)) { + var manager = getManagerDetails(managedByDN); + if (manager.isPresent()) { + return manager.get(); + } + } + } + } + + return null; + } + + /** + * When OUs are nested, they appear next to each other in the DN (i.e. OU=Child,OU=Parent) + * This helper method extracts all possible organizational unit DNs in order from the most + * specific (the one user is in directly) to the most generic one. + */ + private List extractOrganizationalUnitsDNs(String distinguishedName) { + var result = new ArrayList(); + + var components = distinguishedName.split(","); + for (var i = 0; i < components.length; i++) { + var component = components[i].trim(); + if (component.startsWith("OU=")) { + var dn = Arrays.stream(components).skip(i).collect(Collectors.joining(",")); + result.add(dn); + } + } + + return result; + } + + private Optional getManagerDetails(String managerDN) throws NamingException { + var search = activeDirectory.newSearch().distinguishedName(managerDN).search(); + for (var result : search) { + var attributes = result.getAttributes(); + var principalNameAttribute = attributes.get(Attribute.PRINCIPAL_NAME.getKey()); + var principalName = principalNameAttribute != null ? (String) principalNameAttribute.get() : ""; + if (!principalName.isBlank()) { + return this.userRepository.findFirstByPrincipalName(principalName); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserSynchronizer.java b/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserSynchronizer.java index 6cf46d69..e7199e0a 100644 --- a/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserSynchronizer.java +++ b/src/main/java/info/fingo/urlopia/user/ActiveDirectoryUserSynchronizer.java @@ -11,7 +11,6 @@ import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -27,9 +26,6 @@ public class ActiveDirectoryUserSynchronizer { private static final Logger LOGGER = LoggerFactory.getLogger(ActiveDirectoryUserSynchronizer.class); - @Value("${ad.groups.users}") - private String usersGroup; - private final UserRepository userRepository; private final HistoryLogService historyLogService; private final ActiveDirectory activeDirectory; @@ -46,9 +42,13 @@ public void addNewUsers() { LOGGER.info("Synchronisation succeed: find new users"); } - private void saveNewUser(User user){ - userRepository.save(user); - automaticVacationDayService.addForNewUser(user); + private void saveNewUser(User user) { + try { + userRepository.save(user); + automaticVacationDayService.addForNewUser(user); + } catch (Exception exception) { + LOGGER.error("Exception when saving a new user", exception); + } } public void deactivateDeletedUsers() { @@ -101,15 +101,13 @@ private void synchronize(Stream adUsers) { private List pickUsersFromActiveDirectory() { return activeDirectory.newSearch() - .objectClass(ActiveDirectoryObjectClass.Person) - .memberOf(usersGroup) + .objectClass(ActiveDirectoryObjectClass.PERSON) .search(); } - private List pickDisabledUsersFromActiveDirectory(){ + private List pickDisabledUsersFromActiveDirectory() { return activeDirectory.newSearch() - .objectClass(ActiveDirectoryObjectClass.Person) - .memberOf(usersGroup) + .objectClass(ActiveDirectoryObjectClass.PERSON) .isDisabled() .search(); } diff --git a/src/main/java/info/fingo/urlopia/user/UserService.java b/src/main/java/info/fingo/urlopia/user/UserService.java index 5c8a0ad0..2569440a 100644 --- a/src/main/java/info/fingo/urlopia/user/UserService.java +++ b/src/main/java/info/fingo/urlopia/user/UserService.java @@ -8,7 +8,6 @@ import info.fingo.urlopia.config.persistance.filter.Filter; import info.fingo.urlopia.history.HistoryLogService; import info.fingo.urlopia.history.UserDetailsChangeEvent; -import info.fingo.urlopia.team.AllUsersLeaderProvider; import info.fingo.urlopia.team.Team; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +20,7 @@ import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -37,9 +37,7 @@ public class UserService { private final UserRepository userRepository; private final HistoryLogService historyLogService; private final AutomaticVacationDayService automaticVacationDayService; - private final AllUsersLeaderProvider allUsersLeaderProvider; - - + private final ActiveDirectoryUserLeaderProvider userLeaderProvider; public List get(Filter filter, Sort sort) { @@ -81,8 +79,8 @@ public User getFirstByAccountName(String accountName) { }); } - public User getAllUsersLeader() { - return allUsersLeaderProvider.getAllUsersLeader(); + public User getAcceptanceLeaderForUser(User user) { + return userLeaderProvider.getUserLeader(user); } // *** ACTIONS *** @@ -168,9 +166,17 @@ public boolean isCurrentUserAdmin() { return authorities.contains(ADMIN_AUTHORITY); } + public User getTeamLeader(User user, Team team) { + var teamLeader = team.getLeader(); + return user.equals(teamLeader) + ? userLeaderProvider.getUserLeader(user) + : team.getLeader(); + } + private Set getLeaders(User user) { return user.getTeams().stream() - .map(Team::getLeader) + .map(team -> getTeamLeader(user, team)) + .filter(Objects::nonNull) .collect(Collectors.toUnmodifiableSet()); } } \ No newline at end of file diff --git a/src/main/resources/scripts/U3_5_0_1__allow_for_longer_ad_names.sql b/src/main/resources/scripts/U3_5_0_1__allow_for_longer_ad_names.sql new file mode 100644 index 00000000..46faf6fa --- /dev/null +++ b/src/main/resources/scripts/U3_5_0_1__allow_for_longer_ad_names.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ALTER COLUMN ad_name TYPE VARCHAR(100); +ALTER TABLE teams ALTER COLUMN ad_name TYPE VARCHAR(100); diff --git a/src/main/resources/scripts/V3_5_0_1__allow_for_longer_ad_names.sql b/src/main/resources/scripts/V3_5_0_1__allow_for_longer_ad_names.sql new file mode 100644 index 00000000..d8a5ffb8 --- /dev/null +++ b/src/main/resources/scripts/V3_5_0_1__allow_for_longer_ad_names.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ALTER COLUMN ad_name TYPE VARCHAR(255); +ALTER TABLE teams ALTER COLUMN ad_name TYPE VARCHAR(255); diff --git a/src/test/groovy/info/fingo/urlopia/api/v2/request/normal/NormalRequestServiceSpec.groovy b/src/test/groovy/info/fingo/urlopia/api/v2/request/normal/NormalRequestServiceSpec.groovy index 504e04da..d1177ac2 100644 --- a/src/test/groovy/info/fingo/urlopia/api/v2/request/normal/NormalRequestServiceSpec.groovy +++ b/src/test/groovy/info/fingo/urlopia/api/v2/request/normal/NormalRequestServiceSpec.groovy @@ -176,6 +176,8 @@ class NormalRequestServiceSpec extends Specification{ and: "request mock with repo with endDate before startDate" def request = Mock(Request){ getRequester() >> user + getStatus() >> Request.Status.PENDING + getAcceptances() >> Collections.emptySet() getId() >> 2 } requestRepository.findByRequesterId(userId) >> [request] diff --git a/src/test/groovy/info/fingo/urlopia/config/ad/ActiveDirectoryUtilsTest.groovy b/src/test/groovy/info/fingo/urlopia/config/ad/ActiveDirectoryUtilsTest.groovy new file mode 100644 index 00000000..6f62ba08 --- /dev/null +++ b/src/test/groovy/info/fingo/urlopia/config/ad/ActiveDirectoryUtilsTest.groovy @@ -0,0 +1,42 @@ +package info.fingo.urlopia.config.ad + + +import spock.lang.Specification + +class ActiveDirectoryUtilsTest extends Specification { + + def "getParentDN WHEN parent exists SHOULD return it"() { + when: + def result = ActiveDirectoryUtils.getParentDN(actualDN) + + then: + result == parentDN + + where: + actualDN | parentDN + "OU=SomeNestedTeam,OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" | "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" + "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" | "OU=Teams,DC=fingo,DC=pl" + "OU=Teams" | "" + "" | "" + } + + def "getRelativeDN WHEN base matches, ends with or differs SHOULD return correct relative DN"() { + when: + def result = ActiveDirectoryUtils.getRelativeDN(distinguishedName, base) + + then: + result == expectedRelativeDN + + where: + distinguishedName | base | expectedRelativeDN + "OU=SomeNestedTeam,OU=SomeTeam,OU=Teams,DC=fingo,DC=pl"| "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl"| "OU=SomeNestedTeam" + "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" | "OU=Teams,DC=fingo,DC=pl" | "OU=SomeTeam" + "OU=Teams,DC=fingo,DC=pl" | "OU=Teams,DC=fingo,DC=pl" | "" + "OU=RandomTeam,OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" | "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl"| "OU=RandomTeam" + "OU=Teams,DC=fingo,DC=pl" | "OU=DifferentBase,DC=fingo,DC=pl" | "OU=Teams,DC=fingo,DC=pl" + "" | "" | "" + "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" | "" | "OU=SomeTeam,OU=Teams,DC=fingo,DC=pl" + } + + +} diff --git a/src/test/groovy/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProviderSpec.groovy b/src/test/groovy/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProviderSpec.groovy new file mode 100644 index 00000000..d799d6d7 --- /dev/null +++ b/src/test/groovy/info/fingo/urlopia/team/ActiveDirectoryTeamLeaderProviderSpec.groovy @@ -0,0 +1,155 @@ +package info.fingo.urlopia.team + +import info.fingo.urlopia.config.ad.tree.ActiveDirectoryTree +import info.fingo.urlopia.user.User +import info.fingo.urlopia.user.UserRepository +import spock.lang.Specification + +import javax.naming.directory.Attribute +import javax.naming.directory.Attributes +import javax.naming.directory.SearchResult + +class ActiveDirectoryTeamLeaderProviderSpec extends Specification { + + def userRepository = Mock(UserRepository) + def activeDirectoryTeamLeaderProvider = new ActiveDirectoryTeamLeaderProvider(userRepository) + + def "getTeamLeader WHEN ad team has direct leader SHOULD return it"() { + given: + def base = "OU=Teams,DC=fingo,DC=info" + + and: + def parentTeam = Mock(SearchResult) + def parentTeamAttributes = Mock(Attributes) + + def parentTeamDN = ": OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def parentTeamDNObj = Mock(Attribute) + parentTeamDNObj.toString() >> parentTeamDN + + parentTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> parentTeamDNObj + parentTeam.getAttributes() >> parentTeamAttributes + + and: + def childTeam = Mock(SearchResult) + def childTeamAttributes = Mock(Attributes) + + def childTeamDN = ": OU=ChildTeam,OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def childTeamDNObj = Mock(Attribute) + childTeamDNObj.toString() >> childTeamDN + + def managedBy = ": CN=SomeUser,OU=ChildTeam,OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def managedByObj = Mock(Attribute) + managedByObj.toString() >> managedBy + + childTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> childTeamDNObj + childTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.MANAGED_BY.getKey()) >> managedByObj + childTeam.getAttributes() >> childTeamAttributes + + and: + def adTeamsTree = new ActiveDirectoryTree(base) + adTeamsTree.put(parentTeam) + adTeamsTree.put(childTeam) + + and: + def user = Mock(User) + userRepository.findFirstByAdName(managedBy.substring(managedBy.indexOf(':') + 2)) >> Optional.of(user) + + when: + var teamDN = childTeamDN.substring(childTeamDN.indexOf(':') + 2) + def result = activeDirectoryTeamLeaderProvider.getTeamLeader(teamDN, adTeamsTree) + + then: + result.get() == user + } + + def "getTeamLeader WHEN ad team has no direct leader SHOULD return leader from upper team"() { + given: + def base = "OU=Teams,DC=fingo,DC=info" + + and: + def parentTeam = Mock(SearchResult) + def parentTeamAttributes = Mock(Attributes) + + def parentTeamDN = ": OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def parentTeamDNObj = Mock(Attribute) + parentTeamDNObj.toString() >> parentTeamDN + + def managedBy = ": CN=SomeUser,OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def managedByObj = Mock(Attribute) + managedByObj.toString() >> managedBy + + parentTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> parentTeamDNObj + parentTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.MANAGED_BY.getKey()) >> managedByObj + parentTeam.getAttributes() >> parentTeamAttributes + + and: + def childTeam = Mock(SearchResult) + def childTeamAttributes = Mock(Attributes) + + def childTeamDN = ": OU=ChildTeam,OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def childTeamDNObj = Mock(Attribute) + childTeamDNObj.toString() >> childTeamDN + + childTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> childTeamDNObj + childTeam.getAttributes() >> childTeamAttributes + + and: + def adTeamsTree = new ActiveDirectoryTree(base) + adTeamsTree.put(parentTeam) + adTeamsTree.put(childTeam) + + and: + def user = Mock(User) + userRepository.findFirstByAdName(managedBy.substring(managedBy.indexOf(':') + 2)) >> Optional.of(user) + + when: + var teamDN = childTeamDN.substring(childTeamDN.indexOf(':') + 2) + def result = activeDirectoryTeamLeaderProvider.getTeamLeader(teamDN, adTeamsTree) + + then: + result.get() == user + } + + def "getTeamLeader WHEN there is no leader in structure SHOULD return empty"() { + given: + def base = "OU=Teams,DC=fingo,DC=info" + + and: + def parentTeam = Mock(SearchResult) + def parentTeamAttributes = Mock(Attributes) + + def parentTeamDN = ": OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def parentTeamDNObj = Mock(Attribute) + parentTeamDNObj.toString() >> parentTeamDN + + parentTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> parentTeamDNObj + parentTeam.getAttributes() >> parentTeamAttributes + + and: + def childTeam = Mock(SearchResult) + def childTeamAttributes = Mock(Attributes) + + def childTeamDN = ": OU=ChildTeam,OU=ParentTeam,OU=Teams,DC=fingo,DC=info" + def childTeamDNObj = Mock(Attribute) + childTeamDNObj.toString() >> childTeamDN + + childTeamAttributes.get(info.fingo.urlopia.config.ad.Attribute.DISTINGUISHED_NAME.getKey()) >> childTeamDNObj + childTeam.getAttributes() >> childTeamAttributes + + and: + def adTeamsTree = new ActiveDirectoryTree(base) + adTeamsTree.put(parentTeam) + adTeamsTree.put(childTeam) + + and: + def user = Mock(User) + userRepository.findFirstByAdName(_ as String) >> Optional.of(user) + + when: + var teamDN = childTeamDN.substring(childTeamDN.indexOf(':') + 2) + def result = activeDirectoryTeamLeaderProvider.getTeamLeader(teamDN, adTeamsTree) + + then: + result.isEmpty() + } +} diff --git a/src/test/groovy/info/fingo/urlopia/user/UserServiceSpec.groovy b/src/test/groovy/info/fingo/urlopia/user/UserServiceSpec.groovy index e04f1dc3..2c0bc79a 100644 --- a/src/test/groovy/info/fingo/urlopia/user/UserServiceSpec.groovy +++ b/src/test/groovy/info/fingo/urlopia/user/UserServiceSpec.groovy @@ -5,7 +5,7 @@ import info.fingo.urlopia.api.v2.exceptions.UnauthorizedException import info.fingo.urlopia.api.v2.history.DetailsChangeEventInput import info.fingo.urlopia.config.persistance.filter.Filter import info.fingo.urlopia.history.HistoryLogService -import info.fingo.urlopia.team.AllUsersLeaderProvider +import info.fingo.urlopia.team.Team import org.springframework.security.core.Authentication import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.context.SecurityContext @@ -16,9 +16,9 @@ class UserServiceSpec extends Specification { def userRepository = Mock(UserRepository) def historyLogService = Mock(HistoryLogService) def automaticVacationDayService = Mock(AutomaticVacationDayService) - def allUsersLeaderProvider = Mock(AllUsersLeaderProvider) + def userLeaderProvider = Mock(ActiveDirectoryUserLeaderProvider) - def userService = new UserService(userRepository, historyLogService, automaticVacationDayService, allUsersLeaderProvider) + def userService = new UserService(userRepository, historyLogService, automaticVacationDayService, userLeaderProvider) def filter = Mock(Filter) def "get() SHOULD return list of users"() { @@ -141,4 +141,37 @@ class UserServiceSpec extends Specification { then: thrown(NoSuchUserException) } + + def "getTeamLeader() WHEN user is not a team leader SHOULD return a team leader"() { + given: + def userOne = Mock(User) + def userTwo = Mock(User) + def team = Mock(Team) + + and: + team.getLeader() >> userTwo + + when: + def teamLeader = userService.getTeamLeader(userOne, team) + + then: + teamLeader == userTwo + } + + def "getTeamLeader() WHEN user is a team leader SHOULD return ad leader"() { + given: + def user = Mock(User) + def adUser = Mock(User) + def team = Mock(Team) + + and: + team.getLeader() >> user + userLeaderProvider.getUserLeader(user) >> adUser + + when: + def teamLeader = userService.getTeamLeader(user, team) + + then: + teamLeader == adUser + } }