diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
index cbb25efb0c..4353296769 100644
--- a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
+++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntitySearcherDAO.java
@@ -136,6 +136,7 @@ protected EntitySearchFilter[] addFilter(EntitySearchFilter[] filters, EntitySea
private PreparedStatement buildStatement(EntitySearchFilter[] filters, boolean isCount, boolean selectAll, Connection conn) {
String query = this.createQueryString(filters, isCount, selectAll);
+
PreparedStatement stat = null;
try {
stat = conn.prepareStatement(query);
@@ -567,4 +568,4 @@ protected String getMasterTableName() {
return this.getEntityMasterTableName();
}
-}
\ No newline at end of file
+}
diff --git a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java
index 2fa04470e5..cf868a58bc 100644
--- a/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java
+++ b/engine/src/main/java/com/agiletec/aps/system/services/authorization/AuthorizationManager.java
@@ -40,7 +40,7 @@
/**
* Servizio di autorizzazione. Il servizio espone tutti i metodi necessari per
- * la verifica verifica delle autorizzazioni utente, qualsiasi sia la sua
+ * la verifica delle autorizzazioni utente, qualsiasi sia la sua
* provenienza e definizione.
* @author E.Santoboni
*/
@@ -749,4 +749,4 @@ public void setGroupManager(IGroupManager groupManager) {
private IRoleManager _roleManager;
private IGroupManager _groupManager;
-}
\ No newline at end of file
+}
diff --git a/keycloak-plugin/README.md b/keycloak-plugin/README.md
index b43b7c72a4..52676521c9 100644
--- a/keycloak-plugin/README.md
+++ b/keycloak-plugin/README.md
@@ -10,6 +10,7 @@ Information below is for building from source or running locally as a contributo
### What this plugin does
* Enables SSO capabilities to an Entando Instance by using Keycloak.
* Moves User Management to Keycloak.
+* Assigns on-the-fly authorizations to the logging-in users; such authorizations might come from keycloak itself or external sources
### What this plugin does not
This plugin doesn't come with Role and Group management, because Entando Core roles/groups model isn't compatible with Keycloak. That means that even with the same users across multiple Entando Instances, the role and group mappings have to be configured on each instance.
@@ -24,6 +25,10 @@ This plugin doesn't come with Role and Group management, because Entando Core ro
>- `keycloak.secure.uris`: **[OPTIONAL]** Use if you want to secure an endpoint. Works with wildcards, comma separated.
>- `keycloak.authenticated.user.default.authorizations`: **[OPTIONAL]** Use if you want to automatically assign `group:role` to any user that logs in, comma separated. Example: `administrators:admin,readers`
+## Environment variables
+>- `KC_MAPPING_UPDATE`: specifies the refresh period -Chron style!- of the dynamic configuration used to assign authorizations to the loggin-in users. The default is `0 * * * * *`
+
+
## Installing
### Installing on your project
diff --git a/keycloak-plugin/pom.xml b/keycloak-plugin/pom.xml
index 53a50910c8..69fb56e03c 100644
--- a/keycloak-plugin/pom.xml
+++ b/keycloak-plugin/pom.xml
@@ -13,6 +13,37 @@
Entando Plugin: Keycloak
http://www.entando.com/
+
+
+ src/main/resources
+
+ src/main/resources/component
+
+
+
+ src/main/resources
+
+ **/*component.xml
+
+ true
+
+
+ src/main/java
+
+ **/*.properties
+ **/*.xml
+ **/*.xsd
+ **/*.txt
+
+
+
+ src/test/java
+
+ **/*.sql
+ **/*.json
+
+
+
org.apache.maven.plugins
diff --git a/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java b/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java
index 16261165a5..4b1a7f4c36 100644
--- a/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java
+++ b/keycloak-plugin/src/main/java/org/entando/entando/aps/servlet/security/KeycloakAuthenticationFilter.java
@@ -87,6 +87,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi
}
final String bearerToken = authorization.substring("Bearer ".length());
+
final ResponseEntity resp = oidcService.validateToken(bearerToken);
final AccessToken accessToken = resp.getBody();
@@ -111,7 +112,7 @@ public Authentication attemptAuthentication(final HttpServletRequest request, fi
setUserOnContext(request, user, userAuthentication);
// TODO optimise to not check on every request
- keycloakGroupManager.processNewUser(user);
+ keycloakGroupManager.processNewUser(user, bearerToken, true);
return userAuthentication;
} catch (EntException e) {
@@ -174,4 +175,4 @@ public void onAuthenticationFailure(HttpServletRequest request,
response.addHeader("Content-Type", "application/json");
response.getOutputStream().println(objectMapper.writeValueAsString(restResponse));
}
-}
\ No newline at end of file
+}
diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java
index bda6cff8b4..ccc7e824fd 100644
--- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java
+++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/filter/KeycloakFilter.java
@@ -256,7 +256,7 @@ private void doLogin(final HttpServletRequest request, final HttpServletResponse
session.setAttribute(SESSION_PARAM_ID_TOKEN, responseEntity.getBody().getIdToken());
session.setAttribute(SESSION_PARAM_REFRESH_TOKEN, responseEntity.getBody().getRefreshToken());
- keycloakGroupManager.processNewUser(user);
+ keycloakGroupManager.processNewUser(user, responseEntity.getBody().getAccessToken(), true);
saveUserOnSession(request, user);
log.info("Successfully authenticated user {}", user.getUsername());
} catch (HttpClientErrorException e) {
diff --git a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java
index 417c6db9ba..57d277d6ef 100644
--- a/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java
+++ b/keycloak-plugin/src/main/java/org/entando/entando/keycloak/services/KeycloakAuthorizationManager.java
@@ -1,60 +1,234 @@
package org.entando.entando.keycloak.services;
+import static java.util.Collections.emptyList;
+import static java.util.Optional.ofNullable;
+import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.CLIENTROLE;
+import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUP;
+import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.GROUPROLE;
+import static org.entando.entando.keycloak.services.mapping.DynamicMappingKind.ROLE;
+
+import com.agiletec.aps.system.common.AbstractService;
import com.agiletec.aps.system.services.authorization.Authorization;
import com.agiletec.aps.system.services.authorization.AuthorizationManager;
+import com.agiletec.aps.system.services.baseconfig.BaseConfigManager;
import com.agiletec.aps.system.services.group.Group;
import com.agiletec.aps.system.services.group.GroupManager;
import com.agiletec.aps.system.services.role.Role;
import com.agiletec.aps.system.services.role.RoleManager;
import com.agiletec.aps.system.services.user.UserDetails;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.google.common.collect.Sets;
-import org.apache.commons.lang3.StringUtils;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.stereotype.Service;
-
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.stream.Collectors;
-
-import static java.util.Optional.ofNullable;
-
+import java.util.stream.StreamSupport;
+import org.apache.commons.lang3.StringUtils;
import org.entando.entando.ent.exception.EntException;
+import org.entando.entando.ent.util.EntLogging.EntLogFactory;
+import org.entando.entando.ent.util.EntLogging.EntLogger;
+import org.entando.entando.keycloak.services.mapping.DynamicMapping;
+import org.entando.entando.keycloak.services.mapping.DynamicMappingElement;
+import org.entando.entando.keycloak.services.oidc.model.KeycloakUser;
+import org.springframework.beans.factory.annotation.Autowired;
-@Service
-public class KeycloakAuthorizationManager {
+public class KeycloakAuthorizationManager extends AbstractService {
+
+ private static final EntLogger log = EntLogFactory.getSanitizedLogger(KeycloakAuthorizationManager.class);
+ private static final String DEFAULT_SEPARATOR = "_";
private final KeycloakConfiguration configuration;
private final AuthorizationManager authorizationManager;
private final GroupManager groupManager;
private final RoleManager roleManager;
+ private final BaseConfigManager configManager;
private static final int GROUP_POSITION = 0;
private static final int ROLE_POSITION = 1;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final XmlMapper xmlMapper = new XmlMapper();
+
+ private final transient ReadWriteLock configUpdateLock = new ReentrantReadWriteLock();
+ private final transient Lock readLock = configUpdateLock.readLock();
+ private final transient Lock writeLock = configUpdateLock.writeLock();
+
@Autowired
public KeycloakAuthorizationManager(final KeycloakConfiguration configuration,
- final AuthorizationManager authorizationManager,
- final GroupManager groupManager,
- final RoleManager roleManager) {
+ final AuthorizationManager authorizationManager,
+ final GroupManager groupManager,
+ final RoleManager roleManager,
+ final BaseConfigManager configManager1) {
this.configuration = configuration;
this.authorizationManager = authorizationManager;
this.groupManager = groupManager;
this.roleManager = roleManager;
+ this.configManager = configManager1;
}
- public void processNewUser(final UserDetails user) {
- if (StringUtils.isEmpty(configuration.getDefaultAuthorizations())) {
- return;
+ /**
+ * Immutable list of mapping elements that are currently active. The configuration is constantly updated
+ * either by reloading the global configuration or after a certain amount of time (by default,
+ * one minute)
+ */
+ private transient List profileMappings;
+ private transient List jwtMappings;
+
+ @Override
+ public void init() throws Exception {
+ writeLock.lock();
+ profileMappings = new ArrayList<>();
+ jwtMappings = new ArrayList<>();
+ try {
+ String xml = configManager.getConfigItem("dynamicAuthMapping");
+ if (StringUtils.isNotBlank(xml)) {
+ DynamicMapping dynConf = xmlMapper.readValue(xml, DynamicMapping.class);
+ if (dynConf != null && dynConf.mapping != null) {
+
+ Map> partitioned =
+ dynConf.mapping.stream()
+ .filter(this::isValid)
+ .collect(Collectors.partitioningBy(
+ item -> item.kind.isJwtMapping()
+ ));
+ profileMappings = List.copyOf(partitioned.get(false));
+ jwtMappings = List.copyOf(partitioned.get(true));
+ log.debug("{} dynamic auth mapping found, {} profileMappings",
+ dynConf.mapping.size(), profileMappings.size());
+ }
+ }
+ if (profileMappings != null) {
+ profileMappings.forEach(m -> log.debug("mapping active: {}", m.toString()));
+ }
+ } catch (Exception e) {
+ log.error("Error initializing KeycloakAuthorizationManager", e);
+ throw e;
+ } finally {
+ writeLock.unlock();
+ }
+ }
+
+ /**
+ * This is invoked after a configured time to update the configuration
+ */
+ public void refreshConfiguration() {
+ try {
+ init();
+ } catch (Exception e) {
+ log.error("Error refreshing dynamic mapping configuration");
+ }
+ }
+
+ /**
+ * Check whether the dynamic configuration element provided is valid
+ * @param elem the single dynamic mapping element to validate
+ * @return true if the element is valid, false otherwise
+ */
+ private boolean isValid(DynamicMappingElement elem) {
+ if (elem.kind == null) {
+ log.error("invalid dynamic mapping element, 'kind' is blank");
+ return false;
+ }
+ if (StringUtils.isBlank(elem.attribute) && elem.kind != CLIENTROLE) {
+ log.error("invalid dynamic mapping element, 'attribute' is blank");
+ return false;
+ }
+ if (StringUtils.isBlank(elem.client) && elem.kind == CLIENTROLE) {
+ log.error("invalid dynamic mapping element, 'client' is blank for CLIENTROLE kind");
+ return false;
+ }
+ if (StringUtils.isBlank(elem.separator) && elem.kind == GROUPROLE) {
+ log.error("invalid dynamic mapping element, 'separator' is blank for GROUPROLE kind");
+ return false;
+ }
+ return true;
+ }
+
+ public void processNewUser(final UserDetails user, final String token, final boolean decode) {
+ processNewUser(user);
+ readLock.lock();
+ try {
+ // TODO for the future: handle also groups ~ "claim to group import" type
+ final List jwtRoleMapper = ofNullable(jwtMappings)
+ .orElse(emptyList())
+ .stream()
+ .filter(m -> m.kind == CLIENTROLE)
+ .collect(Collectors.toList());
+ // process client role claims, if any...
+ if (StringUtils.isNotBlank(token) && !jwtRoleMapper.isEmpty()) {
+ for (DynamicMappingElement cur: jwtRoleMapper) {
+ processRoleClaimAttributes(user, token, decode, cur);
+ }
+ }
+ // ...then process attributes coming from the user profile, if needed
+ if (user instanceof KeycloakUser
+ && profileMappings != null
+ && !profileMappings.isEmpty()) {
+ processProfileAttributes((KeycloakUser) user);
+ }
+ } finally {
+ readLock.unlock();
+ }
+ }
+
+ /**
+ * Analyze the JWT looking for known mappings to translate into Entando roles
+ * @param user logged in user
+ * @param token access token
+ * @param decode is true the access token is decoded from the base64 form
+ * @param tokenMapper the mapping configuration
+ */
+ private void processRoleClaimAttributes(UserDetails user, String token, boolean decode, DynamicMappingElement tokenMapper) {
+ final String payload = decode ? token.split("\\.")[1] : token;
+ final String json = decode ? new String(Base64.getUrlDecoder().decode(payload)) : payload;
+
+ try {
+ final JsonNode root = mapper.readTree(json);
+
+ JsonNode roleNode = root
+ .path("resource_access")
+ .path(tokenMapper.client)
+ .path("roles");
+
+ if (roleNode == null) {
+ return ;
+ }
+
+ List roles = StreamSupport.stream(roleNode.spliterator(), false)
+ .map(JsonNode::asText)
+ .collect(Collectors.toList());
+ if (user instanceof KeycloakUser) {
+ finalizeRoleAssociation((KeycloakUser) user, tokenMapper, roles);
+ }
+
+ } catch (Exception e) {
+ log.error("error importing client role into Entando roles", e);
}
- final Set defaultAuthorizations = Sets.newHashSet(configuration.getDefaultAuthorizations().split(","));
- final Set userAuthorizations = user.getAuthorizations().stream().map(authorization -> {
- final String group = ofNullable(authorization.getGroup()).map(Group::getName).orElse("");
- final String role = ofNullable(authorization.getRole()).map(Role::getName).orElse("");
- return StringUtils.isEmpty(role) ? group : group + ":" + role;
- }).collect(Collectors.toSet());
+ }
- defaultAuthorizations.stream()
- .filter(defaultGroup -> !userAuthorizations.contains(defaultGroup))
- .forEach(authorization -> this.assignGroupToUser(authorization, user));
+ private void processNewUser(final UserDetails user) {
+ if (StringUtils.isNotEmpty(configuration.getDefaultAuthorizations())) {
+ // process group and role coming from the configuration
+ final Set defaultAuthorizations = Sets.newHashSet(configuration.getDefaultAuthorizations().split(","));
+ final Set userAuthorizations = user.getAuthorizations().stream().map(authorization -> {
+ final String group = ofNullable(authorization.getGroup()).map(Group::getName).orElse("");
+ final String role = ofNullable(authorization.getRole()).map(Role::getName).orElse("");
+ return StringUtils.isEmpty(role) ? group : group + ":" + role;
+ }).collect(Collectors.toSet());
+
+ defaultAuthorizations.stream()
+ .filter(defaultGroup -> !userAuthorizations.contains(defaultGroup))
+ .forEach(authorization -> this.assignGroupToUser(authorization, user));
+ }
}
private void assignGroupToUser(final String authorization, final UserDetails user) {
@@ -66,43 +240,294 @@ private void assignGroupToUser(final String authorization, final UserDetails use
.map(this::findOrCreateGroup).orElse(null);
final Role role = ofNullable(roleName).filter(StringUtils::isNotEmpty)
.map(this::findOrCreateRole).orElse(null);
- groupName = ofNullable(group).map(Group::getName).orElse(null); // null or "" ?
- roleName = ofNullable(role).map(Role::getName).orElse(null); // null or "" ?
+ groupName = ofNullable(group).map(Group::getName).orElse(null); // null or ""?
+ roleName = ofNullable(role).map(Role::getName).orElse(null); // null or ""?
authorizationManager.addUserAuthorization(user.getUsername(), groupName, roleName);
- user.addAuthorization(new Authorization(group, role));
} catch (EntException e) {
throw new RuntimeException(e);
}
}
- private Group findOrCreateGroup(final String groupName) {
+ private synchronized Group findOrCreateGroup(final String groupName) {
+ Group group = groupManager.getGroup(groupName);
+ if (group == null) {
+ group = new Group();
+ group.setName(groupName);
+ group.setDescription(groupName);
+ try {
+ groupManager.addGroup(group);
+ } catch (EntException e) {
+ log.error("Failed to create group: {}", groupName, e);
+ }
+ }
+ return group;
+ }
+
+ private synchronized Role findOrCreateRole(final String roleName) {
+ Role role = roleManager.getRole(roleName);
+ if (role == null) {
+ role = new Role();
+ role.setName(roleName);
+ role.setDescription(roleName);
+ try {
+ roleManager.addRole(role);
+ } catch (EntException e) {
+ log.error("Failed to create role: {}", roleName, e);
+ }
+ }
+ return role;
+ }
+
+ /**
+ * Map dynamically, optionally persisting, authorization coming from the user profile in
+ * keycloak
+ * @param user the currently logged user
+ */
+ private synchronized void processProfileAttributes(final KeycloakUser user) {
+ profileMappings.forEach(m -> {
+ if (m.kind == ROLE) {
+ doProcessRole(user, m);
+ }
+ if (m.kind == GROUP) {
+ doProcessGroup(user, m);
+ }
+ if (m.kind == GROUPROLE) {
+ doProcessGroupRole(user, m);
+ }
+ });
+ }
+
+ private void doProcessGroupRole(KeycloakUser user, DynamicMappingElement elem) {
+ final String separator = StringUtils.isBlank(elem.separator) ?
+ DEFAULT_SEPARATOR : elem.separator;
+
try {
- Group group = groupManager.getGroup(groupName);
- if (group == null) {
+ final List authorizations = processUserProfileAttribute(user, elem);
+
+ if (authorizations == null) {
+ return;
+ }
+ for (String groupRoleToken : authorizations) {
+ parseAuthForGroupRole(user, elem, groupRoleToken, separator);
+ }
+ } catch (Exception e) {
+ log.error("error processing dynamic GRUOPROLE association", e);
+ }
+ }
+
+ private void parseAuthForGroupRole(KeycloakUser user, DynamicMappingElement elem, String groupRoleToken, String separator)
+ throws EntException {
+ final String[] tokens = groupRoleToken.split(separator);
+
+ if (tokens.length != 2
+ || StringUtils.isBlank(tokens[0])
+ || StringUtils.isBlank(tokens[1])) {
+ log.error("invalid dynamic config configuration detected");
+ return;
+ }
+
+ final String groupName = tokens[0];
+ final String roleName = tokens[1];
+
+ Authorization authorization;
+ Group group = null;
+ Role role = null;
+
+ if (elem.persist) {
+ if (StringUtils.isNotBlank(groupName)) {
+ group = findOrCreateGroup(groupName);
+ }
+
+ if (StringUtils.isNotBlank(roleName)) {
+ role = findOrCreateRole(roleName);
+ }
+ authorization = new Authorization(group, role);
+
+ persistAuthIfMissing(user, authorization);
+ } else {
+ if (StringUtils.isNotBlank(groupName)) {
group = new Group();
group.setName(groupName);
- group.setDescription(groupName);
- groupManager.addGroup(group);
+ group.setDescription("sys:" + groupName);
}
- return group;
- } catch (EntException e) {
- throw new RuntimeException(e);
+ if (StringUtils.isNotBlank(roleName)) {
+ // make sure all the permissions are assigned to the current role
+ role = roleManager.getRole(roleName);
+ }
+ authorization = new Authorization(group, role);
}
+ user.addAuthorization(authorization);
}
- private Role findOrCreateRole(final String roleName) {
- try {
- Role role = roleManager.getRole(roleName);
- if (role == null) {
- role = new Role();
- role.setName(roleName);
- role.setDescription(roleName);
- roleManager.addRole(role);
+ /**
+ * Process the dynamic Role authorization for the given user
+ * @param user the currently logging-in user
+ * @param elem a single dynamic configuration
+ */
+ private void doProcessRole(KeycloakUser user, DynamicMappingElement elem) {
+ final List authorizations = processUserProfileAttribute(user, elem);
+ finalizeRoleAssociation(user, elem, authorizations);
+ }
+
+ private void finalizeRoleAssociation(KeycloakUser user, DynamicMappingElement elem, List authorizations) {
+ if (authorizations == null) {
+ return;
+ }
+ for (String kca: authorizations) {
+ try {
+ // skip if the group is already mapped
+ if (user.getAuthorizations()
+ .stream()
+ .anyMatch(a -> a.getRole() != null
+ && a.getRole().getName().equals(kca))) {
+ log.debug("Role {} already assigned to user {}", kca, user.getUsername());
+ return;
+ }
+ final Authorization auth = elem.persist
+ ? createPersistedRoleAuthorization(user, kca)
+ : createTransientRoleAuthorization(kca);
+
+ user.addAuthorization(auth);
+ log.info("Successfully assigned role {} to user {}", kca, user.getUsername());
+ } catch (Exception e) {
+ log.error("Error processing dynamic role '{}' for user {}", kca , user.getUsername(), e);
}
- return role;
- } catch (EntException e) {
- throw new RuntimeException(e);
}
}
+ private Authorization createPersistedRoleAuthorization(KeycloakUser user, String roleName) throws EntException {
+ Role role = findOrCreateRole(roleName);
+ Authorization auth = new Authorization(null, role);
+ persistAuthIfMissing(user, auth);
+ return auth;
+ }
+
+ private Authorization createTransientRoleAuthorization(String roleName) {
+ Role role = roleManager.getRole(roleName);
+ if (role == null) {
+ role = new Role();
+ role.setName(roleName);
+ }
+ return new Authorization(null, role);
+ }
+
+ /**
+ * Process the dynamic Group authorization for the given user
+ * @param user the currently logging-in user
+ * @param elem a single dynamic configuration
+ */
+ private void doProcessGroup(KeycloakUser user, DynamicMappingElement elem) {
+ final List authorizations = processUserProfileAttribute(user, elem);
+ if (authorizations == null) {
+ return;
+ }
+ for (String kca: authorizations) {
+ try {
+ // skip if the role is already mapped
+ if (user.getAuthorizations()
+ .stream()
+ .anyMatch(a -> a.getGroup() != null
+ && a.getGroup().getName().equals(kca))) {
+ log.debug("Group {} already assigned to user {}", kca, user.getUsername());
+ return;
+ }
+ final Authorization auth = elem.persist
+ ? createPersistedGroupAuthorization(user, kca)
+ : createTransientGroupAuthorization(kca);
+
+ user.addAuthorization(auth);
+ log.info("Successfully assigned group {} to user {}", kca, user.getUsername());
+ } catch (Exception e) {
+ log.error("Error processing dynamic group for user {}", user.getUsername(), e);
+ }
+ }
+ }
+
+ private Authorization createPersistedGroupAuthorization(KeycloakUser user, String groupName) throws EntException {
+ final Group group = findOrCreateGroup(groupName);
+ final Authorization auth = new Authorization(group, null);
+ persistAuthIfMissing(user, auth);
+ return auth;
+ }
+
+ private Authorization createTransientGroupAuthorization(String groupName) {
+ Group group = new Group();
+ group.setName(groupName);
+ return new Authorization(group, null);
+ }
+
+ /**
+ * Process dynamic configuration element for a user. If the attribute is missing, it skips processing.
+ * @param user the Keycloak user
+ * @param elem the dynamic mapping element
+ * @return the list of processed attribute tokens or null if the attribute is missing
+ */
+ private static List processUserProfileAttribute(KeycloakUser user, DynamicMappingElement elem) {
+ if (user.getUserRepresentation() == null
+ || user.getUserRepresentation().getAttributes() == null
+ || !user.getUserRepresentation().getAttributes().containsKey(elem.attribute)) {
+ log.info("skipping dynamic processing for user {}", user.getUsername());
+ return Collections.emptyList();
+ }
+ final Object kcProfileAttr = user.getUserRepresentation()
+ .getAttributes()
+ .get(elem.attribute);
+ return handleKeycloakAttribute(kcProfileAttr);
+ }
+
+ /**
+ * To avoid creating duplicate records, we are forced to check if the authorization already exists.
+ * @param user the user being processed
+ * @param auth the authorization to persist
+ * @throws EntException in case of errors
+ */
+ private synchronized void persistAuthIfMissing(KeycloakUser user, Authorization auth) throws EntException {
+ final List existing = authorizationManager.getUserAuthorizations(user.getUsername());
+ if (existing.stream()
+ .noneMatch(a -> (a.getGroup() != null && a.getRole() == null
+ && auth.getGroup() != null
+ && a.getGroup().getName().equals(auth.getGroup().getName()))
+ ||
+ (a.getRole() != null && a.getGroup() == null
+ && auth.getRole() != null
+ && a.getRole().getName().equals(auth.getRole().getName()))
+
+ ||
+ (a.getRole() != null && a.getGroup() != null
+ && auth.getRole() != null
+ && auth.getGroup() != null
+ && a.getRole().getName().equals(auth.getRole().getName())
+ && a.getGroup().getName().equals(auth.getGroup().getName()))
+ )
+ ) {
+ log.debug("dynamically persisting authorization for user '{}' : group {}, role {}", user.getUsername(),
+ auth.getGroup() != null ? auth.getGroup().getName() : "N/A",
+ auth.getRole() != null ? auth.getRole().getName() : "N/A");
+ authorizationManager.addUserAuthorization(user.getUsername(), auth);
+ }
+ }
+
+ /**
+ * Process user attribute of the Keycloak profile. If it's a list, it will be flattened and split by whitespace.
+ * If it's a string, it will be split by whitespace.
+ * @param attribute the attribute data
+ * @return the list of the processed attribute tokens
+ */
+ protected static List handleKeycloakAttribute(Object attribute) {
+ if (attribute instanceof List) {
+ List