Skip to content

Commit

Permalink
Bug/optimize fix user select (#1758)
Browse files Browse the repository at this point in the history
* engine-rest init user resolve

* engine-rest init user resolve

* engine-rest rename resolveGroups to resolveUserGroups

* engine-rest use val instead of var

* engine-rest init engine optimize authorizations

* engine-rest refactor ldap cache namings

* engine-rest refactor ldap cache namings

* engine-rest init ldap resolve groups and users

* engine-rest implement user filter with engine and ldap

* engine-rest implement user filter with engine and ldap

* engine-rest add caching for new implementations

* engine-rest fix typo

* engine-rest init EngineRestUserFilterTest

* engine-rest update .run config

* engine-rest fix/refactor cache config

* engine-rest update logging

* engine-rest update logging

* engine-rest update logging

* stack add kibana

* stack add kibana

* engine-rest init ldap test

* engine-rest test update ldap data

* engine-rest update LdapAdapterTest

* engine-rest update LdapAdapterTest testResolveUserGroups

* engine-rest update LdapAdapterTest testGetGroupsMembers

* engine-rest cleanup LdapTestConfiguration

* engine-rest cleanup LdapTestConfiguration

* engine-rest update java doc

* engine-rest cleanup code

Co-authored-by: markostreich <[email protected]>

* engine-rest update README.md

---------

Co-authored-by: markostreich <[email protected]>
  • Loading branch information
simonhir and markostreich authored Jun 12, 2024
1 parent c45e3a5 commit bbe03db
Show file tree
Hide file tree
Showing 27 changed files with 708 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .run/EngineRestServiceApplication.run.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="EngineRestServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" value="local, streaming, no-ldap,no-mail" />
<option name="ACTIVE_PROFILES" value="local, groups-mock, internal" />
<module name="digiwf-engine-rest-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="de.muenchen.oss.digiwf.EngineRestServiceApplication" />
<extension name="net.ashald.envfile">
Expand Down
10 changes: 10 additions & 0 deletions digiwf-engine/digiwf-engine-rest-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<scope>test</scope>
</dependency>
</dependencies>


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import de.muenchen.oss.digiwf.adapter.in.rest.EngineRestGroupFilter;
import de.muenchen.oss.digiwf.adapter.in.rest.EngineRestUserFilter;
import de.muenchen.oss.digiwf.application.port.in.ResolveUserGroupsInPort;
import de.muenchen.oss.digiwf.application.port.in.ResolveUserInPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
Expand Down Expand Up @@ -41,4 +43,20 @@ public FilterRegistrationBean<EngineRestGroupFilter> engineRestGroupFilter(
filterRegistrationBean.addUrlPatterns("/engine-rest/engine/default/group");
return filterRegistrationBean;
}

/**
* Register filter for camunda user profile request.
*/
@Bean
@Profile({"groups-ldap", "groups-mock"})
public FilterRegistrationBean<EngineRestUserFilter> engineRestUserFilter(
final ObjectMapper objectMapper,
final ResolveUserInPort resolveUserInPort
) {
FilterRegistrationBean<EngineRestUserFilter> filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new EngineRestUserFilter(objectMapper, resolveUserInPort));
filterRegistrationBean.setOrder(102);
filterRegistrationBean.addUrlPatterns("/engine-rest/engine/default/user/*");
return filterRegistrationBean;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import jakarta.ws.rs.core.MultivaluedHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;

import java.io.IOException;
import java.util.HashMap;
Expand All @@ -30,18 +31,18 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
log.debug("EngineRestGroupFilter called");

if (servletRequest instanceof HttpServletRequest && servletResponse instanceof HttpServletResponse response) {
var params = new HashMap<String, String>();
val params = new HashMap<String, String>();
servletRequest.getParameterMap().forEach((key, values) -> params.put(key, values[0]));
var queryDto = new OptimizeGroupQueryDto(objectMapper, new MultivaluedHashMap<>(params));
var username = queryDto.getMember();
log.debug("Asking membership for user: {}", username);
val queryDto = new OptimizeGroupQueryDto(objectMapper, new MultivaluedHashMap<>(params));
val username = queryDto.getMember();
log.trace("Asking membership for user: {}", username);

var payload = resolveUserGroupsInPort
.resolveGroups(username)
.resolveUserGroups(username)
.stream()
.map(OptimizeGroupDto::fromGroup)
.collect(Collectors.toList());
log.info("Resolved user {} to groups: {}", username, payload);
log.debug("Resolved user {} to groups: {}", username, payload);

response.setStatus(200);
response.setContentType("application/json");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package de.muenchen.oss.digiwf.adapter.in.rest;

import com.fasterxml.jackson.databind.ObjectMapper;
import de.muenchen.oss.digiwf.application.port.in.ResolveUserInPort;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;

import java.io.IOException;
import java.util.regex.Pattern;

@Slf4j
@RequiredArgsConstructor
public class EngineRestUserFilter implements Filter {

public static final String ROUTE_PATTERN = "/engine/default/user/([\\w.]+)/profile";
private final ObjectMapper objectMapper;
private final Pattern pattern = Pattern.compile(ROUTE_PATTERN);
private final ResolveUserInPort resolveUserInPort;

@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.debug("EngineRestUserFilter called");

if (servletRequest instanceof HttpServletRequest request && servletResponse instanceof HttpServletResponse response) {
val path = request.getPathInfo();
val matcher = pattern.matcher(path);
if (!matcher.matches()) {
log.warn("Request to user endpoint not matching profile path: {}", path);
filterChain.doFilter(servletRequest, servletResponse);
return;
}
val username = matcher.group(1);
log.trace("Asking profile for user {}", username);

val payload = resolveUserInPort.resolveUser(username);
log.debug("Resolved user {} to: {}", username, payload);
if (payload == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
} else {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json");
objectMapper.writeValue(response.getWriter(), payload);
}
} else {
log.debug("Skipped filter");
filterChain.doFilter(servletRequest, servletResponse);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package de.muenchen.oss.digiwf.adapter.out.engine;

import de.muenchen.oss.digiwf.application.port.out.EngineAuthorizationsOutPort;
import de.muenchen.oss.digiwf.domain.Group;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.camunda.bpm.engine.AuthorizationService;
import org.camunda.bpm.engine.authorization.Permissions;
import org.camunda.bpm.engine.authorization.Resources;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import java.util.List;

@Component
@RequiredArgsConstructor
@Slf4j
@Validated
public class EngineAdapter implements EngineAuthorizationsOutPort {
private final AuthorizationService authorizationService;

@NonNull
@Override
@Cacheable(EngineCacheConfiguration.OPTIMIZE_AUTH_CACHE)
public List<Group> getOptimizeAuthorizedGroups() {
log.info("Loading optimize authorized groups");
return authorizationService.createAuthorizationQuery()
.resourceType(Resources.APPLICATION)
.hasPermission(Permissions.ACCESS)
.list().stream().filter(
i -> i.getGroupId() != null &&
(i.getResourceId().equals("optimize") || i.getResourceId().equals("*"))
)
.map(i -> new Group(i.getGroupId()))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package de.muenchen.oss.digiwf.adapter.out.engine;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import org.springframework.cache.Cache;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
@EnableCaching
public class EngineCacheConfiguration {
static final String OPTIMIZE_AUTH_CACHE = "optimizeAuthCache";
private static final int ENGINE_CACHE_ENTRY_SECONDS_TO_EXPIRE = 60 * 15;

@Bean
public Cache optimizeAuthCache(final Ticker ticker) {
return new CaffeineCache(OPTIMIZE_AUTH_CACHE,
Caffeine.newBuilder()
.expireAfterWrite(ENGINE_CACHE_ENTRY_SECONDS_TO_EXPIRE, TimeUnit.SECONDS)
.ticker(ticker)
.build()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package de.muenchen.oss.digiwf.adapter.out.ldap;

import de.muenchen.oss.digiwf.application.port.out.GroupsOutPort;
import de.muenchen.oss.digiwf.application.port.out.ResolveUserGroupsOutPort;
import de.muenchen.oss.digiwf.application.port.out.ResolveUserOutPort;
import de.muenchen.oss.digiwf.domain.Group;
import de.muenchen.oss.digiwf.domain.User;
import jakarta.validation.constraints.NotEmpty;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.cache.annotation.Cacheable;
Expand All @@ -10,9 +15,13 @@
import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.ContextSource;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.OrFilter;
import org.springframework.ldap.query.LdapQuery;
import org.springframework.ldap.query.LdapQueryBuilder;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;

import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
Expand All @@ -23,8 +32,8 @@
@Component
@Profile("groups-ldap")
@Slf4j
public class LdapAdapter extends LdapTemplate implements ResolveUserGroupsOutPort {
static final String GROUP_CACHE = "userGroups";
@Validated
public class LdapAdapter extends LdapTemplate implements ResolveUserGroupsOutPort, ResolveUserOutPort, GroupsOutPort {
private final LdapProperties properties;

public LdapAdapter(final ContextSource contextSource, final LdapProperties properties) {
Expand All @@ -34,9 +43,9 @@ public LdapAdapter(final ContextSource contextSource, final LdapProperties prope

@Override
@NonNull
@Cacheable(GROUP_CACHE)
public List<Group> resolveGroups(@NonNull final String username) {
log.debug("Resolving groups for user via ldap: {}", username);
@Cacheable(LdapCacheConfiguration.USER_GROUPS_CACHE)
public List<Group> resolveUserGroups(@NonNull final String username) {
log.debug("Resolving groups for user: {}", username);
String userDn = resolveUserDn(username);
// build query
LdapQuery query = LdapQueryBuilder.query().base(properties.getGroupBase())
Expand Down Expand Up @@ -75,7 +84,7 @@ public List<Group> resolveGroups(@NonNull final String username) {
// map to group
.map(str -> Group.builder().name(str).build())
.toList();
log.debug("Resolved groups for user {}: {}", username, groups);
log.info("Resolved groups for user {}: {}", username, groups);
return groups;
}

Expand All @@ -93,15 +102,113 @@ private String resolveUserDn(@NonNull final String username) {
.and("cn").is(username);
List<String> result = super.search(query, (AttributesMapper<String>) attrs -> attrs.get("distinguishedName").get().toString());
if (result.isEmpty()) {
log.error("Username {} not found", username);
throw new IllegalStateException(String.format("Username '%s' not found via ldap adapter", username));
}
if (result.size() > 1) {
log.error("Username {} found more than once", username);
throw new IllegalStateException(String.format("Multiple users found for username '%s'", username));
}
val userDn = result.get(0);
log.debug("Resolved user {} to dn {}", username, userDn);
log.info("Resolved user {} to dn {}", username, userDn);
return userDn;
}

@Override
@Cacheable(LdapCacheConfiguration.USER_CACHE)
public User resolveUser(@NonNull final String username) {
log.trace("Resolving user: {}", username);
LdapQuery query = LdapQueryBuilder.query()
.base(properties.getUserBase())
.where("objectclass").is("user")
.and("cn").is(username);
List<User> result = super.search(query, (AttributesMapper<User>) attrs ->
User.builder().id(username)
.firstName(attrs.get("givenName").get().toString())
.lastName(attrs.get("sn").get().toString())
.email(attrs.get("mail").get().toString())
.build()
);
if (result.isEmpty()) {
log.info("Username {} not found", username);
return null;
}
if (result.size() > 1) {
throw new IllegalStateException(String.format("Multiple users found for username '%s'", username));
}
val user = result.get(0);
log.info("Resolved user {} to {}", username, user);
return user;
}

@NonNull
@Override
@Cacheable(LdapCacheConfiguration.GROUPS_MEMBERS)
public List<String> getGroupsMembers(@NonNull @NotEmpty final List<Group> groups) {
val resolvedGroups = resolveGroups(groups);
val resolvedGroupsCn = resolvedGroups.stream()
.flatMap(i -> i.subGroups().stream())
.map(this::dnToCn)
.map(Group::new).toList();
log.info("Resolved {} engine groups to {} ldap groups", groups.size(), resolvedGroups.size());
// resolve one level of recursion
resolvedGroups.addAll(resolveGroups(resolvedGroupsCn));
// map groups with users to unique users
return resolvedGroups.stream()
.flatMap(i -> i.users().stream())
.map(this::dnToCn)
.distinct().sorted().toList();
}

/**
* Resolves a list of groups to subgroups and member users.
*
* @param groups List of groups to resolve.
* @return List of LdapUsers with corresponding subgroups and member users.
*/
private List<LdapGroup> resolveGroups(@NonNull @NotEmpty final List<Group> groups) {
log.trace("Resolving groups {}", groups);
// build ldap search filter and query
val groupNameFilter = new OrFilter();
for (Group group : groups) {
groupNameFilter.or(new EqualsFilter("cn", group.name()));
}
val filter = new AndFilter();
filter.and(groupNameFilter);
filter.and(new EqualsFilter("objectclass", "group"));
LdapQuery query = LdapQueryBuilder.query()
.base(properties.getGroupBase())
.filter(filter);
// search and resolve groups
return super.search(query, (AttributesMapper<LdapGroup>) attrs -> {
val users = new ArrayList<String>(List.of());
val childGroups = new ArrayList<String>(List.of());
if (attrs.get("member") != null) {
attrs.get("member").getAll().asIterator().forEachRemaining(i -> {
if (((String) i).endsWith(properties.getUserBase()))
users.add(i.toString());
else if (((String) i).endsWith(properties.getGroupBase())) {
childGroups.add(i.toString());
}
});
}
return new LdapGroup(attrs.get("cn").get().toString(), users, childGroups);
});
}

/**
* Converts a ldap dn to a cn.
*
* @param dn The dn to convert.
* @return The extracted cn.
*/
@SneakyThrows
private String dnToCn(@NonNull final String dn) {
val parsedDn = new LdapName(dn);
return parsedDn.getRdns().stream()
.filter(i -> i.getType().equalsIgnoreCase("CN"))
.map(i -> i.getValue().toString())
.findFirst().orElseThrow();
}

private record LdapGroup(String cn, List<String> users, List<String> subGroups) {
}
}
Loading

0 comments on commit bbe03db

Please sign in to comment.